Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/plugin-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Plugin Build

on:
pull_request:
paths:
- 'wt-intellij-plugin/**'
push:
branches: [main]
paths:
- 'wt-intellij-plugin/**'

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Build and test
working-directory: wt-intellij-plugin
run: ./gradlew build --no-build-cache
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![Tests](https://github.com/block/wt/actions/workflows/test.yml/badge.svg)](https://github.com/block/wt/actions/workflows/test.yml)
[![ShellCheck](https://github.com/block/wt/actions/workflows/lint.yml/badge.svg)](https://github.com/block/wt/actions/workflows/lint.yml)
[![Plugin Build](https://github.com/block/wt/actions/workflows/plugin-build.yml/badge.svg)](https://github.com/block/wt/actions/workflows/plugin-build.yml)

A streamlined workflow for developing in large Bazel + IntelliJ monorepos using Git worktrees.

Expand Down
10 changes: 8 additions & 2 deletions lib/wt-common
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ wt_read_git_config() {

# Read all wt.* keys into local variables
local gc_worktrees_base="" gc_idea_files_base=""
local gc_active_worktree="" gc_base_branch="" gc_metadata_patterns=""
local gc_active_worktree="" gc_base_branch="" gc_metadata_patterns="" gc_context_name=""
local has_any=false

local key value line lkey
Expand All @@ -124,6 +124,7 @@ wt_read_git_config() {
wt.activeworktree) gc_active_worktree="$value" ;;
wt.basebranch) gc_base_branch="$value" ;;
wt.metadatapatterns) gc_metadata_patterns="$value" ;;
wt.contextname) gc_context_name="$value" ;;
esac
done < <(git config --local --get-regexp '^wt\.' 2>/dev/null)

Expand Down Expand Up @@ -155,6 +156,7 @@ wt_read_git_config() {
# Optional keys: apply if present
[[ -n "$gc_active_worktree" ]] && WT_ACTIVE_WORKTREE="$gc_active_worktree"
[[ -n "$gc_metadata_patterns" ]] && WT_METADATA_PATTERNS="$gc_metadata_patterns"
[[ -n "$gc_context_name" ]] && WT_CONTEXT_NAME="$gc_context_name"

return 0
}
Expand Down Expand Up @@ -370,7 +372,11 @@ wt_show_context_banner() {
fi

if [[ -n "$current_context" ]]; then
printf "${BLUE}[Context: %s]${NC} ${YELLOW}(wt context to switch)${NC}\n" "$current_context" >&2
if [[ "$(git config --local --get wt.enabled 2>/dev/null)" == "true" ]]; then
printf "${BLUE}[Context: %s 📌]${NC}\n" "$current_context" >&2
else
printf "${BLUE}[Context: %s]${NC} ${YELLOW}(wt context to switch)${NC}\n" "$current_context" >&2
fi
else
printf "%s[No context set]%s %s(wt context to switch)%s\n" "$YELLOW" "$NC" "$YELLOW" "$NC" >&2
fi
Expand Down
4 changes: 4 additions & 0 deletions wt-jetbrains-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
build/
.gradle/
.intellijPlatform/
gradle/wrapper/gradle-wrapper.jar
181 changes: 181 additions & 0 deletions wt-jetbrains-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Worktree Manager - IntelliJ Plugin

Native git worktree management for all JetBrains IDEs, using atomic symlink switching for sub-second context switches. This is the IDE companion to the [wt CLI](http://go/wt).

## Requirements

- JetBrains IDE **2025.3+** (IntelliJ IDEA, Android Studio, CLion, WebStorm, etc.)
- Git on PATH
- macOS or Linux
- [wt CLI](http://go/wt) setup recommended

## Installation

```bash
./gradlew buildPlugin
```

Then **Settings > Plugins > gear icon > Install Plugin from Disk...** and select `build/distributions/wt-intellij-plugin-0.1.0.zip`.

To update, rebuild and reinstall. The old version is replaced automatically.

## Screenshot

![Tool Window](src/main/resources/ui.png)

## Features

| wt CLI command | Plugin equivalent |
|---|---|
| `wt list [-v]` | **Worktrees** tool window with async status indicators |
| `wt add [-b] <branch>` | **Create Worktree** dialog (stash/pull/create/restore) |
| `wt switch <worktree>` | **Switch Worktree** with atomic symlink swap |
| `wt remove [--merged]` | **Remove Worktree** / **Remove Merged** with safety checks |
| `wt context [name]` | **Context selector** in status bar + popup |
| `wt metadata-export` | **Export Metadata to Vault** |
| `wt metadata-import` | **Import Metadata from Vault** |
| `wt-metadata-refresh` | **Refresh Bazel Targets** |
| `wt cd` | **Open in Terminal** |

The plugin reads the same `~/.wt/` config files as the CLI. Both work side by side. A file watcher auto-refreshes the tool window on external changes.

---

## Usage

### Tool Window

**View > Tool Windows > Worktrees** shows all worktrees:

| Column | Description |
|---|---|
| `*` | Currently linked worktree |
| Path | Directory name |
| Branch | Checked-out branch |
| Status | `⚠`conflicts `●`staged `✱`modified `…`untracked `↑`ahead `↓`behind |
| Agent | Active Claude Code session IDs (truncated; hover for full) |
| Provisioned | `✓` current context, `~` other context |

Double-click a row to switch. Hover Status or Agent cells for details.

### Shortcuts

| Action | Shortcut |
|---|---|
| Switch Worktree | `Ctrl+Alt+W` |
| Create Worktree | `Ctrl+Alt+Shift+W` |

Also available under **VCS > Worktrees**.

### Status Bar

Shows current context (e.g. `wt: java`). Click to switch.

### Settings

**Settings > Tools > Worktree Manager**:

- Auto-refresh interval
- Status indicator loading
- Auto-export metadata on shutdown (default: off)
- Switch/remove confirmation dialogs
- Provision prompt on switch

---

## How It Works

### Symlink Swap

```
1. Create temp symlink: .active.<uuid>.tmp -> /new/worktree
2. Atomic rename: rename(.tmp, active) -- single syscall, zero gap
3. VFS refresh: save docs -> swap -> reload editors -> refresh VFS -> update git
```

### Metadata Vault

`~/.wt/repos/<context>/idea-files/` stores symlinks to worktree metadata:

- **Export**: vault symlinks point to current worktree's `.idea/`, `.ijwb/`, `.run/`, etc.
- **Import**: copies from vault (following symlinks) into target worktree

### Agent Detection

Two complementary methods:

- **Process** (`/usr/sbin/lsof`): finds running `claude` processes by cwd
- **Session files** (`~/.claude/projects/`): checks for recently-modified `.jsonl` transcripts (30 min window)

A worktree shows the agent indicator if either method detects activity.

---

## Development

JDK 25 toolchain is auto-provisioned by Gradle (via [foojay](https://github.com/gradle/foojay-toolchains)). Just needs JDK 17+ to run Gradle itself.

```bash
./gradlew buildPlugin # Build ZIP
./gradlew test # 65 tests
./gradlew runIde # Launch sandbox IDE
./gradlew clean buildPlugin test # Full rebuild
```

Debug: **Gradle tool window > Tasks > intellij platform > runIde > right-click > Debug**.

### Project Structure

```
src/main/kotlin/com/block/wt/
model/ WorktreeInfo, WorktreeStatus, ContextConfig, ProvisionMarker
git/ GitParser, GitBranchHelper, GitDirResolver
provision/ ProvisionMarkerService, ProvisionHelper, ProvisionSwitchHelper
agent/ AgentDetection (interface), AgentDetector (lsof + session files)
util/ PathHelper, PathExtensions, ConfigFileHelper, ProcessHelper/Runner
services/ WorktreeService (facade), GitClient, WorktreeEnricher,
WorktreeRefreshScheduler, CreateWorktreeUseCase,
SymlinkSwitchService, ContextService, MetadataService,
BazelService, ExternalChangeWatcher
actions/ 14 actions: worktree/, context/, metadata/, util/
ui/ WorktreePanel, WorktreeTableModel, ContextPopupHelper,
ContextStatusBarWidgetFactory, dialogs, Notifications
settings/ WtPluginSettings, WtSettingsComponent, WtSettingsConfigurable
src/test/kotlin/ 65 tests across 8 classes + test fakes
```

### Tests

| Test class | Coverage |
|---|---|
| `PathHelperTest` | Atomic symlink, tilde expansion, normalization |
| `ConfigFileHelperTest` | Config parse/write, missing files, quoted paths |
| `WorktreeInfoTest` | Porcelain parsing, WorktreeStatus sealed class, isDirty |
| `WorktreeServiceTest` | Parsing + agent enrichment with test fakes |
| `MetadataServiceTest` | Path deduplication, directory copy |
| `MetadataServiceStaticTest` | Static export/import |
| `ProvisionMarkerServiceTest` | Markers: write/read/remove, multi-context, linked worktrees |
| `GitDirResolverTest` | Git dir resolution (main + linked) |

---

## Architecture

| Decision | Rationale |
|---|---|
| Atomic symlink swap | `create-temp + Files.move(ATOMIC_MOVE)` -- `rename(2)` syscall, zero gap |
| Git CLI over Git4Idea | `git worktree list --porcelain` is more reliable for all worktree states |
| Direct `~/.wt/` file I/O | Ensures CLI interop (no `PersistentStateComponent`) |
| Coroutines + StateFlow | Reactive UI from background loading |
| Facade pattern | `WorktreeService` delegates to `GitClient`, `WorktreeEnricher`, `WorktreeRefreshScheduler` internally; 18 callers unchanged |
| Dual agent detection | `lsof` for running processes + session files for recently active; path encoding matches Claude Code (`[^a-zA-Z0-9]` -> `-`) |
| `WorktreeStatus` sealed class | Replaces 6 nullable `Int?` with `NotLoaded` / `Loaded` states |
| `Result<Unit>` mutations | `ProvisionMarkerService` preserves exceptions instead of bare `Boolean` |

## Compatibility

| | |
|---|---|
| IDE versions | 2025.3 (253) through 2026.1 (261.*) |
| Platforms | macOS, Linux |
| Build | Gradle 9.3.1, Kotlin 2.3.0, IntelliJ Platform Gradle Plugin 2.11.0, JDK 25 toolchain → JVM 21 bytecode |
72 changes: 72 additions & 0 deletions wt-jetbrains-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType

plugins {
id("org.jetbrains.kotlin.jvm") version "2.3.0"
id("org.jetbrains.intellij.platform") version "2.11.0"
}

group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()

kotlin {
jvmToolchain(25)
compilerOptions {
// JDK 25 toolchain for compilation, JVM 21 bytecode for IDE compatibility.
// IntelliJ 2025.3 bundles JBR 21; bump to JVM_25 when targeting 2026.1+ (JBR 25).
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}

repositories {
maven(url = "https://maven.global.square/artifactory/square-public")
intellijPlatform {
defaultRepositories()
}
}

dependencies {
intellijPlatform {
intellijIdea(providers.gradleProperty("platformVersion"))

bundledPlugin("Git4Idea")
bundledPlugin("org.jetbrains.plugins.terminal")

testFramework(TestFrameworkType.Platform)
}

testImplementation("junit:junit:4.13.2")
}

intellijPlatform {
pluginConfiguration {
name = providers.gradleProperty("pluginName")
version = providers.gradleProperty("pluginVersion")

ideaVersion {
sinceBuild = providers.gradleProperty("pluginSinceBuild")
untilBuild = providers.gradleProperty("pluginUntilBuild")
}
}

pluginVerification {
ides {
recommended()
}
}
}

java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}

tasks {
runIde {
jvmArgs("-Xmx2g")
systemProperty("idea.is.internal", "true")
}

test {
systemProperty("idea.home.path", layout.buildDirectory.dir("idea-sandbox").get().asFile.absolutePath)
}
}
12 changes: 12 additions & 0 deletions wt-jetbrains-plugin/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pluginGroup = com.block.wt
pluginName = Worktree Manager
pluginVersion = 0.1.0

pluginSinceBuild = 253
pluginUntilBuild = 261.*

platformVersion = 2025.3.3

kotlin.stdlib.default.dependency = false
org.gradle.configuration-cache = true
org.gradle.caching = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading