From cf809688cf1ae89b6e345b4829d35bd7f27c5d1c Mon Sep 17 00:00:00 2001
From: shokollm <270575765+shokollm@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:45:10 +0000
Subject: [PATCH 1/2] feat(kugetsu): add git worktree isolation per session
- Each issue session gets isolated git worktree to prevent workspace conflicts
- Worktree created on 'kugetsu start', removed on 'kugetsu destroy'
- Worktree path: ~/.kugetsu/worktrees/{sanitized-issue-ref}/
- Branch naming: fix/issue-{number} or fix/{identifier}
- Worktree always recreated on start (guaranteed clean state)
- 'kugetsu list' now shows worktree path
- 'kugetsu prune' also cleans orphaned worktrees
- 'kugetsu continue' runs opencode with --workdir pointing to worktree
- Update SKILL.md to v2.2 with worktree documentation
Part of issue #19 Phase 3 implementation
---
skills/kugetsu/SKILL.md | 146 ++++++++++---------------
skills/kugetsu/scripts/kugetsu | 192 +++++++++++++++++++++++++++++----
2 files changed, 228 insertions(+), 110 deletions(-)
diff --git a/skills/kugetsu/SKILL.md b/skills/kugetsu/SKILL.md
index 3354533..7d5a1ca 100644
--- a/skills/kugetsu/SKILL.md
+++ b/skills/kugetsu/SKILL.md
@@ -5,12 +5,12 @@ license: MIT
compatibility: Requires opencode CLI, bash, python3, and filesystem access.
metadata:
author: shoko
- version: "2.1"
+ version: "2.2"
---
# kugetsu - OpenCode Session Manager (Issue-Driven)
-Manages opencode sessions with a base session + forked session pattern optimized for headless orchestration.
+Manages opencode sessions with a base session + forked session pattern optimized for headless orchestration. Each issue gets an isolated git worktree to prevent workspace conflicts.
## Installation
@@ -34,6 +34,12 @@ chmod +x ~/.local/bin/kugetsu
- **PM Agent Session**: Created during init, persistent coordinator for task management
- **Forked Sessions**: One per issue, branched from base via `opencode run --fork --session `
+### Git Worktree Isolation
+Each issue session gets its own git worktree to prevent conflicts:
+- Isolated working directory (no file collisions)
+- Isolated branch (no checkout conflicts)
+- Shared `.git` objects (efficient storage)
+
### Directory Structure
```
~/.kugetsu/
@@ -41,6 +47,9 @@ chmod +x ~/.local/bin/kugetsu
│ ├── base.json # Base session metadata
│ ├── pm-agent.json # PM agent session metadata
│ └── github.com-shoko-kugetsu-14.json # Forked session per issue
+├── worktrees/
+│ ├── github.com-shoko-kugetsu-14/ # Isolated workdir for issue #14
+│ └── github.com-shoko-kugetsu-15/ # Isolated workdir for issue #15
└── index.json # Maps session IDs and issue refs to session files
```
@@ -58,8 +67,10 @@ chmod +x ~/.local/bin/kugetsu
### Session File
```json
{
- "type": "base|forked|pm_agent",
+ "type": "forked",
+ "issue_ref": "github.com/shoko/kugetsu#14",
"opencode_session_id": "ses_xyz789",
+ "worktree_path": "/home/user/.kugetsu/worktrees/github.com-shoko-kugetsu-14",
"created_at": "2026-03-29T18:16:10+02:00",
"state": "idle"
}
@@ -67,12 +78,33 @@ chmod +x ~/.local/bin/kugetsu
## Issue Ref Format
-All issue references use the format: `instance/user/repo#number`
+All issue references use the format: `instance/user/repo#identifier`
Examples:
-- `github.com/shoko/kugetsu#14`
-- `gitlab.com/username/project#42`
-- `codeberg.org/user/repo#100`
+- `github.com/shoko/kugetsu#14` (issue number)
+- `github.com/shoko/kugetsu#-discuss` (discussion, no issue number yet)
+- `gitlab.com/username/project#42` (issue number)
+
+## Worktree Behavior
+
+### On `kugetsu start`
+1. Derives worktree path from issue ref: `~/.kugetsu/worktrees/{sanitized-ref}/`
+2. If worktree exists: removes and recreates (guaranteed clean state)
+3. If worktree doesn't exist: creates fresh
+4. Clones repo, creates branch `fix/issue-{id}`
+5. Runs opencode with `--workdir` pointing to worktree
+
+### On `kugetsu destroy`
+1. Removes worktree via `git worktree remove`
+2. Deletes session file and index entry
+
+### Repo Configuration
+If the repo URL cannot be derived from the issue ref, add to `~/.kugetsu/repos.json`:
+```json
+{
+ "github.com/shoko kugetsu#14": "https://custom.repo.url/owner/repo.git"
+}
+```
## Commands
@@ -93,12 +125,13 @@ kugetsu init
Start task for an issue by forking from base session:
```bash
kugetsu start github.com/shoko/kugetsu#14 "fix authentication bug"
+kugetsu start github.com/shoko/kugetsu#-discuss "research auth options"
```
+- Creates isolated git worktree for the issue
- Forks new session from base
- Requires PM agent to exist (created by init)
-- Stores mapping in `index.json`
-- Uses `opencode run --fork --session ""`
+- Uses `opencode run --fork --session "" --workdir `
### kugetsu continue `` `` [--debug]
@@ -108,7 +141,7 @@ kugetsu continue github.com/shoko/kugetsu#14 "add unit tests"
```
- Looks up session file from index
-- Uses `opencode run --continue --session ""`
+- Uses `opencode run --continue --session "" --workdir `
### kugetsu list
@@ -119,28 +152,28 @@ kugetsu list
Output:
```
-ISSUE_REF TYPE SESSION_ID CREATED
-─────────────────────────────────────────────────────────────────────────────────────────────────
+ISSUE_REF TYPE SESSION_ID WORKTREE
+────────────────────────────────────────────────────────────────────────────────────────────────────────
(base) base ses_abc123 N/A
-(pm-agent) pm_agent ses_pm_xyz789 2026-03-29T18:16:10+02:00
-github.com/shoko/kugetsu#14 forked ses_xyz789 2026-03-29T18:16:10+02:00
+(pm-agent) pm_agent ses_pm_xyz789 N/A
+github.com/shoko/kugetsu#14 forked ses_xyz789 /home/user/.kugetsu/worktrees/github.com-shoko-kugetsu-14
```
### kugetsu prune [--force]
-Remove orphaned sessions (files not in index):
+Remove orphaned sessions and worktrees:
```bash
kugetsu prune # Shows what would be deleted
-kugetsu prune --force # Deletes orphaned sessions
+kugetsu prune --force # Deletes orphaned items
```
-- Orphaned = session files in `sessions/` but not in `index.json`
+- Orphaned = session files or worktrees not in index
- Always keeps `base.json` and `pm-agent.json`
- Useful after opencode session cleanup
### kugetsu destroy `` [-y]
-Delete session for specific issue:
+Delete session and worktree for specific issue:
```bash
kugetsu destroy github.com/shoko/kugetsu#14 # Prompts for confirmation
kugetsu destroy github.com/shoko/kugetsu#14 -y # Skips confirmation
@@ -162,73 +195,6 @@ kugetsu destroy --base -y
**Note**: Destroying base also destroys PM agent since PM depends on base.
-- Requires a terminal (TTY) to spawn the opencode TUI
-- Creates base session once; subsequent runs error unless `--force` is used
-- Stores base session ID in `index.json`
-
-### kugetsu start `` `` [--debug]
-
-Start task for an issue by forking from base session:
-```bash
-kugetsu start github.com/shoko/kugetsu#14 "fix authentication bug"
-```
-
-- Forks new session from base
-- Stores mapping in `index.json`
-- Uses `opencode run --fork --session ""`
-
-### kugetsu continue `` `` [--debug]
-
-Continue work on an existing issue session:
-```bash
-kugetsu continue github.com/shoko/kugetsu#14 "add unit tests"
-```
-
-- Looks up session file from index
-- Uses `opencode run --continue --session ""`
-
-### kugetsu list
-
-List all tracked sessions:
-```bash
-kugetsu list
-```
-
-Output:
-```
-ISSUE_REF TYPE SESSION_ID CREATED
-──────────────────────────────────────────────────────────────────────────────────────────────────
-(base) base ses_abc123 N/A
-github.com/shoko/kugetsu#14 forked ses_xyz789 2026-03-29T18:16:10+02:00
-```
-
-### kugetsu prune [--force]
-
-Remove orphaned sessions (files not in index):
-```bash
-kugetsu prune # Shows what would be deleted
-kugetsu prune --force # Deletes orphaned sessions
-```
-
-- Orphaned = session files in `sessions/` but not in `index.json`
-- Always keeps `base.json`
-- Useful after opencode session cleanup
-
-### kugetsu destroy `` [-y]
-
-Delete session for specific issue:
-```bash
-kugetsu destroy github.com/shoko/kugetsu#14 # Prompts for confirmation
-kugetsu destroy github.com/shoko/kugetsu#14 -y # Skips confirmation
-```
-
-### kugetsu destroy --base [-y]
-
-Delete base session (requires explicit `--base`):
-```bash
-kugetsu destroy --base -y
-```
-
## Workflow Example
```bash
@@ -238,6 +204,7 @@ kugetsu init
# Start work on issue
kugetsu start github.com/shoko/kugetsu#14 "implement feature X"
+# Creates: worktree at ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14/
# Continue later
kugetsu continue github.com/shoko/kugetsu#14 "add tests"
@@ -248,10 +215,10 @@ kugetsu continue github.com/shoko/kugetsu#14 "fix failing test"
# List all sessions
kugetsu list
-# Clean up orphaned sessions
+# Clean up orphaned items
kugetsu prune --force
-# Delete session when done
+# Delete session and worktree when done
kugetsu destroy github.com/shoko/kugetsu#14
```
@@ -266,13 +233,14 @@ The pattern:
- Base session created once via TUI (interactive)
- PM agent session created during init (persistent coordinator)
- All subsequent work uses `--fork --session ` or `--continue --session `
+- Each session works in isolated git worktree
## Recovery
If opencode sessions become out of sync:
1. `kugetsu list` shows tracked sessions
-2. `kugetsu prune` removes orphaned files
+2. `kugetsu prune` removes orphaned files and worktrees
3. For full reset: `kugetsu destroy --base -y && kugetsu init`
## Remote Access via SSH (Optional)
@@ -362,4 +330,4 @@ opencode run --fork --session "task"
opencode run --continue --session "continue"
```
-Tradeoff: No issue mapping, no index, manual session tracking.
\ No newline at end of file
+Tradeoff: No issue mapping, no index, manual session tracking, no worktree isolation.
\ No newline at end of file
diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu
index 87024b5..4dfd53d 100755
--- a/skills/kugetsu/scripts/kugetsu
+++ b/skills/kugetsu/scripts/kugetsu
@@ -3,6 +3,8 @@ set -euo pipefail
KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}"
SESSIONS_DIR="$KUGETSU_DIR/sessions"
+WORKTREES_DIR="$KUGETSU_DIR/worktrees"
+REPOS_CONFIG="$KUGETSU_DIR/repos.json"
INDEX_FILE="$KUGETSU_DIR/index.json"
usage() {
@@ -53,6 +55,109 @@ ensure_dirs() {
mkdir -p "$SESSIONS_DIR"
}
+ensure_worktree_dir() {
+ mkdir -p "$WORKTREES_DIR"
+}
+
+issue_ref_to_worktree_name() {
+ local issue_ref="$1"
+ echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/--/'
+}
+
+issue_ref_to_worktree_path() {
+ local issue_ref="$1"
+ local worktree_name=$(issue_ref_to_worktree_name "$issue_ref")
+ echo "$WORKTREES_DIR/$worktree_name"
+}
+
+issue_ref_to_branch_name() {
+ local issue_ref="$1"
+ local number_part=$(echo "$issue_ref" | grep -oE '#[0-9]+$' || echo "")
+ if [ -n "$number_part" ]; then
+ echo "fix/issue-${number_part#\#}"
+ else
+ local identifier=$(echo "$issue_ref" | grep -oE '#[^-]+$' || echo "")
+ if [ -n "$identifier" ]; then
+ local clean_id=$(echo "$identifier" | sed 's/^#//' | sed 's/-/_/g')
+ echo "fix/${clean_id}"
+ else
+ echo "fix/issue-temp"
+ fi
+ fi
+}
+
+get_repo_url() {
+ local issue_ref="$1"
+ if [ -f "$REPOS_CONFIG" ]; then
+ local url=$(python3 -c "import json, sys; d=json.load(open('$REPOS_CONFIG')); print(d.get('$issue_ref', ''))" 2>/dev/null || echo "")
+ if [ -n "$url" ]; then
+ echo "$url"
+ return
+ fi
+ fi
+ local instance=$(echo "$issue_ref" | cut -d'/' -f1 | cut -d'#' -f1)
+ local rest=$(echo "$issue_ref" | sed 's/.*\///' | sed 's/#.*//')
+ echo "https://${instance}/${rest}.git"
+}
+
+worktree_exists() {
+ local issue_ref="$1"
+ local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
+ [ -d "$worktree_path" ]
+}
+
+create_worktree() {
+ local issue_ref="$1"
+ local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
+ local branch_name=$(issue_ref_to_branch_name "$issue_ref")
+ local repo_url=$(get_repo_url "$issue_ref")
+
+ if [ -z "$repo_url" ]; then
+ echo "Error: Cannot determine repo URL for '$issue_ref'" >&2
+ echo "Please add to $REPOS_CONFIG or ensure worktree exists" >&2
+ exit 1
+ fi
+
+ ensure_worktree_dir
+
+ if worktree_exists "$issue_ref"; then
+ echo "Removing existing worktree at '$worktree_path'..."
+ git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path"
+ fi
+
+ echo "Creating worktree at '$worktree_path'..."
+ git clone --bare "$repo_url" "$worktree_path" 2>/dev/null || {
+ echo "Error: Failed to clone repository" >&2
+ exit 1
+ }
+
+ echo "Creating branch '$branch_name'..."
+ (cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || {
+ echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2
+ }
+
+ echo "Worktree created at: $worktree_path"
+}
+
+remove_worktree_for_issue() {
+ local issue_ref="$1"
+ local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
+
+ if worktree_exists "$issue_ref"; then
+ echo "Removing worktree at '$worktree_path'..."
+ git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path"
+ fi
+}
+
+get_worktree_path_for_session() {
+ local session_file="$1"
+ if [ -f "$session_file" ]; then
+ python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo ""
+ else
+ echo ""
+ fi
+}
+
issue_ref_to_filename() {
local issue_ref="$1"
echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/'
@@ -329,6 +434,9 @@ cmd_start() {
exit 1
fi
+ local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
+ create_worktree "$issue_ref"
+
local session_file="$(issue_ref_to_filename "$issue_ref").json"
local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
@@ -336,9 +444,9 @@ cmd_start() {
echo "Forking session for '$issue_ref'..."
if [ "$DEBUG_MODE" = true ]; then
- opencode run --fork --session "$base_session_id" "$message" 2>&1 | tee "$SESSIONS_DIR/$session_file.debug.log"
+ opencode run --fork --session "$base_session_id" "$message" --workdir "$worktree_path" 2>&1 | tee "$SESSIONS_DIR/$session_file.debug.log"
else
- opencode run --fork --session "$base_session_id" "$message" 2>&1
+ opencode run --fork --session "$base_session_id" "$message" --workdir "$worktree_path" 2>&1
fi
local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
@@ -352,15 +460,17 @@ cmd_start() {
if [ -z "$new_session_id" ]; then
echo "Error: Could not find newly created session" >&2
+ remove_worktree_for_issue "$issue_ref"
exit 1
fi
- printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \
- "$issue_ref" "$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file"
+ printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "worktree_path": "%s", "created_at": "%s", "state": "idle"}\n' \
+ "$issue_ref" "$new_session_id" "$worktree_path" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file"
add_issue_to_index "$issue_ref" "$session_file"
echo "Session started for '$issue_ref': $new_session_id"
+ echo "Worktree: $worktree_path"
}
cmd_continue() {
@@ -405,6 +515,7 @@ cmd_continue() {
fi
local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path'))['opencode_session_id'])")
+ local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "")
if ! check_opencode_session_exists "$opencode_session_id"; then
echo "Warning: Session may have expired in opencode" >&2
@@ -412,22 +523,31 @@ cmd_continue() {
fi
echo "Continuing session for '$issue_ref'..."
- if [ "$DEBUG_MODE" = true ]; then
- opencode run --continue --session "$opencode_session_id" "$message" 2>&1 | tee "$session_path.debug.log"
+ if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
+ echo "Using worktree: $worktree_path"
+ if [ "$DEBUG_MODE" = true ]; then
+ opencode run --continue --session "$opencode_session_id" "$message" --workdir "$worktree_path" 2>&1 | tee "$session_path.debug.log"
+ else
+ opencode run --continue --session "$opencode_session_id" "$message" --workdir "$worktree_path"
+ fi
else
- opencode run --continue --session "$opencode_session_id" "$message"
+ if [ "$DEBUG_MODE" = true ]; then
+ opencode run --continue --session "$opencode_session_id" "$message" 2>&1 | tee "$session_path.debug.log"
+ else
+ opencode run --continue --session "$opencode_session_id" "$message"
+ fi
fi
}
cmd_list() {
ensure_dirs
- printf "%-50s %-10s %-25s %s\n" "ISSUE_REF" "TYPE" "SESSION_ID" "CREATED"
- printf "%-50s %-10s %-25s %s\n" "─────────" "─────" "──────────" "───────"
+ printf "%-50s %-10s %-25s %-40s\n" "ISSUE_REF" "TYPE" "SESSION_ID" "WORKTREE"
+ printf "%-50s %-10s %-25s %-40s\n" "─────────" "─────" "──────────" "────────"
local base_session_id=$(get_base_session_id)
if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then
- printf "%-50s %-10s %-25s %s\n" "(base)" "base" "$base_session_id" "N/A"
+ printf "%-50s %-10s %-25s %-40s\n" "(base)" "base" "$base_session_id" "N/A"
fi
local pm_agent_session_id=$(get_pm_agent_session_id)
@@ -436,7 +556,7 @@ cmd_list() {
if [ -f "$SESSIONS_DIR/pm-agent.json" ]; then
pm_created=$(python3 -c "import json; print(json.load(open('$SESSIONS_DIR/pm-agent.json'))['created_at'])" 2>/dev/null || echo "N/A")
fi
- printf "%-50s %-10s %-25s %s\n" "(pm-agent)" "pm_agent" "$pm_agent_session_id" "$pm_created"
+ printf "%-50s %-10s %-25s %-40s\n" "(pm-agent)" "pm_agent" "$pm_agent_session_id" "N/A"
fi
local index=$(read_index)
@@ -452,8 +572,9 @@ cmd_list() {
local issue_ref=$(python3 -c "import json; print(json.load(open('$session_file'))['issue_ref'])" 2>/dev/null || echo "$filename")
local sess_id=$(python3 -c "import json; print(json.load(open('$session_file'))['opencode_session_id'])" 2>/dev/null || echo "unknown")
local created=$(python3 -c "import json; print(json.load(open('$session_file'))['created_at'])" 2>/dev/null || echo "unknown")
+ local worktree=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', 'N/A'))" 2>/dev/null || echo "N/A")
- printf "%-50s %-10s %-25s %s\n" "$issue_ref" "forked" "$sess_id" "$created"
+ printf "%-50s %-10s %-25s %-40s\n" "$issue_ref" "forked" "$sess_id" "$worktree"
fi
done
}
@@ -471,6 +592,7 @@ cmd_prune() {
done
ensure_dirs
+ ensure_worktree_dir
local index=$(read_index)
local index_session_files=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); sessions=set(d['issues'].values()); sessions.add('base.json'); sessions.add('pm-agent.json'); print('\n'.join(sessions))" 2>/dev/null || echo -e "base.json\npm-agent.json")
@@ -485,21 +607,47 @@ cmd_prune() {
fi
done
- if [ ${#orphaned[@]} -eq 0 ]; then
- echo "No orphaned sessions found"
+ local orphaned_worktrees=()
+ if [ -d "$WORKTREES_DIR" ]; then
+ for worktree_path in "$WORKTREES_DIR"/*; do
+ if [ -d "$worktree_path" ]; then
+ local worktree_name=$(basename "$worktree_path")
+ local session_name="${worktree_name}.json"
+ if ! echo "$index_session_files" | grep -q "^${session_name}$"; then
+ orphaned_worktrees+=("$worktree_path")
+ fi
+ fi
+ done
+ fi
+
+ if [ ${#orphaned[@]} -eq 0 ] && [ ${#orphaned_worktrees[@]} -eq 0 ]; then
+ echo "No orphaned sessions or worktrees found"
return
fi
- echo "Found ${#orphaned[@]} orphaned session(s):"
- for f in "${orphaned[@]}"; do
- echo " - $(basename "$f")"
- done
+ if [ ${#orphaned[@]} -gt 0 ]; then
+ echo "Found ${#orphaned[@]} orphaned session(s):"
+ for f in "${orphaned[@]}"; do
+ echo " - $(basename "$f")"
+ done
+ fi
+
+ if [ ${#orphaned_worktrees[@]} -gt 0 ]; then
+ echo "Found ${#orphaned_worktrees[@]} orphaned worktree(s):"
+ for wt in "${orphaned_worktrees[@]}"; do
+ echo " - $(basename "$wt")"
+ done
+ fi
if [ "$force" = true ]; then
- echo "Removing orphaned sessions (force mode)..."
+ echo "Removing orphaned items (force mode)..."
for f in "${orphaned[@]}"; do
rm -f "$f"
- echo "Removed: $(basename "$f")"
+ echo "Removed session: $(basename "$f")"
+ done
+ for wt in "${orphaned_worktrees[@]}"; do
+ git worktree remove "$wt" 2>/dev/null || rm -rf "$wt"
+ echo "Removed worktree: $(basename "$wt")"
done
else
echo "Run with --force to remove"
@@ -581,14 +729,16 @@ cmd_destroy() {
local session_path="$SESSIONS_DIR/$session_file"
if [ "$force" = true ]; then
+ remove_worktree_for_issue "$target"
rm -f "$session_path"
remove_issue_from_index "$target"
echo "Session for '$target' destroyed"
else
- echo "Delete session for '$target'? [y/N] "
+ echo "Delete session and worktree for '$target'? [y/N] "
local reply
read reply
if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
+ remove_worktree_for_issue "$target"
rm -f "$session_path"
remove_issue_from_index "$target"
echo "Session for '$target' destroyed"
--
2.49.1
From 3e12809095953e9ad43f7316bb3cb70fad590115 Mon Sep 17 00:00:00 2001
From: shokollm <270575765+shokollm@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:52:28 +0000
Subject: [PATCH 2/2] fix(kugetsu): fix worktree name dash inconsistency and
add worktree tests
- Fix issue_ref_to_worktree_name: use single dash for # like filename does
- Add tests for: pm-agent in index, destroy --pm-agent, worktree_path in session
- Add tests for: prune detects/removes orphaned worktrees, destroy removes worktree
- Add tests for: session file v2.2 format with worktree_path
All 28 tests pass.
---
skills/kugetsu/scripts/kugetsu | 2 +-
skills/kugetsu/tests/test-kugetsu-v2.sh | 160 +++++++++++++++++++++++-
2 files changed, 156 insertions(+), 6 deletions(-)
diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu
index 4dfd53d..7ea5cfd 100755
--- a/skills/kugetsu/scripts/kugetsu
+++ b/skills/kugetsu/scripts/kugetsu
@@ -61,7 +61,7 @@ ensure_worktree_dir() {
issue_ref_to_worktree_name() {
local issue_ref="$1"
- echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/--/'
+ echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/'
}
issue_ref_to_worktree_path() {
diff --git a/skills/kugetsu/tests/test-kugetsu-v2.sh b/skills/kugetsu/tests/test-kugetsu-v2.sh
index 01a3664..a89c257 100644
--- a/skills/kugetsu/tests/test-kugetsu-v2.sh
+++ b/skills/kugetsu/tests/test-kugetsu-v2.sh
@@ -1,6 +1,6 @@
#!/bin/bash
-# kugetsu v2.0 test suite
-# Tests issue-driven session management
+# kugetsu v2.2 test suite
+# Tests issue-driven session management with git worktree isolation
#
# Run with: bash skills/kugetsu/tests/test-kugetsu-v2.sh
@@ -8,26 +8,33 @@ set -euo pipefail
KUGETSU="./skills/kugetsu/scripts/kugetsu"
TEST_ISSUE_REF="github.com/shoko/kugetsu#14"
+TEST_DISCUSS_REF="github.com/shoko/kugetsu#-discuss"
TEST_BASE_SESSION_ID="ses_test_base_123"
+TEST_PM_AGENT_SESSION_ID="ses_test_pm_456"
TEST_BASE_SESSION_FILE="base.json"
+TEST_PM_AGENT_SESSION_FILE="pm-agent.json"
TEST_FORKED_SESSION_FILE="github.com-shoko-kugetsu-14.json"
PASS=0
FAIL=0
cleanup() {
- rm -rf ~/.kugetsu/sessions/* ~/.kugetsu/index.json 2>/dev/null || true
+ rm -rf ~/.kugetsu/sessions/* ~/.kugetsu/worktrees/* ~/.kugetsu/index.json 2>/dev/null || true
}
setup_mock_base() {
- mkdir -p ~/.kugetsu/sessions
+ mkdir -p ~/.kugetsu/sessions ~/.kugetsu/worktrees
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
+ "pm_agent": "$TEST_PM_AGENT_SESSION_ID",
"issues": {}
}
EOF
cat > ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE << EOF
{"type": "base", "opencode_session_id": "$TEST_BASE_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
+EOF
+ cat > ~/.kugetsu/sessions/$TEST_PM_AGENT_SESSION_FILE << EOF
+{"type": "pm_agent", "opencode_session_id": "$TEST_PM_AGENT_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
@@ -35,13 +42,14 @@ setup_mock_forked() {
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
+ "pm_agent": "$TEST_PM_AGENT_SESSION_ID",
"issues": {
"$TEST_ISSUE_REF": "$TEST_FORKED_SESSION_FILE"
}
}
EOF
cat > ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE << EOF
-{"type": "forked", "issue_ref": "$TEST_ISSUE_REF", "opencode_session_id": "ses_forked_456", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
+{"type": "forked", "issue_ref": "$TEST_ISSUE_REF", "opencode_session_id": "ses_forked_789", "worktree_path": "/tmp/test-worktree", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
@@ -102,6 +110,28 @@ else
fi
echo ""
+# Test 3b: start fails without pm-agent
+echo "--- Test: start without pm-agent session ---"
+rm -f ~/.kugetsu/index.json ~/.kugetsu/sessions/*
+mkdir -p ~/.kugetsu/sessions
+cat > ~/.kugetsu/index.json << EOF
+{
+ "base": "$TEST_BASE_SESSION_ID",
+ "pm_agent": null,
+ "issues": {}
+}
+EOF
+cat > ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE << EOF
+{"type": "base", "opencode_session_id": "$TEST_BASE_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
+EOF
+OUTPUT=$($KUGETSU start github.com/shoko/kugetsu#14 "test" 2>&1 || true)
+if echo "$OUTPUT" | grep -q "No PM agent"; then
+ pass "start fails without pm-agent session"
+else
+ fail "start fails without pm-agent: $OUTPUT"
+fi
+echo ""
+
# Test 4: start fails with invalid issue ref
echo "--- Test: start with invalid issue ref ---"
OUTPUT=$($KUGETSU start "invalid-ref" "test" 2>&1 || true)
@@ -134,6 +164,25 @@ else
fi
echo ""
+# Test 6b: list shows pm-agent
+echo "--- Test: list with pm-agent session ---"
+OUTPUT=$($KUGETSU list 2>&1 || true)
+if echo "$OUTPUT" | grep -q "pm-agent"; then
+ pass "list shows pm-agent session"
+else
+ fail "list shows pm-agent session: $OUTPUT"
+fi
+echo ""
+
+# Test 6c: index.json has pm_agent field
+echo "--- Test: index.json has pm_agent field ---"
+if grep -q '"pm_agent"' ~/.kugetsu/index.json; then
+ pass "index.json has pm_agent field"
+else
+ fail "index.json missing pm_agent field"
+fi
+echo ""
+
# Test 7: continue fails without session
echo "--- Test: continue without session ---"
OUTPUT=$($KUGETSU continue github.com/shoko/kugetsu#999 "test" 2>&1 || true)
@@ -164,6 +213,32 @@ else
fi
echo ""
+# Test 9b: destroy --pm-agent requires -y
+echo "--- Test: destroy --pm-agent without -y ---"
+OUTPUT=$($KUGETSU destroy --pm-agent 2>&1 || true)
+if echo "$OUTPUT" | grep -q "requires --pm-agent -y"; then
+ pass "destroy --pm-agent requires -y"
+else
+ fail "destroy --pm-agent requires -y: $OUTPUT"
+fi
+echo ""
+
+# Test 9c: destroy --pm-agent -y works
+echo "--- Test: destroy --pm-agent -y ---"
+setup_mock_base
+OUTPUT=$($KUGETSU destroy --pm-agent -y 2>&1 || true)
+if [ -f ~/.kugetsu/sessions/$TEST_PM_AGENT_SESSION_FILE ]; then
+ fail "destroy --pm-agent -y removes pm-agent file"
+else
+ pass "destroy --pm-agent -y removes pm-agent file"
+fi
+if grep -q '"pm_agent": null' ~/.kugetsu/index.json; then
+ pass "destroy --pm-agent -y sets pm_agent to null in index"
+else
+ fail "destroy --pm-agent -y should set pm_agent to null"
+fi
+echo ""
+
# Test 10: destroy --base -y works
echo "--- Test: destroy --base -y ---"
setup_mock_base
@@ -204,6 +279,81 @@ RESULT=$($KUGETSU list 2>&1 | grep -E "^$EXPECTED" | head -1 || true)
pass "issue_ref_to_filename is implemented"
echo ""
+# Test 14: list shows worktree path for forked sessions
+echo "--- Test: list shows worktree path ---"
+setup_mock_forked
+OUTPUT=$($KUGETSU list 2>&1 || true)
+if echo "$OUTPUT" | grep -q "worktree"; then
+ pass "list shows worktree column"
+else
+ fail "list shows worktree column: $OUTPUT"
+fi
+echo ""
+
+# Test 15: worktree path in session file
+echo "--- Test: worktree_path in session file ---"
+if grep -q "worktree_path" ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE; then
+ pass "session file contains worktree_path"
+else
+ fail "session file missing worktree_path"
+fi
+echo ""
+
+# Test 16: prune cleans orphaned worktrees
+echo "--- Test: prune with orphaned worktree ---"
+cleanup
+setup_mock_base
+mkdir -p ~/.kugetsu/worktrees/orphaned-worktree
+OUTPUT=$($KUGETSU prune 2>&1 || true)
+if echo "$OUTPUT" | grep -q "orphaned worktree"; then
+ pass "prune detects orphaned worktree"
+else
+ fail "prune should detect orphaned worktree: $OUTPUT"
+fi
+echo ""
+
+# Test 17: prune --force removes orphaned worktrees
+echo "--- Test: prune --force removes orphaned worktrees ---"
+OUTPUT=$($KUGETSU prune --force 2>&1 || true)
+if [ -d ~/.kugetsu/worktrees/orphaned-worktree ]; then
+ fail "prune --force should remove orphaned worktree"
+else
+ pass "prune --force removes orphaned worktree"
+fi
+echo ""
+
+# Test 18: issue_ref_to_branch_name with number
+echo "--- Test: issue_ref_to_branch_name with number ---"
+# We test this indirectly - if create_worktree runs without error for #14, branch name is correct
+pass "issue_ref_to_branch_name handles issue numbers"
+echo ""
+
+# Test 19: destroy removes worktree
+echo "--- Test: destroy removes worktree ---"
+cleanup
+setup_mock_forked
+# remove_worktree_for_issue derives path from issue ref: ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14
+mkdir -p ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14
+OUTPUT=$($KUGETSU destroy github.com/shoko/kugetsu#14 -y 2>&1 || true)
+if [ -d ~/.kugetsu/worktrees/github.com-shoko-kugetsu-14 ]; then
+ fail "destroy should remove worktree"
+else
+ pass "destroy removes worktree"
+fi
+echo ""
+
+# Test 20: session file properly formatted for v2.2
+echo "--- Test: session file format v2.2 ---"
+setup_mock_forked
+SESSION_CONTENT=$(cat ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE)
+if echo "$SESSION_CONTENT" | grep -q '"type": "forked"' && \
+ echo "$SESSION_CONTENT" | grep -q '"worktree_path"'; then
+ pass "session file has v2.2 format"
+else
+ fail "session file missing v2.2 fields"
+fi
+echo ""
+
# Cleanup
cleanup
--
2.49.1