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
This commit is contained in:
shokollm
2026-03-30 13:45:10 +00:00
parent 12ad4eb3b7
commit cf809688cf
2 changed files with 228 additions and 110 deletions

View File

@@ -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"