diff --git a/.github/workflows/plugin-build.yml b/.github/workflows/plugin-build.yml new file mode 100644 index 0000000..aaf73ba --- /dev/null +++ b/.github/workflows/plugin-build.yml @@ -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 diff --git a/README.md b/README.md index 8e600f1..9556e17 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/wt-common b/lib/wt-common index 6ee31bf..19a2bc5 100644 --- a/lib/wt-common +++ b/lib/wt-common @@ -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 @@ -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) @@ -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 } @@ -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 diff --git a/wt-jetbrains-plugin/.gitignore b/wt-jetbrains-plugin/.gitignore new file mode 100644 index 0000000..1b8490b --- /dev/null +++ b/wt-jetbrains-plugin/.gitignore @@ -0,0 +1,4 @@ +build/ +.gradle/ +.intellijPlatform/ +gradle/wrapper/gradle-wrapper.jar diff --git a/wt-jetbrains-plugin/README.md b/wt-jetbrains-plugin/README.md new file mode 100644 index 0000000..13845a6 --- /dev/null +++ b/wt-jetbrains-plugin/README.md @@ -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] ` | **Create Worktree** dialog (stash/pull/create/restore) | +| `wt switch ` | **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..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//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` 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 | diff --git a/wt-jetbrains-plugin/build.gradle.kts b/wt-jetbrains-plugin/build.gradle.kts new file mode 100644 index 0000000..c0c9eeb --- /dev/null +++ b/wt-jetbrains-plugin/build.gradle.kts @@ -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) + } +} diff --git a/wt-jetbrains-plugin/gradle.properties b/wt-jetbrains-plugin/gradle.properties new file mode 100644 index 0000000..87003ac --- /dev/null +++ b/wt-jetbrains-plugin/gradle.properties @@ -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 diff --git a/wt-jetbrains-plugin/gradle/wrapper/gradle-wrapper.properties b/wt-jetbrains-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/wt-jetbrains-plugin/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/wt-jetbrains-plugin/gradlew b/wt-jetbrains-plugin/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/wt-jetbrains-plugin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/wt-jetbrains-plugin/gradlew.bat b/wt-jetbrains-plugin/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/wt-jetbrains-plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/wt-jetbrains-plugin/install-plugin-to-intellij.sh b/wt-jetbrains-plugin/install-plugin-to-intellij.sh new file mode 100755 index 0000000..6a03b5c --- /dev/null +++ b/wt-jetbrains-plugin/install-plugin-to-intellij.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PLUGIN_DIR_NAME="wt-intellij-plugin" + +# Detect OS +OS="$(uname -s)" +case "$OS" in + Darwin) PLATFORM="macos" ;; + Linux) PLATFORM="linux" ;; + MINGW*|MSYS*|CYGWIN*) PLATFORM="windows" ;; + *) + echo "Error: Unsupported OS: $OS" >&2 + exit 1 + ;; +esac + +# Build the plugin +echo "Building plugin..." +if [[ "$PLATFORM" == "windows" ]]; then + ./gradlew.bat buildPlugin +else + ./gradlew buildPlugin +fi + +# Find the built ZIP +ZIP_FILE=$(ls build/distributions/wt-intellij-plugin-*.zip 2>/dev/null | head -1) +if [[ -z "$ZIP_FILE" ]]; then + echo "Error: No plugin ZIP found in build/distributions/" >&2 + exit 1 +fi +echo "Built: $ZIP_FILE" + +# Find JetBrains config directory based on OS +case "$PLATFORM" in + macos) + JETBRAINS_DIR="$HOME/Library/Application Support/JetBrains" + ;; + linux) + JETBRAINS_DIR="$HOME/.config/JetBrains" + ;; + windows) + JETBRAINS_DIR="$APPDATA/JetBrains" + ;; +esac + +if [[ ! -d "$JETBRAINS_DIR" ]]; then + echo "Error: JetBrains directory not found at $JETBRAINS_DIR" >&2 + exit 1 +fi + +# Find the most recent IntelliJ IDEA directory +IDEA_DIR=$(ls -dt "$JETBRAINS_DIR"/IntelliJIdea* 2>/dev/null | head -1) +if [[ -z "$IDEA_DIR" ]]; then + echo "Error: No IntelliJ IDEA installation found in $JETBRAINS_DIR" >&2 + exit 1 +fi + +PLUGINS_DIR="$IDEA_DIR/plugins" +mkdir -p "$PLUGINS_DIR" + +echo "IntelliJ plugins dir: $PLUGINS_DIR" + +# Remove existing version of the plugin +if [[ -d "$PLUGINS_DIR/$PLUGIN_DIR_NAME" ]]; then + echo "Removing existing plugin..." + rm -rf "$PLUGINS_DIR/$PLUGIN_DIR_NAME" +fi + +# Extract the new version +echo "Installing plugin..." +unzip -qo "$ZIP_FILE" -d "$PLUGINS_DIR" + +echo "" +echo "Plugin installed successfully!" + +# Restart IntelliJ IDEA +case "$PLATFORM" in + macos) + IDEA_APP_PATH=$(ps aux | grep -o '/[^[:space:]]*IntelliJ IDEA[^/]*.app' | head -1 || true) + IDEA_PID=$(pgrep -f "IntelliJ IDEA.*/MacOS/idea" | head -1 || true) + if [[ -n "$IDEA_PID" && -n "$IDEA_APP_PATH" ]]; then + echo "Restarting IntelliJ IDEA (pid $IDEA_PID)..." + kill "$IDEA_PID" + while kill -0 "$IDEA_PID" 2>/dev/null; do sleep 1; done + open -a "$IDEA_APP_PATH" + echo "IntelliJ IDEA restarted." + else + echo "IntelliJ IDEA is not running. Launch it manually to use the plugin." + fi + ;; + linux) + IDEA_PID=$(pgrep -f "idea" 2>/dev/null | head -1) + if [[ -n "$IDEA_PID" ]]; then + # Find the binary path before killing + IDEA_BIN=$(readlink -f "/proc/$IDEA_PID/exe" 2>/dev/null || true) + echo "Restarting IntelliJ IDEA..." + kill "$IDEA_PID" + while kill -0 "$IDEA_PID" 2>/dev/null; do sleep 1; done + if [[ -n "$IDEA_BIN" && -x "$IDEA_BIN" ]]; then + nohup "$IDEA_BIN" &>/dev/null & + else + # Fall back to common install locations + for candidate in \ + /snap/intellij-idea-ultimate/current/bin/idea.sh \ + /snap/intellij-idea-community/current/bin/idea.sh \ + "$HOME/.local/share/JetBrains/Toolbox/apps/IDEA-U/ch-0/*/bin/idea.sh" \ + "$HOME/.local/share/JetBrains/Toolbox/apps/IDEA-C/ch-0/*/bin/idea.sh" \ + /opt/idea/bin/idea.sh; do + # shellcheck disable=SC2086 + FOUND=$(ls -t $candidate 2>/dev/null | head -1) + if [[ -n "$FOUND" && -x "$FOUND" ]]; then + nohup "$FOUND" &>/dev/null & + break + fi + done + fi + echo "IntelliJ IDEA restarted." + else + echo "IntelliJ IDEA is not running. Launch it manually to use the plugin." + fi + ;; + windows) + IDEA_PID=$(tasklist 2>/dev/null | grep -i "idea" | awk '{print $2}' | head -1) + if [[ -n "$IDEA_PID" ]]; then + echo "Restarting IntelliJ IDEA..." + taskkill //PID "$IDEA_PID" //F 2>/dev/null || true + sleep 3 + # Try common install locations + for candidate in \ + "$LOCALAPPDATA/Programs/IntelliJ IDEA Ultimate/bin/idea64.exe" \ + "$LOCALAPPDATA/Programs/IntelliJ IDEA Community/bin/idea64.exe" \ + "C:/Program Files/JetBrains/IntelliJ IDEA/bin/idea64.exe"; do + if [[ -f "$candidate" ]]; then + start "" "$candidate" & + break + fi + done + echo "IntelliJ IDEA restarted." + else + echo "IntelliJ IDEA is not running. Launch it manually to use the plugin." + fi + ;; +esac diff --git a/wt-jetbrains-plugin/settings.gradle.kts b/wt-jetbrains-plugin/settings.gradle.kts new file mode 100644 index 0000000..1caa1bd --- /dev/null +++ b/wt-jetbrains-plugin/settings.gradle.kts @@ -0,0 +1,5 @@ +rootProject.name = "wt-intellij-plugin" + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/WtAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/WtAction.kt new file mode 100644 index 0000000..98b6818 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/WtAction.kt @@ -0,0 +1,73 @@ +package com.block.wt.actions + +import com.block.wt.services.ContextService +import com.block.wt.model.ContextConfig +import com.block.wt.ui.WorktreePanel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.progress.runBlockingCancellable + +/** + * Base action for all wt plugin actions. Provides DumbAware, BGT update thread, + * and a `runInBackground` template method for background work with coroutine bridging. + */ +abstract class WtAction : AnAction(), DumbAware { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = isAvailable(e) + } + + protected open fun isAvailable(e: AnActionEvent): Boolean = + e.project != null + + protected fun runInBackground( + project: Project, + title: String, + cancellable: Boolean = true, + action: suspend (ProgressIndicator) -> Unit, + ) { + ProgressManager.getInstance().run(object : Task.Backgroundable(project, title, cancellable) { + override fun run(indicator: ProgressIndicator) { + runBlockingCancellable { action(indicator) } + } + }) + } +} + +/** + * Base action for actions that require a configured wt context. + * Greyed out when no context is auto-detected for the current project. + */ +abstract class WtConfigAction : WtAction() { + override fun isAvailable(e: AnActionEvent): Boolean { + val project = e.project ?: return false + return ContextService.getInstance(project).getCurrentConfig() != null + } + + protected fun requireConfig(e: AnActionEvent): ContextConfig? { + val project = e.project ?: return null + return ContextService.getInstance(project).getCurrentConfig() + } +} + +/** + * Base action for actions that operate on a selected table row. + * Uses EDT for update() since reading table selection requires Swing thread. + */ +abstract class WtTableAction : AnAction(), DumbAware { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = getSelectedPanel(e)?.getSelectedWorktree() != null + } + + protected fun getSelectedPanel(e: AnActionEvent): WorktreePanel? = + e.getData(WorktreePanel.DATA_KEY) +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt new file mode 100644 index 0000000..e378134 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt @@ -0,0 +1,95 @@ +package com.block.wt.actions.context + +import com.block.wt.actions.WtAction +import com.block.wt.model.ContextConfig +import com.block.wt.services.ContextService +import com.block.wt.services.MetadataService +import com.block.wt.services.WorktreeService +import com.block.wt.ui.AddContextDialog +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent +import java.nio.file.Files + +class AddContextAction : WtAction() { + + override fun isAvailable(e: AnActionEvent): Boolean = true + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project + + val dialog = AddContextDialog(project) + if (!dialog.showAndGet()) return + + val repoPath = dialog.repoPath ?: return + val contextName = dialog.contextName + val baseBranch = dialog.baseBranch + val activeWorktree = dialog.activeWorktree ?: return + val mainRepoRoot = dialog.mainRepoRoot ?: return + val worktreesBase = dialog.worktreesBase ?: return + val ideaFilesBase = dialog.ideaFilesBase ?: return + val patterns = dialog.selectedPatterns + + runInBackground(project ?: return, "Creating Context", cancellable = false) { indicator -> + try { + indicator.text = "Creating directories..." + Files.createDirectories(worktreesBase) + Files.createDirectories(ideaFilesBase) + + // Migration: move repo to mainRepoRoot, create symlink at activeWorktree + // Matches shell's _wt_migrate_repo() + if (PathHelper.isSymlink(activeWorktree)) { + // Already a symlink — previously set up, skip migration + } else if (Files.isDirectory(activeWorktree)) { + if (Files.exists(mainRepoRoot)) { + error("${mainRepoRoot} already exists; cannot migrate") + } + Files.createDirectories(mainRepoRoot.parent) + + // Use temp dir for safety (handles nested paths like ~/java -> ~/java/.wt/...) + val tempDir = activeWorktree.resolveSibling( + ".${activeWorktree.fileName}.wt-migrate-${System.currentTimeMillis()}" + ) + indicator.text = "Moving repository to temp location..." + Files.move(activeWorktree, tempDir) + + indicator.text = "Moving to ${mainRepoRoot}..." + Files.move(tempDir, mainRepoRoot) + + indicator.text = "Creating symlink..." + Files.createSymbolicLink(activeWorktree, mainRepoRoot) + } else if (!Files.exists(activeWorktree)) { + // Neither exists — create symlink if mainRepoRoot exists + if (Files.isDirectory(mainRepoRoot)) { + Files.createSymbolicLink(activeWorktree, mainRepoRoot) + } + } + + indicator.text = "Writing configuration..." + val config = ContextConfig( + name = contextName, + mainRepoRoot = mainRepoRoot, + worktreesBase = worktreesBase, + activeWorktree = activeWorktree, + ideaFilesBase = ideaFilesBase, + baseBranch = baseBranch, + metadataPatterns = patterns, + ) + ContextService.getInstance(project).addContext(config) + + if (patterns.isNotEmpty()) { + indicator.text = "Exporting metadata..." + MetadataService.exportMetadataStatic(mainRepoRoot, ideaFilesBase, patterns) + .onFailure { ex -> + Notifications.warning(project, "Metadata Export Failed", ex.message ?: "Unknown error") + } + } + + WorktreeService.getInstance(project).refreshWorktreeList() + Notifications.info(project, "Context Created", "Context '$contextName' created") + } catch (ex: Exception) { + Notifications.error(project, "Context Creation Failed", ex.message ?: "Unknown error") + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/DeleteContextAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/DeleteContextAction.kt new file mode 100644 index 0000000..25d4216 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/DeleteContextAction.kt @@ -0,0 +1,51 @@ +package com.block.wt.actions.context + +import com.block.wt.actions.WtConfigAction +import com.block.wt.git.GitConfigHelper +import com.block.wt.services.ContextService +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import java.nio.file.Files + +class DeleteContextAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val config = requireConfig(e) ?: return + val project = e.project ?: return + + val answer = Messages.showYesNoDialog( + project, + "Delete context '${config.name}'?\n\n" + + "Your repository lives at ${config.mainRepoRoot}.\n" + + "The symlink at ${config.activeWorktree} will be removed.\n" + + "You may need to move the repo back manually.\n\n" + + "Existing worktree directories will be kept.", + "Delete Context", + Messages.getWarningIcon(), + ) + if (answer != Messages.YES) return + + runInBackground(project, "Deleting Context", cancellable = false) { indicator -> + try { + indicator.text = "Removing git config..." + GitConfigHelper.removeAllConfig(config.mainRepoRoot) + + indicator.text = "Removing .conf file..." + val confFile = PathHelper.reposDir.resolve("${config.name}.conf") + Files.deleteIfExists(confFile) + + indicator.text = "Removing symlink..." + if (PathHelper.isSymlink(config.activeWorktree)) { + Files.delete(config.activeWorktree) + } + + ContextService.getInstance(project).reload() + Notifications.info(project, "Context Deleted", "Context '${config.name}' deleted") + } catch (ex: Exception) { + Notifications.error(project, "Delete Failed", ex.message ?: "Unknown error") + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ReprovisionContextAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ReprovisionContextAction.kt new file mode 100644 index 0000000..de78d63 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ReprovisionContextAction.kt @@ -0,0 +1,123 @@ +package com.block.wt.actions.context + +import com.block.wt.actions.WtConfigAction +import com.block.wt.git.GitConfigHelper +import com.block.wt.services.ContextService +import com.block.wt.services.MetadataService +import com.block.wt.services.WorktreeService +import com.block.wt.ui.AddContextDialog +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import java.nio.file.Files + +class ReprovisionContextAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val config = requireConfig(e) ?: return + val project = e.project ?: return + + val answer = Messages.showYesNoDialog( + project, + "This will remove the current wt configuration for '${config.name}' and let you re-create it.\n" + + "Existing worktree directories will be kept.\n\n" + + "Continue?", + "Re-provision Context", + Messages.getQuestionIcon(), + ) + if (answer != Messages.YES) return + + runInBackground(project, "Re-provisioning Context", cancellable = false) { indicator -> + try { + indicator.text = "Removing git config..." + GitConfigHelper.removeAllConfig(config.mainRepoRoot) + + indicator.text = "Removing .conf file..." + val confFile = PathHelper.reposDir.resolve("${config.name}.conf") + Files.deleteIfExists(confFile) + + indicator.text = "Removing symlink..." + if (PathHelper.isSymlink(config.activeWorktree)) { + Files.delete(config.activeWorktree) + } + + ContextService.getInstance(project).reload() + } catch (ex: Exception) { + Notifications.error(project, "Re-provision Failed", ex.message ?: "Unknown error") + return@runInBackground + } + + // Open AddContextDialog on EDT for the user to re-create + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + val dialog = AddContextDialog(project) + if (!dialog.showAndGet()) return@invokeLater + + val repoPath = dialog.repoPath ?: return@invokeLater + val contextName = dialog.contextName + val baseBranch = dialog.baseBranch + val activeWorktree = dialog.activeWorktree ?: return@invokeLater + val mainRepoRoot = dialog.mainRepoRoot ?: return@invokeLater + val worktreesBase = dialog.worktreesBase ?: return@invokeLater + val ideaFilesBase = dialog.ideaFilesBase ?: return@invokeLater + val patterns = dialog.selectedPatterns + + runInBackground(project, "Creating Context", cancellable = false) { indicator2 -> + try { + indicator2.text = "Creating directories..." + Files.createDirectories(worktreesBase) + Files.createDirectories(ideaFilesBase) + + // Migration logic (same as AddContextAction) + if (PathHelper.isSymlink(activeWorktree)) { + // Already a symlink — skip + } else if (Files.isDirectory(activeWorktree)) { + if (Files.exists(mainRepoRoot)) { + error("${mainRepoRoot} already exists; cannot migrate") + } + Files.createDirectories(mainRepoRoot.parent) + val tempDir = activeWorktree.resolveSibling( + ".${activeWorktree.fileName}.wt-migrate-${System.currentTimeMillis()}" + ) + indicator2.text = "Moving repository to temp location..." + Files.move(activeWorktree, tempDir) + indicator2.text = "Moving to ${mainRepoRoot}..." + Files.move(tempDir, mainRepoRoot) + indicator2.text = "Creating symlink..." + Files.createSymbolicLink(activeWorktree, mainRepoRoot) + } else if (!Files.exists(activeWorktree)) { + if (Files.isDirectory(mainRepoRoot)) { + Files.createSymbolicLink(activeWorktree, mainRepoRoot) + } + } + + indicator2.text = "Writing configuration..." + val newConfig = com.block.wt.model.ContextConfig( + name = contextName, + mainRepoRoot = mainRepoRoot, + worktreesBase = worktreesBase, + activeWorktree = activeWorktree, + ideaFilesBase = ideaFilesBase, + baseBranch = baseBranch, + metadataPatterns = patterns, + ) + ContextService.getInstance(project).addContext(newConfig) + + if (patterns.isNotEmpty()) { + indicator2.text = "Exporting metadata..." + MetadataService.exportMetadataStatic(mainRepoRoot, ideaFilesBase, patterns) + .onFailure { ex -> + Notifications.warning(project, "Metadata Export Failed", ex.message ?: "Unknown error") + } + } + + WorktreeService.getInstance(project).refreshWorktreeList() + Notifications.info(project, "Context Re-provisioned", "Context '$contextName' re-provisioned") + } catch (ex: Exception) { + Notifications.error(project, "Context Creation Failed", ex.message ?: "Unknown error") + } + } + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ShowContextConfigAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ShowContextConfigAction.kt new file mode 100644 index 0000000..fbacec5 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/ShowContextConfigAction.kt @@ -0,0 +1,47 @@ +package com.block.wt.actions.context + +import com.block.wt.actions.WtConfigAction +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.panel +import javax.swing.SwingConstants + +class ShowContextConfigAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val config = requireConfig(e) ?: return + val project = e.project ?: return + + val symlinkTarget = PathHelper.readSymlink(config.activeWorktree) + + val content = panel { + row("Name:") { cell(JBLabel(config.name)) } + row("Main repo root:") { cell(JBLabel(config.mainRepoRoot.toString())) } + row("Active worktree:") { + val text = if (symlinkTarget != null) { + "${config.activeWorktree} -> $symlinkTarget" + } else { + config.activeWorktree.toString() + } + cell(JBLabel(text)) + } + row("Worktrees base:") { cell(JBLabel(config.worktreesBase.toString())) } + row("Metadata vault:") { cell(JBLabel(config.ideaFilesBase.toString())) } + row("Base branch:") { cell(JBLabel(config.baseBranch)) } + if (config.metadataPatterns.isNotEmpty()) { + row("Metadata patterns:") { cell(JBLabel(config.metadataPatterns.joinToString(", "))) } + } + } + + JBPopupFactory.getInstance() + .createComponentPopupBuilder(content, null) + .setTitle("Context: ${config.name}") + .setFocusable(true) + .setRequestFocus(true) + .setMovable(true) + .createPopup() + .showCenteredInCurrentWindow(project) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ExportMetadataAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ExportMetadataAction.kt new file mode 100644 index 0000000..b43c469 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ExportMetadataAction.kt @@ -0,0 +1,36 @@ +package com.block.wt.actions.metadata + +import com.block.wt.actions.WtConfigAction +import com.block.wt.services.MetadataService +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent + +class ExportMetadataAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val config = requireConfig(e) ?: return + + val source = if (PathHelper.isSymlink(config.activeWorktree)) { + val raw = PathHelper.readSymlink(config.activeWorktree) ?: config.mainRepoRoot + if (raw.isAbsolute) raw else config.activeWorktree.parent.resolve(raw).normalize() + } else { + config.mainRepoRoot + } + + runInBackground(project, "Exporting Metadata", cancellable = false) { + it.text = "Exporting metadata to vault..." + MetadataService.getInstance(project) + .exportMetadata(source, config.ideaFilesBase, config.metadataPatterns) + .fold( + onSuccess = { count -> + Notifications.info(project, "Metadata Exported", "Exported $count metadata directories to vault") + }, + onFailure = { ex -> + Notifications.error(project, "Export Failed", ex.message ?: "Unknown error") + } + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ImportMetadataAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ImportMetadataAction.kt new file mode 100644 index 0000000..a50a6a2 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/ImportMetadataAction.kt @@ -0,0 +1,36 @@ +package com.block.wt.actions.metadata + +import com.block.wt.actions.WtConfigAction +import com.block.wt.services.MetadataService +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.actionSystem.AnActionEvent + +class ImportMetadataAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val config = requireConfig(e) ?: return + + val target = if (PathHelper.isSymlink(config.activeWorktree)) { + val raw = PathHelper.readSymlink(config.activeWorktree) ?: config.activeWorktree + if (raw.isAbsolute) raw else config.activeWorktree.parent.resolve(raw).normalize() + } else { + config.activeWorktree + } + + runInBackground(project, "Importing Metadata", cancellable = false) { + it.text = "Importing metadata from vault..." + MetadataService.getInstance(project) + .importMetadata(config.ideaFilesBase, target) + .fold( + onSuccess = { count -> + Notifications.info(project, "Metadata Imported", "Imported $count metadata directories") + }, + onFailure = { ex -> + Notifications.error(project, "Import Failed", ex.message ?: "Unknown error") + } + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/RefreshBazelTargetsAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/RefreshBazelTargetsAction.kt new file mode 100644 index 0000000..31c4903 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/metadata/RefreshBazelTargetsAction.kt @@ -0,0 +1,37 @@ +package com.block.wt.actions.metadata + +import com.block.wt.actions.WtConfigAction +import com.block.wt.services.BazelService +import com.block.wt.services.MetadataService +import com.block.wt.ui.Notifications +import com.intellij.openapi.actionSystem.AnActionEvent + +class RefreshBazelTargetsAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val config = requireConfig(e) ?: return + + runInBackground(project, "Refreshing Bazel Targets") { indicator -> + indicator.text = "Refreshing Bazel targets..." + BazelService.getInstance(project) + .refreshAllBazelMetadata(config.mainRepoRoot) + .fold( + onSuccess = { count -> + if (count > 0) { + indicator.text = "Re-exporting metadata..." + MetadataService.getInstance(project) + .exportMetadata(config.mainRepoRoot, config.ideaFilesBase, config.metadataPatterns) + .onFailure { ex -> + Notifications.warning(project, "Metadata Export Failed", ex.message ?: "Unknown error") + } + } + Notifications.info(project, "Bazel Targets Refreshed", "Refreshed $count Bazel IDE directories") + }, + onFailure = { ex -> + Notifications.error(project, "Refresh Failed", ex.message ?: "Unknown error") + } + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/CopyWorktreePathAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/CopyWorktreePathAction.kt new file mode 100644 index 0000000..e1f4c22 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/CopyWorktreePathAction.kt @@ -0,0 +1,14 @@ +package com.block.wt.actions.util + +import com.block.wt.actions.WtTableAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import java.awt.datatransfer.StringSelection + +class CopyWorktreePathAction : WtTableAction() { + + override fun actionPerformed(e: AnActionEvent) { + val wt = getSelectedPanel(e)?.getSelectedWorktree() ?: return + CopyPasteManager.getInstance().setContents(StringSelection(wt.path.toString())) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/OpenInTerminalAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/OpenInTerminalAction.kt new file mode 100644 index 0000000..f697f19 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/OpenInTerminalAction.kt @@ -0,0 +1,47 @@ +package com.block.wt.actions.util + +import com.block.wt.actions.WtConfigAction +import com.block.wt.services.WorktreeService +import com.block.wt.ui.Notifications +import com.block.wt.ui.WorktreePanel +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.wm.ToolWindowManager +import org.jetbrains.plugins.terminal.TerminalToolWindowManager + +class OpenInTerminalAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + val selectedPath = e.getData(WorktreePanel.DATA_KEY)?.getSelectedWorktree()?.path + ?: run { + val worktreeService = WorktreeService.getInstance(project) + val worktrees = worktreeService.worktrees.value + worktrees.firstOrNull { it.isLinked }?.path + ?: project.basePath?.let { java.nio.file.Path.of(it) } + } + ?: return + + try { + val terminalWindow = ToolWindowManager.getInstance(project).getToolWindow("Terminal") + if (terminalWindow != null) { + terminalWindow.activate { + val projectRoot = project.basePath + if (projectRoot != null && selectedPath.toString() != projectRoot) { + try { + val manager = TerminalToolWindowManager.getInstance(project) + @Suppress("DEPRECATION") + manager.createLocalShellWidget(selectedPath.toString(), selectedPath.fileName?.toString() ?: "Terminal") + } catch (_: Throwable) { + // Fallback: just activate the terminal window (user can cd manually) + } + } + } + } else { + Notifications.warning(project, "Terminal", "Terminal tool window is not available") + } + } catch (ex: Exception) { + Notifications.error(project, "Open Terminal", "Failed to open terminal: ${ex.message}") + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RefreshWorktreeListAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RefreshWorktreeListAction.kt new file mode 100644 index 0000000..5d64307 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RefreshWorktreeListAction.kt @@ -0,0 +1,13 @@ +package com.block.wt.actions.util + +import com.block.wt.actions.WtConfigAction +import com.block.wt.services.WorktreeService +import com.intellij.openapi.actionSystem.AnActionEvent + +class RefreshWorktreeListAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + WorktreeService.getInstance(project).refreshWorktreeList() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RevealInFinderAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RevealInFinderAction.kt new file mode 100644 index 0000000..cbdc100 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/RevealInFinderAction.kt @@ -0,0 +1,18 @@ +package com.block.wt.actions.util + +import com.block.wt.actions.WtTableAction +import com.intellij.ide.actions.RevealFileAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class RevealInFinderAction : WtTableAction() { + + override fun update(e: AnActionEvent) { + super.update(e) + e.presentation.text = RevealFileAction.getActionName() + } + + override fun actionPerformed(e: AnActionEvent) { + val wt = getSelectedPanel(e)?.getSelectedWorktree() ?: return + RevealFileAction.openDirectory(wt.path) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/WelcomeAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/WelcomeAction.kt new file mode 100644 index 0000000..0fd52e6 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/util/WelcomeAction.kt @@ -0,0 +1,19 @@ +package com.block.wt.actions.util + +import com.block.wt.actions.WtAction +import com.block.wt.ui.WelcomePageHelper +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider +import com.intellij.ui.jcef.JBCefApp + +class WelcomeAction : WtAction() { + + override fun isAvailable(e: AnActionEvent): Boolean = + e.project != null && JBCefApp.isSupported() + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val html = WelcomePageHelper.buildThemedHtml() ?: return + HTMLEditorProvider.openEditor(project, "Worktree Manager Welcome", html) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/CreateWorktreeAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/CreateWorktreeAction.kt new file mode 100644 index 0000000..1974317 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/CreateWorktreeAction.kt @@ -0,0 +1,74 @@ +package com.block.wt.actions.worktree + +import com.block.wt.actions.WtConfigAction +import com.block.wt.progress.asScope +import com.block.wt.provision.ProvisionHelper +import com.block.wt.git.GitBranchHelper +import com.block.wt.services.ContextService +import com.block.wt.services.CreateWorktreeUseCase +import com.block.wt.services.WorktreeService +import com.block.wt.ui.CreateWorktreeDialog +import com.block.wt.ui.Notifications +import com.intellij.openapi.actionSystem.AnActionEvent +import java.nio.file.Path + +class CreateWorktreeAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + val dialog = CreateWorktreeDialog(project) + if (!dialog.showAndGet()) return + + val branchName = GitBranchHelper.sanitizeBranchName(dialog.branchName) + val worktreePath = Path.of(dialog.worktreePath) + val createNewBranch = dialog.createNewBranch + + runInBackground(project, "Creating Worktree") { indicator -> + val worktreeService = WorktreeService.getInstance(project) + val contextService = ContextService.getInstance(project) + val config = contextService.getCurrentConfig() + + if (createNewBranch && config != null) { + val useCase = CreateWorktreeUseCase(worktreeService, project) + useCase.runCreateNewBranchFlow( + indicator, config.mainRepoRoot, + config.baseBranch, branchName, worktreePath, config, + ) + } else { + indicator.isIndeterminate = false + val scope = indicator.asScope() + + // Step 1: Create worktree (0%–85%) — no progress signal + scope.fraction(0.0) + scope.text("Creating worktree...") + val result = worktreeService.createWorktree( + worktreePath, branchName, createNewBranch, + ) + + result.fold( + onSuccess = { + // Step 2: Provision (85%–95%) + scope.fraction(0.85) + if (config != null) { + ProvisionHelper.provisionWorktree( + project, worktreePath, config, + scope = scope.sub(0.85, 0.10), + ) + } + // Step 3: Refresh (95%–100%) + scope.fraction(0.95) + scope.text("Refreshing worktree list...") + scope.text2("") + worktreeService.refreshWorktreeList() + scope.fraction(1.0) + Notifications.info(project, "Worktree Created", "Created worktree at $worktreePath") + }, + onFailure = { + Notifications.error(project, "Create Failed", it.message ?: "Unknown error") + } + ) + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/ProvisionWorktreeAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/ProvisionWorktreeAction.kt new file mode 100644 index 0000000..ce02e01 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/ProvisionWorktreeAction.kt @@ -0,0 +1,59 @@ +package com.block.wt.actions.worktree + +import com.block.wt.actions.WtTableAction +import com.block.wt.progress.asScope +import com.block.wt.provision.ProvisionHelper +import com.block.wt.services.ContextService +import com.block.wt.services.WorktreeService +import com.block.wt.ui.Notifications +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.runBlockingCancellable + +class ProvisionWorktreeAction : WtTableAction() { + + override fun update(e: AnActionEvent) { + val project = e.project + val wt = getSelectedPanel(e)?.getSelectedWorktree() + + if (project == null || wt == null) { + e.presentation.isEnabledAndVisible = false + return + } + + e.presentation.isEnabled = !wt.isProvisionedByCurrentContext + e.presentation.isVisible = true + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val wt = getSelectedPanel(e)?.getSelectedWorktree() ?: return + + val config = ContextService.getInstance(project).getCurrentConfig() + val currentContextName = config?.name + + if (currentContextName == null) { + Notifications.error(project, "No Context", "No wt context is configured. Add a context first.") + return + } + + if (wt.isProvisionedByCurrentContext) { + Notifications.info(project, "Already Provisioned", "This worktree is already provisioned by '$currentContextName'") + return + } + + ProgressManager.getInstance().run(object : Task.Backgroundable( + project, "Provisioning Worktree", true + ) { + override fun run(indicator: com.intellij.openapi.progress.ProgressIndicator) { + indicator.isIndeterminate = false + runBlockingCancellable { + ProvisionHelper.provisionWorktree(project, wt.path, config, scope = indicator.asScope()) + WorktreeService.getInstance(project).refreshWorktreeList() + Notifications.info(project, "Worktree Provisioned", "Provisioned ${wt.displayName} for context '$currentContextName'") + } + } + }) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveMergedAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveMergedAction.kt new file mode 100644 index 0000000..8a55c41 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveMergedAction.kt @@ -0,0 +1,104 @@ +package com.block.wt.actions.worktree + +import com.block.wt.actions.WtConfigAction +import com.block.wt.progress.RemovalProgress +import com.block.wt.progress.asScope +import com.block.wt.services.WorktreeService +import com.block.wt.ui.Notifications +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.progress.runBlockingCancellable + +class RemoveMergedAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val worktreeService = WorktreeService.getInstance(project) + + runInBackground(project, "Finding Merged Worktrees") { indicator -> + indicator.text = "Checking merged branches..." + + val worktrees = worktreeService.listWorktrees() + val mergedBranches = worktreeService.getMergedBranches().toSet() + + val mergedWorktrees = worktrees.filter { wt -> + !wt.isMain && !wt.isLinked && wt.branch in mergedBranches + } + + if (mergedWorktrees.isEmpty()) { + Notifications.info(project, "No Merged Worktrees", "No worktrees with merged branches found") + return@runInBackground + } + + val dirtyWorktrees = mergedWorktrees.filter { wt -> + worktreeService.hasUncommittedChanges(wt.path) + } + + val cleanWorktrees = mergedWorktrees - dirtyWorktrees.toSet() + + val message = buildString { + append("Remove ${cleanWorktrees.size} merged worktree(s)?") + for (wt in cleanWorktrees) { + append("\n - ${wt.displayName} (${wt.shortPath})") + } + if (dirtyWorktrees.isNotEmpty()) { + append("\n\nSkipping ${dirtyWorktrees.size} dirty worktree(s):") + for (wt in dirtyWorktrees) { + append("\n - ${wt.displayName} (${wt.shortPath}) [dirty]") + } + } + } + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + val answer = Messages.showYesNoDialog( + project, message, "Remove Merged Worktrees", Messages.getWarningIcon() + ) + + if (answer == Messages.YES) { + ProgressManager.getInstance().run(object : Task.Backgroundable( + project, "Removing Merged Worktrees", false + ) { + override fun run(indicator: ProgressIndicator) { + runBlockingCancellable { + indicator.isIndeterminate = false + val scope = indicator.asScope() + var removed = 0 + var failed = 0 + + for ((i, wt) in cleanWorktrees.withIndex()) { + val wtStart = i.toDouble() / cleanWorktrees.size * 0.95 + val wtSize = 0.95 / cleanWorktrees.size + val wtScope = scope.sub(wtStart, wtSize) + + scope.text("Removing ${wt.displayName}...") + val result = RemovalProgress.removeWithProgress( + wtScope, wt.path, worktreeService, + ) + if (result.isSuccess) removed++ else failed++ + } + + scope.fraction(0.95) + scope.text("Refreshing worktree list...") + scope.text2("") + worktreeService.refreshWorktreeList() + scope.fraction(1.0) + + val msg = buildString { + append("Removed $removed worktree(s)") + if (failed > 0) append(", $failed failed") + if (dirtyWorktrees.isNotEmpty()) { + append(", ${dirtyWorktrees.size} skipped (dirty)") + } + } + Notifications.info(project, "Merged Worktrees Removed", msg) + } + } + }) + } + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveWorktreeAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveWorktreeAction.kt new file mode 100644 index 0000000..ccd961e --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/RemoveWorktreeAction.kt @@ -0,0 +1,128 @@ +package com.block.wt.actions.worktree + +import com.block.wt.actions.WtConfigAction +import com.block.wt.model.WorktreeInfo +import com.block.wt.progress.RemovalProgress +import com.block.wt.progress.asScope +import com.block.wt.services.ContextService +import com.block.wt.services.SymlinkSwitchService +import com.block.wt.services.WorktreeService +import com.block.wt.settings.WtPluginSettings +import com.block.wt.ui.Notifications +import com.block.wt.ui.WorktreePanel +import com.block.wt.util.normalizeSafe +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep + +class RemoveWorktreeAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val worktreeService = WorktreeService.getInstance(project) + + // If invoked from the table with a selected row, use that directly + val selected = e.getData(WorktreePanel.DATA_KEY)?.getSelectedWorktree() + if (selected != null && !selected.isMain) { + confirmAndRemove(project, selected, worktreeService) + return + } + + runInBackground(project, "Loading Worktrees") { + val worktrees = worktreeService.listWorktrees() + val removable = worktrees.filter { !it.isMain } + if (removable.isEmpty()) { + Notifications.info(project, "No Worktrees", "No removable worktrees found (main worktree cannot be removed)") + return@runInBackground + } + + val displayNames = removable.map { wt -> + buildString { + if (wt.isLinked) append("* ") + append(wt.displayName) + append(" (${wt.shortPath})") + } + } + + ApplicationManager.getApplication().invokeLater { + val step = object : BaseListPopupStep("Remove Worktree", displayNames) { + override fun onChosen(selectedValue: String, finalChoice: Boolean): PopupStep<*>? { + if (finalChoice) { + val index = displayNames.indexOf(selectedValue) + val wt = removable.getOrNull(index) ?: return PopupStep.FINAL_CHOICE + confirmAndRemove(project, wt, worktreeService) + } + return PopupStep.FINAL_CHOICE + } + } + + JBPopupFactory.getInstance() + .createListPopup(step) + .showCenteredInCurrentWindow(project) + } + } + } + + private fun confirmAndRemove(project: Project, wt: WorktreeInfo, worktreeService: WorktreeService) { + val config = ContextService.getInstance(project).getCurrentConfig() + if (config != null && wt.path.normalizeSafe() == config.mainRepoRoot.normalizeSafe()) { + Notifications.error(project, "Cannot Remove", "Cannot remove the main repository worktree") + return + } + + val needsConfirmation = WtPluginSettings.getInstance().state.confirmBeforeRemove || wt.isDirty == true + + if (needsConfirmation) { + val message = buildString { + append("Remove worktree '${wt.displayName}' at ${wt.path}?") + if (wt.isDirty == true) { + append("\n\nWARNING: This worktree has uncommitted changes!") + } + if (wt.isLinked) { + append("\n\nThis is the currently linked worktree. The symlink will be switched to main.") + } + } + + val answer = Messages.showYesNoDialog(project, message, "Remove Worktree", Messages.getWarningIcon()) + if (answer != Messages.YES) return + } + + runInBackground(project, "Removing Worktree", cancellable = false) { indicator -> + indicator.isIndeterminate = false + val scope = indicator.asScope() + + if (wt.isLinked && config != null) { + scope.fraction(0.0) + scope.text("Switching to main worktree...") + SymlinkSwitchService.getInstance(project).doSwitch( + config.mainRepoRoot, indicator, + scope = scope.sub(0.0, 0.05), + ) + } + + scope.fraction(0.05) + scope.text("Removing worktree...") + val force = wt.isDirty == true + val result = RemovalProgress.removeWithProgress( + scope.sub(0.05, 0.90), wt.path, worktreeService, force, + ) + + result.fold( + onSuccess = { + scope.fraction(0.95) + scope.text("Refreshing worktree list...") + worktreeService.refreshWorktreeList() + scope.fraction(1.0) + Notifications.info(project, "Worktree Removed", "Removed ${wt.displayName}") + }, + onFailure = { ex -> + Notifications.error(project, "Remove Failed", ex.message ?: "Unknown error") + }, + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/SwitchWorktreeAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/SwitchWorktreeAction.kt new file mode 100644 index 0000000..6ab1adb --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/worktree/SwitchWorktreeAction.kt @@ -0,0 +1,59 @@ +package com.block.wt.actions.worktree + +import com.block.wt.actions.WtConfigAction +import com.block.wt.provision.ProvisionSwitchHelper +import com.block.wt.services.WorktreeService +import com.block.wt.ui.WorktreePanel +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep + +class SwitchWorktreeAction : WtConfigAction() { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + // If invoked from the table with a selected row, use that directly + val selected = e.getData(WorktreePanel.DATA_KEY)?.getSelectedWorktree() + if (selected != null && !selected.isLinked) { + ProvisionSwitchHelper.switchWithProvisionPrompt(project, selected) + return + } + + val worktreeService = WorktreeService.getInstance(project) + + runInBackground(project, "Loading Worktrees") { + val worktrees = worktreeService.listWorktrees() + if (worktrees.isEmpty()) return@runInBackground + + val displayNames = worktrees.map { wt -> + buildString { + if (wt.isLinked) append("* ") + append(wt.displayName) + if (wt.isMain) append(" [main]") + } + } + + ApplicationManager.getApplication().invokeLater { + val step = object : BaseListPopupStep("Switch Worktree", displayNames) { + override fun onChosen(selectedValue: String, finalChoice: Boolean): PopupStep<*>? { + if (finalChoice) { + val index = displayNames.indexOf(selectedValue) + val wt = worktrees.getOrNull(index) ?: return PopupStep.FINAL_CHOICE + if (!wt.isLinked) { + ProvisionSwitchHelper.switchWithProvisionPrompt(project, wt) + } + } + return PopupStep.FINAL_CHOICE + } + } + + JBPopupFactory.getInstance() + .createListPopup(step) + .showCenteredInCurrentWindow(project) + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetection.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetection.kt new file mode 100644 index 0000000..bff522a --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetection.kt @@ -0,0 +1,13 @@ +package com.block.wt.agent + +import java.nio.file.Path + +/** + * Interface for detecting active Claude agent sessions. + * Production implementation uses lsof and filesystem scanning; + * test implementation returns canned responses. + */ +interface AgentDetection { + fun detectActiveAgentDirs(): Set + fun detectActiveSessionIds(worktreePath: Path): List +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetector.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetector.kt new file mode 100644 index 0000000..72f9552 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/agent/AgentDetector.kt @@ -0,0 +1,146 @@ +package com.block.wt.agent + +import com.intellij.openapi.diagnostic.Logger +import java.nio.file.Files +import java.nio.file.Path + +object AgentDetector : AgentDetection { + + private val log = Logger.getInstance(AgentDetector::class.java) + + const val CLAUDE_PROJECTS_DIR = ".claude/projects" + const val SESSION_ACTIVE_THRESHOLD_MS = 30L * 60 * 1000 // 30 minutes + private val NON_ALNUM = Regex("[^a-zA-Z0-9]") + + /** + * Detects working directories that have active Claude processes. + * Uses `/usr/sbin/lsof` to find running `claude` processes and their cwds. + * Falls back to scanning `~/.claude/projects/` for recent session files. + */ + override fun detectActiveAgentDirs(): Set { + val fromProcess = detectViaLsof() + val fromSessions = detectViaSessions() + return fromProcess + fromSessions + } + + /** + * Finds active Claude session IDs for a worktree by checking `.jsonl` transcripts + * in `~/.claude/projects//` modified within the threshold. + */ + override fun detectActiveSessionIds(worktreePath: Path): List { + return try { + val projectDir = resolveProjectDir(worktreePath) ?: return emptyList() + val cutoff = System.currentTimeMillis() - SESSION_ACTIVE_THRESHOLD_MS + + Files.list(projectDir).use { stream -> + stream + .filter { it.fileName.toString().endsWith(".jsonl") } + .filter { Files.getLastModifiedTime(it).toMillis() > cutoff } + .map { it.fileName.toString().removeSuffix(".jsonl") } + .sorted(Comparator.reverseOrder()) + .toList() + } + } catch (_: Exception) { + emptyList() + } + } + + /** + * Detects active claude process cwds via lsof. + * Uses full path `/usr/sbin/lsof` to avoid PATH issues in IntelliJ. + * Returns empty set on failure (Windows, permission denied, lsof not found, etc.) + */ + private fun detectViaLsof(): Set { + if (isWindows()) return emptySet() + + return try { + val lsofPath = if (Files.exists(Path.of("/usr/sbin/lsof"))) "/usr/sbin/lsof" + else if (Files.exists(Path.of("/usr/bin/lsof"))) "/usr/bin/lsof" + else "lsof" + + val process = ProcessBuilder(lsofPath, "-a", "-d", "cwd", "-c", "claude", "-Fn") + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().readText() + val exited = process.waitFor() + if (exited != 0) return emptySet() + + output.lines() + .filter { it.startsWith("n") && it.length > 1 } + .mapNotNull { line -> + val pathStr = line.removePrefix("n") + if (pathStr == "/") return@mapNotNull null + try { Path.of(pathStr) } catch (_: Exception) { null } + } + .toSet() + } catch (e: Exception) { + log.debug("lsof detection failed, falling back to session files", e) + emptySet() + } + } + + /** + * Scans `~/.claude/projects/` for dirs with recently-modified session files. + * Uses the correct encoding (all non-alphanumeric → `-`). + */ + private fun detectViaSessions(): Set { + return try { + val projectsDir = claudeProjectsDir() ?: return emptySet() + val cutoff = System.currentTimeMillis() - SESSION_ACTIVE_THRESHOLD_MS + + Files.list(projectsDir).use { stream -> + stream.toList() + .filter { Files.isDirectory(it) } + .filter { dir -> hasRecentSession(dir, cutoff) } + .mapNotNull { dir -> decodeDirToPath(dir.fileName.toString()) } + .toSet() + } + } catch (e: Exception) { + log.debug("Session-based detection failed", e) + emptySet() + } + } + + private fun resolveProjectDir(worktreePath: Path): Path? { + val projectsDir = claudeProjectsDir() ?: return null + val encoded = encodePath(worktreePath) + val dir = projectsDir.resolve(encoded) + return if (Files.isDirectory(dir)) dir else null + } + + private fun claudeProjectsDir(): Path? { + val dir = Path.of(System.getProperty("user.home")).resolve(CLAUDE_PROJECTS_DIR) + return if (Files.isDirectory(dir)) dir else null + } + + private fun hasRecentSession(projectDir: Path, cutoff: Long): Boolean { + return try { + Files.list(projectDir).use { stream -> + stream.anyMatch { file -> + file.fileName.toString().endsWith(".jsonl") && + Files.getLastModifiedTime(file).toMillis() > cutoff + } + } + } catch (_: Exception) { + false + } + } + + /** Claude Code encodes paths by replacing all non-alphanumeric chars with `-`. */ + private fun encodePath(path: Path): String = NON_ALNUM.replace(path.toString(), "-") + + /** Best-effort reverse of encodePath. Lossy, so we validate via round-trip. */ + private fun decodeDirToPath(encoded: String): Path? { + if (isWindows()) return null + val candidate = "/" + encoded.replace("-", "/") + return try { + val path = Path.of(candidate).normalize() + if (Files.isDirectory(path) && encodePath(path) == encoded) path else null + } catch (_: Exception) { + null + } + } + + private fun isWindows(): Boolean = System.getProperty("os.name").lowercase().contains("win") +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitBranchHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitBranchHelper.kt new file mode 100644 index 0000000..879899b --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitBranchHelper.kt @@ -0,0 +1,19 @@ +package com.block.wt.git + +import java.nio.file.Path + +object GitBranchHelper { + + fun sanitizeBranchName(name: String): String { + val trimmed = name.trim() + require(!trimmed.contains("..")) { "Branch name cannot contain '..' (path traversal)" } + require(trimmed.isNotBlank()) { "Branch name cannot be blank" } + require(!trimmed.startsWith("-")) { "Branch name cannot start with '-'" } + return trimmed + } + + fun worktreePathForBranch(worktreesBase: Path, branchName: String): Path { + val safeName = branchName.replace("/", "-") + return worktreesBase.resolve(safeName) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitConfigHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitConfigHelper.kt new file mode 100644 index 0000000..879b247 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitConfigHelper.kt @@ -0,0 +1,131 @@ +package com.block.wt.git + +import com.block.wt.model.ContextConfig +import com.block.wt.util.PathHelper +import com.block.wt.util.ProcessHelper +import java.nio.file.Path + +object GitConfigHelper { + + /** + * Reads wt.* config from git local config (.git/config). + * Uses `git config --local --get-regexp '^wt\.'` — the same command the shell CLI + * uses in `wt_read_git_config()` (PR #23). + * Returns null if wt.enabled is not true or any required key is missing (all-or-nothing). + * For linked worktrees, reads from the main repo's .git/config (shared via gitdir pointer). + */ + fun readConfig(repoOrWorktreePath: Path): ContextConfig? { + val mainGitDir = GitDirResolver.resolveMainGitDir(repoOrWorktreePath) ?: return null + val mainRepoRoot = mainGitDir.parent + + val result = ProcessHelper.runGit( + listOf("config", "--local", "--get-regexp", "^wt\\."), + workingDir = mainRepoRoot, + ) + if (!result.isSuccess) return null + + // git config lowercases keys: "wt.worktreesbase" not "wt.worktreesBase" + val values = result.stdout.lines() + .filter { it.isNotBlank() } + .associate { line -> + val spaceIdx = line.indexOf(' ') + if (spaceIdx < 0) return@associate line.lowercase() to "" + line.substring(0, spaceIdx).lowercase() to line.substring(spaceIdx + 1) + } + + if (values["wt.enabled"]?.equals("true", ignoreCase = true) != true) return null + + // All 3 required keys must be present (all-or-nothing, matching shell behavior) + val worktreesBase = values["wt.worktreesbase"] ?: return null + val ideaFilesBase = values["wt.ideafilesbase"] ?: return null + val baseBranch = values["wt.basebranch"] ?: return null + + // Context name: prefer wt.contextName from git config; fall back to dirname derivation + val name = values["wt.contextname"] + ?: mainRepoRoot.fileName?.toString() + ?.removeSuffix("-master")?.removeSuffix("-main") + ?: return null + + // Optional keys + val activeWorktreeStr = values["wt.activeworktree"] + val metadataPatternsStr = values["wt.metadatapatterns"] + + return ContextConfig( + name = name, + mainRepoRoot = mainRepoRoot, + worktreesBase = PathHelper.expandTilde(worktreesBase), + ideaFilesBase = PathHelper.expandTilde(ideaFilesBase), + baseBranch = baseBranch, + activeWorktree = activeWorktreeStr?.let { PathHelper.expandTilde(it) } + ?: mainRepoRoot, + metadataPatterns = metadataPatternsStr + ?.split(" ")?.filter { it.isNotBlank() } + ?: emptyList(), + ) + } + + /** + * Writes wt.* config to git local config (.git/config). + * Uses individual `git config --local wt. ` calls. + * Note: the shell CLI never writes to git config (read-only); only the plugin writes. + */ + fun writeConfig(repoPath: Path, config: ContextConfig) { + val mainGitDir = GitDirResolver.resolveMainGitDir(repoPath) + ?: error("Not a git repo: $repoPath") + val repoRoot = mainGitDir.parent + + fun gitSet(key: String, value: String) { + ProcessHelper.runGit(listOf("config", "--local", key, value), workingDir = repoRoot) + } + + gitSet("wt.enabled", "true") + gitSet("wt.contextName", config.name) + gitSet("wt.worktreesBase", config.worktreesBase.toString()) + gitSet("wt.ideaFilesBase", config.ideaFilesBase.toString()) + gitSet("wt.baseBranch", config.baseBranch) + gitSet("wt.activeWorktree", config.activeWorktree.toString()) + if (config.metadataPatterns.isNotEmpty()) { + gitSet("wt.metadataPatterns", config.metadataPatterns.joinToString(" ")) + } else { + // --unset may fail if key doesn't exist — that's fine + ProcessHelper.runGit( + listOf("config", "--local", "--unset", "wt.metadataPatterns"), + workingDir = repoRoot, + ) + } + } + + /** + * Removes all wt.* config keys from git local config. + * Used by re-provision and delete context actions. + */ + fun removeAllConfig(repoPath: Path) { + val mainGitDir = GitDirResolver.resolveMainGitDir(repoPath) ?: return + val repoRoot = mainGitDir.parent + val keys = listOf( + "wt.enabled", "wt.contextName", "wt.worktreesBase", "wt.ideaFilesBase", + "wt.baseBranch", "wt.activeWorktree", "wt.metadataPatterns", + ) + for (key in keys) { + // --unset-all may fail if key doesn't exist — that's fine + ProcessHelper.runGit(listOf("config", "--local", "--unset-all", key), workingDir = repoRoot) + } + } + + /** + * Quick check: is wt.enabled=true in git local config? + * Uses `git config --local --get wt.enabled`. + */ + fun isEnabled(repoOrWorktreePath: Path): Boolean { + val mainGitDir = GitDirResolver.resolveMainGitDir(repoOrWorktreePath) ?: return false + return try { + val result = ProcessHelper.runGit( + listOf("config", "--local", "--get", "wt.enabled"), + workingDir = mainGitDir.parent, + ) + result.isSuccess && result.stdout.trim().equals("true", ignoreCase = true) + } catch (_: Exception) { + false + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitDirResolver.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitDirResolver.kt new file mode 100644 index 0000000..ebc28ac --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitDirResolver.kt @@ -0,0 +1,50 @@ +package com.block.wt.git + +import java.nio.file.Files +import java.nio.file.Path + +object GitDirResolver { + + /** + * Resolves the git directory for a worktree path. + * For main worktrees this is `/.git/`, for linked worktrees it follows + * the `.git` file pointer (e.g. `gitdir: /path/to/.git/worktrees/`). + */ + fun resolveGitDir(worktreePath: Path): Path? { + val dotGit = worktreePath.resolve(".git") + return when { + Files.isDirectory(dotGit) -> dotGit + Files.isRegularFile(dotGit) -> try { + // Linked worktree: .git is a file containing "gitdir: " + val content = Files.readString(dotGit).trim() + if (!content.startsWith("gitdir: ")) return null + val gitDirStr = content.removePrefix("gitdir: ") + val gitDir = Path.of(gitDirStr) + if (gitDir.isAbsolute) gitDir else worktreePath.resolve(gitDir).normalize() + } catch (_: Exception) { + null + } + else -> null + } + } + + /** + * Resolves the main `.git` directory for a path (repo or linked worktree). + * For a main repo, returns `/.git`. + * For a linked worktree (where gitdir points to `.git/worktrees/`), + * walks up from the worktree-specific git dir to find the `.git` parent. + */ + fun resolveMainGitDir(path: Path): Path? { + val gitDir = resolveGitDir(path) ?: return null + // If it's already the main .git directory, return it + if (gitDir.fileName?.toString() == ".git") return gitDir + // For linked worktrees, gitDir is like .git/worktrees/ + // Walk up to find the .git directory + var dir = gitDir + while (dir.parent != null) { + if (dir.fileName?.toString() == ".git") return dir + dir = dir.parent + } + return null + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitParser.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitParser.kt new file mode 100644 index 0000000..336218f --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/git/GitParser.kt @@ -0,0 +1,57 @@ +package com.block.wt.git + +import com.block.wt.model.WorktreeInfo +import com.block.wt.util.normalizeSafe +import java.nio.file.Path + +object GitParser { + + fun parsePorcelainOutput(output: String, linkedWorktreePath: Path? = null): List { + if (output.isBlank()) return emptyList() + + val worktrees = mutableListOf() + val blocks = output.trim().split("\n\n") + + for ((index, block) in blocks.withIndex()) { + val lines = block.lines().filter { it.isNotBlank() } + var path: Path? = null + var head: String? = null + var branch: String? = null + var isPrunable = false + + for (line in lines) { + when { + line.startsWith("worktree ") -> path = Path.of(line.removePrefix("worktree ")) + line.startsWith("HEAD ") -> head = line.removePrefix("HEAD ") + line.startsWith("branch ") -> { + branch = line.removePrefix("branch ") + if (branch.startsWith("refs/heads/")) { + branch = branch.removePrefix("refs/heads/") + } + } + line == "detached" -> branch = null + line == "prunable" -> isPrunable = true + } + } + + if (path != null && head != null) { + val isMain = index == 0 + val isLinked = linkedWorktreePath != null && + path.normalizeSafe() == linkedWorktreePath.normalizeSafe() + + worktrees.add( + WorktreeInfo( + path = path, + branch = branch, + head = head, + isMain = isMain, + isLinked = isLinked, + isPrunable = isPrunable, + ) + ) + } + } + + return worktrees + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ContextConfig.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ContextConfig.kt new file mode 100644 index 0000000..63e4af7 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ContextConfig.kt @@ -0,0 +1,13 @@ +package com.block.wt.model + +import java.nio.file.Path + +data class ContextConfig( + val name: String, + val mainRepoRoot: Path, + val worktreesBase: Path, + val activeWorktree: Path, + val ideaFilesBase: Path, + val baseBranch: String, + val metadataPatterns: List, +) diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/MetadataPattern.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/MetadataPattern.kt new file mode 100644 index 0000000..604b0e7 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/MetadataPattern.kt @@ -0,0 +1,28 @@ +package com.block.wt.model + +data class MetadataPattern( + val name: String, + val description: String, +) { + companion object { + val KNOWN_PATTERNS: List = listOf( + MetadataPattern(".idea", "IntelliJ IDEA project settings"), + MetadataPattern(".ijwb", "IntelliJ with Bazel plugin settings"), + MetadataPattern(".aswb", "Android Studio with Bazel plugin settings"), + MetadataPattern(".clwb", "CLion with Bazel plugin settings"), + MetadataPattern(".bazelproject", "Bazel project view file"), + MetadataPattern(".xcodeproj", "Xcode project"), + MetadataPattern(".xcworkspace", "Xcode workspace"), + MetadataPattern(".swiftpm", "Swift Package Manager settings"), + MetadataPattern(".vscode", "VS Code settings"), + MetadataPattern(".bsp", "Build Server Protocol settings"), + MetadataPattern(".metals", "Metals (Scala) settings"), + MetadataPattern(".eclipse", "Eclipse project settings"), + MetadataPattern(".classpath", "Eclipse classpath file"), + MetadataPattern(".project", "Eclipse project file"), + MetadataPattern(".settings", "Eclipse workspace settings"), + ) + + val BAZEL_IDE_PATTERNS: Set = setOf(".ijwb", ".aswb", ".clwb") + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ProvisionMarker.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ProvisionMarker.kt new file mode 100644 index 0000000..2e64356 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/ProvisionMarker.kt @@ -0,0 +1,14 @@ +package com.block.wt.model + +import com.google.gson.annotations.SerializedName + +data class ProvisionMarker( + val current: String, + val provisions: List, +) + +data class ProvisionEntry( + val context: String, + @SerializedName("provisioned_at") val provisionedAt: String, + @SerializedName("provisioned_by") val provisionedBy: String, +) diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/WorktreeInfo.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/WorktreeInfo.kt new file mode 100644 index 0000000..2818cbe --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/model/WorktreeInfo.kt @@ -0,0 +1,48 @@ +package com.block.wt.model + +import com.block.wt.util.relativizeAgainst +import java.nio.file.Path + +sealed class WorktreeStatus { + data object NotLoaded : WorktreeStatus() + data class Loaded( + val staged: Int, + val modified: Int, + val untracked: Int, + val conflicts: Int, + val ahead: Int?, + val behind: Int?, + ) : WorktreeStatus() { + val isDirty: Boolean get() = staged + modified + untracked + conflicts > 0 + } +} + +data class WorktreeInfo( + val path: Path, + val branch: String?, + val head: String, + val isMain: Boolean = false, + val isLinked: Boolean = false, + val isPrunable: Boolean = false, + val status: WorktreeStatus = WorktreeStatus.NotLoaded, + val isProvisioned: Boolean = false, + val isProvisionedByCurrentContext: Boolean = false, + val activeAgentSessionIds: List = emptyList(), +) { + val hasActiveAgent: Boolean get() = activeAgentSessionIds.isNotEmpty() + + /** True if the worktree has any uncommitted changes. Null if status not loaded yet. */ + val isDirty: Boolean? + get() = when (status) { + is WorktreeStatus.NotLoaded -> null + is WorktreeStatus.Loaded -> status.isDirty + } + + val displayName: String + get() = branch ?: head.take(8) + + val shortPath: String + get() = path.fileName?.toString() ?: path.toString() + + fun relativePath(worktreesBase: Path?): String = path.relativizeAgainst(worktreesBase) +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/ProgressScope.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/ProgressScope.kt new file mode 100644 index 0000000..9430dee --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/ProgressScope.kt @@ -0,0 +1,29 @@ +package com.block.wt.progress + +import com.intellij.openapi.progress.ProgressIndicator + +/** + * Maps a sub-range of a [ProgressIndicator]'s fraction to 0.0..1.0. + * + * Example: if the overall bar is at 0.40..0.80 for "Creating worktree", + * `ProgressScope(indicator, start=0.40, size=0.40)` lets you call + * `fraction(0.5)` to set the bar to 0.60 (the midpoint of that range). + */ +class ProgressScope( + private val indicator: ProgressIndicator, + private val start: Double, + private val size: Double, +) { + fun fraction(progress: Double) { + indicator.fraction = start + progress.coerceIn(0.0, 1.0) * size + } + + fun text(value: String) { indicator.text = value } + fun text2(value: String) { indicator.text2 = value } + fun checkCanceled() { indicator.checkCanceled() } + + fun sub(subStart: Double, subSize: Double) = + ProgressScope(indicator, start + subStart * size, subSize * size) +} + +fun ProgressIndicator.asScope() = ProgressScope(this, 0.0, 1.0) diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/RemovalProgress.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/RemovalProgress.kt new file mode 100644 index 0000000..d68896e --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/progress/RemovalProgress.kt @@ -0,0 +1,61 @@ +package com.block.wt.progress + +import com.block.wt.services.WorktreeService +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.file.Files +import java.nio.file.Path + +object RemovalProgress { + + /** + * Removes a worktree while polling file count for progress. + * + * Counts files before removal, launches a polling coroutine that updates + * `scope.fraction` based on (deleted / total), then invokes `git worktree remove`. + * Poll interval is 250ms -- a compromise between responsiveness and I/O cost. + */ + suspend fun removeWithProgress( + scope: ProgressScope, + path: Path, + worktreeService: WorktreeService, + force: Boolean = false, + ): Result { + val totalFiles = countFiles(path) + if (totalFiles == 0L) { + return worktreeService.removeWorktree(path, force = force) + } + + return coroutineScope { + val monitorJob = launch { + while (isActive) { + delay(250) + val remaining = countFiles(path) + val deleted = totalFiles - remaining + if (deleted > 0) { + scope.fraction(deleted.toDouble() / totalFiles) + scope.text2("$deleted / $totalFiles files removed") + } + if (remaining == 0L) break + } + } + + val result = worktreeService.removeWorktree(path, force = force) + monitorJob.cancel() + scope.text2("") + scope.fraction(1.0) + result + } + } + + fun countFiles(dir: Path): Long { + if (!Files.isDirectory(dir)) return 0 + return try { + Files.walk(dir).use { stream -> stream.count() } + } catch (_: Exception) { + 0 + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionHelper.kt new file mode 100644 index 0000000..c0472b7 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionHelper.kt @@ -0,0 +1,81 @@ +package com.block.wt.provision + +import com.block.wt.model.ContextConfig +import com.block.wt.progress.ProgressScope +import com.block.wt.services.BazelService +import com.block.wt.services.MetadataService +import com.block.wt.ui.Notifications +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import java.nio.file.Path + +/** + * Consolidates the provision flow: write marker -> import metadata -> install Bazel symlinks. + * Collects errors and reports them instead of silently swallowing. + */ +object ProvisionHelper { + + private val log = Logger.getInstance(ProvisionHelper::class.java) + + /** + * Performs the full provision sequence for a single worktree. + * + * @param keepExistingFiles If true, only writes the provision marker without importing metadata. + * @param scope Optional progress scope for UI feedback. + */ + suspend fun provisionWorktree( + project: Project, + worktreePath: Path, + config: ContextConfig, + keepExistingFiles: Boolean = false, + scope: ProgressScope? = null, + ) { + val errors = mutableListOf() + + // Step 1: Write marker (0%–5%) + scope?.checkCanceled() + scope?.text("Writing provision marker...") + scope?.fraction(0.0) + ProvisionMarkerService.writeProvisionMarker(worktreePath, config.name).onFailure { + log.warn("Failed to write provision marker for $worktreePath", it) + errors.add("Failed to write provision marker: ${it.message}") + } + + if (!keepExistingFiles) { + // Step 2: Import metadata (5%–85%) + scope?.checkCanceled() + scope?.fraction(0.05) + scope?.text("Importing metadata...") + runCatching { + MetadataService.getInstance(project).importMetadata( + config.ideaFilesBase, worktreePath, + scope = scope?.sub(0.05, 0.80), + ) + }.onFailure { + log.warn("Metadata import failed for $worktreePath", it) + errors.add("Metadata import: ${it.message}") + } + + // Step 3: Bazel symlinks (85%–100%) + scope?.checkCanceled() + scope?.fraction(0.85) + scope?.text("Installing Bazel symlinks...") + runCatching { + BazelService.getInstance(project).installBazelSymlinks(config.mainRepoRoot, worktreePath) + }.onFailure { + log.warn("Bazel symlink install failed for $worktreePath", it) + errors.add("Bazel symlinks: ${it.message}") + } + } + + scope?.fraction(1.0) + + if (errors.isNotEmpty()) { + Notifications.warning( + project, + "Provisioning Warnings", + "Provisioned with issues:\n${errors.joinToString("\n• ", prefix = "• ")}", + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionMarkerService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionMarkerService.kt new file mode 100644 index 0000000..4977a2a --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionMarkerService.kt @@ -0,0 +1,147 @@ +package com.block.wt.provision + +import com.block.wt.git.GitDirResolver +import com.block.wt.model.ProvisionEntry +import com.block.wt.model.ProvisionMarker +import com.google.gson.GsonBuilder +import com.intellij.openapi.diagnostic.Logger +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant + +object ProvisionMarkerService { + + private val log = Logger.getInstance(ProvisionMarkerService::class.java) + + const val MARKER_FILE = "wt-provisioned" + + val IDE_METADATA_DIRS = listOf(".idea", ".ijwb", ".aswb", ".clwb", ".vscode") + + private val gson = GsonBuilder().setPrettyPrinting().create() + + /** + * Fast check whether a worktree has been provisioned by any context. + * No subprocess — reads the filesystem directly. + */ + fun isProvisioned(worktreePath: Path): Boolean { + val gitDir = GitDirResolver.resolveGitDir(worktreePath) ?: return false + return Files.exists(gitDir.resolve(MARKER_FILE)) + } + + /** + * Checks whether a worktree is currently provisioned by a specific context + * (i.e. that context is the `current` provisioner). + */ + fun isProvisionedByContext(worktreePath: Path, contextName: String): Boolean { + val marker = readProvisionMarker(worktreePath) ?: return false + return marker.current == contextName + } + + /** + * Reads and parses the provision marker file, returning null if not provisioned. + */ + fun readProvisionMarker(worktreePath: Path): ProvisionMarker? { + val gitDir = GitDirResolver.resolveGitDir(worktreePath) ?: return null + val markerPath = gitDir.resolve(MARKER_FILE) + if (!Files.exists(markerPath)) return null + + return try { + val marker = gson.fromJson(Files.readString(markerPath), ProvisionMarker::class.java) + // Gson can produce non-null Kotlin types with null values when JSON fields + // are missing or null. The null checks below are runtime-necessary despite + // Kotlin's type system saying they're redundant. + @Suppress("SENSELESS_COMPARISON") + if (marker == null || marker.current == null || marker.provisions == null) { + log.warn("Incomplete provision marker for $worktreePath") + null + } else { + marker + } + } catch (e: Exception) { + log.warn("Failed to parse provision marker for $worktreePath", e) + null + } + } + + /** + * Writes (or updates) the provision marker for a worktree. + * Sets the given context as `current` and adds/updates its entry in the provisions array. + */ + fun writeProvisionMarker(worktreePath: Path, contextName: String): Result { + val gitDir = GitDirResolver.resolveGitDir(worktreePath) + ?: return Result.failure(IllegalStateException("Cannot resolve git dir for $worktreePath")) + val markerPath = gitDir.resolve(MARKER_FILE) + + val existing = readProvisionMarker(worktreePath) + val now = Instant.now().toString() + + val provisions = (existing?.provisions?.filter { it.context != contextName } ?: emptyList()) + + ProvisionEntry(context = contextName, provisionedAt = now, provisionedBy = "intellij-plugin") + + val marker = ProvisionMarker(current = contextName, provisions = provisions) + return try { + Files.writeString(markerPath, markerToJson(marker)) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Removes the provision marker for a worktree. + * If contextName is null, deletes the entire marker file. + * If non-null, removes just that context's entry; clears current if it matched; + * if the array becomes empty, deletes the file. + */ + fun removeProvisionMarker(worktreePath: Path, contextName: String? = null): Result { + val gitDir = GitDirResolver.resolveGitDir(worktreePath) + ?: return Result.failure(IllegalStateException("Cannot resolve git dir for $worktreePath")) + val markerPath = gitDir.resolve(MARKER_FILE) + if (!Files.exists(markerPath)) return Result.success(Unit) + + if (contextName == null) { + return try { + Files.deleteIfExists(markerPath) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + val existing = readProvisionMarker(worktreePath) ?: return Result.success(Unit) + val remaining = existing.provisions.filter { it.context != contextName } + + if (remaining.isEmpty()) { + return try { + Files.deleteIfExists(markerPath) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + val newCurrent = if (existing.current == contextName) remaining.last().context else existing.current + val marker = ProvisionMarker(current = newCurrent, provisions = remaining) + + return try { + Files.writeString(markerPath, markerToJson(marker)) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Checks whether a worktree already has IDE metadata directories (e.g. .idea/, .ijwb/). + * Used to detect worktrees that were set up outside the provision flow and don't need + * a full metadata import — just the provision marker. + */ + fun hasExistingMetadata(worktreePath: Path): Boolean { + for (pattern in IDE_METADATA_DIRS) { + if (Files.isDirectory(worktreePath.resolve(pattern))) return true + } + return false + } + + private fun markerToJson(marker: ProvisionMarker): String = gson.toJson(marker) +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionSwitchHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionSwitchHelper.kt new file mode 100644 index 0000000..767ab6a --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/provision/ProvisionSwitchHelper.kt @@ -0,0 +1,137 @@ +package com.block.wt.provision + +import com.block.wt.model.WorktreeInfo +import com.block.wt.progress.asScope +import com.block.wt.services.ContextService +import com.block.wt.services.SymlinkSwitchService +import com.block.wt.settings.WtPluginSettings +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +/** + * Handles the "should we provision before switching?" flow. + * Extracted from SwitchWorktreeAction.Companion to give it a proper home + * in the provision/ package. Called from SwitchWorktreeAction and WorktreePanel. + */ +object ProvisionSwitchHelper { + + fun switchWithProvisionPrompt(project: Project, wt: WorktreeInfo) { + val settings = WtPluginSettings.getInstance().state + + // Standard confirm-before-switch check + if (settings.confirmBeforeSwitch) { + val answer = Messages.showYesNoDialog( + project, + "Switch to '${wt.displayName}'?", + "Confirm Switch", + Messages.getQuestionIcon(), + ) + if (answer != Messages.YES) return + } + + // Provision prompt: only when the worktree is not provisioned by the current context + val contextService = ContextService.getInstance(project) + val config = contextService.getCurrentConfig() + val currentContextName = config?.name + + if (settings.promptProvisionOnSwitch && currentContextName != null && !wt.isProvisionedByCurrentContext) { + val hasMetadata = ProvisionMarkerService.hasExistingMetadata(wt.path) + + if (hasMetadata) { + // Worktree already has project files — ask whether to claim them or overwrite + val options = arrayOf( + "Provision (keep files)", + "Provision (overwrite from vault)", + "Switch Only", + "Cancel", + ) + val answer = Messages.showDialog( + project, + "Worktree '${wt.displayName}' has existing project files but hasn't been " + + "provisioned by context '$currentContextName'.\n\n" + + "Keep files: mark as provisioned without changing anything.\n" + + "Overwrite: replace project files with this context's vault.", + "Provision Worktree?", + options, + 0, // default: keep files + Messages.getQuestionIcon(), + ) + + when (answer) { + 0 -> { + // Provision (keep files) — marker only, no import + ProvisionMarkerService.writeProvisionMarker(wt.path, currentContextName) + .onFailure { /* best-effort: switch anyway */ } + SymlinkSwitchService.getInstance(project).switchWorktree(wt.path) + return + } + 1 -> { + // Provision (overwrite from vault) — full import then switch + provisionAndSwitch(project, wt) + return + } + 2 -> { + // Switch Only — fall through + } + else -> return // Cancel or closed + } + } else { + // No existing metadata — standard provision prompt + val answer = Messages.showYesNoCancelDialog( + project, + "Worktree '${wt.displayName}' is not provisioned by context '$currentContextName'.\n\n" + + "Provisioning will import IDE metadata and install Bazel symlinks.", + "Provision Worktree?", + "Provision && Switch", + "Switch Only", + "Cancel", + Messages.getQuestionIcon(), + ) + + when (answer) { + Messages.YES -> { + provisionAndSwitch(project, wt) + return + } + Messages.NO -> { + // Switch Only — fall through + } + else -> return // Cancel + } + } + } + + SymlinkSwitchService.getInstance(project).switchWorktree(wt.path) + } + + private fun provisionAndSwitch(project: Project, wt: WorktreeInfo) { + val config = ContextService.getInstance(project).getCurrentConfig() ?: return + + ProgressManager.getInstance().run(object : Task.Backgroundable( + project, "Provisioning & Switching Worktree", true + ) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = false + val scope = indicator.asScope() + + runBlockingCancellable { + ProvisionHelper.provisionWorktree( + project, wt.path, config, + scope = scope.sub(0.0, 0.40), + ) + + scope.fraction(0.40) + SymlinkSwitchService.getInstance(project).doSwitch( + wt.path, indicator, + scope = scope.sub(0.40, 0.60), + ) + scope.fraction(1.0) + } + } + }) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/BazelService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/BazelService.kt new file mode 100644 index 0000000..a13b6b2 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/BazelService.kt @@ -0,0 +1,157 @@ +package com.block.wt.services + +import com.block.wt.model.MetadataPattern +import com.block.wt.util.ProcessHelper +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +@Service(Service.Level.PROJECT) +class BazelService( + private val project: Project, + private val cs: CoroutineScope, +) { + private val log = Logger.getInstance(BazelService::class.java) + + companion object { + val BAZEL_SYMLINKS = listOf("bazel-out", "bazel-bin", "bazel-testlogs", "bazel-genfiles") + + fun getInstance(project: Project): BazelService = project.service() + } + + suspend fun installBazelSymlinks( + mainRepo: Path, + worktree: Path, + ): Result = withContext(Dispatchers.IO) { + try { + var count = 0 + for (name in BAZEL_SYMLINKS) { + val mainLink = mainRepo.resolve(name) + if (!Files.isSymbolicLink(mainLink)) continue + + val target = Files.readSymbolicLink(mainLink) + val worktreeLink = worktree.resolve(name) + + if (Files.exists(worktreeLink) || Files.isSymbolicLink(worktreeLink)) { + Files.delete(worktreeLink) + } + + Files.createSymbolicLink(worktreeLink, target) + count++ + log.info("Installed Bazel symlink: $name -> $target") + } + Result.success(count) + } catch (e: Exception) { + log.error("Failed to install Bazel symlinks", e) + Result.failure(e) + } + } + + suspend fun refreshTargets(bazelDir: Path): Result = withContext(Dispatchers.IO) { + try { + val projectViewFile = findProjectViewFile(bazelDir) ?: run { + log.info("No .bazelproject file found in $bazelDir") + return@withContext Result.success(null) + } + + val directories = parseDirectoriesSection(projectViewFile) + if (directories.isEmpty()) { + log.info("No directories found in $projectViewFile") + return@withContext Result.success(null) + } + + val queryExpr = directories.joinToString(" + ") { "//\$it/..." } + val fullQuery = "kind('.*', $queryExpr)" + + val workingDir = bazelDir.parent ?: bazelDir + val result = ProcessHelper.run( + listOf("bazel", "query", fullQuery, "--output=label", "--keep_going"), + workingDir = workingDir, + timeoutSeconds = 300, + ) + + if (!result.isSuccess && result.stdout.isBlank()) { + return@withContext Result.failure(RuntimeException(result.stderr)) + } + + // Write targets file + val targetsDir = bazelDir.resolve("targets") + Files.createDirectories(targetsDir) + + val existingTargetsFile = Files.list(targetsDir).use { stream -> + stream.filter { it.fileName.toString().startsWith("targets-") } + .findFirst() + .orElse(null) + } + + val outputFile = existingTargetsFile ?: targetsDir.resolve("targets-${bazelDir.fileName}") + val sortedTargets = result.stdout.lines() + .filter { it.isNotBlank() } + .sorted() + .joinToString("\n") + + Files.writeString(outputFile, sortedTargets + "\n") + log.info("Wrote ${sortedTargets.lines().size} targets to $outputFile") + + Result.success(outputFile) + } catch (e: Exception) { + log.error("Failed to refresh Bazel targets", e) + Result.failure(e) + } + } + + suspend fun refreshAllBazelMetadata(repoPath: Path): Result = withContext(Dispatchers.IO) { + try { + var count = 0 + for (pattern in MetadataPattern.BAZEL_IDE_PATTERNS) { + val bazelDir = repoPath.resolve(pattern) + if (bazelDir.exists()) { + val result = refreshTargets(bazelDir) + result.fold( + onSuccess = { path -> if (path != null) count++ }, + onFailure = { log.warn("Failed to refresh targets in $bazelDir", it) }, + ) + } + } + Result.success(count) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun findProjectViewFile(bazelDir: Path): Path? { + val projectView = bazelDir.resolve(".bazelproject") + return if (projectView.exists()) projectView else null + } + + internal fun parseDirectoriesSection(projectViewFile: Path): List { + val lines = projectViewFile.readText().lines() + val directories = mutableListOf() + var inDirectoriesSection = false + + for (line in lines) { + val trimmed = line.trim() + when { + trimmed == "directories:" -> inDirectoriesSection = true + inDirectoriesSection && trimmed.isBlank() -> continue + inDirectoriesSection && !trimmed.startsWith("#") && !trimmed.startsWith("-") -> { + if (trimmed.endsWith(":")) { + // New section started + break + } + directories.add(trimmed) + } + } + } + + return directories + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt new file mode 100644 index 0000000..35c2ec7 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt @@ -0,0 +1,114 @@ +package com.block.wt.services + +import com.block.wt.git.GitConfigHelper +import com.block.wt.git.GitDirResolver +import com.block.wt.model.ContextConfig +import com.block.wt.util.ConfigFileHelper +import com.block.wt.util.PathHelper +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.nio.file.Path + +@Service(Service.Level.PROJECT) +class ContextService( + private val project: Project, + private val cs: CoroutineScope, +) { + private val log = Logger.getInstance(ContextService::class.java) + + private val _contexts = MutableStateFlow>(emptyList()) + val contexts: StateFlow> = _contexts.asStateFlow() + + private val _config = MutableStateFlow(null) + val config: StateFlow = _config.asStateFlow() + + fun initialize() { + reload() + } + + fun reload() { + _contexts.value = ConfigFileHelper.listConfigFiles().mapNotNull { ConfigFileHelper.readConfig(it) } + val detected = detectContext() + _config.value = detected + } + + fun getCurrentConfig(): ContextConfig? = _config.value + + fun addContext(config: ContextConfig) { + // Primary: write to git local config + runCatching { GitConfigHelper.writeConfig(config.mainRepoRoot, config) } + .onFailure { log.warn("Failed to write git local config: ${it.message}") } + + // Backward compat: write .conf file + val confFile = PathHelper.reposDir.resolve("${config.name}.conf") + ConfigFileHelper.writeConfig(confFile, config) + + // Write ~/.wt/current only when creating a new active context (not re-provisioning a different one) + val currentActive = getCurrentConfig()?.name + if (currentActive == null || currentActive == config.name) { + runCatching { ConfigFileHelper.writeCurrentContext(config.name) } + .onFailure { log.warn("Failed to write ~/.wt/current: ${it.message}") } + } + + reload() + } + + /** + * Detects context: tries git local config first, falls back to .conf file matching. + */ + private fun detectContext(): ContextConfig? { + val projectPath = project.basePath?.let { Path.of(it) } ?: return null + + // Try git local config first (from PR #23 convention) + val gitConfig = GitConfigHelper.readConfig(projectPath) + if (gitConfig != null) { + log.info("Auto-detected context '${gitConfig.name}' from git config for $projectPath") + return gitConfig + } + + // Fallback: match against .conf files + val repoRoot = findGitRepoRoot(projectPath) ?: return null + return detectFromConfFiles(repoRoot) + } + + /** + * Matches repo root against loaded .conf file configs. + */ + private fun detectFromConfFiles(repoRoot: Path): ContextConfig? { + val repoRootPaths = resolvePaths(repoRoot) + + return _contexts.value.firstOrNull { config -> + val mainRootPaths = resolvePaths(config.mainRepoRoot) + repoRootPaths.any { it in mainRootPaths } + }?.also { log.info("Auto-detected context '${it.name}' from .conf for project") } + } + + /** + * Walks up from the given path to find the git repo root. + * Delegates to GitDirResolver for .git resolution. + */ + private fun findGitRepoRoot(startPath: Path): Path? { + var current = startPath.normalize() + while (true) { + val mainGitDir = GitDirResolver.resolveMainGitDir(current) + if (mainGitDir != null) return mainGitDir.parent + current = current.parent ?: break + } + return null + } + + private fun resolvePaths(path: Path): Set = buildSet { + add(path.normalize()) + runCatching { path.toRealPath() }.onSuccess { add(it) } + } + + companion object { + fun getInstance(project: Project): ContextService = project.service() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/CreateWorktreeUseCase.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/CreateWorktreeUseCase.kt new file mode 100644 index 0000000..d3f0d56 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/CreateWorktreeUseCase.kt @@ -0,0 +1,90 @@ +package com.block.wt.services + +import com.block.wt.progress.asScope +import com.block.wt.provision.ProvisionHelper +import com.block.wt.model.ContextConfig +import com.block.wt.ui.Notifications +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import java.nio.file.Path + +class CreateWorktreeUseCase( + private val worktreeService: WorktreeService, + private val project: Project, +) { + suspend fun runCreateNewBranchFlow( + indicator: ProgressIndicator, + mainRepoRoot: Path, + baseBranch: String, + branchName: String, + worktreePath: Path, + config: ContextConfig, + ) { + val scope = indicator.asScope() + val stashName = "wta-${System.currentTimeMillis()}-${ProcessHandle.current().pid()}" + + val origBranch = worktreeService.getCurrentBranch(mainRepoRoot) + ?: worktreeService.getCurrentRevision(mainRepoRoot) + ?: "HEAD" + + try { + indicator.isIndeterminate = false + + // Step 1: Stash (0%–2%) + scope.checkCanceled() + scope.fraction(0.0) + scope.text("Stashing uncommitted changes...") + if (worktreeService.hasUncommittedChanges(mainRepoRoot)) { + worktreeService.stashSave(mainRepoRoot, stashName) + } + + // Step 2: Checkout (2%–5%) + scope.checkCanceled() + scope.fraction(0.02) + scope.text("Checking out $baseBranch...") + worktreeService.checkout(mainRepoRoot, baseBranch) + + // Step 3: Pull (5%–45%) — git streams progress via stderr + scope.checkCanceled() + scope.fraction(0.05) + scope.text("Pulling latest changes...") + val pullScope = scope.sub(0.05, 0.40) + worktreeService.pullFfOnly(mainRepoRoot) { gitPct -> + pullScope.fraction(gitPct) + } + + // Step 4: Create worktree (45%–85%) — no progress signal, bar sits at 45% + scope.checkCanceled() + scope.fraction(0.45) + scope.text("Creating worktree...") + val result = worktreeService.createWorktree( + worktreePath, branchName, createNewBranch = true, + ) + result.getOrThrow() + + // Step 5: Provision (85%–95%) + scope.fraction(0.85) + ProvisionHelper.provisionWorktree( + project, worktreePath, config, + scope = scope.sub(0.85, 0.10), + ) + + // Step 6: Refresh (95%–100%) + scope.fraction(0.95) + scope.text("Refreshing worktree list...") + scope.text2("") + worktreeService.refreshWorktreeList() + scope.fraction(1.0) + Notifications.info(project, "Worktree Created", "Created worktree '$branchName' at $worktreePath") + } catch (e: Exception) { + Notifications.error(project, "Create Failed", e.message ?: "Unknown error") + } finally { + scope.text2("") + scope.text("Restoring original state...") + val checkoutOk = runCatching { worktreeService.checkout(mainRepoRoot, origBranch) }.isSuccess + if (checkoutOk) { + runCatching { worktreeService.stashPop(mainRepoRoot, stashName) } + } + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ExternalChangeWatcher.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ExternalChangeWatcher.kt new file mode 100644 index 0000000..f1ac19e --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ExternalChangeWatcher.kt @@ -0,0 +1,246 @@ +package com.block.wt.services + +import com.block.wt.model.ContextConfig +import com.block.wt.settings.WtPluginSettings +import com.block.wt.util.PathHelper +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchService + +@Service(Service.Level.PROJECT) +class ExternalChangeWatcher( + private val project: Project, + private val cs: CoroutineScope, +) : Disposable { + private val log = Logger.getInstance(ExternalChangeWatcher::class.java) + private var watchService: WatchService? = null + private var watchJob: Job? = null + private var debounceJob: Job? = null + private var lastWatchState: WatchState = WatchState.EMPTY + + fun startWatching() { + stopWatching() + + watchJob = cs.launch(Dispatchers.IO) { + try { + val ws = FileSystems.getDefault().newWatchService() + watchService = ws + + val pathsToWatch = mutableListOf() + + // Watch ~/.wt/ for `current` file changes (shell context switch) + val wtRoot = PathHelper.wtRoot + if (Files.isDirectory(wtRoot)) { + wtRoot.register( + ws, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, + ) + pathsToWatch.add(wtRoot) + } + + // Watch ~/.wt/repos/ for .conf file changes (NIO watches are not recursive) + val reposDir = PathHelper.reposDir + if (Files.isDirectory(reposDir)) { + reposDir.register( + ws, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, + ) + pathsToWatch.add(reposDir) + } + + // Watch the symlink's parent directory for symlink changes + val config = ContextService.getInstance(project).getCurrentConfig() + val symlinkParent = config?.activeWorktree?.parent + if (symlinkParent != null && Files.isDirectory(symlinkParent)) { + symlinkParent.register( + ws, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, + ) + pathsToWatch.add(symlinkParent) + } + + // Watch .git/worktrees/ for new/deleted linked worktrees + val mainRepoRoot = config?.mainRepoRoot + val gitWorktreesDir = mainRepoRoot?.resolve(".git/worktrees") + if (gitWorktreesDir != null && Files.isDirectory(gitWorktreesDir)) { + gitWorktreesDir.register( + ws, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + ) + pathsToWatch.add(gitWorktreesDir) + } + + // Watch .git/ for config file changes (external git config edits to wt.* keys) + val gitDir = mainRepoRoot?.resolve(".git") + if (gitDir != null && Files.isDirectory(gitDir)) { + gitDir.register( + ws, + StandardWatchEventKinds.ENTRY_MODIFY, + ) + pathsToWatch.add(gitDir) + } + + lastWatchState = buildWatchState(config, pathsToWatch.toSet()) + log.info("Watching for external changes: $pathsToWatch") + + // Note: `config` is intentionally captured once per watch session. Context-changing + // events always go through `.git/config` or `~/.wt/current` writes, which are detected + // without referencing `config`. When state changes, `debouncedRefresh()` detects the + // WatchState mismatch and re-registers watches with the updated config. + while (true) { + val key = ws.take() + var relevant = false + + val watchedPath = key.watchable() as? Path + + for (event in key.pollEvents()) { + val context = event.context() as? Path ?: continue + val fileName = context.fileName?.toString() ?: continue + + if (isRelevantEvent(fileName, watchedPath, config)) { + relevant = true + } + } + + if (!key.reset()) { + log.info("Watch key invalidated for ${key.watchable()}, re-registering watches") + startWatching() + return@launch + } + + if (relevant) { + debouncedRefresh() + } + } + } catch (_: java.nio.file.ClosedWatchServiceException) { + // Normal shutdown + } catch (e: Exception) { + log.warn("File watcher error", e) + } + } + } + + private fun debouncedRefresh() { + debounceJob?.cancel() + debounceJob = cs.launch { + delay(WtPluginSettings.getInstance().state.debounceDelayMs) + log.info("External change detected, refreshing") + ContextService.getInstance(project).reload() + WorktreeService.getInstance(project).refreshWorktreeList() + + // Re-register watches if context-derived state changed + val config = ContextService.getInstance(project).getCurrentConfig() + val currentState = buildWatchState(config) + if (currentState != lastWatchState) { + log.info("Watch state changed, re-registering watches") + startWatching() + } + } + } + + fun stopWatching() { + watchJob?.cancel() + watchJob = null + debounceJob?.cancel() + debounceJob = null + runCatching { watchService?.close() } + watchService = null + } + + override fun dispose() { + stopWatching() + } + + /** + * Immutable snapshot of the state that determines which paths we watch and which + * events we filter. When any field changes, watches must be re-registered. + */ + data class WatchState( + val paths: Set, + val activeWorktreeFileName: String?, + ) { + companion object { + val EMPTY = WatchState(emptySet(), null) + } + } + + companion object { + fun getInstance(project: Project): ExternalChangeWatcher = project.service() + + /** + * Determines whether a file-system event is relevant to the wt plugin. + * Pure function — extracted for testability. + * + * @param fileName the name of the changed file (event context) + * @param watchedPath the directory that emitted the event + * @param config the current context config (may be null) + */ + internal fun isRelevantEvent( + fileName: String, + watchedPath: Path?, + config: ContextConfig?, + ): Boolean { + // .conf file changes in ~/.wt/repos/ (scoped to reposDir to avoid spurious matches in .git/) + if (fileName.endsWith(".conf") && watchedPath == PathHelper.reposDir) return true + + // ~/.wt/current changed — shell context switch + if (fileName == "current" && watchedPath == PathHelper.wtRoot) return true + + // Active worktree symlink changed (scoped to its parent directory) + if (fileName == config?.activeWorktree?.fileName?.toString() + && watchedPath == config?.activeWorktree?.parent) return true + + // .git/worktrees/ changes — worktree created/removed + val gitWorktreesDir = config?.mainRepoRoot?.resolve(".git/worktrees") + if (watchedPath != null && gitWorktreesDir != null && watchedPath == gitWorktreesDir) { + return true + } + + // .git/config modified — may contain wt.* key changes + val gitDir = config?.mainRepoRoot?.resolve(".git") + if (watchedPath != null && gitDir != null && watchedPath == gitDir && fileName == "config") { + return true + } + + return false + } + + /** + * Builds the watch state from the current config. + * When called from debouncedRefresh(), applies the same existence guards + * used by startWatching() so the path sets are comparable. + */ + internal fun buildWatchState( + config: ContextConfig?, + existingPaths: Set? = null, + ): WatchState { + val paths = existingPaths ?: buildSet { + if (Files.isDirectory(PathHelper.wtRoot)) add(PathHelper.wtRoot) + if (Files.isDirectory(PathHelper.reposDir)) add(PathHelper.reposDir) + config?.activeWorktree?.parent?.let { if (Files.isDirectory(it)) add(it) } + config?.mainRepoRoot?.resolve(".git/worktrees")?.let { if (Files.isDirectory(it)) add(it) } + config?.mainRepoRoot?.resolve(".git")?.let { if (Files.isDirectory(it)) add(it) } + } + return WatchState( + paths = paths, + activeWorktreeFileName = config?.activeWorktree?.fileName?.toString(), + ) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/GitClient.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/GitClient.kt new file mode 100644 index 0000000..09ceced --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/GitClient.kt @@ -0,0 +1,142 @@ +package com.block.wt.services + +import com.block.wt.util.ProcessHelper +import com.block.wt.util.ProcessRunner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path + +class GitClient(private val processRunner: ProcessRunner) { + + suspend fun createWorktree( + repoRoot: Path, + path: Path, + branch: String, + createNewBranch: Boolean = false, + onProgress: ((Double) -> Unit)? = null, + ): Result = withContext(Dispatchers.IO) { + val args = mutableListOf("worktree", "add") + if (createNewBranch) { + args.add("-b") + args.add(branch) + args.add(path.toString()) + } else { + args.add(path.toString()) + args.add(branch) + } + + val result = if (onProgress != null && processRunner is ProcessHelper) { + processRunner.runGitWithProgress(args, workingDir = repoRoot, onProgress = onProgress) + } else { + processRunner.runGit(args, workingDir = repoRoot) + } + if (result.isSuccess) { + Result.success(Unit) + } else { + Result.failure(RuntimeException(result.stderr.ifBlank { "Failed to create worktree" })) + } + } + + suspend fun removeWorktree(repoRoot: Path, path: Path, force: Boolean = false): Result = + withContext(Dispatchers.IO) { + val args = mutableListOf("worktree", "remove") + if (force) args.add("--force") + args.add(path.toString()) + + val result = processRunner.runGit(args, workingDir = repoRoot) + if (result.isSuccess) { + Result.success(Unit) + } else { + Result.failure(RuntimeException(result.stderr.ifBlank { "Failed to remove worktree" })) + } + } + + suspend fun getMergedBranches(repoRoot: Path, baseBranch: String): List = + withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("branch", "--merged", baseBranch), + workingDir = repoRoot, + ) + if (!result.isSuccess) return@withContext emptyList() + + result.stdout.lines() + .map { it.trim().removePrefix("* ") } + .filter { it.isNotBlank() && it != baseBranch } + } + + suspend fun hasUncommittedChanges(worktreePath: Path): Boolean = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("status", "--porcelain"), + workingDir = worktreePath, + ) + result.isSuccess && result.stdout.isNotBlank() + } + + suspend fun stashSave(worktreePath: Path, name: String): Result = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("stash", "push", "-m", name), + workingDir = worktreePath, + ) + if (result.isSuccess) Result.success(Unit) + else Result.failure(RuntimeException(result.stderr)) + } + + suspend fun stashPop(worktreePath: Path, name: String): Result = withContext(Dispatchers.IO) { + val listResult = processRunner.runGit( + listOf("stash", "list"), + workingDir = worktreePath, + ) + if (!listResult.isSuccess) return@withContext Result.failure(RuntimeException(listResult.stderr)) + + val stashIndex = listResult.stdout.lines() + .indexOfFirst { it.contains(name) } + + if (stashIndex < 0) return@withContext Result.success(Unit) + + val result = processRunner.runGit( + listOf("stash", "pop", "stash@{$stashIndex}"), + workingDir = worktreePath, + ) + if (result.isSuccess) Result.success(Unit) + else Result.failure(RuntimeException(result.stderr)) + } + + suspend fun checkout(worktreePath: Path, branchOrRev: String): Result = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("checkout", branchOrRev), + workingDir = worktreePath, + ) + if (result.isSuccess) Result.success(Unit) + else Result.failure(RuntimeException(result.stderr)) + } + + suspend fun pullFfOnly( + worktreePath: Path, + onProgress: ((Double) -> Unit)? = null, + ): Result = withContext(Dispatchers.IO) { + val args = listOf("pull", "--ff-only", "--progress") + val result = if (onProgress != null && processRunner is ProcessHelper) { + processRunner.runGitWithProgress(args, workingDir = worktreePath, onProgress = onProgress) + } else { + processRunner.runGit(args, workingDir = worktreePath) + } + if (result.isSuccess) Result.success(Unit) + else Result.failure(RuntimeException(result.stderr)) + } + + suspend fun getCurrentBranch(worktreePath: Path): String? = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("rev-parse", "--abbrev-ref", "HEAD"), + workingDir = worktreePath, + ) + if (result.isSuccess) result.stdout.trim().ifBlank { null } else null + } + + suspend fun getCurrentRevision(worktreePath: Path): String? = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("rev-parse", "HEAD"), + workingDir = worktreePath, + ) + if (result.isSuccess) result.stdout.trim().ifBlank { null } else null + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/MetadataService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/MetadataService.kt new file mode 100644 index 0000000..df2ab75 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/MetadataService.kt @@ -0,0 +1,195 @@ +package com.block.wt.services + +import com.block.wt.model.MetadataPattern +import com.block.wt.progress.ProgressScope +import com.block.wt.util.PathHelper +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet + +@Service(Service.Level.PROJECT) +class MetadataService( + private val project: Project, + private val cs: CoroutineScope, +) { + private val log = Logger.getInstance(MetadataService::class.java) + + suspend fun exportMetadata( + source: Path, + vault: Path, + patterns: List, + ): Result = withContext(Dispatchers.IO) { + exportMetadataStatic(source, vault, patterns) + } + + suspend fun importMetadata( + vault: Path, + target: Path, + scope: ProgressScope? = null, + ): Result = withContext(Dispatchers.IO) { + try { + if (!Files.isDirectory(vault)) { + return@withContext Result.failure( + IllegalArgumentException("Vault directory does not exist: $vault") + ) + } + + val entries = Files.list(vault).use { it.toList() } + val total = entries.size + var count = 0 + + for ((i, entry) in entries.withIndex()) { + scope?.fraction(i.toDouble() / total.coerceAtLeast(1)) + scope?.text2("${i + 1} / $total directories") + + val realPath = if (Files.isSymbolicLink(entry)) { + try { + entry.toRealPath() + } catch (_: Exception) { + log.warn("Broken symlink in vault: $entry, cleaning up") + Files.deleteIfExists(entry) + continue + } + } else { + entry + } + + if (!Files.isDirectory(realPath)) continue + + val targetDir = target.resolve(entry.fileName) + copyDirectory(realPath, targetDir) + count++ + log.info("Imported metadata: ${entry.fileName}") + } + + scope?.text2("") + scope?.fraction(1.0) + Result.success(count) + } catch (e: Exception) { + log.error("Failed to import metadata", e) + Result.failure(e) + } + } + + fun detectPatterns(repoPath: Path): List { + val found = mutableListOf() + for (pattern in MetadataPattern.KNOWN_PATTERNS) { + val candidate = repoPath.resolve(pattern.name) + if (Files.exists(candidate)) { + found.add(pattern.name) + } + } + return found + } + + internal fun deduplicateNested(paths: List): List = deduplicateNestedStatic(paths) + + private fun copyDirectory(source: Path, target: Path) { + // FOLLOW_LINKS so vault symlinks are resolved to actual content + Files.walkFileTree(source, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, + object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + val targetDir = target.resolve(source.relativize(dir)) + Files.createDirectories(targetDir) + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + val targetFile = target.resolve(source.relativize(file)) + try { + Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING) + } catch (e: java.nio.file.NoSuchFileException) { + log.warn("Skipping broken symlink during copy: $file") + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: java.io.IOException): FileVisitResult { + log.warn("Failed to access file during copy, skipping: $file (${exc.message})") + return FileVisitResult.CONTINUE + } + }) + } + + companion object { + private val staticLog = Logger.getInstance(MetadataService::class.java) + + fun getInstance(project: Project): MetadataService = project.service() + + fun exportMetadataStatic( + source: Path, + vault: Path, + patterns: List, + ): Result { + return try { + Files.createDirectories(vault) + + val foundPaths = findMetadataDirsStatic(source, patterns) + val deduplicated = deduplicateNestedStatic(foundPaths) + + var count = 0 + for (metaPath in deduplicated) { + val relative = source.relativize(metaPath) + val vaultLink = vault.resolve(relative) + Files.createDirectories(vaultLink.parent) + + if (Files.isSymbolicLink(vaultLink)) { + Files.delete(vaultLink) + } + + Files.createSymbolicLink(vaultLink, metaPath) + count++ + staticLog.info("Exported metadata: vault/$relative -> $metaPath") + } + + Result.success(count) + } catch (e: Exception) { + staticLog.error("Failed to export metadata", e) + Result.failure(e) + } + } + + private fun findMetadataDirsStatic( + source: Path, + patterns: List, + maxDepth: Int = 5, + ): List { + val found = mutableListOf() + Files.walkFileTree(source, emptySet(), maxDepth, object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + val name = dir.fileName?.toString() ?: return FileVisitResult.CONTINUE + if (name in patterns) { + found.add(dir) + return FileVisitResult.SKIP_SUBTREE + } + return FileVisitResult.CONTINUE + } + }) + return found + } + + internal fun deduplicateNestedStatic(paths: List): List { + val sorted = paths.sortedBy { it.nameCount } + val kept = mutableListOf() + for (path in sorted) { + val isNested = kept.any { path.startsWith(it) } + if (!isNested) { + kept.add(path) + } + } + return kept + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/SymlinkSwitchService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/SymlinkSwitchService.kt new file mode 100644 index 0000000..a6eff61 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/SymlinkSwitchService.kt @@ -0,0 +1,142 @@ +package com.block.wt.services + +import com.block.wt.progress.ProgressScope +import com.block.wt.progress.asScope +import com.block.wt.ui.Notifications +import com.block.wt.util.PathHelper +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.VcsDirtyScopeManager +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFileManager +import git4idea.repo.GitRepositoryManager +import com.intellij.openapi.progress.runBlockingCancellable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path + +@Service(Service.Level.PROJECT) +class SymlinkSwitchService( + private val project: Project, + private val cs: CoroutineScope, +) { + fun switchWorktree(newTarget: Path) { + ProgressManager.getInstance().run(object : Task.Backgroundable( + project, "Switching Worktree", false + ) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = false + val scope = indicator.asScope() + runBlockingCancellable { doSwitch(newTarget, indicator, scope) } + } + }) + } + + suspend fun doSwitch( + newTarget: Path, + indicator: ProgressIndicator? = null, + scope: ProgressScope? = null, + ) { + val contextService = ContextService.getInstance(project) + val config = contextService.getCurrentConfig() + if (config == null) { + Notifications.error(project, "No Context", "No active wt context detected for this project") + return + } + + val symlinkPath = config.activeWorktree + + val app = ApplicationManager.getApplication() + + try { + // Phase 1: Save documents (0%–10%) + scope?.fraction(0.0) + scope?.text("Saving documents...") + indicator?.text = "Saving documents..." + app.invokeAndWait({ + WriteAction.run { + FileDocumentManager.getInstance().saveAllDocuments() + } + }, ModalityState.defaultModalityState()) + + // Phase 2: Atomic symlink swap (10%–20%) + scope?.fraction(0.10) + scope?.text("Swapping symlink...") + indicator?.text = "Swapping symlink..." + withContext(Dispatchers.IO) { + PathHelper.atomicSetSymlink(symlinkPath, newTarget) + } + + // Phase 3: Reload editors (20%–40%) + scope?.fraction(0.20) + scope?.text("Reloading editors...") + indicator?.text = "Reloading editors..." + app.invokeAndWait({ + WriteAction.run { + val fdm = FileDocumentManager.getInstance() + for (openFile in FileEditorManager.getInstance(project).openFiles) { + val doc = fdm.getCachedDocument(openFile) ?: continue + fdm.reloadFromDisk(doc) + } + } + }, ModalityState.defaultModalityState()) + + // Phase 4: VFS refresh (40%–65%) + // Re-resolve projectRoot AFTER symlink swap so VFS picks up the new target + scope?.fraction(0.40) + scope?.text("Refreshing file system...") + indicator?.text = "Refreshing file system..." + val projectRoot = project.basePath?.let { VfsUtil.findFileByIoFile(java.io.File(it), true) } + if (projectRoot != null) { + projectRoot.refresh(false, true) + VfsUtil.markDirtyAndRefresh(false, true, true, projectRoot) + } + VirtualFileManager.getInstance().asyncRefresh {} + + // Phase 5: Git state (65%–85%) + scope?.fraction(0.65) + scope?.text("Updating git state...") + indicator?.text = "Updating git state..." + withContext(Dispatchers.IO) { + val repos = GitRepositoryManager.getInstance(project).repositories + for (repo in repos) { + repo.update() + } + } + VcsDirtyScopeManager.getInstance(project).markEverythingDirty() + + // Phase 6: Refresh list (85%–100%) + scope?.fraction(0.85) + scope?.text("Refreshing worktree list...") + indicator?.text = "Refreshing worktree list..." + WorktreeService.getInstance(project).refreshWorktreeList() + scope?.fraction(1.0) + + Notifications.info( + project, + "Worktree Switched", + "Switched to ${newTarget.fileName}", + ) + } catch (e: Exception) { + Notifications.error( + project, + "Switch Failed", + "Failed to switch worktree: ${e.message}", + ) + } + } + + companion object { + fun getInstance(project: Project): SymlinkSwitchService = project.service() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeEnricher.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeEnricher.kt new file mode 100644 index 0000000..334df73 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeEnricher.kt @@ -0,0 +1,46 @@ +package com.block.wt.services + +import com.block.wt.provision.ProvisionMarkerService +import com.block.wt.agent.AgentDetection +import com.block.wt.model.WorktreeInfo +import com.block.wt.util.normalizeSafe + +interface WorktreeEnricher { + fun enrich(worktrees: List): List +} + +class ProvisionStatusEnricher(private val contextService: ContextService) : WorktreeEnricher { + override fun enrich(worktrees: List): List { + val currentContextName = contextService.getCurrentConfig()?.name + return worktrees.map { wt -> + val provisioned = ProvisionMarkerService.isProvisioned(wt.path) + val provisionedByCtx = if (provisioned && currentContextName != null) { + ProvisionMarkerService.isProvisionedByContext(wt.path, currentContextName) + } else false + wt.copy(isProvisioned = provisioned, isProvisionedByCurrentContext = provisionedByCtx) + } + } +} + +class AgentStatusEnricher(private val agentDetection: AgentDetection) : WorktreeEnricher { + override fun enrich(worktrees: List): List { + // Process detection (lsof) catches running-but-idle agents; + // session file check catches recently active agents. + // detectActiveAgentDirs() returns the union of both. + val activeDirs = agentDetection.detectActiveAgentDirs() + return worktrees.map { wt -> + val hasRunningProcess = activeDirs.any { it.normalizeSafe() == wt.path.normalizeSafe() } + val sessionIds = agentDetection.detectActiveSessionIds(wt.path) + // Show 🤖 if either a process is running or sessions are recent. + // For idle processes with no recent sessions, use a placeholder ID. + val effectiveIds = if (sessionIds.isNotEmpty()) { + sessionIds + } else if (hasRunningProcess) { + listOf("(running)") + } else { + emptyList() + } + wt.copy(activeAgentSessionIds = effectiveIds) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeRefreshScheduler.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeRefreshScheduler.kt new file mode 100644 index 0000000..63d6072 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeRefreshScheduler.kt @@ -0,0 +1,33 @@ +package com.block.wt.services + +import com.block.wt.settings.WtPluginSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class WorktreeRefreshScheduler( + private val cs: CoroutineScope, + private val onRefresh: () -> Unit, +) { + @Volatile + private var periodicRefreshJob: Job? = null + + fun start() { + periodicRefreshJob?.cancel() + val intervalSeconds = WtPluginSettings.getInstance().state.autoRefreshIntervalSeconds + if (intervalSeconds <= 0) return + + periodicRefreshJob = cs.launch { + while (true) { + delay(intervalSeconds * 1000L) + onRefresh() + } + } + } + + fun stop() { + periodicRefreshJob?.cancel() + periodicRefreshJob = null + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeService.kt new file mode 100644 index 0000000..cc5fb06 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WorktreeService.kt @@ -0,0 +1,260 @@ +package com.block.wt.services + +import com.block.wt.agent.AgentDetection +import com.block.wt.agent.AgentDetector +import com.block.wt.git.GitParser +import com.block.wt.model.WorktreeInfo +import com.block.wt.model.WorktreeStatus +import com.block.wt.settings.WtPluginSettings +import com.block.wt.util.PathHelper +import com.block.wt.util.ProcessHelper +import com.block.wt.util.ProcessRunner +import com.block.wt.util.normalizeSafe +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import git4idea.repo.GitRepositoryManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.nio.file.Path + +@Service(Service.Level.PROJECT) +class WorktreeService( + private val project: Project, + private val cs: CoroutineScope, +) { + private val log = Logger.getInstance(WorktreeService::class.java) + + // Injectable for testing — default to production singletons + internal var processRunner: ProcessRunner = ProcessHelper + internal var agentDetection: AgentDetection = AgentDetector + + private val gitClient by lazy { GitClient(processRunner) } + private val enrichers: List by lazy { + listOf( + ProvisionStatusEnricher(ContextService.getInstance(project)), + AgentStatusEnricher(agentDetection), + ) + } + private val refreshScheduler = WorktreeRefreshScheduler(cs) { refreshWorktreeList() } + + private val _worktrees = MutableStateFlow>(emptyList()) + val worktrees: StateFlow> = _worktrees.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val statusMutex = Mutex() + + fun refreshWorktreeList() { + _isLoading.value = true + cs.launch { + try { + val list = listWorktrees() + _worktrees.value = list + lastRefreshTime = System.currentTimeMillis() + + // Async-load status indicators (skip if disabled in settings) + if (!WtPluginSettings.getInstance().state.statusLoadingEnabled) return@launch + val statusJobs = list.withIndex().map { (index, wt) -> + async { + val loadedStatus = loadStatusIndicators(wt).status + statusMutex.withLock { + val current = _worktrees.value.toMutableList() + if (index < current.size && current[index].path == wt.path) { + current[index] = current[index].copy(status = loadedStatus) + _worktrees.value = current + } + } + } + } + statusJobs.awaitAll() + } finally { + _isLoading.value = false + } + } + } + + suspend fun listWorktrees(): List = withContext(Dispatchers.IO) { + val repoRoot = getMainRepoRoot() ?: return@withContext emptyList() + + // worktree list has no programmatic API — use subprocess + val result = processRunner.runGit( + listOf("worktree", "list", "--porcelain"), + workingDir = repoRoot, + ) + + if (!result.isSuccess) { + log.warn("git worktree list failed (exit=${result.exitCode}): ${result.stderr.trim()}") + return@withContext emptyList() + } + + val linkedPath = getLinkedWorktreePath() + parseAndEnrich(result.stdout, linkedPath) + } + + internal fun parseAndEnrich(porcelainOutput: String, linkedPath: Path?): List { + val parsed = GitParser.parsePorcelainOutput(porcelainOutput, linkedPath) + return enrichers.fold(parsed) { list, enricher -> enricher.enrich(list) } + } + + /** + * Loads status via `git status --porcelain=v1 -b` subprocess. + * Runs in a separate process — zero JVM heap impact + * for DirCache, pack indices, and working tree scanning. + */ + private suspend fun loadStatusIndicators(wt: WorktreeInfo): WorktreeInfo = withContext(Dispatchers.IO) { + val result = processRunner.runGit( + listOf("status", "--porcelain=v1", "-b"), + workingDir = wt.path, + ) + if (!result.isSuccess) { + log.warn("git status failed for ${wt.path}: ${result.stderr.trim()}") + return@withContext wt + } + parseGitStatusOutput(wt, result.stdout) + } + + /** + * Parses `git status --porcelain=v1 -b` output. + * + * Format: + * ``` + * ## branch...origin/branch [ahead 1, behind 2] + * M staged-file.txt + * M unstaged-file.txt + * ?? untracked.txt + * UU conflicting.txt + * ``` + */ + internal fun parseGitStatusOutput(wt: WorktreeInfo, output: String): WorktreeInfo { + var staged = 0; var modified = 0; var untracked = 0; var conflicts = 0 + var ahead: Int? = null; var behind: Int? = null + + for (line in output.lines()) { + if (line.startsWith("## ")) { + // Parse branch line: "## branch...origin/branch [ahead 1, behind 2]" + val bracketContent = line.substringAfter("[", "").substringBefore("]", "") + if (bracketContent.isNotEmpty()) { + for (part in bracketContent.split(",")) { + val trimmed = part.trim() + if (trimmed.startsWith("ahead ")) { + ahead = trimmed.removePrefix("ahead ").trim().toIntOrNull() + } else if (trimmed.startsWith("behind ")) { + behind = trimmed.removePrefix("behind ").trim().toIntOrNull() + } + } + } + continue + } + if (line.length < 2) continue + val x = line[0] // staged status + val y = line[1] // unstaged status + + // Conflicts: UU, AA, DD, AU, UA, DU, UD + if ((x == 'U' || y == 'U') || (x == 'A' && y == 'A') || (x == 'D' && y == 'D')) { + conflicts++; continue + } + // Untracked + if (x == '?' && y == '?') { untracked++; continue } + // Staged changes (X column) + if (x in "MADRC") staged++ + // Unstaged changes (Y column) + if (y in "MD") modified++ + } + + return wt.copy( + status = WorktreeStatus.Loaded(staged, modified, untracked, conflicts, ahead, behind), + ) + } + + // --- Facade delegates to GitClient --- + + suspend fun createWorktree( + path: Path, + branch: String, + createNewBranch: Boolean = false, + onProgress: ((Double) -> Unit)? = null, + ): Result { + val repoRoot = getMainRepoRoot() + ?: return Result.failure(IllegalStateException("No git repository found")) + return gitClient.createWorktree(repoRoot, path, branch, createNewBranch, onProgress) + } + + suspend fun removeWorktree(path: Path, force: Boolean = false): Result { + val repoRoot = getMainRepoRoot() + ?: return Result.failure(IllegalStateException("No git repository found")) + return gitClient.removeWorktree(repoRoot, path, force) + } + + suspend fun getMergedBranches(): List { + val repoRoot = getMainRepoRoot() ?: return emptyList() + val contextService = ContextService.getInstance(project) + val baseBranch = contextService.getCurrentConfig()?.baseBranch ?: "main" + return gitClient.getMergedBranches(repoRoot, baseBranch) + } + + suspend fun hasUncommittedChanges(worktreePath: Path): Boolean = + gitClient.hasUncommittedChanges(worktreePath) + + suspend fun stashSave(worktreePath: Path, name: String): Result = + gitClient.stashSave(worktreePath, name) + + suspend fun stashPop(worktreePath: Path, name: String): Result = + gitClient.stashPop(worktreePath, name) + + suspend fun checkout(worktreePath: Path, branchOrRev: String): Result = + gitClient.checkout(worktreePath, branchOrRev) + + suspend fun pullFfOnly(worktreePath: Path, onProgress: ((Double) -> Unit)? = null): Result = + gitClient.pullFfOnly(worktreePath, onProgress) + + suspend fun getCurrentBranch(worktreePath: Path): String? = + gitClient.getCurrentBranch(worktreePath) + + suspend fun getCurrentRevision(worktreePath: Path): String? = + gitClient.getCurrentRevision(worktreePath) + + // --- Repo root resolution --- + + fun getMainRepoRoot(): Path? { + val contextService = ContextService.getInstance(project) + val config = contextService.getCurrentConfig() + if (config != null) return config.mainRepoRoot + + // Fallback: use the project's git root + val repos = GitRepositoryManager.getInstance(project).repositories + return repos.firstOrNull()?.root?.toNioPath() + } + + private fun getLinkedWorktreePath(): Path? { + val contextService = ContextService.getInstance(project) + val config = contextService.getCurrentConfig() ?: return null + val symlinkPath = config.activeWorktree + return PathHelper.readSymlink(symlinkPath)?.normalizeSafe() + } + + // --- Periodic refresh --- + + @Volatile + var lastRefreshTime: Long = 0L + private set + + fun startPeriodicRefresh() = refreshScheduler.start() + + fun stopPeriodicRefresh() = refreshScheduler.stop() + + companion object { + fun getInstance(project: Project): WorktreeService = project.service() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WtStartupActivity.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WtStartupActivity.kt new file mode 100644 index 0000000..e4bc42c --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/WtStartupActivity.kt @@ -0,0 +1,51 @@ +package com.block.wt.services + +import com.block.wt.settings.WtPluginSettings +import com.block.wt.ui.ContextSetupDialog +import com.block.wt.ui.WelcomePageHelper +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.ui.jcef.JBCefApp +class WtStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { + // Initialize context service (project-level, auto-detects context) + ContextService.getInstance(project).initialize() + + // Start watching for external changes + ExternalChangeWatcher.getInstance(project).startWatching() + + // Initial worktree list load + val worktreeService = WorktreeService.getInstance(project) + worktreeService.refreshWorktreeList() + + // Show context setup dialog if this context hasn't been set up yet + val config = ContextService.getInstance(project).getCurrentConfig() + if (config != null) { + val worktrees = worktreeService.listWorktrees() + ApplicationManager.getApplication().invokeLater { + ContextSetupDialog.showIfNeeded(project, config, worktrees) + } + } + + // Show welcome tab on first install or version update + showWelcomeTabIfNeeded(project) + } + + private fun showWelcomeTabIfNeeded(project: Project) { + val currentVersion = PluginManagerCore.getPlugin(PluginId.getId("com.block.wt"))?.version ?: return + val settings = WtPluginSettings.getInstance() + if (settings.state.lastWelcomeVersion == currentVersion) return + if (!JBCefApp.isSupported()) return + + settings.state.lastWelcomeVersion = currentVersion + + ApplicationManager.getApplication().invokeLater { + val html = WelcomePageHelper.buildThemedHtml() ?: return@invokeLater + HTMLEditorProvider.openEditor(project, "Worktree Manager Welcome", html) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtPluginSettings.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtPluginSettings.kt new file mode 100644 index 0000000..c06ed91 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtPluginSettings.kt @@ -0,0 +1,43 @@ +package com.block.wt.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service(Service.Level.APP) +@State( + name = "com.block.wt.settings.WtPluginSettings", + storages = [Storage("WtPluginSettings.xml")] +) +class WtPluginSettings : PersistentStateComponent { + + data class State( + var showStatusBarWidget: Boolean = true, + var autoRefreshOnExternalChange: Boolean = true, + var confirmBeforeSwitch: Boolean = false, + var confirmBeforeRemove: Boolean = true, + var statusLoadingEnabled: Boolean = true, + var debounceDelayMs: Long = 500, + var promptProvisionOnSwitch: Boolean = true, + var autoRefreshIntervalSeconds: Int = 30, + var setupCompletedContexts: MutableList = mutableListOf(), + var lastWelcomeVersion: String = "", + ) + + @Volatile + private var state = State() + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + companion object { + fun getInstance(): WtPluginSettings = + ApplicationManager.getApplication().service() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsComponent.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsComponent.kt new file mode 100644 index 0000000..efad20e --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsComponent.kt @@ -0,0 +1,91 @@ +package com.block.wt.settings + +import com.intellij.ui.dsl.builder.bindIntValue +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import javax.swing.JComponent +import javax.swing.JPanel + +class WtSettingsComponent { + + private val settings = WtPluginSettings.getInstance() + private var showStatusBar = settings.state.showStatusBarWidget + private var autoRefresh = settings.state.autoRefreshOnExternalChange + private var confirmSwitch = settings.state.confirmBeforeSwitch + private var confirmRemove = settings.state.confirmBeforeRemove + private var statusLoading = settings.state.statusLoadingEnabled + private var promptProvision = settings.state.promptProvisionOnSwitch + private var autoRefreshInterval = settings.state.autoRefreshIntervalSeconds + + val panel: JPanel = panel { + group("General") { + row { + checkBox("Show context widget in status bar") + .bindSelected(::showStatusBar) + } + row { + checkBox("Auto-refresh on external changes (CLI usage)") + .bindSelected(::autoRefresh) + } + row { + checkBox("Load status indicators (dirty, ahead/behind) asynchronously") + .bindSelected(::statusLoading) + .comment("Disable to speed up worktree list loading for repos with many worktrees") + } + row { + checkBox("Prompt to provision when switching to non-provisioned worktrees") + .bindSelected(::promptProvision) + .comment("Shows Provision & Switch / Switch Only / Cancel dialog") + } + row("Auto-refresh interval (seconds, 0 to disable):") { + spinner(0..600, 5) + .bindIntValue(::autoRefreshInterval) + .comment("Periodically refreshes the worktree list to detect external changes") + } + } + group("Confirmations") { + row { + checkBox("Confirm before switching worktrees") + .bindSelected(::confirmSwitch) + } + row { + checkBox("Confirm before removing worktrees") + .bindSelected(::confirmRemove) + } + } + } + + fun getComponent(): JComponent = panel + + fun isModified(): Boolean { + return showStatusBar != settings.state.showStatusBarWidget || + autoRefresh != settings.state.autoRefreshOnExternalChange || + confirmSwitch != settings.state.confirmBeforeSwitch || + confirmRemove != settings.state.confirmBeforeRemove || + statusLoading != settings.state.statusLoadingEnabled || + promptProvision != settings.state.promptProvisionOnSwitch || + autoRefreshInterval != settings.state.autoRefreshIntervalSeconds + } + + fun apply() { + settings.loadState(settings.state.copy( + showStatusBarWidget = showStatusBar, + autoRefreshOnExternalChange = autoRefresh, + confirmBeforeSwitch = confirmSwitch, + confirmBeforeRemove = confirmRemove, + statusLoadingEnabled = statusLoading, + promptProvisionOnSwitch = promptProvision, + autoRefreshIntervalSeconds = autoRefreshInterval, + )) + } + + fun reset() { + showStatusBar = settings.state.showStatusBarWidget + autoRefresh = settings.state.autoRefreshOnExternalChange + confirmSwitch = settings.state.confirmBeforeSwitch + confirmRemove = settings.state.confirmBeforeRemove + statusLoading = settings.state.statusLoadingEnabled + promptProvision = settings.state.promptProvisionOnSwitch + autoRefreshInterval = settings.state.autoRefreshIntervalSeconds + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsConfigurable.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsConfigurable.kt new file mode 100644 index 0000000..2c4c511 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/settings/WtSettingsConfigurable.kt @@ -0,0 +1,30 @@ +package com.block.wt.settings + +import com.intellij.openapi.options.Configurable +import javax.swing.JComponent + +class WtSettingsConfigurable : Configurable { + + private var component: WtSettingsComponent? = null + + override fun getDisplayName(): String = "Worktree Manager" + + override fun createComponent(): JComponent { + component = WtSettingsComponent() + return component!!.getComponent() + } + + override fun isModified(): Boolean = component?.isModified() == true + + override fun apply() { + component?.apply() + } + + override fun reset() { + component?.reset() + } + + override fun disposeUIResources() { + component = null + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/AddContextDialog.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/AddContextDialog.kt new file mode 100644 index 0000000..22be88e --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/AddContextDialog.kt @@ -0,0 +1,254 @@ +package com.block.wt.ui + +import com.block.wt.git.GitConfigHelper +import com.block.wt.git.GitDirResolver +import com.block.wt.model.MetadataPattern +import com.block.wt.util.PathHelper +import com.block.wt.util.ProcessHelper +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.panel +import java.nio.file.Files +import java.nio.file.Path +import javax.swing.JComponent +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +class AddContextDialog(private val project: Project?) : DialogWrapper(project) { + + private val repoPathField = JBTextField().apply { isEditable = false } + private val contextNameField = JBTextField() + private val baseBranchField = JBTextField() + private val mainRepoRootLabel = JBLabel() + private val worktreesBaseLabel = JBLabel() + private val ideaFilesBaseLabel = JBLabel() + private val migrationInfoLabel = JBLabel() + private val patternCheckboxes = mutableListOf>() + + private var isGitRepo: Boolean = false + private var hasExistingGitConfig: Boolean = false + + var repoPath: Path? = null + private set + var contextName: String = "" + private set + var baseBranch: String = "main" + private set + var mainRepoRoot: Path? = null + private set + var activeWorktree: Path? = null + private set + var worktreesBase: Path? = null + private set + var ideaFilesBase: Path? = null + private set + var selectedPatterns: List = emptyList() + private set + + init { + title = "Add Context" + init() + prefillFromProject() + setupAutoDerivation() + } + + private fun prefillFromProject() { + val basePath = project?.basePath ?: return + // Resolve to git root (follow symlinks, find actual repo root) + val projectPath = Path.of(basePath) + val resolved = runCatching { projectPath.toRealPath() }.getOrElse { projectPath.normalize() } + repoPathField.text = resolved.toString() + rederive() + } + + private fun setupAutoDerivation() { + contextNameField.document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent) = rederivePaths() + override fun removeUpdate(e: DocumentEvent) = rederivePaths() + override fun changedUpdate(e: DocumentEvent) = rederivePaths() + }) + } + + private fun rederive() { + val pathStr = repoPathField.text.trim() + if (pathStr.isBlank()) return + + val path = Path.of(pathStr) + if (!Files.isDirectory(path)) return + + // Auto-derive context name from repo basename + val basename = path.fileName?.toString() ?: return + val name = basename + .removeSuffix("-master") + .removeSuffix("-main") + contextNameField.text = name + + // Auto-detect base branch, validate git repo, and check existing config (runs off EDT) + data class DeriveResult(val branch: String, val isGit: Boolean, val hasGitConfig: Boolean) + val result = ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + val isGit = GitDirResolver.resolveGitDir(path) != null + val branch = if (isGit) detectBaseBranch(path) else "main" + val hasGitConfig = if (isGit) GitConfigHelper.isEnabled(path) else false + DeriveResult(branch, isGit, hasGitConfig) + }, + "Detecting Repository Info", + false, + project, + ) + isGitRepo = result.isGit + hasExistingGitConfig = result.hasGitConfig + baseBranchField.text = result.branch + + // Detect metadata patterns + detectAndShowPatterns(path) + + rederivePaths() + } + + private fun rederivePaths() { + val name = contextNameField.text.trim() + if (name.isBlank()) return + + val wBase = PathHelper.reposDir.resolve(name).resolve("worktrees") + val iBase = PathHelper.reposDir.resolve(name).resolve("idea-files") + val mainRepo = PathHelper.reposDir.resolve(name).resolve("base") + + worktreesBaseLabel.text = wBase.toString() + ideaFilesBaseLabel.text = iBase.toString() + mainRepoRootLabel.text = mainRepo.toString() + + val repoPathStr = repoPathField.text.trim() + if (repoPathStr.isNotBlank()) { + migrationInfoLabel.text = "Repo will be moved to $mainRepo and a symlink created at $repoPathStr" + } + } + + private fun detectBaseBranch(repoPath: Path): String { + try { + val result = ProcessHelper.runGit( + listOf("symbolic-ref", "refs/remotes/origin/HEAD"), + workingDir = repoPath, + ) + if (result.isSuccess) { + val ref = result.stdout.trim() + return ref.substringAfterLast("/") + } + } catch (_: Exception) {} + + // Fallback: check if main or master exists + for (branch in listOf("main", "master")) { + try { + val result = ProcessHelper.runGit( + listOf("rev-parse", "--verify", "refs/heads/$branch"), + workingDir = repoPath, + ) + if (result.isSuccess) return branch + } catch (_: Exception) {} + } + + return "main" + } + + private fun detectAndShowPatterns(repoPath: Path) { + patternCheckboxes.clear() + for (pattern in MetadataPattern.KNOWN_PATTERNS) { + val candidate = repoPath.resolve(pattern.name) + if (Files.exists(candidate)) { + val checkbox = JBCheckBox("${pattern.name} - ${pattern.description}", true) + patternCheckboxes.add(Pair(checkbox, pattern.name)) + } + } + } + + override fun createCenterPanel(): JComponent { + return panel { + row("Repository path:") { + cell(repoPathField).resizableColumn() + } + row("Context name:") { + cell(contextNameField).resizableColumn() + } + row("Base branch:") { + cell(baseBranchField).resizableColumn() + } + row("Main repo root:") { + cell(mainRepoRootLabel) + } + row("Worktrees base:") { + cell(worktreesBaseLabel) + } + row("Metadata vault:") { + cell(ideaFilesBaseLabel) + } + row("") { + cell(migrationInfoLabel) + } + if (patternCheckboxes.isNotEmpty()) { + group("Metadata Patterns") { + for ((checkbox, _) in patternCheckboxes) { + row { cell(checkbox) } + } + } + } + } + } + + override fun doValidate(): ValidationInfo? { + val pathStr = repoPathField.text.trim() + if (pathStr.isBlank()) { + return ValidationInfo("Repository path is required", repoPathField) + } + val path = Path.of(pathStr) + if (!Files.isDirectory(path)) { + return ValidationInfo("Directory does not exist", repoPathField) + } + // Check it's a git repo (computed off-EDT during rederive) + if (!isGitRepo) { + return ValidationInfo("Not a git repository", repoPathField) + } + + val name = contextNameField.text.trim() + if (name.isBlank()) { + return ValidationInfo("Context name is required", contextNameField) + } + if (!name.matches(Regex("[a-zA-Z0-9_-]+"))) { + return ValidationInfo("Context name must contain only letters, digits, hyphens, and underscores", contextNameField) + } + val existingConf = PathHelper.reposDir.resolve("$name.conf") + if (Files.exists(existingConf)) { + return ValidationInfo("A context named '$name' already exists", contextNameField) + } + if (hasExistingGitConfig) { + return ValidationInfo("This repository already has wt config in git config", repoPathField) + } + + if (baseBranchField.text.trim().isBlank()) { + return ValidationInfo("Base branch is required", baseBranchField) + } + + return null + } + + override fun doOKAction() { + val pathStr = repoPathField.text.trim() + repoPath = Path.of(pathStr) + contextName = contextNameField.text.trim() + baseBranch = baseBranchField.text.trim() + // activeWorktree = original repo path (the symlink location) + activeWorktree = repoPath + // mainRepoRoot = where repo will be moved to + mainRepoRoot = PathHelper.reposDir.resolve(contextName).resolve("base") + worktreesBase = PathHelper.reposDir.resolve(contextName).resolve("worktrees") + ideaFilesBase = PathHelper.reposDir.resolve(contextName).resolve("idea-files") + selectedPatterns = patternCheckboxes + .filter { it.first.isSelected } + .map { it.second } + super.doOKAction() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/ContextSetupDialog.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/ContextSetupDialog.kt new file mode 100644 index 0000000..0e0ced4 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/ContextSetupDialog.kt @@ -0,0 +1,209 @@ +package com.block.wt.ui + +import com.block.wt.progress.asScope +import com.block.wt.provision.ProvisionHelper +import com.block.wt.provision.ProvisionMarkerService +import com.block.wt.model.ContextConfig +import com.block.wt.model.WorktreeInfo +import com.block.wt.services.WorktreeService +import com.block.wt.settings.WtPluginSettings +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.CheckBoxList +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * One-time setup dialog shown when a context is first used. + * Lists all non-provisioned worktrees and lets the user pick which to provision. + * + * Results: + * - OK ("Provision Selected") → provisions checked worktrees, marks context as set up + * - CANCEL with "Skip" → marks context as set up without provisioning + * - CANCEL with close button → does nothing, will ask again next time + */ +class ContextSetupDialog( + private val project: Project, + private val config: ContextConfig, + private val worktrees: List, +) : DialogWrapper(project) { + + private val checkBoxList = CheckBoxList() + private var skipped = false + + data class WorktreeEntry( + val wt: WorktreeInfo, + val hasMetadata: Boolean, + ) { + override fun toString(): String = buildString { + append(wt.displayName) + append(" (${wt.shortPath})") + if (hasMetadata) append(" — has project files, will keep") + else append(" — no project files, will import from vault") + } + } + + init { + title = "Set Up Context: ${config.name}" + setOKButtonText("Provision Selected") + setCancelButtonText("Remind Me Later") + init() + } + + override fun createCenterPanel(): JComponent { + val panel = JPanel(BorderLayout()) + panel.preferredSize = Dimension(600, 350) + + val header = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.emptyBottom(12) + + add(JBLabel("The following worktrees haven't been provisioned by context '${config.name}'.")) + add(JBLabel("Select which ones to provision:").apply { + border = JBUI.Borders.emptyTop(4) + }) + } + panel.add(header, BorderLayout.NORTH) + + // Populate the checkbox list with non-provisioned worktrees + val unprovisioned = worktrees.filter { !it.isProvisionedByCurrentContext } + for (wt in unprovisioned) { + val hasMetadata = ProvisionMarkerService.hasExistingMetadata(wt.path) + val entry = WorktreeEntry(wt, hasMetadata) + checkBoxList.addItem(entry, entry.toString(), true) + } + panel.add(JBScrollPane(checkBoxList), BorderLayout.CENTER) + + val footer = JBLabel("Worktrees with existing project files will be marked as provisioned without changes. " + + "Others will have metadata imported from the vault.").apply { + foreground = JBUI.CurrentTheme.Label.disabledForeground() + border = JBUI.Borders.emptyTop(8) + } + panel.add(footer, BorderLayout.SOUTH) + + return panel + } + + override fun createLeftSideActions(): Array { + val skipAction = object : DialogWrapperAction("Skip Setup") { + override fun doAction(e: java.awt.event.ActionEvent) { + skipped = true + close(CANCEL_EXIT_CODE) + } + } + return arrayOf(skipAction) + } + + /** + * Returns true if the user explicitly chose "Skip Setup" (marks context as done). + * Returns false if the user closed the dialog via X or "Remind Me Later". + */ + fun wasSkipped(): Boolean = skipped + + /** + * Returns the checked worktree entries. Only valid after OK. + */ + fun getSelectedEntries(): List { + val selected = mutableListOf() + for (i in 0 until checkBoxList.itemsCount) { + if (checkBoxList.isItemSelected(i)) { + checkBoxList.getItemAt(i)?.let { selected.add(it) } + } + } + return selected + } + + companion object { + /** + * Shows the context setup dialog if the current context hasn't been set up yet. + * Called from startup activity or context switch. + */ + fun showIfNeeded(project: Project, config: ContextConfig, worktrees: List) { + val settings = WtPluginSettings.getInstance() + if (config.name in settings.state.setupCompletedContexts) return + + // Only show if there are non-provisioned worktrees + val hasUnprovisioned = worktrees.any { !it.isProvisionedByCurrentContext } + if (!hasUnprovisioned) { + // All worktrees are already provisioned — mark as done silently + markSetupComplete(config.name) + return + } + + val dialog = ContextSetupDialog(project, config, worktrees) + if (dialog.showAndGet()) { + // OK — provision selected worktrees + val selected = dialog.getSelectedEntries() + if (selected.isNotEmpty()) { + runProvisioning(project, config, selected) + } + markSetupComplete(config.name) + } else if (dialog.wasSkipped()) { + // Skip — mark as done without provisioning + markSetupComplete(config.name) + } + // else: Remind Me Later / closed — do nothing, will ask again + } + + private fun markSetupComplete(contextName: String) { + val settings = WtPluginSettings.getInstance() + if (contextName !in settings.state.setupCompletedContexts) { + val newState = settings.state.copy( + setupCompletedContexts = (settings.state.setupCompletedContexts + contextName).toMutableList() + ) + settings.loadState(newState) + } + } + + private fun runProvisioning( + project: Project, + config: ContextConfig, + entries: List, + ) { + ProgressManager.getInstance().run(object : Task.Backgroundable( + project, "Provisioning Worktrees", true + ) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = false + val scope = indicator.asScope() + + runBlockingCancellable { + for ((i, entry) in entries.withIndex()) { + indicator.checkCanceled() + val wtStart = i.toDouble() / entries.size + val wtSize = 1.0 / entries.size + scope.text("Provisioning ${entry.wt.displayName}...") + + ProvisionHelper.provisionWorktree( + project, + entry.wt.path, + config, + keepExistingFiles = entry.hasMetadata, + scope = scope.sub(wtStart, wtSize), + ) + } + + scope.fraction(1.0) + scope.text("Refreshing worktree list...") + WorktreeService.getInstance(project).refreshWorktreeList() + Notifications.info( + project, + "Context Setup Complete", + "Provisioned ${entries.size} worktree(s) for context '${config.name}'", + ) + } + } + }) + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/CreateWorktreeDialog.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/CreateWorktreeDialog.kt new file mode 100644 index 0000000..320e358 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/CreateWorktreeDialog.kt @@ -0,0 +1,66 @@ +package com.block.wt.ui + +import com.block.wt.git.GitBranchHelper +import com.block.wt.services.ContextService +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import javax.swing.JComponent + +class CreateWorktreeDialog(private val project: Project) : DialogWrapper(project) { + + var branchName: String = "" + var createNewBranch: Boolean = false + var worktreePath: String = "" + + private lateinit var branchField: JBTextField + private lateinit var pathField: JBTextField + + init { + title = "Create Worktree" + init() + updatePath() + } + + override fun createCenterPanel(): JComponent { + return panel { + row("Branch:") { + textField() + .bindText(::branchName) + .focused() + .onChanged { updatePath() } + .also { branchField = it.component } + } + row("") { + checkBox("Create new branch (-b)") + .bindSelected(::createNewBranch) + } + row("Path:") { + textField() + .bindText(::worktreePath) + .comment("Auto-derived from branch name. Override if needed.") + .also { pathField = it.component } + } + } + } + + private fun updatePath() { + val config = ContextService.getInstance(project).getCurrentConfig() ?: return + if (branchField.text.isNotBlank()) { + pathField.text = GitBranchHelper.worktreePathForBranch(config.worktreesBase, branchField.text).toString() + } + } + + override fun doValidate(): ValidationInfo? { + val branch = branchField.text + if (branch.isBlank()) return ValidationInfo("Branch name is required", branchField) + if (branch.contains("..")) return ValidationInfo("Branch name cannot contain '..'", branchField) + if (branch.startsWith("-")) return ValidationInfo("Branch name cannot start with '-'", branchField) + if (pathField.text.isBlank()) return ValidationInfo("Worktree path is required", pathField) + return null + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/Notifications.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/Notifications.kt new file mode 100644 index 0000000..9fd7049 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/Notifications.kt @@ -0,0 +1,29 @@ +package com.block.wt.ui + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project + +object Notifications { + + private const val GROUP_ID = "Worktree Manager" + + fun info(project: Project?, title: String, content: String) { + notify(project, title, content, NotificationType.INFORMATION) + } + + fun warning(project: Project?, title: String, content: String) { + notify(project, title, content, NotificationType.WARNING) + } + + fun error(project: Project?, title: String, content: String) { + notify(project, title, content, NotificationType.ERROR) + } + + private fun notify(project: Project?, title: String, content: String, type: NotificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification(title, content, type) + .notify(project) + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WelcomePageHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WelcomePageHelper.kt new file mode 100644 index 0000000..d0f3ba4 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WelcomePageHelper.kt @@ -0,0 +1,123 @@ +package com.block.wt.ui + +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Color +import java.util.Base64 + +/** + * Builds the welcome page HTML with IntelliJ theme colors injected as CSS variables + * and the screenshot embedded as a base64 data URI. + */ +object WelcomePageHelper { + + fun buildThemedHtml(): String? { + val template = javaClass.getResource("/welcome.html")?.readText() ?: return null + val screenshotDataUri = loadScreenshotDataUri() + val themeStyle = buildThemeStyle() + + return template + .replace("/*{{THEME_CSS}}*/", themeStyle) + .replace("{{SCREENSHOT_SRC}}", screenshotDataUri) + } + + private fun loadScreenshotDataUri(): String { + val bytes = javaClass.getResourceAsStream("/ui.png")?.readBytes() + ?: return "" + val encoded = Base64.getEncoder().encodeToString(bytes) + return "data:image/png;base64,$encoded" + } + + private fun buildThemeStyle(): String { + val scheme = EditorColorsManager.getInstance().globalScheme + val bg = scheme.defaultBackground + val fg = scheme.defaultForeground + val panelBg = UIUtil.getPanelBackground() + val linkColor = JBUI.CurrentTheme.Link.Foreground.ENABLED + val separatorColor = JBColor.namedColor("Group.separatorColor", panelBg) + val infoFg = JBColor.namedColor("Component.infoForeground", fg) + + // Derive surface and accent colors from the theme + val isDark = ColorUtil.isDark(bg) + val surface = if (isDark) ColorUtil.brighten(panelBg, 0.06) else Color.WHITE + val surfaceHover = if (isDark) ColorUtil.brighten(panelBg, 0.10) else ColorUtil.darken(Color.WHITE, 0.02) + val border = if (isDark) ColorUtil.brighten(panelBg, 0.15) else ColorUtil.darken(Color.WHITE, 0.10) + val borderStrong = if (isDark) ColorUtil.brighten(panelBg, 0.25) else ColorUtil.darken(Color.WHITE, 0.18) + val muted = infoFg + val subtle = if (isDark) ColorUtil.blend(fg, bg, 0.45) else ColorUtil.blend(fg, bg, 0.55) + val accentBg = if (isDark) ColorUtil.blend(linkColor, bg, 0.15) else ColorUtil.blend(linkColor, Color.WHITE, 0.10) + val accentBorder = if (isDark) ColorUtil.blend(linkColor, bg, 0.35) else ColorUtil.blend(linkColor, Color.WHITE, 0.30) + val kbdBg = if (isDark) ColorUtil.brighten(panelBg, 0.04) else ColorUtil.darken(Color.WHITE, 0.04) + val kbdBorder = borderStrong + val kbdShadow = if (isDark) ColorUtil.darken(bg, 0.15) else ColorUtil.darken(Color.WHITE, 0.25) + val placeholderBg = if (isDark) ColorUtil.brighten(bg, 0.04) else ColorUtil.darken(Color.WHITE, 0.03) + val placeholderBorder = border + val placeholderFg = subtle + + return """ + :root { + --bg: ${bg.css()}; + --fg: ${fg.css()}; + --muted: ${muted.css()}; + --subtle: ${subtle.css()}; + --surface: ${surface.css()}; + --surface-hover: ${surfaceHover.css()}; + --border: ${border.css()}; + --border-strong: ${borderStrong.css()}; + --accent: ${linkColor.css()}; + --accent-fg: ${if (isDark) ColorUtil.darken(linkColor, 0.7).css() else "#ffffff"}; + --accent-muted: ${linkColor.css()}; + --accent-bg: ${accentBg.css()}; + --accent-border: ${accentBorder.css()}; + --kbd-bg: ${kbdBg.css()}; + --kbd-border: ${kbdBorder.css()}; + --kbd-shadow: ${kbdShadow.css()}; + --step-num: ${linkColor.css()}; + --placeholder-bg: ${placeholderBg.css()}; + --placeholder-border: ${placeholderBorder.css()}; + --placeholder-fg: ${placeholderFg.css()}; + --diagram-line: ${border.css()}; + --diagram-node: ${linkColor.css()}; + --diagram-node-fg: ${if (isDark) ColorUtil.darken(linkColor, 0.7).css() else "#ffffff"}; + --diagram-arrow: ${subtle.css()}; + --tag-bg: ${accentBg.css()}; + --tag-border: ${accentBorder.css()}; + --tag-fg: ${linkColor.css()}; + --shadow-card: 0 1px 3px ${ColorUtil.withAlpha(Color.BLACK, if (isDark) 0.20 else 0.04)}, 0 1px 2px ${ColorUtil.withAlpha(Color.BLACK, if (isDark) 0.15 else 0.06)}; + --shadow-card-hover: 0 4px 12px ${ColorUtil.withAlpha(Color.BLACK, if (isDark) 0.25 else 0.06)}, 0 2px 4px ${ColorUtil.withAlpha(Color.BLACK, if (isDark) 0.15 else 0.04)}; + } + """.trimIndent() + } + + private fun Color.css(): String = "rgb($red, $green, $blue)" +} + +private object ColorUtil { + fun isDark(c: Color): Boolean = (c.red * 0.299 + c.green * 0.587 + c.blue * 0.114) < 128 + + fun brighten(c: Color, amount: Double): Color { + val r = (c.red + (255 - c.red) * amount).toInt().coerceIn(0, 255) + val g = (c.green + (255 - c.green) * amount).toInt().coerceIn(0, 255) + val b = (c.blue + (255 - c.blue) * amount).toInt().coerceIn(0, 255) + return Color(r, g, b) + } + + fun darken(c: Color, amount: Double): Color { + val r = (c.red * (1 - amount)).toInt().coerceIn(0, 255) + val g = (c.green * (1 - amount)).toInt().coerceIn(0, 255) + val b = (c.blue * (1 - amount)).toInt().coerceIn(0, 255) + return Color(r, g, b) + } + + fun blend(c1: Color, c2: Color, ratio: Double): Color { + val r = (c1.red * ratio + c2.red * (1 - ratio)).toInt().coerceIn(0, 255) + val g = (c1.green * ratio + c2.green * (1 - ratio)).toInt().coerceIn(0, 255) + val b = (c1.blue * ratio + c2.blue * (1 - ratio)).toInt().coerceIn(0, 255) + return Color(r, g, b) + } + + fun withAlpha(c: Color, alpha: Double): String = + "rgba(${c.red}, ${c.green}, ${c.blue}, $alpha)" +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreePanel.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreePanel.kt new file mode 100644 index 0000000..1d858e7 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreePanel.kt @@ -0,0 +1,340 @@ +package com.block.wt.ui + +import com.block.wt.provision.ProvisionMarkerService +import com.block.wt.provision.ProvisionSwitchHelper +import com.block.wt.model.WorktreeInfo +import com.block.wt.model.WorktreeStatus +import com.block.wt.services.ContextService +import com.block.wt.services.WorktreeService +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.project.Project +import com.intellij.ui.PopupHandler +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.SwingConstants +import javax.swing.table.TableCellRenderer + +class WorktreePanel(private val project: Project) : JPanel(BorderLayout()), DataProvider, Disposable { + + private val tableModel = WorktreeTableModel() + private val table = object : JBTable(tableModel) { + override fun getToolTipText(event: MouseEvent): String? { + val row = rowAtPoint(event.point) + val col = columnAtPoint(event.point) + if (row < 0) return super.getToolTipText(event) + + val wt = tableModel.getWorktreeAt(row) ?: return super.getToolTipText(event) + + if (col == WorktreeTableModel.COL_PATH) { + return wt.path.toString() + } + + if (col == WorktreeTableModel.COL_STATUS) { + return buildStatusTooltip(wt) + } + + if (col == WorktreeTableModel.COL_AGENT && wt.hasActiveAgent) { + return buildAgentTooltip(wt) + } + + if (col == WorktreeTableModel.COL_PROVISIONED) { + return buildProvisionTooltip(wt) + } + + return super.getToolTipText(event) + } + + override fun prepareRenderer(renderer: TableCellRenderer, row: Int, column: Int): Component { + val comp = super.prepareRenderer(renderer, row, column) + if (!isRowSelected(row)) { + val wt = tableModel.getWorktreeAt(row) + if (wt != null && wt.isLinked) { + comp.background = linkedRowBackground() + } else { + // Explicitly reset: DefaultTableCellRenderer caches setBackground() calls + // in its unselectedBackground field, so the linked row's green tint leaks + // to subsequent rows without this reset. + comp.background = getBackground() + } + } + return comp + } + } + private val contextLabel = JLabel("", SwingConstants.LEFT) + private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val cardLayout = CardLayout() + private val centerPanel = JPanel(cardLayout) + + companion object { + val DATA_KEY: DataKey = DataKey.create("WtWorktreePanel") + private const val CARD_TABLE = "table" + private const val CARD_EMPTY = "empty" + private const val CARD_LOADING = "loading" + } + + init { + setupTable() + setupLoadingState() + setupEmptyState() + setupToolbar() + setupContextLabel() + setupListeners() + cardLayout.show(centerPanel, CARD_LOADING) + observeState() + // Panel may be created after startup activity completed; re-init to ensure fresh state + ContextService.getInstance(project).initialize() + WorktreeService.getInstance(project).refreshWorktreeList() + } + + override fun getData(dataId: String): Any? { + if (DATA_KEY.`is`(dataId)) return this + return null + } + + private fun setupTable() { + table.columnModel.getColumn(WorktreeTableModel.COL_PATH).apply { + minWidth = 40; preferredWidth = 200 + } + table.columnModel.getColumn(WorktreeTableModel.COL_BRANCH).apply { + minWidth = 40; preferredWidth = 160 + } + table.columnModel.getColumn(WorktreeTableModel.COL_STATUS).apply { + minWidth = 30; preferredWidth = 130 + } + table.columnModel.getColumn(WorktreeTableModel.COL_AGENT).apply { + minWidth = 20; preferredWidth = 30 + } + table.columnModel.getColumn(WorktreeTableModel.COL_PROVISIONED).apply { + minWidth = 20; preferredWidth = 35 + } + + table.autoResizeMode = javax.swing.JTable.AUTO_RESIZE_NEXT_COLUMN + table.setShowGrid(false) + table.rowHeight = 24 + + // Right-click context menu + PopupHandler.installPopupMenu(table, "Wt.WorktreeRowContextMenu", "WtWorktreeTablePopup") + + centerPanel.add(JBScrollPane(table), CARD_TABLE) + add(centerPanel, BorderLayout.CENTER) + } + + private fun setupLoadingState() { + val loadingPanel = panel { + row { + label("Loading worktree context...") + .align(Align.CENTER) + } + }.apply { + border = JBUI.Borders.empty(40, 20) + } + centerPanel.add(loadingPanel, CARD_LOADING) + } + + private fun setupEmptyState() { + val emptyPanel = panel { + row { + label("No wt context configured") + .bold() + .align(Align.CENTER) + } + row { + comment("Set up a context to manage git worktrees from the IDE") + .align(Align.CENTER) + } + row { + button("Add Context...") { + val action = ActionManager.getInstance().getAction("Wt.AddContext") + if (action != null) { + ActionUtil.invokeAction(action, this@WorktreePanel, ActionPlaces.TOOLWINDOW_CONTENT, null, null) + } + }.align(Align.CENTER) + } + row { + comment("Or run 'wt context add' in the terminal") + .align(Align.CENTER) + } + }.apply { + border = JBUI.Borders.empty(40, 20) + } + + centerPanel.add(emptyPanel, CARD_EMPTY) + } + + private fun setupToolbar() { + val actionGroup = ActionManager.getInstance().getAction("Wt.ToolWindowToolbar") as? DefaultActionGroup + ?: return + + val toolbar = ActionManager.getInstance() + .createActionToolbar("WtToolWindow", actionGroup, true) + toolbar.targetComponent = this + + add(toolbar.component, BorderLayout.NORTH) + } + + private fun setupContextLabel() { + val config = ContextService.getInstance(project).getCurrentConfig() + updateContextLabelText(config?.name) + contextLabel.border = javax.swing.BorderFactory.createEmptyBorder(2, 4, 2, 4) + + add(contextLabel, BorderLayout.SOUTH) + } + + private fun updateContextLabelText(name: String?) { + contextLabel.text = if (name != null) " Context: $name" else " No context" + } + + private fun setupListeners() { + // Double-click to switch worktree + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) { + val row = table.rowAtPoint(e.point) + val wt = tableModel.getWorktreeAt(row) ?: return + if (!wt.isLinked) { + switchToWorktree(wt) + } + } + } + }) + } + + private fun observeState() { + val worktreeService = WorktreeService.getInstance(project) + val contextService = ContextService.getInstance(project) + + cs.launch { + combine( + worktreeService.worktrees, + contextService.config, + worktreeService.isLoading, + ) { worktrees, config, isLoading -> + Triple(worktrees, config, isLoading) + }.collectLatest { (worktrees, config, isLoading) -> + // Update worktreesBase for relative paths + tableModel.worktreesBase = config?.worktreesBase + tableModel.setWorktrees(worktrees) + updateContextLabelText(config?.name) + + // Show loading until first refresh completes, then empty or table + when { + isLoading && worktrees.isEmpty() -> cardLayout.show(centerPanel, CARD_LOADING) + config == null && worktrees.isEmpty() -> cardLayout.show(centerPanel, CARD_EMPTY) + else -> cardLayout.show(centerPanel, CARD_TABLE) + } + } + } + } + + private fun switchToWorktree(wt: WorktreeInfo) { + ProvisionSwitchHelper.switchWithProvisionPrompt(project, wt) + } + + fun getSelectedWorktree(): WorktreeInfo? { + val row = table.selectedRow + return if (row >= 0) tableModel.getWorktreeAt(row) else null + } + + private fun buildStatusTooltip(wt: WorktreeInfo): String? { + val status = wt.status + if (status !is WorktreeStatus.Loaded) return null + + val lines = mutableListOf() + if (status.staged > 0) lines.add("Staged: ${status.staged}") + if (status.modified > 0) lines.add("Modified: ${status.modified}") + if (status.untracked > 0) lines.add("Untracked: ${status.untracked}") + if (status.conflicts > 0) lines.add("Conflicts: ${status.conflicts}") + status.ahead?.let { if (it > 0) lines.add("Ahead: $it") } + status.behind?.let { if (it > 0) lines.add("Behind: $it") } + + if (lines.isEmpty()) return "Clean" + return "${lines.joinToString("
")}" + } + + private fun buildAgentTooltip(wt: WorktreeInfo): String { + val ids = wt.activeAgentSessionIds + return when { + ids.size == 1 -> "Claude agent active
Session: ${ids[0]}" + ids.size > 1 -> "${ids.size} active sessions:
${ids.joinToString("
") { "  $it" }}" + else -> "Claude agent active" + } + } + + private fun buildProvisionTooltip(wt: WorktreeInfo): String { + if (!wt.isProvisioned) { + return "Not provisioned \u2014 right-click to provision" + } + + val marker = ProvisionMarkerService.readProvisionMarker(wt.path) ?: return "Provisioned" + val currentContextName = ContextService.getInstance(project).getCurrentConfig()?.name + val otherContexts = marker.provisions + .map { it.context } + .filter { it != marker.current } + + return buildString { + append("Current: ${marker.current}") + if (marker.current == currentContextName) { + append(" (this context)") + } + + if (otherContexts.isNotEmpty()) { + append(" | Also provisioned by: ") + append(otherContexts.joinToString(", ") { name -> + if (name == currentContextName) "$name (this context)" else name + }) + } else if (currentContextName != null && marker.current != currentContextName) { + append(" | Not provisioned by this context") + } + } + } + + private fun linkedRowBackground(): Color { + val bg = table.background + val isDark = (bg.red * 0.299 + bg.green * 0.587 + bg.blue * 0.114) < 128 + return if (isDark) { + Color(bg.red, (bg.green + 30).coerceAtMost(255), bg.blue, bg.alpha) + } else { + Color((bg.red * 0.92).toInt(), (bg.green * 0.98).toInt(), (bg.blue * 0.92).toInt(), bg.alpha) + } + } + + /** + * Refreshes the worktree list if at least 2 seconds have passed since the last refresh. + * Used for tool window focus events to avoid rapid re-refreshes. + */ + fun refreshIfStale() { + val worktreeService = WorktreeService.getInstance(project) + if (System.currentTimeMillis() - worktreeService.lastRefreshTime > 2000) { + worktreeService.refreshWorktreeList() + } + } + + override fun dispose() { + cs.cancel() + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeStatusBarWidgetFactory.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeStatusBarWidgetFactory.kt new file mode 100644 index 0000000..ee1d837 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeStatusBarWidgetFactory.kt @@ -0,0 +1,110 @@ +package com.block.wt.ui + +import com.block.wt.model.WorktreeInfo +import com.block.wt.provision.ProvisionSwitchHelper +import com.block.wt.services.WorktreeService +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.util.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.awt.event.MouseEvent + +class WorktreeStatusBarWidgetFactory : StatusBarWidgetFactory { + + override fun getId(): String = "WtWorktreeWidget" + + override fun getDisplayName(): String = "Active Worktree" + + override fun isAvailable(project: Project): Boolean = true + + override fun createWidget(project: Project): StatusBarWidget { + return WorktreeStatusBarWidget(project) + } + + override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true +} + +private class WorktreeStatusBarWidget( + private val project: Project, +) : StatusBarWidget, StatusBarWidget.MultipleTextValuesPresentation { + + private var statusBar: StatusBar? = null + private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override fun ID(): String = "WtWorktreeWidget" + + override fun install(statusBar: StatusBar) { + this.statusBar = statusBar + + cs.launch { + WorktreeService.getInstance(project).worktrees.collectLatest { + statusBar.updateWidget(ID()) + } + } + } + + override fun dispose() { + cs.cancel() + statusBar = null + } + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getSelectedValue(): String? { + val linked = WorktreeService.getInstance(project).worktrees.value + .firstOrNull { it.isLinked } + return "active worktree: ${linked?.displayName ?: "(none)"}" + } + + override fun getTooltipText(): String = "Active worktree. Click to switch." + + override fun getPopup(): ListPopup? { + val worktreeService = WorktreeService.getInstance(project) + val worktrees = worktreeService.worktrees.value + if (worktrees.isEmpty()) return null + + val displayNames = worktrees.map { wt -> + buildString { + if (wt.isLinked) append("* ") + append(wt.displayName) + if (wt.isMain) append(" [main]") + } + } + + val step = object : BaseListPopupStep("Switch Worktree", displayNames) { + override fun getDefaultOptionIndex(): Int { + return worktrees.indexOfFirst { it.isLinked }.coerceAtLeast(0) + } + + override fun onChosen(selectedValue: String, finalChoice: Boolean): PopupStep<*>? { + if (finalChoice) { + val index = displayNames.indexOf(selectedValue) + val wt = worktrees.getOrNull(index) ?: return PopupStep.FINAL_CHOICE + if (!wt.isLinked) { + ApplicationManager.getApplication().invokeLater { + ProvisionSwitchHelper.switchWithProvisionPrompt(project, wt) + } + } + } + return PopupStep.FINAL_CHOICE + } + } + + return JBPopupFactory.getInstance().createListPopup(step) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getClickConsumer(): Consumer? = null +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeTableModel.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeTableModel.kt new file mode 100644 index 0000000..9a59f9b --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeTableModel.kt @@ -0,0 +1,79 @@ +package com.block.wt.ui + +import com.block.wt.model.WorktreeInfo +import com.block.wt.model.WorktreeStatus +import java.nio.file.Path +import javax.swing.table.AbstractTableModel + +class WorktreeTableModel : AbstractTableModel() { + + private var worktrees: List = emptyList() + var worktreesBase: Path? = null + + companion object { + const val COL_PATH = 0 + const val COL_BRANCH = 1 + const val COL_STATUS = 2 + const val COL_AGENT = 3 + const val COL_PROVISIONED = 4 + + val COLUMN_NAMES = arrayOf("Path", "Branch", "Status", "Agent", "Provisioned") + } + + fun setWorktrees(newWorktrees: List) { + worktrees = newWorktrees + fireTableDataChanged() + } + + fun getWorktreeAt(row: Int): WorktreeInfo? { + return worktrees.getOrNull(row) + } + + override fun getRowCount(): Int = worktrees.size + + override fun getColumnCount(): Int = COLUMN_NAMES.size + + override fun getColumnName(column: Int): String = COLUMN_NAMES[column] + + override fun getColumnClass(columnIndex: Int): Class<*> = String::class.java + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any? { + val wt = worktrees.getOrNull(rowIndex) ?: return null + return when (columnIndex) { + COL_PATH -> wt.relativePath(worktreesBase) + COL_BRANCH -> buildString { + append(wt.displayName) + if (wt.isMain) append(" [main]") + } + COL_STATUS -> formatStatus(wt) + COL_AGENT -> if (wt.hasActiveAgent) { + "\uD83E\uDD16 " + wt.activeAgentSessionIds.joinToString(", ") { it.take(8) } + } else "" + COL_PROVISIONED -> when { + wt.isProvisionedByCurrentContext -> "âś“" + wt.isProvisioned -> "~" + else -> "" + } + else -> null + } + } + + private fun formatStatus(wt: WorktreeInfo): String { + val status = wt.status + if (status !is WorktreeStatus.Loaded) return "" + + val parts = mutableListOf() + + // Local changes: âš conflicts â—Źstaged âś±modified …untracked + if (status.conflicts > 0) parts.add("\u26A0${status.conflicts}") + if (status.staged > 0) parts.add("\u25CF${status.staged}") + if (status.modified > 0) parts.add("\u2731${status.modified}") + if (status.untracked > 0) parts.add("\u2026${status.untracked}") + + // Remote tracking: ↑ahead ↓behind + status.ahead?.let { if (it > 0) parts.add("↑$it") } + status.behind?.let { if (it > 0) parts.add("↓$it") } + + return parts.joinToString(" ") + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeToolWindowFactory.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeToolWindowFactory.kt new file mode 100644 index 0000000..bef6682 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/ui/WorktreeToolWindowFactory.kt @@ -0,0 +1,38 @@ +package com.block.wt.ui + +import com.block.wt.services.WorktreeService +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ex.ToolWindowManagerListener +import com.intellij.ui.content.ContentFactory + +class WorktreeToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = WorktreePanel(project) + val content = ContentFactory.getInstance().createContent(panel, "", false) + Disposer.register(content, panel) + toolWindow.contentManager.addContent(content) + + // Start periodic refresh + WorktreeService.getInstance(project).startPeriodicRefresh() + + // Refresh worktree list when the tool window becomes visible + val connection = project.messageBus.connect(content) + connection.subscribe( + ToolWindowManagerListener.TOPIC, + object : ToolWindowManagerListener { + override fun toolWindowShown(tw: ToolWindow) { + if (tw.id == toolWindow.id) { + panel.refreshIfStale() + } + } + }, + ) + } + + override fun shouldBeAvailable(project: Project): Boolean = true +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ConfigFileHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ConfigFileHelper.kt new file mode 100644 index 0000000..68273d4 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ConfigFileHelper.kt @@ -0,0 +1,79 @@ +package com.block.wt.util + +import com.block.wt.model.ContextConfig +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +object ConfigFileHelper { + + private val KEY_PATTERN = Regex("""^(WT_[A-Z_]+)=["']?(.*?)["']?\s*$""") + + fun readConfig(confFile: Path): ContextConfig? { + if (!confFile.exists()) return null + + val values = mutableMapOf() + for (line in Files.readAllLines(confFile)) { + val match = KEY_PATTERN.matchEntire(line) ?: continue + values[match.groupValues[1]] = match.groupValues[2] + } + + val name = confFile.fileName.toString().removeSuffix(".conf") + + return ContextConfig( + name = name, + mainRepoRoot = PathHelper.expandTilde(values["WT_MAIN_REPO_ROOT"] ?: return null), + worktreesBase = PathHelper.expandTilde(values["WT_WORKTREES_BASE"] ?: return null), + activeWorktree = PathHelper.expandTilde(values["WT_ACTIVE_WORKTREE"] ?: return null), + ideaFilesBase = PathHelper.expandTilde(values["WT_IDEA_FILES_BASE"] ?: return null), + baseBranch = values["WT_BASE_BRANCH"] ?: "master", + metadataPatterns = (values["WT_METADATA_PATTERNS"] ?: "") + .split(" ") + .filter { it.isNotBlank() }, + ) + } + + fun writeConfig(confFile: Path, config: ContextConfig) { + Files.createDirectories(confFile.parent) + confFile.writeText( + buildString { + appendLine("""WT_MAIN_REPO_ROOT="${config.mainRepoRoot}"""") + appendLine("""WT_WORKTREES_BASE="${config.worktreesBase}"""") + appendLine("""WT_ACTIVE_WORKTREE="${config.activeWorktree}"""") + appendLine("""WT_IDEA_FILES_BASE="${config.ideaFilesBase}"""") + appendLine("""WT_BASE_BRANCH="${config.baseBranch}"""") + appendLine("""WT_METADATA_PATTERNS="${config.metadataPatterns.joinToString(" ")}"""") + } + ) + } + + /** + * Reads the current context name from ~/.wt/current. + * Returns null if the file doesn't exist or is empty. + * Reads only the first line to match shell semantics (`head -1`). + */ + fun readCurrentContext(file: Path = PathHelper.currentFile): String? { + if (!file.exists()) return null + return Files.newBufferedReader(file).use { it.readLine() }?.trim()?.ifEmpty { null } + } + + /** + * Writes the context name to ~/.wt/current. + * Creates parent directory if needed. + */ + fun writeCurrentContext(name: String, file: Path = PathHelper.currentFile) { + Files.createDirectories(file.parent) + file.writeText(buildString { appendLine(name) }) + } + + fun listConfigFiles(): List { + val reposDir = PathHelper.reposDir + if (!Files.isDirectory(reposDir)) return emptyList() + return Files.list(reposDir).use { stream -> + stream.filter { it.fileName.toString().endsWith(".conf") } + .toList() + } + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathExtensions.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathExtensions.kt new file mode 100644 index 0000000..436dfd9 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathExtensions.kt @@ -0,0 +1,12 @@ +package com.block.wt.util + +import java.nio.file.Path + +fun Path.normalizeSafe(): Path = try { + toRealPath() +} catch (_: Exception) { + toAbsolutePath().normalize() +} + +fun Path.relativizeAgainst(base: Path?): String = + if (base != null && startsWith(base)) base.relativize(this).toString() else toString() diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathHelper.kt new file mode 100644 index 0000000..728c7e0 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/PathHelper.kt @@ -0,0 +1,58 @@ +package com.block.wt.util + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.UUID + +object PathHelper { + + private val HOME: Path = Path.of(System.getProperty("user.home")) + + fun expandTilde(path: String): Path { + return if (path.startsWith("~/") || path == "~") { + HOME.resolve(path.removePrefix("~/").removePrefix("~")) + } else { + Path.of(path) + } + } + + fun normalize(path: Path): Path { + return path.toRealPath() + } + + fun normalizeSafe(path: Path): Path { + return try { + path.toRealPath() + } catch (_: Exception) { + path.toAbsolutePath().normalize() + } + } + + fun atomicSetSymlink(linkPath: Path, newTarget: Path) { + val parent = linkPath.parent + ?: throw IllegalArgumentException("Link path must have a parent directory: $linkPath") + Files.createDirectories(parent) + + val tempLink = parent.resolve(".${linkPath.fileName}.${UUID.randomUUID()}.tmp") + Files.createSymbolicLink(tempLink, newTarget) + try { + Files.move(tempLink, linkPath, StandardCopyOption.ATOMIC_MOVE) + } catch (e: Exception) { + runCatching { Files.deleteIfExists(tempLink) } + throw e + } + } + + fun isSymlink(path: Path): Boolean = Files.isSymbolicLink(path) + + fun readSymlink(path: Path): Path? { + return if (Files.isSymbolicLink(path)) Files.readSymbolicLink(path) else null + } + + val wtRoot: Path get() = HOME.resolve(".wt") + + val reposDir: Path get() = wtRoot.resolve("repos") + + val currentFile: Path get() = wtRoot.resolve("current") +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessHelper.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessHelper.kt new file mode 100644 index 0000000..b78575a --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessHelper.kt @@ -0,0 +1,96 @@ +package com.block.wt.util + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Key +import java.nio.file.Path + +object ProcessHelper : ProcessRunner { + + private val log = Logger.getInstance(ProcessHelper::class.java) + + private val GIT_PROGRESS_REGEX = Regex("""\b(\d{1,3})%""") + + data class ProcessResult( + val exitCode: Int, + val stdout: String, + val stderr: String, + ) { + val isSuccess: Boolean get() = exitCode == 0 + } + + override fun run( + command: List, + workingDir: Path?, + timeoutSeconds: Long, + ): ProcessResult { + return runInternal(command, workingDir, timeoutSeconds, onProgress = null) + } + + override fun runGit(args: List, workingDir: Path?): ProcessResult { + return run(listOf("git") + args, workingDir) + } + + /** + * Runs a git command while streaming stderr to parse progress percentages. + * Git outputs lines like "Receiving objects: 45% (123/273)" to stderr. + * The [onProgress] callback receives values in 0.0..1.0. + */ + fun runGitWithProgress( + args: List, + workingDir: Path?, + onProgress: (Double) -> Unit, + ): ProcessResult { + return runInternal(listOf("git") + args, workingDir, timeoutSeconds = 300, onProgress = onProgress) + } + + private fun runInternal( + command: List, + workingDir: Path?, + timeoutSeconds: Long, + onProgress: ((Double) -> Unit)?, + ): ProcessResult { + val cli = GeneralCommandLine(command) + if (workingDir != null) { + cli.workDirectory = workingDir.toFile() + } + cli.charset = Charsets.UTF_8 + + val handler = CapturingProcessHandler(cli) + + if (onProgress != null) { + handler.addProcessListener(object : ProcessListener { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (outputType == ProcessOutputTypes.STDERR) { + val match = GIT_PROGRESS_REGEX.find(event.text) + if (match != null) { + val pct = match.groupValues[1].toIntOrNull() ?: return + onProgress(pct.coerceIn(0, 100) / 100.0) + } + } + } + }) + } + + val output = handler.runProcess((timeoutSeconds * 1000).toInt()) + + if (output.isTimeout) { + log.warn("Command timed out after ${timeoutSeconds}s: ${command.joinToString(" ")}") + return ProcessResult(-1, output.stdout, "Process timed out after ${timeoutSeconds}s") + } + + val result = ProcessResult( + exitCode = output.exitCode, + stdout = output.stdout, + stderr = output.stderr, + ) + if (!result.isSuccess) { + log.debug("Command failed (exit=${result.exitCode}): ${command.joinToString(" ")}${if (result.stderr.isNotBlank()) "\nstderr: ${result.stderr.trim()}" else ""}") + } + return result + } +} diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessRunner.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessRunner.kt new file mode 100644 index 0000000..1477af9 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/util/ProcessRunner.kt @@ -0,0 +1,12 @@ +package com.block.wt.util + +import java.nio.file.Path + +/** + * Interface for running external processes. Production implementation uses IntelliJ's + * OSProcessHandler; test implementation returns canned responses. + */ +interface ProcessRunner { + fun run(command: List, workingDir: Path? = null, timeoutSeconds: Long = 60): ProcessHelper.ProcessResult + fun runGit(args: List, workingDir: Path? = null): ProcessHelper.ProcessResult +} diff --git a/wt-jetbrains-plugin/src/main/resources/META-INF/plugin.xml b/wt-jetbrains-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..f8bd2a8 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,210 @@ + + com.block.wt + Worktree Manager + Block + +
+ Features: +
    +
  • List, create, switch, and remove git worktrees from the IDE
  • +
  • Atomic symlink switching — switch worktrees in <1 second with zero IDE restarts
  • +
  • Multi-repo context management — interoperable with the wt CLI
  • +
  • IDE metadata vault — export/import .idea, .ijwb, .vscode dirs across worktrees
  • +
  • Bazel symlink management — shared build cache across worktrees
  • +
+ ]]>
+ + com.intellij.modules.platform + Git4Idea + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/wt-jetbrains-plugin/src/main/resources/icons/worktree.svg b/wt-jetbrains-plugin/src/main/resources/icons/worktree.svg new file mode 100644 index 0000000..4de5de6 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/resources/icons/worktree.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/wt-jetbrains-plugin/src/main/resources/ui.png b/wt-jetbrains-plugin/src/main/resources/ui.png new file mode 100644 index 0000000..ddca660 Binary files /dev/null and b/wt-jetbrains-plugin/src/main/resources/ui.png differ diff --git a/wt-jetbrains-plugin/src/main/resources/welcome.html b/wt-jetbrains-plugin/src/main/resources/welcome.html new file mode 100644 index 0000000..7428844 --- /dev/null +++ b/wt-jetbrains-plugin/src/main/resources/welcome.html @@ -0,0 +1,647 @@ + + + + + +Worktree Manager + + + +
+ + +
+
+ + + + + + + + + + +
+
+

Worktree Manager

+
Native git worktree management for JetBrains IDEs.
Atomic symlink switching — zero restarts, sub-second context changes.
+ wt CLI companion +
+
+ + + +
+
+
+ +
+
+
Switch Worktree Ctrl+Alt+W
+
Swap active worktree instantly via symlink
+
+
+
+
+ +
+
+
Create Worktree Ctrl+Alt+Shift+W
+
New worktree from any branch or commit
+
+
+
+
+ +
+
+
Provision
+
Set up IDE metadata & Bazel symlinks
+
+
+
+
+ +
+
+
Metadata Vault
+
Export & import .idea, .ijwb, .vscode
+
+
+
+
+ +
+
+
Agent Detection
+
Track Claude & AI agents in worktrees
+
+
+
+
+ +
+
+
Status Bar & Tool Window
+
Active context visible at a glance
+
+
+
+ + +
+ +
+
+
+
~/dev/myrepo
+
symlink
+
+
+
+ +
points to
+
+
+
+
feature-a/
+
active worktree
+
+
+
+ +
atomic swap
+
+
+
+
feature-b/
+
other worktree
+
+
+
+
+ + +
+ +
+ Worktree Manager tool window showing worktree list with branch, status, agent, and provision columns +
Worktrees tool window — branch, status, agent activity, and provision state at a glance
+
+
+ + +
+ +
    +
  1. +
    1
    +
    +
    Register your repository
    +
    Go to VCS > Worktrees > Add Context to connect a repo managed by the wt CLI. This links the plugin to your ~/.wt/ configuration.
    +
    +
  2. +
  3. +
    2
    +
    +
    Create or switch worktrees
    +
    Press Ctrl+Alt+Shift+W to create a new worktree, or Ctrl+Alt+W to switch. Switching atomically swaps the symlink — all open editors reload instantly.
    +
    +
  4. +
  5. +
    3
    +
    +
    Browse in the tool window
    +
    Open the Worktrees panel (bottom bar) to see all worktrees with branch, status, agent activity, and provision state. Double-click to switch.
    +
    +
  6. +
  7. +
    4
    +
    +
    Provision on first switch
    +
    When switching to a worktree for the first time, the plugin offers to provision it — importing IDE metadata from the vault and installing Bazel symlinks. This is one-time per worktree.
    +
    +
  8. +
  9. +
    5
    +
    +
    Keep metadata in sync
    +
    Use VCS > Worktrees > Export Metadata to save your current IDE settings to the vault before switching, so other worktrees can import them.
    +
    +
  10. +
+
+ + +
+ +
+
+
Shortcut
+
The status bar widget (bottom-right) shows your active context. Click it to quickly switch between repositories.
+
+
+
CLI Sync
+
Changes from the wt CLI are detected automatically via file watchers. No manual refresh needed.
+
+
+
Provision
+
If a worktree already has project files, provisioning offers to keep existing or overwrite from vault — nothing is lost silently.
+
+
+
Right-Click
+
Right-click any worktree row for Open Terminal, Reveal in Finder, Copy Path, and more.
+
+
+
+ + + + +
+ + diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitConfigHelperTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitConfigHelperTest.kt new file mode 100644 index 0000000..af1f9ea --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitConfigHelperTest.kt @@ -0,0 +1,538 @@ +package com.block.wt.git + +import com.block.wt.model.ContextConfig +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import com.block.wt.util.ProcessHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class GitConfigHelperTest { + + private fun createTempGitRepo(): Path { + val dir = Files.createTempDirectory("gitconfig-test") + ProcessHelper.runGit(listOf("init"), workingDir = dir) + return dir + } + + private fun setWtConfig(repoDir: Path, enabled: Boolean = true, extra: Map = emptyMap()) { + ProcessHelper.runGit( + listOf("config", "--local", "wt.enabled", enabled.toString()), + workingDir = repoDir, + ) + for ((key, value) in extra) { + ProcessHelper.runGit( + listOf("config", "--local", "wt.$key", value), + workingDir = repoDir, + ) + } + } + + @Test + fun testReadConfigReturnsContextConfigWhenAllRequiredKeysPresent() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt/worktrees", + "ideaFilesBase" to "/tmp/wt/idea-files", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + assertEquals(Path.of("/tmp/wt/worktrees"), config!!.worktreesBase) + assertEquals(Path.of("/tmp/wt/idea-files"), config.ideaFilesBase) + assertEquals("main", config.baseBranch) + assertEquals(dir, config.mainRepoRoot) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigReturnsNullWhenEnabledIsFalse() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + enabled = false, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigReturnsNullWhenEnabledIsMissing() { + val dir = createTempGitRepo() + try { + // Don't set wt.enabled at all + val config = GitConfigHelper.readConfig(dir) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigReturnsNullWhenRequiredKeyMissing() { + val dir = createTempGitRepo() + try { + // Missing ideaFilesBase + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigReturnsNullForNonGitDirectory() { + val dir = Files.createTempDirectory("gitconfig-test-nogit") + try { + val config = GitConfigHelper.readConfig(dir) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigHandlesOptionalActiveWorktree() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + "activeWorktree" to "/tmp/active", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + assertEquals(Path.of("/tmp/active"), config!!.activeWorktree) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigDefaultsActiveWorktreeToMainRepoRoot() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + // activeWorktree defaults to mainRepoRoot when absent + assertEquals(dir, config!!.activeWorktree) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigHandlesMetadataPatterns() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + "metadataPatterns" to ".idea .ijwb .vscode", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + assertEquals(listOf(".idea", ".ijwb", ".vscode"), config!!.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigDefaultsEmptyMetadataPatterns() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + assertEquals(emptyList(), config!!.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteConfigSetsAllKeys() { + val dir = createTempGitRepo() + try { + val config = ContextConfig( + name = "test", + mainRepoRoot = dir, + worktreesBase = Path.of("/tmp/wt"), + ideaFilesBase = Path.of("/tmp/idea"), + baseBranch = "main", + activeWorktree = Path.of("/tmp/active"), + metadataPatterns = listOf(".idea", ".vscode"), + ) + + GitConfigHelper.writeConfig(dir, config) + + // Verify by reading back with git config + fun gitGet(key: String): String? { + val result = ProcessHelper.runGit( + listOf("config", "--local", "--get", key), + workingDir = dir, + ) + return if (result.isSuccess) result.stdout.trim() else null + } + + assertEquals("true", gitGet("wt.enabled")) + assertEquals("test", gitGet("wt.contextName")) + assertEquals("/tmp/wt", gitGet("wt.worktreesBase")) + assertEquals("/tmp/idea", gitGet("wt.ideaFilesBase")) + assertEquals("main", gitGet("wt.baseBranch")) + assertEquals("/tmp/active", gitGet("wt.activeWorktree")) + assertEquals(".idea .vscode", gitGet("wt.metadataPatterns")) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteAndReadRoundTrip() { + val dir = createTempGitRepo() + try { + // Use a name different from the dir name to verify wt.contextName round-trip + val original = ContextConfig( + name = "my-custom-context", + mainRepoRoot = dir, + worktreesBase = Path.of("/tmp/wt"), + ideaFilesBase = Path.of("/tmp/idea"), + baseBranch = "develop", + activeWorktree = Path.of("/tmp/active"), + metadataPatterns = listOf(".idea", ".ijwb"), + ) + + GitConfigHelper.writeConfig(dir, original) + val read = GitConfigHelper.readConfig(dir) + + assertNotNull(read) + assertEquals(original.name, read!!.name) + assertEquals(original.worktreesBase, read.worktreesBase) + assertEquals(original.ideaFilesBase, read.ideaFilesBase) + assertEquals(original.baseBranch, read.baseBranch) + assertEquals(original.activeWorktree, read.activeWorktree) + assertEquals(original.metadataPatterns, read.metadataPatterns) + assertEquals(original.mainRepoRoot, read.mainRepoRoot) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsEnabledReturnsTrueWhenEnabled() { + val dir = createTempGitRepo() + try { + setWtConfig(dir, enabled = true) + assertTrue(GitConfigHelper.isEnabled(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsEnabledReturnsFalseWhenDisabled() { + val dir = createTempGitRepo() + try { + setWtConfig(dir, enabled = false) + assertFalse(GitConfigHelper.isEnabled(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsEnabledReturnsFalseWhenAbsent() { + val dir = createTempGitRepo() + try { + assertFalse(GitConfigHelper.isEnabled(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsEnabledReturnsFalseForNonGitDir() { + val dir = Files.createTempDirectory("gitconfig-test-nogit") + try { + assertFalse(GitConfigHelper.isEnabled(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigFromLinkedWorktree() { + val mainDir = createTempGitRepo() + try { + // Create an initial commit so we have a branch + val readmeFile = mainDir.resolve("README.md").toFile() + readmeFile.writeText("hello") + ProcessHelper.runGit(listOf("add", "README.md"), workingDir = mainDir) + ProcessHelper.runGit(listOf("commit", "-m", "initial"), workingDir = mainDir) + + // Set wt config on the main repo + setWtConfig( + mainDir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + ), + ) + + // Simulate a linked worktree: create a dir with .git file pointing back + val worktreeDir = Files.createTempDirectory("gitconfig-linked-wt") + val mainGitDir = mainDir.resolve(".git") + val worktreeGitDir = mainGitDir.resolve("worktrees").resolve("feature") + Files.createDirectories(worktreeGitDir) + Files.writeString(worktreeDir.resolve(".git"), "gitdir: $worktreeGitDir") + + val config = GitConfigHelper.readConfig(worktreeDir) + assertNotNull(config) + assertEquals(Path.of("/tmp/wt"), config!!.worktreesBase) + assertEquals(mainDir, config.mainRepoRoot) + + deleteRecursive(worktreeDir) + } finally { + deleteRecursive(mainDir) + } + } + + @Test + fun testPartialRequiredKeysReturnsNull() { + val dir = createTempGitRepo() + try { + // Set only 2 of 3 required keys + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "baseBranch" to "main", + // Missing ideaFilesBase + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testContextNameDerivesFromRepoBasenameStrippingSuffix() { + val baseDir = Files.createTempDirectory("gitconfig-test") + try { + // Create a repo in a directory named "java-master" + val repoDir = baseDir.resolve("java-master") + Files.createDirectory(repoDir) + ProcessHelper.runGit(listOf("init"), workingDir = repoDir) + + setWtConfig( + repoDir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "master", + ), + ) + + val config = GitConfigHelper.readConfig(repoDir) + assertNotNull(config) + assertEquals("java", config!!.name) + } finally { + deleteRecursive(baseDir) + } + } + + @Test + fun testRemoveAllConfigClearsAllKeys() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + "activeWorktree" to "/tmp/active", + "metadataPatterns" to ".idea .vscode", + ), + ) + + // Verify config is present + assertNotNull(GitConfigHelper.readConfig(dir)) + assertTrue(GitConfigHelper.isEnabled(dir)) + + // Remove all config + GitConfigHelper.removeAllConfig(dir) + + // Verify everything is gone + assertNull(GitConfigHelper.readConfig(dir)) + assertFalse(GitConfigHelper.isEnabled(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigUsesContextNameFromGitConfig() { + val dir = createTempGitRepo() + try { + setWtConfig( + dir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + "contextName" to "mycontext", + ), + ) + + val config = GitConfigHelper.readConfig(dir) + assertNotNull(config) + // Name should come from wt.contextName, not dirname + assertEquals("mycontext", config!!.name) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigFallsBackToDirnameWhenContextNameAbsent() { + val baseDir = Files.createTempDirectory("gitconfig-test") + try { + val repoDir = baseDir.resolve("myrepo-main") + Files.createDirectory(repoDir) + ProcessHelper.runGit(listOf("init"), workingDir = repoDir) + + setWtConfig( + repoDir, + extra = mapOf( + "worktreesBase" to "/tmp/wt", + "ideaFilesBase" to "/tmp/idea", + "baseBranch" to "main", + ), + ) + + val config = GitConfigHelper.readConfig(repoDir) + assertNotNull(config) + // Falls back to dirname with -main stripped + assertEquals("myrepo", config!!.name) + } finally { + deleteRecursive(baseDir) + } + } + + @Test + fun testRemoveAllConfigOnNonGitDirIsNoOp() { + val dir = Files.createTempDirectory("gitconfig-test-nogit") + try { + // Should not throw + GitConfigHelper.removeAllConfig(dir) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteConfigClearsMetadataPatternsWhenEmpty() { + val dir = createTempGitRepo() + try { + // First write with patterns + val configWithPatterns = ContextConfig( + name = dir.fileName.toString(), + mainRepoRoot = dir, + worktreesBase = Path.of("/tmp/wt"), + ideaFilesBase = Path.of("/tmp/idea"), + baseBranch = "main", + activeWorktree = dir, + metadataPatterns = listOf(".idea", ".vscode"), + ) + GitConfigHelper.writeConfig(dir, configWithPatterns) + + // Verify patterns are set + var result = ProcessHelper.runGit( + listOf("config", "--local", "--get", "wt.metadataPatterns"), + workingDir = dir, + ) + assertEquals(".idea .vscode", result.stdout.trim()) + + // Now write with empty patterns — should clear the key + val configNoPatterns = configWithPatterns.copy(metadataPatterns = emptyList()) + GitConfigHelper.writeConfig(dir, configNoPatterns) + + result = ProcessHelper.runGit( + listOf("config", "--local", "--get", "wt.metadataPatterns"), + workingDir = dir, + ) + // Key should be unset (git config --get returns exit code 1 for missing keys) + assertFalse(result.isSuccess) + + // Round-trip: readConfig should return empty list + val read = GitConfigHelper.readConfig(dir) + assertNotNull(read) + assertEquals(emptyList(), read!!.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitDirResolverTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitDirResolverTest.kt new file mode 100644 index 0000000..d026586 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/git/GitDirResolverTest.kt @@ -0,0 +1,172 @@ +package com.block.wt.git + +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class GitDirResolverTest { + + @Test + fun testMainWorktreeWithDotGitDirectory() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val dotGit = dir.resolve(".git") + Files.createDirectory(dotGit) + + val result = GitDirResolver.resolveGitDir(dir) + assertNotNull(result) + assertEquals(dotGit, result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testLinkedWorktreeWithAbsoluteGitdir() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val gitWorktreeDir = dir.resolve("main-repo").resolve(".git").resolve("worktrees").resolve("feature") + Files.createDirectories(gitWorktreeDir) + + val worktree = dir.resolve("worktree") + Files.createDirectory(worktree) + Files.writeString(worktree.resolve(".git"), "gitdir: $gitWorktreeDir") + + val result = GitDirResolver.resolveGitDir(worktree) + assertNotNull(result) + assertEquals(gitWorktreeDir, result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testLinkedWorktreeWithRelativeGitdir() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val mainRepo = dir.resolve("repo") + val gitWorktreeDir = mainRepo.resolve(".git").resolve("worktrees").resolve("feature") + Files.createDirectories(gitWorktreeDir) + + val worktree = dir.resolve("worktree") + Files.createDirectory(worktree) + + // Write a relative gitdir path + val relativePath = worktree.relativize(gitWorktreeDir) + Files.writeString(worktree.resolve(".git"), "gitdir: $relativePath") + + val result = GitDirResolver.resolveGitDir(worktree) + assertNotNull(result) + // The resolved path should be normalized and point to the same directory + assertEquals(gitWorktreeDir.toRealPath(), result!!.toRealPath()) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testNoDotGitReturnsNull() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val result = GitDirResolver.resolveGitDir(dir) + assertNull(result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testMalformedGitFileReturnsNull() { + val dir = Files.createTempDirectory("gitdir-test") + try { + Files.writeString(dir.resolve(".git"), "not a gitdir pointer") + val result = GitDirResolver.resolveGitDir(dir) + assertNull(result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testUnreadableGitFileReturnsNull() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val dotGit = dir.resolve(".git") + Files.writeString(dotGit, "gitdir: /some/path") + dotGit.toFile().setReadable(false) + + val result = GitDirResolver.resolveGitDir(dir) + assertNull(result) + } finally { + // Restore permissions for cleanup + dir.resolve(".git").toFile().setReadable(true) + deleteRecursive(dir) + } + } + + @Test + fun testEmptyGitFileReturnsNull() { + val dir = Files.createTempDirectory("gitdir-test") + try { + Files.writeString(dir.resolve(".git"), "") + val result = GitDirResolver.resolveGitDir(dir) + assertNull(result) + } finally { + deleteRecursive(dir) + } + } + + // --- resolveMainGitDir tests --- + + @Test + fun testResolveMainGitDirForMainRepoReturnsOwnDotGit() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val dotGit = dir.resolve(".git") + Files.createDirectory(dotGit) + + val result = GitDirResolver.resolveMainGitDir(dir) + assertNotNull(result) + assertEquals(dotGit, result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testResolveMainGitDirForLinkedWorktreeWalksUpToDotGit() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val mainRepo = dir.resolve("main-repo") + val mainGitDir = mainRepo.resolve(".git") + val worktreeGitDir = mainGitDir.resolve("worktrees").resolve("feature") + Files.createDirectories(worktreeGitDir) + + val worktree = dir.resolve("worktree") + Files.createDirectory(worktree) + Files.writeString(worktree.resolve(".git"), "gitdir: $worktreeGitDir") + + val result = GitDirResolver.resolveMainGitDir(worktree) + assertNotNull(result) + assertEquals(mainGitDir, result) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testResolveMainGitDirForNonGitPathReturnsNull() { + val dir = Files.createTempDirectory("gitdir-test") + try { + val result = GitDirResolver.resolveMainGitDir(dir) + assertNull(result) + } finally { + deleteRecursive(dir) + } + } + +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/model/WorktreeInfoTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/model/WorktreeInfoTest.kt new file mode 100644 index 0000000..ba788a3 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/model/WorktreeInfoTest.kt @@ -0,0 +1,230 @@ +package com.block.wt.model + +import com.block.wt.git.GitBranchHelper +import com.block.wt.git.GitParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +class WorktreeInfoTest { + + @Test + fun testParsePorcelainBasic() { + val output = """ + worktree /Users/test/repo + HEAD abc123def456 + branch refs/heads/main + + worktree /Users/test/worktrees/feature + HEAD def456abc123 + branch refs/heads/feature/foo + + """.trimIndent() + + val result = GitParser.parsePorcelainOutput(output) + assertEquals(2, result.size) + + val main = result[0] + assertEquals(Path.of("/Users/test/repo"), main.path) + assertEquals("main", main.branch) + assertEquals("abc123def456", main.head) + assertTrue(main.isMain) + + val feature = result[1] + assertEquals(Path.of("/Users/test/worktrees/feature"), feature.path) + assertEquals("feature/foo", feature.branch) + assertFalse(feature.isMain) + } + + @Test + fun testParsePorcelainDetachedHead() { + val output = """ + worktree /Users/test/repo + HEAD abc123 + branch refs/heads/main + + worktree /Users/test/worktrees/detached + HEAD def456 + detached + + """.trimIndent() + + val result = GitParser.parsePorcelainOutput(output) + assertEquals(2, result.size) + + val detached = result[1] + assertNull(detached.branch) + assertEquals("def456", detached.head) + } + + @Test + fun testParsePorcelainPrunable() { + val output = """ + worktree /Users/test/repo + HEAD abc123 + branch refs/heads/main + + worktree /Users/test/worktrees/old + HEAD def456 + branch refs/heads/old-branch + prunable + + """.trimIndent() + + val result = GitParser.parsePorcelainOutput(output) + assertEquals(2, result.size) + assertTrue(result[1].isPrunable) + } + + @Test + fun testParsePorcelainEmpty() { + val result = GitParser.parsePorcelainOutput("") + assertTrue(result.isEmpty()) + } + + @Test + fun testParsePorcelainWithLinkedWorktree() { + val output = """ + worktree /Users/test/repo + HEAD abc123 + branch refs/heads/main + + worktree /Users/test/worktrees/feature + HEAD def456 + branch refs/heads/feature + + """.trimIndent() + + val result = GitParser.parsePorcelainOutput( + output, + linkedWorktreePath = Path.of("/Users/test/worktrees/feature") + ) + assertEquals(2, result.size) + assertFalse(result[0].isLinked) + assertTrue(result[1].isLinked) + } + + @Test + fun testDisplayName() { + val withBranch = WorktreeInfo( + path = Path.of("/test"), + branch = "feature/foo", + head = "abc123", + ) + assertEquals("feature/foo", withBranch.displayName) + + val detached = WorktreeInfo( + path = Path.of("/test2"), + branch = null, + head = "abc123def456", + ) + assertEquals("abc123de", detached.displayName) + } + + @Test + fun testHasActiveAgentDerived() { + val noAgent = WorktreeInfo( + path = Path.of("/test"), + branch = "main", + head = "abc123", + ) + assertFalse(noAgent.hasActiveAgent) + + val withAgent = WorktreeInfo( + path = Path.of("/test2"), + branch = "main", + head = "abc123", + activeAgentSessionIds = listOf("session-1", "session-2"), + ) + assertTrue(withAgent.hasActiveAgent) + } + + @Test + fun testIsDirtyWithNotLoaded() { + val wt = WorktreeInfo( + path = Path.of("/test"), + branch = "main", + head = "abc123", + ) + assertNull(wt.isDirty) + } + + @Test + fun testIsDirtyWithCleanStatus() { + val wt = WorktreeInfo( + path = Path.of("/test"), + branch = "main", + head = "abc123", + status = WorktreeStatus.Loaded( + staged = 0, modified = 0, untracked = 0, conflicts = 0, + ahead = null, behind = null, + ), + ) + assertEquals(false, wt.isDirty) + } + + @Test + fun testIsDirtyWithDirtyStatus() { + val wt = WorktreeInfo( + path = Path.of("/test"), + branch = "main", + head = "abc123", + status = WorktreeStatus.Loaded( + staged = 2, modified = 1, untracked = 0, conflicts = 0, + ahead = null, behind = null, + ), + ) + assertEquals(true, wt.isDirty) + } + + @Test + fun testWorktreeStatusLoadedIsDirty() { + val clean = WorktreeStatus.Loaded(staged = 0, modified = 0, untracked = 0, conflicts = 0, ahead = null, behind = null) + assertFalse(clean.isDirty) + + val dirty = WorktreeStatus.Loaded(staged = 1, modified = 0, untracked = 0, conflicts = 0, ahead = null, behind = null) + assertTrue(dirty.isDirty) + } + + @Test + fun testSanitizeBranchName() { + assertEquals("feature/foo", GitBranchHelper.sanitizeBranchName("feature/foo")) + assertEquals("simple", GitBranchHelper.sanitizeBranchName(" simple ")) + } + + @Test(expected = IllegalArgumentException::class) + fun testSanitizeBranchNamePathTraversal() { + GitBranchHelper.sanitizeBranchName("../evil") + } + + @Test(expected = IllegalArgumentException::class) + fun testSanitizeBranchNameBlank() { + GitBranchHelper.sanitizeBranchName(" ") + } + + @Test(expected = IllegalArgumentException::class) + fun testSanitizeBranchNameDash() { + GitBranchHelper.sanitizeBranchName("-flag") + } + + @Test(expected = IllegalArgumentException::class) + fun testSanitizeBranchNameDashWithLeadingWhitespace() { + GitBranchHelper.sanitizeBranchName(" -flag") + } + + @Test + fun testWorktreePathForBranch() { + val base = Path.of("/worktrees") + assertEquals( + Path.of("/worktrees/feature-foo"), + GitBranchHelper.worktreePathForBranch(base, "feature/foo") + ) + assertEquals( + Path.of("/worktrees/simple"), + GitBranchHelper.worktreePathForBranch(base, "simple") + ) + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/provision/ProvisionMarkerServiceTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/provision/ProvisionMarkerServiceTest.kt new file mode 100644 index 0000000..32c3b92 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/provision/ProvisionMarkerServiceTest.kt @@ -0,0 +1,247 @@ +package com.block.wt.provision + +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class ProvisionMarkerServiceTest { + + @Test + fun testIsProvisionedReturnsFalseWhenNoGitDir() { + val dir = Files.createTempDirectory("provision-test") + try { + assertFalse(ProvisionMarkerService.isProvisioned(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsProvisionedReturnsFalseWhenNoMarker() { + val dir = Files.createTempDirectory("provision-test") + try { + // Create a .git directory (main worktree style) + Files.createDirectory(dir.resolve(".git")) + assertFalse(ProvisionMarkerService.isProvisioned(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteAndReadProvisionMarker() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + + val result = ProvisionMarkerService.writeProvisionMarker(dir, "java") + assertTrue(result.isSuccess) + assertTrue(ProvisionMarkerService.isProvisioned(dir)) + assertTrue(ProvisionMarkerService.isProvisionedByContext(dir, "java")) + assertFalse(ProvisionMarkerService.isProvisionedByContext(dir, "kotlin")) + + val marker = ProvisionMarkerService.readProvisionMarker(dir) + assertNotNull(marker) + assertEquals("java", marker!!.current) + assertEquals(1, marker.provisions.size) + assertEquals("java", marker.provisions[0].context) + assertEquals("intellij-plugin", marker.provisions[0].provisionedBy) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteMultipleContextsPreservesHistory() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + + ProvisionMarkerService.writeProvisionMarker(dir, "java") + ProvisionMarkerService.writeProvisionMarker(dir, "kotlin") + + val marker = ProvisionMarkerService.readProvisionMarker(dir) + assertNotNull(marker) + assertEquals("kotlin", marker!!.current) + assertEquals(2, marker.provisions.size) + + val contexts = marker.provisions.map { it.context }.toSet() + assertTrue(contexts.contains("java")) + assertTrue(contexts.contains("kotlin")) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReProvisionSameContextUpdatesEntry() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + + ProvisionMarkerService.writeProvisionMarker(dir, "java") + ProvisionMarkerService.writeProvisionMarker(dir, "java") + + val marker = ProvisionMarkerService.readProvisionMarker(dir) + assertNotNull(marker) + assertEquals("java", marker!!.current) + assertEquals(1, marker.provisions.size) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testRemoveProvisionMarkerDeletesFile() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + ProvisionMarkerService.writeProvisionMarker(dir, "java") + assertTrue(ProvisionMarkerService.isProvisioned(dir)) + + val result = ProvisionMarkerService.removeProvisionMarker(dir) + assertTrue(result.isSuccess) + assertFalse(ProvisionMarkerService.isProvisioned(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testRemoveSpecificContextKeepsOthers() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + ProvisionMarkerService.writeProvisionMarker(dir, "java") + ProvisionMarkerService.writeProvisionMarker(dir, "kotlin") + + val result = ProvisionMarkerService.removeProvisionMarker(dir, "java") + assertTrue(result.isSuccess) + + val marker = ProvisionMarkerService.readProvisionMarker(dir) + assertNotNull(marker) + assertEquals(1, marker!!.provisions.size) + assertEquals("kotlin", marker.provisions[0].context) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testRemoveCurrentContextUpdatesCurrent() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + ProvisionMarkerService.writeProvisionMarker(dir, "java") + ProvisionMarkerService.writeProvisionMarker(dir, "kotlin") + + // "kotlin" is current, remove it + ProvisionMarkerService.removeProvisionMarker(dir, "kotlin") + + val marker = ProvisionMarkerService.readProvisionMarker(dir) + assertNotNull(marker) + assertEquals("java", marker!!.current) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testRemoveLastContextDeletesFile() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + ProvisionMarkerService.writeProvisionMarker(dir, "java") + + ProvisionMarkerService.removeProvisionMarker(dir, "java") + assertFalse(ProvisionMarkerService.isProvisioned(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testRemoveNonexistentMarkerReturnsTrue() { + val dir = Files.createTempDirectory("provision-test") + try { + Files.createDirectory(dir.resolve(".git")) + assertTrue(ProvisionMarkerService.removeProvisionMarker(dir).isSuccess) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadIncompleteJsonReturnsNull() { + val dir = Files.createTempDirectory("provision-test") + try { + val gitDir = dir.resolve(".git") + Files.createDirectory(gitDir) + + // Empty JSON object — Gson sets non-null fields to null via Unsafe + Files.writeString(gitDir.resolve("wt-provisioned"), "{}") + assertNull(ProvisionMarkerService.readProvisionMarker(dir)) + + // Missing provisions field + Files.writeString(gitDir.resolve("wt-provisioned"), """{"current":"test"}""") + assertNull(ProvisionMarkerService.readProvisionMarker(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadMalformedJsonReturnsNull() { + val dir = Files.createTempDirectory("provision-test") + try { + val gitDir = dir.resolve(".git") + Files.createDirectory(gitDir) + Files.writeString(gitDir.resolve("wt-provisioned"), "not valid json") + + assertNull(ProvisionMarkerService.readProvisionMarker(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testHasExistingMetadata() { + val dir = Files.createTempDirectory("provision-test") + try { + assertFalse(ProvisionMarkerService.hasExistingMetadata(dir)) + + Files.createDirectory(dir.resolve(".idea")) + assertTrue(ProvisionMarkerService.hasExistingMetadata(dir)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testLinkedWorktreeGitFile() { + val dir = Files.createTempDirectory("provision-test") + try { + // Simulate a linked worktree: .git is a file pointing to a gitdir + val gitWorktreeDir = dir.resolve("gitworktrees").resolve("mybranch") + Files.createDirectories(gitWorktreeDir) + + val worktree = dir.resolve("worktree") + Files.createDirectory(worktree) + Files.writeString(worktree.resolve(".git"), "gitdir: ${gitWorktreeDir}") + + val result = ProvisionMarkerService.writeProvisionMarker(worktree, "test-context") + assertTrue(result.isSuccess) + assertTrue(Files.exists(gitWorktreeDir.resolve("wt-provisioned"))) + assertTrue(ProvisionMarkerService.isProvisionedByContext(worktree, "test-context")) + } finally { + deleteRecursive(dir) + } + } + +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/ExternalChangeWatcherTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/ExternalChangeWatcherTest.kt new file mode 100644 index 0000000..d211b6f --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/ExternalChangeWatcherTest.kt @@ -0,0 +1,145 @@ +package com.block.wt.services + +import com.block.wt.model.ContextConfig +import com.block.wt.util.PathHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +class ExternalChangeWatcherTest { + + private fun makeConfig( + activeWorktree: Path = Path.of("/wt/repos/java/worktrees/guodong"), + mainRepoRoot: Path = Path.of("/wt/repos/java/base"), + ) = ContextConfig( + name = "java", + mainRepoRoot = mainRepoRoot, + worktreesBase = Path.of("/wt/repos/java/worktrees"), + activeWorktree = activeWorktree, + ideaFilesBase = Path.of("/wt/repos/java/idea-files"), + baseBranch = "master", + metadataPatterns = emptyList(), + ) + + // --- isRelevantEvent tests --- + + @Test + fun testConfFileChangeIsRelevant() { + assertTrue(ExternalChangeWatcher.isRelevantEvent("java.conf", PathHelper.reposDir, null)) + } + + @Test + fun testConfFileChangeInWrongDirIsNotRelevant() { + val config = makeConfig() + val gitDir = config.mainRepoRoot.resolve(".git") + assertFalse(ExternalChangeWatcher.isRelevantEvent("something.conf", gitDir, config)) + } + + @Test + fun testCurrentFileChangeIsRelevant() { + assertTrue(ExternalChangeWatcher.isRelevantEvent("current", PathHelper.wtRoot, null)) + } + + @Test + fun testCurrentFileChangeInWrongDirIsNotRelevant() { + assertFalse(ExternalChangeWatcher.isRelevantEvent("current", Path.of("/some/dir"), null)) + } + + @Test + fun testActiveWorktreeSymlinkChangeIsRelevant() { + val config = makeConfig() + assertTrue( + ExternalChangeWatcher.isRelevantEvent("guodong", config.activeWorktree.parent, config) + ) + } + + @Test + fun testActiveWorktreeSymlinkChangeInWrongDirIsNotRelevant() { + val config = makeConfig() + assertFalse( + ExternalChangeWatcher.isRelevantEvent("guodong", Path.of("/some/other/dir"), config) + ) + } + + @Test + fun testUnrelatedFileChangeIsNotRelevant() { + val config = makeConfig() + assertFalse( + ExternalChangeWatcher.isRelevantEvent("unrelated.txt", Path.of("/some/dir"), config) + ) + } + + @Test + fun testGitWorktreesDirChangeIsRelevant() { + val config = makeConfig() + val gitWorktreesDir = config.mainRepoRoot.resolve(".git/worktrees") + assertTrue( + ExternalChangeWatcher.isRelevantEvent("feature-branch", gitWorktreesDir, config) + ) + } + + @Test + fun testGitConfigChangeIsRelevant() { + val config = makeConfig() + val gitDir = config.mainRepoRoot.resolve(".git") + assertTrue( + ExternalChangeWatcher.isRelevantEvent("config", gitDir, config) + ) + } + + @Test + fun testGitDirNonConfigChangeIsNotRelevant() { + val config = makeConfig() + val gitDir = config.mainRepoRoot.resolve(".git") + assertFalse( + ExternalChangeWatcher.isRelevantEvent("HEAD", gitDir, config) + ) + } + + @Test + fun testNullConfigStillDetectsConfAndCurrentChanges() { + assertTrue(ExternalChangeWatcher.isRelevantEvent("foo.conf", PathHelper.reposDir, null)) + assertTrue(ExternalChangeWatcher.isRelevantEvent("current", PathHelper.wtRoot, null)) + assertFalse(ExternalChangeWatcher.isRelevantEvent("random.txt", null, null)) + } + + // --- buildWatchState tests --- + + @Test + fun testBuildWatchStateIncludesActiveWorktreeFileName() { + val config = makeConfig() + val paths = setOf(Path.of("/wt"), Path.of("/wt/repos/java/worktrees")) + val state = ExternalChangeWatcher.buildWatchState(config, paths) + assertEquals("guodong", state.activeWorktreeFileName) + assertEquals(paths, state.paths) + } + + @Test + fun testBuildWatchStateNullConfigReturnsNullFileName() { + val state = ExternalChangeWatcher.buildWatchState(null, emptySet()) + assertEquals(null, state.activeWorktreeFileName) + } + + @Test + fun testBuildWatchStateDiffersWhenActiveWorktreeChanges() { + val config1 = makeConfig(activeWorktree = Path.of("/wt/repos/java/worktrees/guodong")) + val config2 = makeConfig(activeWorktree = Path.of("/wt/repos/java/worktrees/feature")) + val paths = setOf(Path.of("/wt")) + // Same paths, different activeWorktree filename → different state + val state1 = ExternalChangeWatcher.buildWatchState(config1, paths) + val state2 = ExternalChangeWatcher.buildWatchState(config2, paths) + assertNotEquals(state1, state2) + } + + @Test + fun testBuildWatchStateSameWhenNothingChanges() { + val config = makeConfig() + val paths = setOf(Path.of("/wt")) + val state1 = ExternalChangeWatcher.buildWatchState(config, paths) + val state2 = ExternalChangeWatcher.buildWatchState(config, paths) + assertEquals(state1, state2) + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceStaticTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceStaticTest.kt new file mode 100644 index 0000000..3dd21f0 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceStaticTest.kt @@ -0,0 +1,101 @@ +package com.block.wt.services + +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class MetadataServiceStaticTest { + + @Test + fun testExportCreatesSymlinksInVault() { + val dir = Files.createTempDirectory("metadata-test") + try { + val source = dir.resolve("repo") + val vault = dir.resolve("vault") + Files.createDirectories(source) + + // Create metadata directories + Files.createDirectories(source.resolve(".idea")) + Files.createFile(source.resolve(".idea").resolve("workspace.xml")) + Files.createDirectories(source.resolve(".vscode")) + Files.createFile(source.resolve(".vscode").resolve("settings.json")) + + val result = MetadataService.exportMetadataStatic(source, vault, listOf(".idea", ".vscode")) + assertTrue(result.isSuccess) + assertEquals(2, result.getOrThrow()) + + // Vault should contain symlinks + assertTrue(Files.isSymbolicLink(vault.resolve(".idea"))) + assertTrue(Files.isSymbolicLink(vault.resolve(".vscode"))) + + // Symlinks should point to the source directories + assertEquals(source.resolve(".idea"), Files.readSymbolicLink(vault.resolve(".idea"))) + assertEquals(source.resolve(".vscode"), Files.readSymbolicLink(vault.resolve(".vscode"))) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testExportWithNoMatchingPatternsReturnsZero() { + val dir = Files.createTempDirectory("metadata-test") + try { + val source = dir.resolve("repo") + val vault = dir.resolve("vault") + Files.createDirectories(source) + + val result = MetadataService.exportMetadataStatic(source, vault, listOf(".idea")) + assertTrue(result.isSuccess) + assertEquals(0, result.getOrThrow()) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testExportReplacesExistingSymlinks() { + val dir = Files.createTempDirectory("metadata-test") + try { + val source = dir.resolve("repo") + val vault = dir.resolve("vault") + Files.createDirectories(source) + Files.createDirectories(vault) + Files.createDirectories(source.resolve(".idea")) + + // Create an existing symlink pointing somewhere else + val oldTarget = dir.resolve("old-target") + Files.createDirectories(oldTarget) + Files.createSymbolicLink(vault.resolve(".idea"), oldTarget) + + val result = MetadataService.exportMetadataStatic(source, vault, listOf(".idea")) + assertTrue(result.isSuccess) + assertEquals(1, result.getOrThrow()) + + // Symlink should now point to the new source + assertEquals(source.resolve(".idea"), Files.readSymbolicLink(vault.resolve(".idea"))) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testExportCreatesVaultDirectoryIfNotExists() { + val dir = Files.createTempDirectory("metadata-test") + try { + val source = dir.resolve("repo") + val vault = dir.resolve("vault").resolve("nested") + Files.createDirectories(source) + Files.createDirectories(source.resolve(".idea")) + + val result = MetadataService.exportMetadataStatic(source, vault, listOf(".idea")) + assertTrue(result.isSuccess) + assertTrue(Files.isDirectory(vault)) + } finally { + deleteRecursive(dir) + } + } + +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceTest.kt new file mode 100644 index 0000000..9679a03 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/MetadataServiceTest.kt @@ -0,0 +1,97 @@ +package com.block.wt.services + +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class MetadataServiceTest { + + @Test + fun testDeduplicateNested() { + // Use a standalone test for the deduplication logic + val paths = listOf( + Path.of("/repo/.ijwb"), + Path.of("/repo/.ijwb/.idea"), + Path.of("/repo/.vscode"), + Path.of("/repo/subdir/.idea"), + ) + + val result = deduplicateNested(paths) + + assertEquals(3, result.size) + assertTrue(result.contains(Path.of("/repo/.ijwb"))) + assertTrue(result.contains(Path.of("/repo/.vscode"))) + assertTrue(result.contains(Path.of("/repo/subdir/.idea"))) + // .ijwb/.idea should be removed because it's nested under .ijwb + assertTrue(!result.contains(Path.of("/repo/.ijwb/.idea"))) + } + + @Test + fun testDeduplicateNestedEmpty() { + assertEquals(emptyList(), deduplicateNested(emptyList())) + } + + @Test + fun testDeduplicateNestedSingle() { + val paths = listOf(Path.of("/repo/.idea")) + assertEquals(paths, deduplicateNested(paths)) + } + + @Test + fun testCopyDirectoryStructure() { + val sourceDir = Files.createTempDirectory("meta-source") + val targetDir = Files.createTempDirectory("meta-target") + try { + // Create source structure + val subDir = sourceDir.resolve("subdir") + Files.createDirectory(subDir) + Files.writeString(sourceDir.resolve("file1.txt"), "content1") + Files.writeString(subDir.resolve("file2.txt"), "content2") + + // Copy + copyDirectory(sourceDir, targetDir) + + // Verify + assertTrue(Files.exists(targetDir.resolve("file1.txt"))) + assertTrue(Files.exists(targetDir.resolve("subdir/file2.txt"))) + assertEquals("content1", Files.readString(targetDir.resolve("file1.txt"))) + assertEquals("content2", Files.readString(targetDir.resolve("subdir/file2.txt"))) + } finally { + deleteRecursive(sourceDir) + deleteRecursive(targetDir) + } + } + + // Standalone implementations of the logic for testing without IntelliJ platform + private fun deduplicateNested(paths: List): List { + val sorted = paths.sortedBy { it.nameCount } + val kept = mutableListOf() + for (path in sorted) { + val isNested = kept.any { path.startsWith(it) } + if (!isNested) { + kept.add(path) + } + } + return kept + } + + private fun copyDirectory(source: Path, target: Path) { + Files.walkFileTree(source, object : java.nio.file.SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: java.nio.file.attribute.BasicFileAttributes): java.nio.file.FileVisitResult { + val targetDir = target.resolve(source.relativize(dir)) + Files.createDirectories(targetDir) + return java.nio.file.FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: java.nio.file.attribute.BasicFileAttributes): java.nio.file.FileVisitResult { + val targetFile = target.resolve(source.relativize(file)) + Files.copy(file, targetFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + return java.nio.file.FileVisitResult.CONTINUE + } + }) + } + +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/WorktreeServiceTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/WorktreeServiceTest.kt new file mode 100644 index 0000000..25f1d7b --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/services/WorktreeServiceTest.kt @@ -0,0 +1,141 @@ +package com.block.wt.services + +import com.block.wt.git.GitParser +import com.block.wt.model.WorktreeInfo +import com.block.wt.testutil.FakeAgentDetection +import com.block.wt.testutil.FakeProcessRunner +import com.block.wt.util.ProcessHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +class WorktreeServiceTest { + + @Test + fun testParseValidPorcelainOutput() { + val output = """ + worktree /Users/test/repo + HEAD abc123def456 + branch refs/heads/main + + worktree /Users/test/worktrees/feature + HEAD def456abc123 + branch refs/heads/feature/foo + + """.trimIndent() + + val result = GitParser.parsePorcelainOutput(output) + assertEquals(2, result.size) + + assertEquals(Path.of("/Users/test/repo"), result[0].path) + assertEquals("main", result[0].branch) + assertTrue(result[0].isMain) + + assertEquals(Path.of("/Users/test/worktrees/feature"), result[1].path) + assertEquals("feature/foo", result[1].branch) + assertFalse(result[1].isMain) + } + + @Test + fun testParseEmptyOutputReturnsEmptyList() { + val result = GitParser.parsePorcelainOutput("") + assertTrue(result.isEmpty()) + } + + @Test + fun testParseMalformedOutputReturnsEmptyList() { + val result = GitParser.parsePorcelainOutput("garbage\ndata\nhere") + assertTrue(result.isEmpty()) + } + + @Test + fun testAgentEnrichmentWithActiveAgents() { + val featurePath = Path.of("/Users/test/worktrees/feature") + val agentDetection = FakeAgentDetection( + activeDirs = setOf(featurePath), + sessionIds = mapOf(featurePath to listOf("session-abc", "session-def")), + ) + + val worktrees = listOf( + WorktreeInfo( + path = Path.of("/Users/test/repo"), + branch = "main", + head = "abc123", + isMain = true, + ), + WorktreeInfo( + path = featurePath, + branch = "feature/foo", + head = "def456", + ), + ) + + val enriched = enrichAgentStatusWith(worktrees, agentDetection) + + assertFalse(enriched[0].hasActiveAgent) + assertTrue(enriched[0].activeAgentSessionIds.isEmpty()) + + assertTrue(enriched[1].hasActiveAgent) + assertEquals(listOf("session-abc", "session-def"), enriched[1].activeAgentSessionIds) + } + + @Test + fun testAgentEnrichmentWithNoAgents() { + val agentDetection = FakeAgentDetection() + + val worktrees = listOf( + WorktreeInfo( + path = Path.of("/Users/test/repo"), + branch = "main", + head = "abc123", + ), + ) + + val enriched = enrichAgentStatusWith(worktrees, agentDetection) + assertFalse(enriched[0].hasActiveAgent) + } + + @Test + fun testFakeProcessRunnerReturnsConfiguredResponses() { + val runner = FakeProcessRunner( + responses = mapOf( + listOf("git", "worktree", "list", "--porcelain") to ProcessHelper.ProcessResult( + exitCode = 0, + stdout = "worktree /test\nHEAD abc123\nbranch refs/heads/main\n", + stderr = "", + ), + ), + ) + + val result = runner.runGit(listOf("worktree", "list", "--porcelain")) + assertTrue(result.isSuccess) + assertTrue(result.stdout.contains("worktree /test")) + } + + @Test + fun testFakeProcessRunnerReturnsFailureForUnknownCommands() { + val runner = FakeProcessRunner() + val result = runner.runGit(listOf("unknown", "command")) + assertFalse(result.isSuccess) + } + + /** + * Mirrors WorktreeService.enrichAgentStatus using a given AgentDetection. + * Allows testing agent enrichment without a Project/service. + */ + private fun enrichAgentStatusWith( + worktrees: List, + agentDetection: FakeAgentDetection, + ): List { + val agentDirs = agentDetection.detectActiveAgentDirs() + return worktrees.map { wt -> + val hasAgent = agentDirs.any { agentDir -> + agentDir == wt.path + } + val sessionIds = if (hasAgent) agentDetection.detectActiveSessionIds(wt.path) else emptyList() + wt.copy(activeAgentSessionIds = sessionIds) + } + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeAgentDetection.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeAgentDetection.kt new file mode 100644 index 0000000..ba6d87c --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeAgentDetection.kt @@ -0,0 +1,15 @@ +package com.block.wt.testutil + +import com.block.wt.agent.AgentDetection +import java.nio.file.Path + +class FakeAgentDetection( + private val activeDirs: Set = emptySet(), + private val sessionIds: Map> = emptyMap(), +) : AgentDetection { + + override fun detectActiveAgentDirs(): Set = activeDirs + + override fun detectActiveSessionIds(worktreePath: Path): List = + sessionIds[worktreePath] ?: emptyList() +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeProcessRunner.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeProcessRunner.kt new file mode 100644 index 0000000..73305ed --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/FakeProcessRunner.kt @@ -0,0 +1,20 @@ +package com.block.wt.testutil + +import com.block.wt.util.ProcessHelper +import com.block.wt.util.ProcessRunner +import java.nio.file.Path + +class FakeProcessRunner( + private val responses: Map, ProcessHelper.ProcessResult> = emptyMap(), +) : ProcessRunner { + + private val defaultResult = ProcessHelper.ProcessResult(exitCode = 1, stdout = "", stderr = "not configured") + + override fun run(command: List, workingDir: Path?, timeoutSeconds: Long): ProcessHelper.ProcessResult { + return responses[command] ?: defaultResult + } + + override fun runGit(args: List, workingDir: Path?): ProcessHelper.ProcessResult { + return responses[listOf("git") + args] ?: responses[args] ?: defaultResult + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/TestFileHelper.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/TestFileHelper.kt new file mode 100644 index 0000000..7accc5e --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/testutil/TestFileHelper.kt @@ -0,0 +1,16 @@ +package com.block.wt.testutil + +import java.nio.file.Files +import java.nio.file.Path + +object TestFileHelper { + + fun deleteRecursive(path: Path) { + if (Files.isDirectory(path) && !Files.isSymbolicLink(path)) { + Files.list(path).use { stream -> + stream.forEach { deleteRecursive(it) } + } + } + Files.deleteIfExists(path) + } +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/ConfigFileHelperTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/ConfigFileHelperTest.kt new file mode 100644 index 0000000..1bcba86 --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/ConfigFileHelperTest.kt @@ -0,0 +1,230 @@ +package com.block.wt.util + +import com.block.wt.model.ContextConfig +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class ConfigFileHelperTest { + + @Test + fun testReadConfig() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("java.conf") + Files.writeString( + confFile, + """ + WT_MAIN_REPO_ROOT="/Users/test/.wt/repos/java/base" + WT_WORKTREES_BASE="/Users/test/.wt/repos/java/worktrees" + WT_ACTIVE_WORKTREE="/Users/test/java" + WT_IDEA_FILES_BASE="/Users/test/.wt/repos/java/idea-files" + WT_BASE_BRANCH="master" + WT_METADATA_PATTERNS=".bazelbsp .ijwb .vscode .run .idea" + """.trimIndent() + ) + + val config = ConfigFileHelper.readConfig(confFile) + assertNotNull(config) + assertEquals("java", config!!.name) + assertEquals(Path.of("/Users/test/.wt/repos/java/base"), config.mainRepoRoot) + assertEquals(Path.of("/Users/test/.wt/repos/java/worktrees"), config.worktreesBase) + assertEquals(Path.of("/Users/test/java"), config.activeWorktree) + assertEquals(Path.of("/Users/test/.wt/repos/java/idea-files"), config.ideaFilesBase) + assertEquals("master", config.baseBranch) + assertEquals(listOf(".bazelbsp", ".ijwb", ".vscode", ".run", ".idea"), config.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigWithDoubleQuotes() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("test.conf") + Files.writeString( + confFile, + """ + WT_MAIN_REPO_ROOT="/path/with spaces/repo" + WT_WORKTREES_BASE="/path/worktrees" + WT_ACTIVE_WORKTREE="/path/active" + WT_IDEA_FILES_BASE="/path/vault" + WT_BASE_BRANCH="main" + WT_METADATA_PATTERNS=".idea" + """.trimIndent() + ) + + val config = ConfigFileHelper.readConfig(confFile) + assertNotNull(config) + assertEquals(Path.of("/path/with spaces/repo"), config!!.mainRepoRoot) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigMissingFile() { + val config = ConfigFileHelper.readConfig(Path.of("/nonexistent/path.conf")) + assertNull(config) + } + + @Test + fun testReadConfigMissingRequiredField() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("bad.conf") + Files.writeString( + confFile, + """ + WT_BASE_BRANCH="main" + """.trimIndent() + ) + + val config = ConfigFileHelper.readConfig(confFile) + assertNull(config) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteAndReadRoundTrip() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("roundtrip.conf") + + val original = ContextConfig( + name = "roundtrip", + mainRepoRoot = Path.of("/Users/test/repo"), + worktreesBase = Path.of("/Users/test/worktrees"), + activeWorktree = Path.of("/Users/test/active"), + ideaFilesBase = Path.of("/Users/test/vault"), + baseBranch = "main", + metadataPatterns = listOf(".idea", ".vscode"), + ) + + ConfigFileHelper.writeConfig(confFile, original) + val read = ConfigFileHelper.readConfig(confFile) + + assertNotNull(read) + // Name is derived from .conf filename, not from ContextConfig.name + assertEquals("roundtrip", read!!.name) + assertEquals(original.mainRepoRoot, read.mainRepoRoot) + assertEquals(original.worktreesBase, read.worktreesBase) + assertEquals(original.activeWorktree, read.activeWorktree) + assertEquals(original.ideaFilesBase, read.ideaFilesBase) + assertEquals(original.baseBranch, read.baseBranch) + assertEquals(original.metadataPatterns, read.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadConfigDefaultsBaseBranchToMaster() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("nobase.conf") + Files.writeString( + confFile, + """ + WT_MAIN_REPO_ROOT="/Users/test/repo" + WT_WORKTREES_BASE="/Users/test/worktrees" + WT_ACTIVE_WORKTREE="/Users/test/active" + WT_IDEA_FILES_BASE="/Users/test/vault" + """.trimIndent() + ) + + val config = ConfigFileHelper.readConfig(confFile) + assertNotNull(config) + assertEquals("master", config!!.baseBranch) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadCurrentContextReturnsNullWhenFileMissing() { + val file = Path.of("/tmp/nonexistent-wt-test-current") + assertNull(ConfigFileHelper.readCurrentContext(file)) + } + + @Test + fun testReadCurrentContextReturnsNullOnEmptyFile() { + val dir = Files.createTempDirectory("current-test") + try { + val file = dir.resolve("current") + Files.writeString(file, "") + assertNull(ConfigFileHelper.readCurrentContext(file)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteAndReadCurrentContextRoundTrip() { + val dir = Files.createTempDirectory("current-test") + try { + val file = dir.resolve("current") + ConfigFileHelper.writeCurrentContext("java", file) + assertEquals("java", ConfigFileHelper.readCurrentContext(file)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadCurrentContextReadsOnlyFirstLine() { + val dir = Files.createTempDirectory("current-test") + try { + val file = dir.resolve("current") + Files.writeString(file, "java\nextra-line\nmore-stuff") + assertEquals("java", ConfigFileHelper.readCurrentContext(file)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteCurrentContextCreatesParentDirectory() { + val dir = Files.createTempDirectory("current-test") + try { + val file = dir.resolve("subdir").resolve("current") + ConfigFileHelper.writeCurrentContext("go", file) + assertEquals("go", ConfigFileHelper.readCurrentContext(file)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testWriteConfigEmptyPatterns() { + val dir = Files.createTempDirectory("config-test") + try { + val confFile = dir.resolve("empty.conf") + + val config = ContextConfig( + name = "empty", + mainRepoRoot = Path.of("/repo"), + worktreesBase = Path.of("/wt"), + activeWorktree = Path.of("/active"), + ideaFilesBase = Path.of("/vault"), + baseBranch = "main", + metadataPatterns = emptyList(), + ) + + ConfigFileHelper.writeConfig(confFile, config) + val read = ConfigFileHelper.readConfig(confFile) + assertNotNull(read) + assertEquals(emptyList(), read!!.metadataPatterns) + } finally { + deleteRecursive(dir) + } + } + +} diff --git a/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/PathHelperTest.kt b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/PathHelperTest.kt new file mode 100644 index 0000000..d1699dd --- /dev/null +++ b/wt-jetbrains-plugin/src/test/kotlin/com/block/wt/util/PathHelperTest.kt @@ -0,0 +1,147 @@ +package com.block.wt.util + +import com.block.wt.testutil.TestFileHelper.deleteRecursive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class PathHelperTest { + + @Test + fun testExpandTildeWithSubpath() { + val result = PathHelper.expandTilde("~/foo/bar") + val home = System.getProperty("user.home") + assertEquals(Path.of(home, "foo", "bar"), result) + } + + @Test + fun testExpandTildeAlone() { + val result = PathHelper.expandTilde("~") + val home = System.getProperty("user.home") + assertEquals(Path.of(home), result) + } + + @Test + fun testExpandTildeAbsolutePath() { + val result = PathHelper.expandTilde("/absolute/path") + assertEquals(Path.of("/absolute/path"), result) + } + + @Test + fun testAtomicSetSymlinkCreate() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val link = dir.resolve("link") + val target = dir.resolve("target") + Files.createDirectory(target) + + PathHelper.atomicSetSymlink(link, target) + + assertTrue(Files.isSymbolicLink(link)) + assertEquals(target, Files.readSymbolicLink(link)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testAtomicSetSymlinkReplace() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val link = dir.resolve("link") + val target1 = dir.resolve("target1") + val target2 = dir.resolve("target2") + Files.createDirectory(target1) + Files.createDirectory(target2) + + PathHelper.atomicSetSymlink(link, target1) + assertEquals(target1, Files.readSymbolicLink(link)) + + // Atomic replace + PathHelper.atomicSetSymlink(link, target2) + assertEquals(target2, Files.readSymbolicLink(link)) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testAtomicSetSymlinkCleanupOnError() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val link = dir.resolve("nonexistent-parent/link") + + try { + PathHelper.atomicSetSymlink(link, dir) + } catch (_: Exception) { + // Expected + } + + // Verify no temp files left behind in parent + val parentDir = link.parent + if (Files.isDirectory(parentDir)) { + val tempFiles = Files.list(parentDir).use { stream -> + stream.filter { it.fileName.toString().contains(".tmp") }.count() + } + assertEquals(0L, tempFiles) + } + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testIsSymlink() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val target = dir.resolve("target") + Files.createDirectory(target) + val link = dir.resolve("link") + Files.createSymbolicLink(link, target) + + assertTrue(PathHelper.isSymlink(link)) + assertFalse(PathHelper.isSymlink(target)) + assertFalse(PathHelper.isSymlink(dir.resolve("nonexistent"))) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testReadSymlink() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val target = dir.resolve("target") + Files.createDirectory(target) + val link = dir.resolve("link") + Files.createSymbolicLink(link, target) + + val read = PathHelper.readSymlink(link) + assertNotNull(read) + assertEquals(target, read) + + // Non-symlink returns null + val notLink = PathHelper.readSymlink(target) + assertEquals(null, notLink) + } finally { + deleteRecursive(dir) + } + } + + @Test + fun testNormalizeSafe() { + val dir = Files.createTempDirectory("pathhelper-test") + try { + val normalized = PathHelper.normalizeSafe(dir) + assertNotNull(normalized) + assertFalse(normalized.toString().contains("..")) + } finally { + deleteRecursive(dir) + } + } + +}