Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
112 changes: 80 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <kbd>Watch</kbd> 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

<!-- Plugin description -->
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)
<!-- Plugin description end -->

---

## Installation

- Using the IDE built-in plugin system:
**From the IDE:**

<kbd>Settings</kbd> → <kbd>Plugins</kbd> → <kbd>Marketplace</kbd> → search for **StackTree** → <kbd>Install</kbd>

**From JetBrains Marketplace:**

Visit [plugins.jetbrains.com/plugin/MARKETPLACE_ID](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID)
and click **Install to …**

**Manual:**

<kbd>Settings/Preferences</kbd> > <kbd>Plugins</kbd> > <kbd>Marketplace</kbd> > <kbd>Search for "stack-worktree"</kbd> >
<kbd>Install</kbd>
Download the [latest release](https://github.com/ydymovopenclaw-bot/stack-worktree/releases/latest)
and install via <kbd>Settings</kbd> → <kbd>Plugins</kbd> → <kbd>⚙</kbd> → <kbd>Install plugin from disk…</kbd>

- 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 <kbd>+</kbd> 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 <kbd>Install to ...</kbd> 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 <kbd>Settings</kbd> → <kbd>Keymap</kbd> → **StackTree**.

---

You can also download the [latest release](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID/versions) from JetBrains Marketplace and install it manually using
<kbd>Settings/Preferences</kbd> > <kbd>Plugins</kbd> > <kbd>⚙️</kbd> > <kbd>Install plugin from disk...</kbd>
## 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
<kbd>Settings/Preferences</kbd> > <kbd>Plugins</kbd> > <kbd>⚙️</kbd> > <kbd>Install plugin from disk...</kbd>
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).
Original file line number Diff line number Diff line change
@@ -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<GitExecutor>()

/** A single commit entry returned by [GitExecutor.log]. */
data class LogEntry(val hash: String, val subject: String, val authorDate: String)

Expand Down Expand Up @@ -37,16 +40,22 @@ class GitExecutor(
private suspend fun exec(vararg args: String): Result<GitRunResult> =
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]")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,6 +13,8 @@ import git4idea.commands.GitLineHandler
import git4idea.rebase.GitRebaser
import git4idea.update.GitUpdateResult

private val LOG = logger<GitLayerImpl>()

/**
* Production [GitLayer] implementation registered as a project-level service.
*
Expand Down Expand Up @@ -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<Worktree> {
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
Expand All @@ -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<String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading