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 ![Build](https://github.com/ydymovopenclaw-bot/stack-worktree/workflows/Build/badge.svg) [![Version](https://img.shields.io/jetbrains/plugin/v/MARKETPLACE_ID.svg)](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID) [![Downloads](https://img.shields.io/jetbrains/plugin/d/MARKETPLACE_ID.svg)](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:** + +SettingsPluginsMarketplace → 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 SettingsPluginsInstall 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 SettingsKeymap → **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-bot com.intellij.modules.platform Git4Idea 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?) { } } /**