diff --git a/CHANGELOG.md b/CHANGELOG.md
index e829401..492749c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,10 +10,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
-- Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template)
-- Project layer structure: `git`, `state`, `ops`, `pr`, `ui` packages with placeholder interfaces
-- `.editorconfig` for consistent coding style across editors
-- GitHub Actions CI workflow running `build`, `test`, and `verifyPlugin` on push and pull requests
+- Initial scaffold from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template)
+- Layered architecture: `git`, `state`, `ops`, `pr`, `ui` packages with clean interface boundaries
+- `.editorconfig` for consistent coding style
+- GitHub Actions CI: build, test (Kover coverage → CodeCov), Qodana static analysis, Plugin Verifier
+
+#### Core features (S3–S5)
+- **Stack graph panel** in the VCS Changes *Stacks* tab: visual DAG of stacked branches colour-coded by health
+- **Branch tracking** — track/untrack local branches via right-click context menu; state persisted to `.idea/stack-worktree.xml`
+- **Worktrees tab** — dedicated panel listing all git worktrees with open-in-new-window and open-in-terminal actions
+- **Create / remove linked worktrees** from the graph context menu or branch list
+- **Restack** (`Ctrl+Alt+Shift+R`) — rebase entire stack bottom-to-top via IntelliJ's three-pane merge dialog
+- **Sync** (`Ctrl+Shift+Y`) — fetch remote, detect merged branches, refresh ahead/behind counts
+- **Insert branch above / below** — structural stack mutations that rebase descendants automatically
+- **Ahead/behind indicator** on each graph node with TTL-cached calculation
+
+#### PR integration (S6)
+- **Submit stack** (`Ctrl+Alt+Shift+S`) — push all branches and create or update GitHub / GitLab PRs; PR descriptions include a stack-navigation table
+- GitHub and GitLab provider implementations; auto-detection based on remote URL
+- PR/CI badge polling in the stack graph (status icon on each node)
+
+#### Settings & UX (S7)
+- **Settings page** under VCS → StackTree: trunk branch, remote name, worktree base path, auto-prune
+- **Status-bar widget** showing the current branch; updates on every checkout event
+- Keyboard shortcut group in Keymap settings (all six actions customisable)
+- Accessibility: ARIA labels and keyboard navigation in the stack graph panel
+
+#### Error handling & logging (S7.3)
+- `StackTreeNotifier` — centralized balloon notifications with **"Show Details"** action for errors
+- `StateCorruptedException` — thrown when the persisted JSON cannot be deserialized; callers recover gracefully
+- `ReentrantLock` in `StateStorage.write()` for safe concurrent writes within a single JVM
+- Structured `DEBUG`/`WARN`/`ERROR` logging throughout the git, state, and UI layers (`idea.log` under `#StackTree`)
+- `StacksTabFactory.initContent()` wrapped in try/catch — returns a readable error panel instead of a blank tab on failure
+- Plugin icons (light + dark) for JetBrains Marketplace listing
### Changed
diff --git a/README.md b/README.md
index 5583ef7..f3f0011 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,100 @@
-# stack-worktree
+# StackTree

[](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID)
[](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID)
-## Template ToDo list
-- [x] Create a new [IntelliJ Platform Plugin Template][template] project.
-- [ ] Get familiar with the [template documentation][template].
-- [ ] Adjust the [pluginGroup](./gradle.properties) and [pluginName](./gradle.properties), as well as the [id](./src/main/resources/META-INF/plugin.xml) and [sources package](./src/main/kotlin).
-- [ ] Adjust the plugin description in `README` (see [Tips][docs:plugin-description])
-- [ ] Review the [Legal Agreements](https://plugins.jetbrains.com/docs/marketplace/legal-agreements.html?from=IJPluginTemplate).
-- [ ] [Publish a plugin manually](https://plugins.jetbrains.com/docs/intellij/publishing-plugin.html?from=IJPluginTemplate) for the first time.
-- [ ] Set the `MARKETPLACE_ID` in the above README badges. You can obtain it once the plugin is published to JetBrains Marketplace.
-- [ ] Set the [Plugin Signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html?from=IJPluginTemplate) related [secrets](https://github.com/JetBrains/intellij-platform-plugin-template#environment-variables).
-- [ ] Set the [Deployment Token](https://plugins.jetbrains.com/docs/marketplace/plugin-upload.html?from=IJPluginTemplate).
-- [ ] Click the Watch button on the top of the [IntelliJ Platform Plugin Template][template] to be notified about releases containing new features and fixes.
-- [ ] Configure the [CODECOV_TOKEN](https://docs.codecov.com/docs/quick-start) secret for automated test coverage reports on PRs
-
-This Fancy IntelliJ Platform Plugin is going to be your implementation of the brilliant ideas that you have.
-
-This specific section is a source for the [plugin.xml](/src/main/resources/META-INF/plugin.xml) file which will be extracted by the [Gradle](/build.gradle.kts) during the build process.
-
-To keep everything working, do not remove `` sections.
+**StackTree** brings stacked-branch workflows to IntelliJ IDEA.
+
+Work on multiple dependent pull requests simultaneously — each branch lives in its own
+[git worktree](https://git-scm.com/docs/git-worktree) so you can switch contexts instantly
+without stashing or committing incomplete work.
+
+### Features
+
+- **Visual stack graph** — see your entire branch stack at a glance in the *Stacks* tab of
+ the VCS Changes view; branches are colour-coded by health (clean / needs-rebase / conflict).
+- **One-click worktree management** — create and remove linked worktrees directly from the
+ graph context menu or the dedicated *Worktrees* tab.
+- **Restack** — rebase every branch in the stack onto its parent in one operation
+ (`Ctrl+Alt+Shift+R`); IntelliJ's three-pane merge dialog opens automatically on conflicts.
+- **Submit stack** — push all branches and create or update GitHub / GitLab pull requests in
+ a single action (`Ctrl+Alt+Shift+S`); PR descriptions include a navigation table so
+ reviewers always know where they are in the stack.
+- **Sync** — fetch the remote, detect merged branches, and refresh ahead/behind counts for
+ every tracked branch (`Ctrl+Shift+Y`).
+- **State persistence** — branch relationships and worktree paths are stored in
+ `.idea/stack-worktree.xml` and survive IDE restarts.
+- **Status-bar widget** — the current branch is always visible in the bottom status bar.
+
+### Requirements
+
+- IntelliJ IDEA 2025.2 or newer (build 252+)
+- Git 2.20+ (for `git worktree` support)
+---
+
## Installation
-- Using the IDE built-in plugin system:
+**From the IDE:**
+
+Settings → Plugins → Marketplace → search for **StackTree** → Install
+
+**From JetBrains Marketplace:**
+
+Visit [plugins.jetbrains.com/plugin/MARKETPLACE_ID](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID)
+and click **Install to …**
+
+**Manual:**
- Settings/Preferences > Plugins > Marketplace > Search for "stack-worktree" >
- Install
+Download the [latest release](https://github.com/ydymovopenclaw-bot/stack-worktree/releases/latest)
+and install via Settings → Plugins → ⚙ → Install plugin from disk…
-- Using JetBrains Marketplace:
+---
+
+## Quick Start
+
+1. Open a project that is a git repository.
+2. Switch to the **Commit** tool window and click the **Stacks** tab.
+3. Click **New Stack** (or press the + toolbar button) and enter a trunk branch
+ name (e.g. `main`).
+4. Right-click any branch node → **Add Branch** to start building your stack.
+5. Use **Restack** to keep all branches up to date after the trunk advances.
+6. Use **Submit Stack** to push and open PRs for all branches at once.
+
+---
- Go to [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID) and install it by clicking the Install to ... button in case your IDE is running.
+## Keyboard Shortcuts
+
+| Action | Default shortcut |
+|--------|-----------------|
+| Restack all | `Ctrl+Alt+Shift+R` |
+| Submit stack | `Ctrl+Alt+Shift+S` |
+| Sync with remote | `Ctrl+Shift+Y` |
+| Refresh graph | `F5` |
+
+All shortcuts are customisable under Settings → Keymap → **StackTree**.
+
+---
- You can also download the [latest release](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID/versions) from JetBrains Marketplace and install it manually using
- Settings/Preferences > Plugins > ⚙️ > Install plugin from disk...
+## Development
-- Manually:
+```bash
+./gradlew build # compile + test + checks
+./gradlew test # run all tests (JUnit 5)
+./gradlew runIde # launch sandboxed IDE with plugin loaded
+./gradlew buildPlugin # produce distributable ZIP
+./gradlew verifyPlugin # run IntelliJ Plugin Verifier
+```
- Download the [latest release](https://github.com/ydymovopenclaw-bot/stack-worktree/releases/latest) and install it manually using
- Settings/Preferences > Plugins > ⚙️ > Install plugin from disk...
+Run a single test class:
+```bash
+./gradlew test --tests "com.github.ydymovopenclawbot.stackworktree.state.StateStorageTest"
+```
---
-Plugin based on the [IntelliJ Platform Plugin Template][template].
-[template]: https://github.com/JetBrains/intellij-platform-plugin-template
-[docs:plugin-description]: https://plugins.jetbrains.com/docs/intellij/plugin-user-experience.html#plugin-description-and-presentation
+Plugin built on the [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template).
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitExecutor.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitExecutor.kt
index 2f00a71..4dc716b 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitExecutor.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitExecutor.kt
@@ -1,10 +1,13 @@
package com.github.ydymovopenclawbot.stackworktree.git
+import com.intellij.openapi.diagnostic.logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.nio.file.Path
+private val LOG = logger()
+
/** A single commit entry returned by [GitExecutor.log]. */
data class LogEntry(val hash: String, val subject: String, val authorDate: String)
@@ -37,16 +40,22 @@ class GitExecutor(
private suspend fun exec(vararg args: String): Result =
withContext(Dispatchers.IO) {
runCatching {
+ LOG.debug("exec: git ${args.joinToString(" ")} [root=$root]")
withTimeout(timeoutMs) {
val result = runner.run(root, args.toList())
if (!result.isSuccess) {
val msg = result.stderr.ifBlank {
"git ${args[0]} failed with exit code ${result.exitCode}"
}
+ LOG.warn("exec: git ${args[0]} failed — $msg")
throw GitException(msg)
}
result
}
+ }.onFailure { t ->
+ if (t is kotlinx.coroutines.TimeoutCancellationException) {
+ LOG.error("exec: git ${args[0]} timed out after ${timeoutMs}ms [root=$root]")
+ }
}
}
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitLayerImpl.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitLayerImpl.kt
index eb26273..74fef91 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitLayerImpl.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/git/GitLayerImpl.kt
@@ -1,6 +1,7 @@
package com.github.ydymovopenclawbot.stackworktree.git
import com.intellij.openapi.components.Service
+import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.Project
@@ -12,6 +13,8 @@ import git4idea.commands.GitLineHandler
import git4idea.rebase.GitRebaser
import git4idea.update.GitUpdateResult
+private val LOG = logger()
+
/**
* Production [GitLayer] implementation registered as a project-level service.
*
@@ -56,37 +59,53 @@ class GitLayerImpl(
// ── Public API ────────────────────────────────────────────────────────────
override fun worktreeAdd(path: String, branch: String): Worktree {
+ LOG.debug("worktreeAdd: path=$path branch=$branch")
val result = runRaw("add", path, branch)
if (!result.success()) {
val err = result.errorOutputAsJoinedString
if (err.contains("already exists", ignoreCase = true)) {
+ LOG.warn("worktreeAdd: path already exists — $path")
throw WorktreeAlreadyExistsException(path)
}
+ LOG.warn("worktreeAdd: command failed — $err")
throw WorktreeCommandException(err)
}
val canonical = java.io.File(path).canonicalPath
- return worktreeList().firstOrNull { java.io.File(it.path).canonicalPath == canonical }
+ val wt = worktreeList().firstOrNull { java.io.File(it.path).canonicalPath == canonical }
?: throw WorktreeCommandException("worktree add succeeded but path not found in list: $path")
+ LOG.debug("worktreeAdd: created worktree [branch=${wt.branch} path=${wt.path}]")
+ return wt
}
override fun worktreeRemove(path: String, force: Boolean) {
+ LOG.debug("worktreeRemove: path=$path force=$force")
val result = if (force) runRaw("remove", "--force", path) else runRaw("remove", path)
if (!result.success()) {
val err = result.errorOutputAsJoinedString
when {
- err.contains("is locked", ignoreCase = true) ->
+ err.contains("is locked", ignoreCase = true) -> {
+ LOG.warn("worktreeRemove: locked — $path")
throw WorktreeIsLockedException(path)
+ }
err.contains("not a working tree", ignoreCase = true) ||
- err.contains("is not a registered worktree", ignoreCase = true) ->
+ err.contains("is not a registered worktree", ignoreCase = true) -> {
+ LOG.warn("worktreeRemove: not found — $path")
throw WorktreeNotFoundException(path)
- else -> throw WorktreeCommandException(err)
+ }
+ else -> {
+ LOG.warn("worktreeRemove: command failed — $err")
+ throw WorktreeCommandException(err)
+ }
}
}
+ LOG.debug("worktreeRemove: removed $path")
}
override fun worktreeList(): List {
val lines = runOrThrow("list", "--porcelain")
- return parsePorcelain(lines)
+ val result = parsePorcelain(lines)
+ LOG.debug("worktreeList: found ${result.size} worktree(s)")
+ return result
}
// Delegate to the companion so the implementation is accessible from unit tests
@@ -95,7 +114,9 @@ class GitLayerImpl(
Companion.parsePorcelain(lines)
override fun worktreePrune() {
+ LOG.debug("worktreePrune: pruning stale worktrees")
runOrThrow("prune")
+ LOG.debug("worktreePrune: done")
}
override fun listLocalBranches(): List {
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateException.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateException.kt
new file mode 100644
index 0000000..5e3635d
--- /dev/null
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateException.kt
@@ -0,0 +1,19 @@
+package com.github.ydymovopenclawbot.stackworktree.state
+
+/**
+ * Base class for all failures in the StackTree state-persistence layer.
+ *
+ * Sealed so callers can exhaustively `when`-match without a catch-all arm.
+ */
+sealed class StateException(msg: String, cause: Throwable? = null) : Exception(msg, cause)
+
+/**
+ * Thrown when the JSON blob stored in `refs/stacktree/state` cannot be deserialized.
+ *
+ * This usually means the blob was written by an incompatible future version of the plugin
+ * or was corrupted externally. Callers should handle this by resetting to a safe default
+ * [StackState] and notifying the user via
+ * [com.github.ydymovopenclawbot.stackworktree.ui.StackTreeNotifier.error].
+ */
+class StateCorruptedException(detail: String, cause: Throwable? = null) :
+ StateException("Stack state is corrupted: $detail", cause)
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateStorage.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateStorage.kt
index d8e9aa9..6d53e15 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateStorage.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/state/StateStorage.kt
@@ -3,11 +3,17 @@ package com.github.ydymovopenclawbot.stackworktree.state
import com.github.ydymovopenclawbot.stackworktree.git.GitException
import com.github.ydymovopenclawbot.stackworktree.git.GitRunResult
import com.github.ydymovopenclawbot.stackworktree.git.GitRunner
+import com.intellij.openapi.diagnostic.logger
+import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Path
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+private val LOG = logger()
/**
* Persists [StackState] as a single JSON blob inside the git object store, pointed to by
@@ -29,19 +35,35 @@ class StateStorage(
private val root: Path,
private val runner: GitRunner,
) : StackStateStore {
+
+ /** Guards [write] against concurrent calls from multiple coroutines in the same JVM. */
+ private val writeLock = ReentrantLock()
+
// ----------------------------------------------------------------------------------
// Public API
// ----------------------------------------------------------------------------------
/** Returns `true` if [REF] already exists in the repository. */
- fun exists(): Boolean = runner.run(root, listOf("rev-parse", "--verify", REF)).isSuccess
+ fun exists(): Boolean {
+ val result = runner.run(root, listOf("rev-parse", "--verify", REF)).isSuccess
+ LOG.debug("exists [root=$root]: $result")
+ return result
+ }
/**
* Reads and deserializes the [StackState] from [REF], or returns `null` if the ref
* does not exist yet (i.e. StackTree has never written state to this repository).
+ *
+ * @throws StateCorruptedException when the stored JSON cannot be deserialized.
+ * @throws GitException when git plumbing commands fail.
*/
override fun read(): StackState? {
- if (!exists()) return null
+ if (!exists()) {
+ LOG.debug("read [root=$root]: ref $REF not found — returning null")
+ return null
+ }
+
+ LOG.debug("read [root=$root]: resolving commit → tree → blob")
// commit → tree SHA
val commitText = exec("cat-file", "-p", REF).stdout
@@ -62,7 +84,14 @@ class StateStorage(
?: throw GitException("Corrupt stack state: unexpected tree entry format: $blobLine")
val jsonStr = exec("cat-file", "blob", blobSha).stdout
- return JSON.decodeFromString(jsonStr)
+ LOG.debug("read [root=$root]: deserializing blob $blobSha (${jsonStr.length} chars)")
+
+ return try {
+ JSON.decodeFromString(jsonStr)
+ } catch (e: SerializationException) {
+ LOG.error("read [root=$root]: JSON deserialization failed for blob $blobSha — throwing StateCorruptedException", e)
+ throw StateCorruptedException("JSON in blob $blobSha cannot be deserialized: ${e.message}", e)
+ }
}
/**
@@ -75,15 +104,18 @@ class StateStorage(
* parent when the ref already exists.
* 4. Advance [REF] to the new commit via `git update-ref`.
*/
- override fun write(state: StackState) {
+ override fun write(state: StackState): Unit = writeLock.withLock {
+ LOG.debug("write [root=$root]: serializing StackState")
val jsonStr = JSON.encodeToString(state)
// Step 1 — blob
val blobSha = execWithStdin(jsonStr, "hash-object", "-w", "--stdin").trim()
+ LOG.debug("write [root=$root]: blob written [$blobSha]")
// Step 2 — tree (mktree reads one entry per line from stdin; tab-separated)
val treeInput = "100644 blob $blobSha\t$BLOB_FILENAME\n"
val treeSha = execWithStdin(treeInput, "mktree").trim()
+ LOG.debug("write [root=$root]: tree written [$treeSha]")
// Step 3 — commit (optionally chain parent)
val parentSha = runner.run(root, listOf("rev-parse", "--verify", REF))
@@ -95,9 +127,11 @@ class StateStorage(
add("-m"); add("stacktree state")
}
val commitSha = execWithEnv(AUTHOR_ENV, *commitArgs.toTypedArray()).trim()
+ LOG.debug("write [root=$root]: commit created [$commitSha] parent=[${parentSha ?: "none"}]")
// Step 4 — advance ref
exec("update-ref", REF, commitSha)
+ LOG.debug("write [root=$root]: ref $REF advanced to $commitSha")
}
override fun delete() {
@@ -167,6 +201,7 @@ class StateStorage(
val finished = process.waitFor(30, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
+ LOG.warn("runProcess [root=$root]: git ${args[0]} timed out after 30s — process killed")
throw GitException("git ${args[0]} timed out after 30s")
}
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StackTreeNotifier.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StackTreeNotifier.kt
new file mode 100644
index 0000000..297691a
--- /dev/null
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StackTreeNotifier.kt
@@ -0,0 +1,66 @@
+package com.github.ydymovopenclawbot.stackworktree.ui
+
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.Messages
+
+/**
+ * Centralized helper for surfacing StackTree events to the user as balloon notifications.
+ *
+ * All three severity levels share the same "StackTree" notification group registered in
+ * `plugin.xml`. When [detail] is supplied on [warn] or [error], a **"Show Details"** action
+ * is added to the balloon so the user can inspect the full error message or stack trace
+ * without needing to open `idea.log`.
+ *
+ * All methods are safe to call from any thread (IntelliJ's notification system dispatches
+ * to the EDT internally).
+ */
+object StackTreeNotifier {
+
+ private const val GROUP = "StackTree"
+
+ /** Shows an INFORMATION balloon with [message]. */
+ fun info(project: Project, message: String) =
+ notify(project, message, NotificationType.INFORMATION, detail = null)
+
+ /**
+ * Shows a WARNING balloon with [message].
+ *
+ * @param detail Optional full text shown when the user clicks "Show Details".
+ */
+ fun warn(project: Project, message: String, detail: String? = null) =
+ notify(project, message, NotificationType.WARNING, detail)
+
+ /**
+ * Shows an ERROR balloon with [message].
+ *
+ * @param detail Optional full text (e.g. stack trace) shown in "Show Details" dialog.
+ */
+ fun error(project: Project, message: String, detail: String? = null) =
+ notify(project, message, NotificationType.ERROR, detail)
+
+ // -------------------------------------------------------------------------
+
+ private fun notify(
+ project: Project,
+ message: String,
+ type: NotificationType,
+ detail: String?,
+ ) {
+ val notification = NotificationGroupManager.getInstance()
+ .getNotificationGroup(GROUP)
+ .createNotification(message, type)
+
+ if (!detail.isNullOrBlank()) {
+ notification.addAction(
+ NotificationAction.createSimple("Show Details") {
+ Messages.showInfoMessage(project, detail, "StackTree — Details")
+ }
+ )
+ }
+
+ notification.notify(project)
+ }
+}
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StacksTabFactory.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StacksTabFactory.kt
index 31b49ea..66927d4 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StacksTabFactory.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/StacksTabFactory.kt
@@ -188,6 +188,8 @@ class StacksTabFactory(private val project: Project) : ChangesViewContentProvide
// ── ChangesViewContentProvider ────────────────────────────────────────────
override fun initContent(): JComponent {
+ LOG.debug("initContent: building Stacks tab for project '${project.name}'")
+ try {
val graph = StackGraphPanel()
graphPanel = graph
val detail = BranchDetailPanel()
@@ -342,6 +344,16 @@ class StacksTabFactory(private val project: Project) : ChangesViewContentProvide
else -> null
}
}
+ } catch (e: Exception) {
+ LOG.error("initContent: failed to initialize Stacks tab", e)
+ StackTreeNotifier.error(project, "Failed to load Stacks tab", e.stackTraceToString())
+ return JBPanel>(BorderLayout()).apply {
+ add(
+ com.intellij.ui.components.JBLabel("Failed to load Stacks tab — check idea.log"),
+ BorderLayout.CENTER,
+ )
+ }
+ }
}
override fun disposeContent() {
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayer.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayer.kt
index f29dd88..8936c91 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayer.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayer.kt
@@ -8,6 +8,15 @@ interface UiLayer {
/** Refreshes all UI components to reflect the current plugin state. */
fun refresh()
- /** Shows a non-blocking notification balloon with [message]. */
+ /** Shows a non-blocking INFO balloon with [message]. */
fun notify(message: String)
+
+ /**
+ * Shows a non-blocking ERROR balloon with [message].
+ *
+ * When [detail] is non-null (e.g. a stack trace or full git error output), a
+ * **"Show Details"** action is added to the balloon so the user can inspect it
+ * without opening `idea.log`.
+ */
+ fun notifyError(message: String, detail: String? = null)
}
diff --git a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayerImpl.kt b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayerImpl.kt
index 98f5288..5292057 100644
--- a/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayerImpl.kt
+++ b/src/main/kotlin/com/github/ydymovopenclawbot/stackworktree/ui/UiLayerImpl.kt
@@ -1,27 +1,33 @@
package com.github.ydymovopenclawbot.stackworktree.ui
-import com.intellij.notification.NotificationGroupManager
-import com.intellij.notification.NotificationType
import com.intellij.openapi.components.Service
+import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
+private val LOG = logger()
+
/**
* Production [UiLayer] implementation.
*
* - [refresh] broadcasts [STACK_STATE_TOPIC] so all UI subscribers rebuild.
- * - [notify] shows a non-blocking balloon notification.
+ * - [notify] shows a non-blocking INFO balloon via [StackTreeNotifier].
+ * - [notifyError] shows an ERROR balloon with an optional "Show Details" action.
*/
@Service(Service.Level.PROJECT)
class UiLayerImpl(private val project: Project) : UiLayer {
override fun refresh() {
+ LOG.debug("refresh: broadcasting STACK_STATE_TOPIC")
project.messageBus.syncPublisher(STACK_STATE_TOPIC).stateChanged()
}
override fun notify(message: String) {
- NotificationGroupManager.getInstance()
- .getNotificationGroup("Stack Worktree Notifications")
- .createNotification(message, NotificationType.INFORMATION)
- .notify(project)
+ LOG.debug("notify: $message")
+ StackTreeNotifier.info(project, message)
+ }
+
+ override fun notifyError(message: String, detail: String?) {
+ LOG.warn("notifyError: $message${if (detail != null) " [detail available]" else ""}")
+ StackTreeNotifier.error(project, message, detail)
}
}
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 9cb7efa..b553ecd 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -1,8 +1,8 @@
com.github.ydymovopenclawbot.stackworktree
- stack-worktree
- ydymovopenclaw-bot
+ StackTree
+ ydymovopenclaw-botcom.intellij.modules.platformGit4Idea
diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg
new file mode 100644
index 0000000..d5060fd
--- /dev/null
+++ b/src/main/resources/META-INF/pluginIcon.svg
@@ -0,0 +1,21 @@
+
diff --git a/src/main/resources/META-INF/pluginIcon_dark.svg b/src/main/resources/META-INF/pluginIcon_dark.svg
new file mode 100644
index 0000000..c9bb4f4
--- /dev/null
+++ b/src/main/resources/META-INF/pluginIcon_dark.svg
@@ -0,0 +1,21 @@
+
diff --git a/src/main/resources/messages/MyBundle.properties b/src/main/resources/messages/MyBundle.properties
index 4136428..4bbaec2 100644
--- a/src/main/resources/messages/MyBundle.properties
+++ b/src/main/resources/messages/MyBundle.properties
@@ -2,6 +2,19 @@ projectService=Project service: {0}
randomLabel=The random number is: {0}
shuffle=Shuffle
stacktree.notification.group=StackTree
+
+# Notification titles (S7.3)
+notification.info.title=StackTree
+notification.warning.title=StackTree Warning
+notification.error.title=StackTree Error
+
+# User-facing error messages (S7.3)
+stacktree.error.stateCorrupted=Stack state is corrupted and has been reset. Previous data may be lost.
+stacktree.error.gitTimeout=Git command timed out. Check that git is accessible and the repository is healthy.
+stacktree.error.initFailed=StackTree failed to initialise. Check idea.log for details.
+stacktree.error.tabFailed=Failed to load Stacks tab. Check idea.log for details.
+
+# Stack operations (S3.x)
stacktree.newStack.created=Stack ''{0}'' created successfully
stacktree.newStack.error=Failed to create stack: {0}
diff --git a/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/integration/StackIntegrationTest.kt b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/integration/StackIntegrationTest.kt
new file mode 100644
index 0000000..1b0d124
--- /dev/null
+++ b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/integration/StackIntegrationTest.kt
@@ -0,0 +1,294 @@
+package com.github.ydymovopenclawbot.stackworktree.integration
+
+import com.github.ydymovopenclawbot.stackworktree.git.ProcessGitRunner
+import com.github.ydymovopenclawbot.stackworktree.state.BranchHealth
+import com.github.ydymovopenclawbot.stackworktree.state.BranchNode
+import com.github.ydymovopenclawbot.stackworktree.state.RepoConfig
+import com.github.ydymovopenclawbot.stackworktree.state.StackState
+import com.github.ydymovopenclawbot.stackworktree.state.StateCorruptedException
+import com.github.ydymovopenclawbot.stackworktree.state.StateStorage
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Integration tests for the critical stack-management path:
+ * create stack → add branch → record state → verify persistence → lifecycle operations
+ *
+ * All tests use [ProcessGitRunner] against a real git repository created in a [TempDir],
+ * so they run without the IntelliJ Platform and can be executed in plain JUnit 5.
+ */
+class StackIntegrationTest {
+
+ @TempDir
+ lateinit var repoDir: Path
+
+ private lateinit var storage: StateStorage
+ private val runner = ProcessGitRunner()
+ private val defaultConfig = RepoConfig(trunk = "main", remote = "origin")
+
+ /** Runs a git command against [repoDir] and asserts success. */
+ private fun git(vararg args: String) {
+ val result = runner.run(repoDir, args.toList())
+ check(result.isSuccess) {
+ "Test fixture git command failed: git ${args.toList()} — ${result.stderr}"
+ }
+ }
+
+ @BeforeEach
+ fun setup() {
+ git("init", "-b", "main")
+ git("config", "user.email", "test@example.com")
+ git("config", "user.name", "Test User")
+ git("commit", "--allow-empty", "-m", "initial")
+ storage = StateStorage(repoDir, runner)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 1: Create stack and persist via StateStorage
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `create stack with one branch and read back yields identical state`() {
+ val state = StackState(
+ repoConfig = defaultConfig,
+ branches = mapOf(
+ "main" to BranchNode(name = "main", parent = null),
+ "feature/login" to BranchNode(
+ name = "feature/login",
+ parent = "main",
+ health = BranchHealth.CLEAN,
+ ),
+ ),
+ )
+
+ storage.write(state)
+ val loaded = storage.read()
+
+ assertNotNull(loaded)
+ assertEquals(defaultConfig, loaded.repoConfig)
+ assertEquals(2, loaded.branches.size)
+ assertEquals("main", loaded.branches["feature/login"]?.parent)
+ assertEquals(BranchHealth.CLEAN, loaded.branches["feature/login"]?.health)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 2: Add branch records worktree path in state
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `add branch with worktree path persists and is readable`() {
+ val worktreePath = "/tmp/feature-auth-worktree"
+
+ val state = StackState(
+ repoConfig = defaultConfig,
+ branches = mapOf(
+ "main" to BranchNode(name = "main", parent = null),
+ "feature/auth" to BranchNode(
+ name = "feature/auth",
+ parent = "main",
+ worktreePath = worktreePath,
+ health = BranchHealth.CLEAN,
+ ),
+ ),
+ )
+
+ storage.write(state)
+ val loaded = storage.read()
+
+ assertNotNull(loaded)
+ val authNode = loaded.branches["feature/auth"]
+ assertNotNull(authNode)
+ assertEquals(worktreePath, authNode.worktreePath)
+ assertEquals("main", authNode.parent)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 3: Multi-branch stack round-trips correctly
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `multi-branch stack with four levels round-trips through state storage`() {
+ val state = StackState(
+ repoConfig = RepoConfig(trunk = "main", remote = "upstream"),
+ branches = mapOf(
+ "main" to BranchNode(
+ name = "main",
+ parent = null,
+ children = listOf("feat/a"),
+ ),
+ "feat/a" to BranchNode(
+ name = "feat/a",
+ parent = "main",
+ children = listOf("feat/b"),
+ worktreePath = "/tmp/wt-a",
+ health = BranchHealth.CLEAN,
+ ),
+ "feat/b" to BranchNode(
+ name = "feat/b",
+ parent = "feat/a",
+ children = listOf("feat/c"),
+ worktreePath = "/tmp/wt-b",
+ health = BranchHealth.NEEDS_REBASE,
+ ),
+ "feat/c" to BranchNode(
+ name = "feat/c",
+ parent = "feat/b",
+ worktreePath = "/tmp/wt-c",
+ health = BranchHealth.NEEDS_REBASE,
+ ),
+ ),
+ )
+
+ storage.write(state)
+ val loaded = storage.read()
+
+ assertNotNull(loaded)
+ assertEquals("upstream", loaded.repoConfig.remote)
+ assertEquals(4, loaded.branches.size)
+ assertEquals(listOf("feat/b"), loaded.branches["feat/a"]?.children)
+ assertEquals("/tmp/wt-b", loaded.branches["feat/b"]?.worktreePath)
+ assertEquals(BranchHealth.NEEDS_REBASE, loaded.branches["feat/c"]?.health)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 4: State updates form an auditable commit chain
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `three sequential writes create a three-commit chain in refs stacktree state`() {
+ storage.write(StackState(repoConfig = RepoConfig(trunk = "v1", remote = "origin")))
+ storage.write(StackState(repoConfig = RepoConfig(trunk = "v2", remote = "origin")))
+ storage.write(StackState(repoConfig = RepoConfig(trunk = "v3", remote = "origin")))
+
+ // Verify the ref points to exactly 3 commits in history
+ val log = runner.run(repoDir, listOf("log", "--oneline", "refs/stacktree/state"))
+ assertTrue(log.isSuccess)
+ val commitCount = log.stdout.lines().count(String::isNotBlank)
+ assertEquals(3, commitCount, "Expected 3 commits in the chain but got $commitCount")
+
+ // The tip should reflect the most recent write
+ assertEquals("v3", storage.read()!!.repoConfig.trunk)
+
+ // The second commit must have a 'parent' line (chains)
+ val commitObj = runner.run(repoDir, listOf("cat-file", "-p", "refs/stacktree/state"))
+ assertTrue(commitObj.stdout.lines().any { it.startsWith("parent ") })
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 5: Corrupted JSON triggers StateCorruptedException
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `corrupted JSON blob in state ref causes StateCorruptedException on read`() {
+ // Write a valid state first so the ref exists.
+ storage.write(StackState(repoConfig = defaultConfig))
+
+ // Overwrite the blob with invalid JSON using git plumbing directly.
+ val badJson = "{ this is not valid JSON !!!"
+ val blobSha = runner.run(repoDir, listOf("hash-object", "-w", "--stdin")).let { r ->
+ // Use ProcessBuilder to pipe stdin
+ val pb = ProcessBuilder(listOf("git", "hash-object", "-w", "--stdin"))
+ .directory(repoDir.toFile())
+ val proc = pb.start()
+ proc.outputStream.bufferedWriter().use { it.write(badJson) }
+ val sha = proc.inputStream.bufferedReader().readText().trim()
+ proc.waitFor()
+ sha
+ }
+
+ // Build a tree pointing to the corrupt blob
+ val treeInput = "100644 blob $blobSha\tstate.json\n"
+ val pb2 = ProcessBuilder(listOf("git", "mktree"))
+ .directory(repoDir.toFile())
+ val proc2 = pb2.start()
+ proc2.outputStream.bufferedWriter().use { it.write(treeInput) }
+ val treeSha = proc2.inputStream.bufferedReader().readText().trim()
+ proc2.waitFor()
+
+ // Create a commit pointing to the corrupt tree
+ val env = mapOf(
+ "GIT_AUTHOR_NAME" to "test", "GIT_AUTHOR_EMAIL" to "t@t.com",
+ "GIT_AUTHOR_DATE" to "1970-01-01T00:00:00+0000",
+ "GIT_COMMITTER_NAME" to "test", "GIT_COMMITTER_EMAIL" to "t@t.com",
+ "GIT_COMMITTER_DATE" to "1970-01-01T00:00:00+0000",
+ )
+ val pb3 = ProcessBuilder(listOf("git", "commit-tree", treeSha, "-m", "corrupt"))
+ .directory(repoDir.toFile())
+ pb3.environment().putAll(env)
+ val proc3 = pb3.start()
+ val commitSha = proc3.inputStream.bufferedReader().readText().trim()
+ proc3.waitFor()
+
+ // Point the ref at the corrupt commit
+ runner.run(repoDir, listOf("update-ref", "refs/stacktree/state", commitSha))
+
+ // Now read() must throw StateCorruptedException
+ assertThrows {
+ storage.read()
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 6: Write + delete lifecycle
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `write then delete clears the ref and subsequent read returns null`() {
+ val state = StackState(
+ repoConfig = defaultConfig,
+ branches = mapOf("main" to BranchNode(name = "main", parent = null)),
+ )
+
+ storage.write(state)
+ assertTrue(storage.exists(), "Ref should exist after write")
+
+ storage.delete()
+ assertNull(storage.read(), "read() should return null after delete()")
+ assertTrue(!storage.exists(), "Ref should not exist after delete()")
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Test 7: Concurrent writes via ReentrantLock produce valid final state
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `ten concurrent writes all complete and final state is valid`() {
+ val pool = Executors.newFixedThreadPool(10)
+ val errors = mutableListOf()
+
+ repeat(10) { i ->
+ pool.submit {
+ try {
+ storage.write(
+ StackState(
+ repoConfig = RepoConfig(trunk = "write-$i", remote = "origin"),
+ )
+ )
+ } catch (t: Throwable) {
+ synchronized(errors) { errors.add(t) }
+ }
+ }
+ }
+
+ pool.shutdown()
+ assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS), "Thread pool did not finish in time")
+ assertTrue(errors.isEmpty(), "Concurrent writes produced errors: $errors")
+
+ // Whatever write won the race, the state must be deserializable.
+ val final = storage.read()
+ assertNotNull(final, "Final state should be readable after concurrent writes")
+ assertTrue(
+ final.repoConfig.trunk.startsWith("write-"),
+ "Expected trunk to be one of the concurrent writes but was '${final.repoConfig.trunk}'"
+ )
+ }
+}
diff --git a/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/OpsLayerImplTest.kt b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/OpsLayerImplTest.kt
index aab5138..a77486a 100644
--- a/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/OpsLayerImplTest.kt
+++ b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/OpsLayerImplTest.kt
@@ -301,10 +301,12 @@ class OpsLayerImplTest {
private class FakeUiLayer : UiLayer {
val notifications = mutableListOf()
+ val errors = mutableListOf()
var refreshCount = 0
override fun refresh() { refreshCount++ }
override fun notify(message: String) { notifications += message }
+ override fun notifyError(message: String, detail: String?) { errors += message }
}
private class FakeStateLayer(initial: PluginState = PluginState()) : StateLayer {
diff --git a/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/RemoveStackTest.kt b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/RemoveStackTest.kt
index 9d286f1..c59513e 100644
--- a/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/RemoveStackTest.kt
+++ b/src/test/kotlin/com/github/ydymovopenclawbot/stackworktree/ops/RemoveStackTest.kt
@@ -195,6 +195,7 @@ class RemoveStackTest {
val notifications = mutableListOf()
override fun refresh() = Unit
override fun notify(message: String) { notifications += message }
+ override fun notifyError(message: String, detail: String?) { }
}
/**