From c88943fc5ed937c730dfde9d0c0f735f050a8bfa Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Mon, 4 May 2026 14:23:20 -0700 Subject: [PATCH 1/7] Use registered worktree inventory --- lib/commands/clean.sh | 18 ++- lib/commands/list.sh | 65 +++++------ lib/core.sh | 187 +++++++++++++++++++++---------- tests/cmd_clean.bats | 22 ++++ tests/cmd_copy.bats | 11 ++ tests/cmd_list.bats | 24 ++++ tests/core_resolve_target.bats | 31 +++++ tests/integration_lifecycle.bats | 12 ++ 8 files changed, 276 insertions(+), 94 deletions(-) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 2c3c1b1..988b2be 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -72,6 +72,10 @@ _clean_should_skip() { _clean_merged() { local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" force="${6:-0}" active_worktree_path="${7:-}" target_ref="${8:-}" + # base_dir and prefix are kept for the helper contract. Merged cleanup uses + # Git's registry so nested registered worktrees are processed directly. + : "$base_dir" "$prefix" + log_step "Checking for worktrees with merged PRs/MRs..." local provider @@ -84,12 +88,14 @@ _clean_merged() { local removed=0 skipped=0 local main_branch main_branch=$(current_branch "$repo_root") + local records + records=$(list_worktree_records "$repo_root") - for dir in "$base_dir/${prefix}"*; do - [ -d "$dir" ] || continue + local is_main dir branch _status + while IFS=$'\t' read -r is_main dir branch _status; do + [ -z "$dir" ] && continue + [ "$is_main" = "1" ] && continue - local branch - branch=$(current_branch "$dir") || true local branch_tip branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) @@ -134,7 +140,9 @@ _clean_merged() { skipped=$((skipped + 1)) fi fi - done + done <branchstatus - local branch status - branch=$(current_branch "$repo_root") - status=$(worktree_status "$repo_root") - printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" + local is_main path branch status linked_rows="" + while IFS=$'\t' read -r is_main path branch status; do + [ -z "$path" ] && continue + if [ "$is_main" = "1" ]; then + printf "%s\t%s\t%s\n" "$path" "$branch" "$status" + else + linked_rows="${linked_rows}${path}"$'\t'"${branch}"$'\t'"${status}"$'\n' + fi + done <branchstatus - # Exclude the base directory itself to avoid matching when prefix is empty - find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch status - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - status=$(worktree_status "$dir") - printf "%s\t%s\t%s\n" "$dir" "$branch" "$status" - done | LC_ALL=C sort -k2,2 + if [ -n "$linked_rows" ]; then + printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k2,2 -k1,1 fi return 0 fi @@ -41,24 +40,26 @@ cmd_list() { printf "%-30s %s\n" "BRANCH" "PATH" printf "%-30s %s\n" "------" "----" - # Always show repo root first - local branch - branch=$(current_branch "$repo_root") - printf "%-30s %s\n" "$branch [main repo]" "$repo_root" + local is_main path branch status linked_rows="" + while IFS=$'\t' read -r is_main path branch status; do + [ -z "$path" ] && continue + if [ "$is_main" = "1" ]; then + printf "%-30s %s\n" "$branch [main repo]" "$path" + else + linked_rows="${linked_rows}${branch}"$'\t'"${path}"$'\n' + fi + done </dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - printf "%-30s %s\n" "$branch" "$dir" - done | LC_ALL=C sort -k1,1 + if [ -n "$linked_rows" ]; then + printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2 | while IFS=$'\t' read -r branch path; do + [ -z "$path" ] && continue + printf "%-30s %s\n" "$branch" "$path" + done fi echo "" echo "" echo "Tip: Use 'git gtr list --porcelain' for machine-readable output" -} \ No newline at end of file +} diff --git a/lib/core.sh b/lib/core.sh index d630f8a..faca772 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -190,49 +190,118 @@ current_branch() { printf "%s" "$branch" } +_worktree_record_status() { + local detached="$1" locked="$2" prunable="$3" + + if [ "$locked" -eq 1 ]; then + printf "locked" + elif [ "$prunable" -eq 1 ]; then + printf "prunable" + elif [ "$detached" -eq 1 ]; then + printf "detached" + else + printf "ok" + fi +} + +_emit_worktree_record() { + local repo_root="$1" + local wt_path="$2" + local wt_branch="$3" + local wt_detached="$4" + local wt_locked="$5" + local wt_prunable="$6" + + [ -z "$wt_path" ] && return 0 + + local is_main=0 branch="$wt_branch" status + [ "$wt_path" = "$repo_root" ] && is_main=1 + [ -z "$branch" ] && branch="(detached)" + status=$(_worktree_record_status "$wt_detached" "$wt_locked" "$wt_prunable") + + printf "%s\t%s\t%s\t%s\n" "$is_main" "$wt_path" "$branch" "$status" +} + +# List registered git worktrees for a repository. +# Usage: list_worktree_records repo_root +# Output: is_mainpathbranchstatus +list_worktree_records() { + local repo_root="$1" + local repo_root_canonical + repo_root_canonical=$(canonicalize_path "$repo_root" || printf "%s" "$repo_root") + + local porcelain_output + + porcelain_output=$(git -C "$repo_root" worktree list --porcelain 2>/dev/null) || return 0 + + local wt_path="" wt_branch="" wt_detached=0 wt_locked=0 wt_prunable=0 + + local line + while IFS= read -r line; do + case "$line" in + "") + _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" + wt_path="" + wt_branch="" + wt_detached=0 + wt_locked=0 + wt_prunable=0 + ;; + "worktree "*) + if [ -n "$wt_path" ]; then + _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" + wt_branch="" + wt_detached=0 + wt_locked=0 + wt_prunable=0 + fi + wt_path="${line#worktree }" + ;; + "branch refs/heads/"*) + wt_branch="${line#branch refs/heads/}" + ;; + "branch "*) + wt_branch="${line#branch }" + ;; + detached) + wt_detached=1 + ;; + locked*) + wt_locked=1 + ;; + prunable*) + wt_prunable=1 + ;; + esac + done </dev/null) - - while IFS= read -r line; do - # Check if this is the start of our target worktree - if [ "$line" = "worktree $target_path" ]; then - in_section=1 + local is_main path branch record_status + while IFS=$'\t' read -r is_main path branch record_status; do + if [ "$path" = "$target_path" ] || [ "$path" = "$target_path_canonical" ]; then found=1 - continue - fi - - # If we're in the target section, check for status lines - if [ "$in_section" -eq 1 ]; then - # Empty line marks end of section - if [ -z "$line" ]; then - break - fi - - # Check for status indicators (priority: locked > prunable > detached) - case "$line" in - locked*) - status="locked" - ;; - prunable*) - [ "$status" = "ok" ] && status="prunable" - ;; - detached) - [ "$status" = "ok" ] && status="detached" - ;; - esac + status="$record_status" + break fi done </dev/null) + local is_main wt_path wt_branch _wt_status + while IFS=$'\t' read -r is_main wt_path wt_branch _wt_status; do + if [ "$wt_branch" = "$identifier" ]; then + printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch" + return 0 + fi + done < Date: Mon, 4 May 2026 16:01:53 -0700 Subject: [PATCH 2/7] Address CodeRabbit review: safe worktree records --- lib/commands/clean.sh | 110 +++++++++++++++++-------------- lib/commands/list.sh | 80 +++++++++++++++++++---- lib/core.sh | 115 +++++++++++++++++++++++++++------ tests/core_resolve_target.bats | 10 +++ 4 files changed, 235 insertions(+), 80 deletions(-) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 988b2be..9fe47f0 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -91,57 +91,73 @@ _clean_merged() { local records records=$(list_worktree_records "$repo_root") - local is_main dir branch _status - while IFS=$'\t' read -r is_main dir branch _status; do - [ -z "$dir" ] && continue - [ "$is_main" = "1" ] && continue - - local branch_tip - branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) - - # Skip main repo branch silently (not counted) - [ "$branch" = "$main_branch" ] && continue - - # Check if branch has a merged PR/MR - if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then - if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then - skipped=$((skipped + 1)) - continue - fi - - if [ "$dry_run" -eq 1 ]; then - log_info "[dry-run] Would remove: $branch ($dir)" - removed=$((removed + 1)) - elif [ "$yes_mode" -eq 1 ] || prompt_yes_no "Remove worktree and delete branch '$branch'?"; then - log_step "Removing worktree: $branch" - - if ! run_hooks_in preRemove "$dir" \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$dir" \ - BRANCH="$branch"; then - log_warn "Pre-remove hook failed for $branch, skipping" - skipped=$((skipped + 1)) - continue - fi - - if remove_worktree "$dir" "$force"; then - git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true - removed=$((removed + 1)) - - if ! run_hooks postRemove \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$dir" \ - BRANCH="$branch"; then - log_warn "Post-remove hook failed for $branch" + local is_main="" dir="" branch="" line + while IFS= read -r line; do + case "$line" in + "") + if [ -n "$dir" ] && [ "$is_main" != "1" ]; then + local branch_tip + branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) + + # Skip main repo branch silently (not counted) + [ "$branch" = "$main_branch" ] && continue + + # Check if branch has a merged PR/MR + if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then + if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then + skipped=$((skipped + 1)) + continue + fi + + if [ "$dry_run" -eq 1 ]; then + log_info "[dry-run] Would remove: $branch ($dir)" + removed=$((removed + 1)) + elif [ "$yes_mode" -eq 1 ] || prompt_yes_no "Remove worktree and delete branch '$branch'?"; then + log_step "Removing worktree: $branch" + + if ! run_hooks_in preRemove "$dir" \ + REPO_ROOT="$repo_root" \ + WORKTREE_PATH="$dir" \ + BRANCH="$branch"; then + log_warn "Pre-remove hook failed for $branch, skipping" + skipped=$((skipped + 1)) + continue + fi + + if remove_worktree "$dir" "$force"; then + git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true + removed=$((removed + 1)) + + if ! run_hooks postRemove \ + REPO_ROOT="$repo_root" \ + WORKTREE_PATH="$dir" \ + BRANCH="$branch"; then + log_warn "Post-remove hook failed for $branch" + fi + fi + else + log_warn "Skipped: $branch (user declined)" + skipped=$((skipped + 1)) + fi fi fi - else - log_warn "Skipped: $branch (user declined)" - skipped=$((skipped + 1)) - fi - fi + is_main="" + dir="" + branch="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + dir="${line#path }" + ;; + "branch "*) + branch="${line#branch }" + ;; + esac done <branchstatus - local is_main path branch status linked_rows="" - while IFS=$'\t' read -r is_main path branch status; do - [ -z "$path" ] && continue + local is_main="" path="" branch="" status="" linked_rows="" line + while IFS= read -r line; do + case "$line" in + "") + [ -z "$path" ] && continue + if [ "$is_main" = "1" ]; then + printf "%s\t%s\t%s\n" "$path" "$branch" "$status" + else + linked_rows="${linked_rows}${path}"$'\t'"${branch}"$'\t'"${status}"$'\n' + fi + is_main="" + path="" + branch="" + status="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + path="${line#path }" + ;; + "branch "*) + branch="${line#branch }" + ;; + "status "*) + status="${line#status }" + ;; + esac + done <pathbranchstatus +# Output: blank-line-delimited records with is_main/path/branch/status fields list_worktree_records() { local repo_root="$1" local repo_root_canonical @@ -293,17 +296,47 @@ worktree_status() { local repo_root repo_root=$(_resolve_main_repo_root) || return 1 - local is_main path branch record_status - while IFS=$'\t' read -r is_main path branch record_status; do - if [ "$path" = "$target_path" ] || [ "$path" = "$target_path_canonical" ]; then - found=1 - status="$record_status" - break - fi + local is_main="" path="" branch="" record_status="" line path_canonical + while IFS= read -r line; do + case "$line" in + "") + [ -z "$path" ] && continue + path_canonical=$(canonicalize_path "$path" || printf "%s" "$path") + if [ "$path" = "$target_path" ] || [ "$path_canonical" = "$target_path_canonical" ]; then + found=1 + status="$record_status" + break + fi + is_main="" + path="" + branch="" + record_status="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + path="${line#path }" + ;; + "branch "*) + branch="${line#branch }" + ;; + "status "*) + record_status="${line#status }" + ;; + esac done < Date: Mon, 4 May 2026 16:04:42 -0700 Subject: [PATCH 3/7] Address CI inventory assertion --- tests/core_resolve_target.bats | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/core_resolve_target.bats b/tests/core_resolve_target.bats index d5926b9..19166e3 100644 --- a/tests/core_resolve_target.bats +++ b/tests/core_resolve_target.bats @@ -115,11 +115,15 @@ teardown() { records=$(list_worktree_records "$TEST_REPO") local repo_root repo_root=$(cd "$TEST_REPO" && pwd -P) - - [[ "$records" == *"1"$'\t'"$repo_root"$'\t'*$'\t'"ok"* ]] - [[ "$records" == *"0"$'\t'"$TEST_WORKTREES_DIR/records-normal"$'\t'"records-normal"$'\t'"ok"* ]] - [[ "$records" == *"0"$'\t'"$TEST_WORKTREES_DIR/records-detached"$'\t'"(detached)"$'\t'"detached"* ]] - [[ "$records" == *"0"$'\t'"$TEST_WORKTREES_DIR/records-locked"$'\t'"records-locked"$'\t'"locked"* ]] + local normal_path detached_path locked_path + normal_path=$(cd "$TEST_WORKTREES_DIR/records-normal" && pwd -P) + detached_path=$(cd "$TEST_WORKTREES_DIR/records-detached" && pwd -P) + locked_path=$(cd "$TEST_WORKTREES_DIR/records-locked" && pwd -P) + + [[ "$records" == *"is_main 1"$'\n'"path $repo_root"$'\n'*"status ok"* ]] + [[ "$records" == *"is_main 0"$'\n'"path $normal_path"$'\n'"branch records-normal"$'\n'"status ok"* ]] + [[ "$records" == *"is_main 0"$'\n'"path $detached_path"$'\n'"branch (detached)"$'\n'"status detached"* ]] + [[ "$records" == *"is_main 0"$'\n'"path $locked_path"$'\n'"branch records-locked"$'\n'"status locked"* ]] git -C "$TEST_REPO" worktree unlock "$TEST_WORKTREES_DIR/records-locked" } From e97652cc40461cbdc2132741c730ea926ccc75de Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Mon, 4 May 2026 16:10:51 -0700 Subject: [PATCH 4/7] Address CodeRabbit review: worktree root context --- lib/commands/copy.sh | 2 +- lib/core.sh | 15 ++++++++++----- tests/integration_lifecycle.bats | 11 +++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/commands/copy.sh b/lib/commands/copy.sh index 13281c1..6b1c6f5 100644 --- a/lib/commands/copy.sh +++ b/lib/commands/copy.sh @@ -62,7 +62,7 @@ cmd_copy() { # Build target list for --all mode if [ "$all_mode" -eq 1 ]; then local all_branches - all_branches=$(list_worktree_branches "$base_dir" "$prefix") + all_branches=$(list_worktree_branches "$base_dir" "$prefix" "$repo_root") if [ -z "$all_branches" ]; then log_error "No worktrees found" exit 1 diff --git a/lib/core.sh b/lib/core.sh index df4aca0..593b2be 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -214,8 +214,11 @@ _emit_worktree_record() { [ -z "$wt_path" ] && return 0 - local is_main=0 branch="$wt_branch" status - [ "$wt_path" = "$repo_root" ] && is_main=1 + local is_main=0 branch="$wt_branch" status wt_path_canonical + wt_path_canonical=$(canonicalize_path "$wt_path" || printf "%s" "$wt_path") + if [ "$wt_path" = "$repo_root" ] || [ "$wt_path_canonical" = "$repo_root" ]; then + is_main=1 + fi [ -z "$branch" ] && branch="(detached)" status=$(_worktree_record_status "$wt_detached" "$wt_locked" "$wt_prunable") @@ -660,13 +663,15 @@ resolve_repo_context() { } # List all worktree branch names (excluding main repo) -# Usage: list_worktree_branches base_dir prefix +# Usage: list_worktree_branches base_dir prefix [repo_root] # Returns: newline-separated list of branch names list_worktree_branches() { local base_dir="$1" local prefix="$2" - local repo_root - repo_root=$(_resolve_main_repo_root) || return 0 + local repo_root="${3:-}" + if [ -z "$repo_root" ]; then + repo_root=$(_resolve_main_repo_root) || return 0 + fi # base_dir and prefix are kept for the public helper contract. Worktree # discovery itself comes from Git's registry so nested registered worktrees diff --git a/tests/integration_lifecycle.bats b/tests/integration_lifecycle.bats index 6139499..8469e61 100644 --- a/tests/integration_lifecycle.bats +++ b/tests/integration_lifecycle.bats @@ -50,6 +50,17 @@ teardown() { [[ "$branches" != *"(detached)"* ]] } +@test "list_worktree_branches uses explicit repo root outside repo" { + local base_dir="${TEST_REPO}-worktrees" + create_worktree "$base_dir" "" "outside-context" "HEAD" "none" "1" "0" "" "" >/dev/null + cd /tmp || false + + local branches + branches=$(list_worktree_branches "$base_dir" "" "$TEST_REPO") + + [[ "$branches" == *"outside-context"* ]] +} + @test "resolve_target finds worktree by branch name" { local default_branch default_branch=$(git rev-parse --abbrev-ref HEAD) From 30a2627e26277e2d032079ffadf0ebd02077f19a Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Mon, 4 May 2026 16:27:36 -0700 Subject: [PATCH 5/7] Address CodeRabbit review: escape resolved paths --- lib/core.sh | 69 ++++++++++++++++++++++++++++++---- tests/core_resolve_target.bats | 13 +++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index 593b2be..b85dd8d 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -348,9 +348,59 @@ EOF printf "%s" "$status" } +_tsv_escape_field() { + local value="$1" + value=${value//\\/\\\\} + value=${value//$'\t'/\\\\t} + value=${value//$'\n'/\\\\n} + printf "%s" "$value" +} + +_tsv_unescape_field() { + local value="$1" out="" char next + + while [ -n "$value" ]; do + char="${value:0:1}" + value="${value:1}" + + if [ "$char" = "\\" ]; then + if [ -z "$value" ]; then + out="${out}\\" + break + fi + + next="${value:0:1}" + value="${value:1}" + case "$next" in + t) + out="${out}"$'\t' + ;; + n) + out="${out}"$'\n' + ;; + "\\") + out="${out}\\" + ;; + *) + out="${out}\\${next}" + ;; + esac + else + out="${out}${char}" + fi + done + + printf "%s" "$out" +} + +_print_resolved_target() { + local is_main="$1" path="$2" branch="$3" + printf "%s\t%s\t%s\n" "$is_main" "$(_tsv_escape_field "$path")" "$(_tsv_escape_field "$branch")" +} + # Resolve a worktree target from branch name or special ID '1' for main repo # Usage: resolve_target identifier repo_root base_dir prefix -# Returns: tab-separated "is_main\tpath\tbranch" on success (is_main: 1 for main repo, 0 for worktrees) +# Returns: tab-separated "is_main\tpath\tbranch" with escaped fields on success (is_main: 1 for main repo, 0 for worktrees) # Exit code: 0 on success, 1 if not found resolve_target() { local identifier="$1" @@ -363,7 +413,7 @@ resolve_target() { if [ "$identifier" = "1" ]; then path="$repo_root" branch=$(get_current_branch "$repo_root") - printf "1\t%s\t%s\n" "$path" "$branch" + _print_resolved_target "1" "$path" "$branch" return 0 fi @@ -371,7 +421,7 @@ resolve_target() { # First check if it's the current branch in repo root (if not ID 1) branch=$(get_current_branch "$repo_root") if [ "$branch" = "$identifier" ]; then - printf "1\t%s\t%s\n" "$repo_root" "$identifier" + _print_resolved_target "1" "$repo_root" "$identifier" return 0 fi @@ -380,7 +430,7 @@ resolve_target() { path="$base_dir/${prefix}${sanitized_name}" if [ -d "$path" ]; then branch=$(current_branch "$path") - printf "0\t%s\t%s\n" "$path" "$branch" + _print_resolved_target "0" "$path" "$branch" return 0 fi @@ -390,7 +440,7 @@ resolve_target() { [ -d "$dir" ] || continue branch=$(current_branch "$dir") if [ "$branch" = "$identifier" ]; then - printf "0\t%s\t%s\n" "$dir" "$branch" + _print_resolved_target "0" "$dir" "$branch" return 0 fi done @@ -402,7 +452,7 @@ resolve_target() { case "$line" in "") if [ "$wt_branch" = "$identifier" ]; then - printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch" + _print_resolved_target "$is_main" "$wt_path" "$wt_branch" return 0 fi is_main="" @@ -424,7 +474,7 @@ $(list_worktree_records "$repo_root") EOF if [ "$wt_branch" = "$identifier" ]; then - printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch" + _print_resolved_target "$is_main" "$wt_path" "$wt_branch" return 0 fi @@ -436,9 +486,12 @@ EOF # Sets: _ctx_is_main, _ctx_worktree_path, _ctx_branch # Usage: unpack_target "$target_string" unpack_target() { + local escaped_path escaped_branch local IFS=$'\t' # shellcheck disable=SC2162 - read _ctx_is_main _ctx_worktree_path _ctx_branch <<< "$1" + read _ctx_is_main escaped_path escaped_branch <<< "$1" + _ctx_worktree_path=$(_tsv_unescape_field "$escaped_path") + _ctx_branch=$(_tsv_unescape_field "$escaped_branch") } # Resolve an identifier to a worktree and set _ctx_* variables in one step diff --git a/tests/core_resolve_target.bats b/tests/core_resolve_target.bats index 19166e3..9f4fb51 100644 --- a/tests/core_resolve_target.bats +++ b/tests/core_resolve_target.bats @@ -76,6 +76,19 @@ teardown() { [ "$_ctx_branch" = "ctx-test" ] } +@test "resolve_worktree preserves tab in externally registered worktree path" { + local tab_path="${TEST_REPO}-external"$'\t'"tab" + git -C "$TEST_REPO" worktree add "$tab_path" -b resolve-tab-path --quiet + local expected_path + expected_path=$(cd "$tab_path" && pwd -P) + + resolve_worktree "resolve-tab-path" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" + + [ "$_ctx_is_main" = "0" ] + [ "$_ctx_worktree_path" = "$expected_path" ] + [ "$_ctx_branch" = "resolve-tab-path" ] +} + @test "resolve_worktree returns 1 for unknown branch" { run resolve_worktree "nope" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$status" -eq 1 ] From f3c1b37b723f83ad6eaa73f52ea22c9d80ced3e1 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Mon, 4 May 2026 19:25:26 -0700 Subject: [PATCH 6/7] Address CodeRabbit review: resolve escape roundtrip --- lib/core.sh | 7 +++---- tests/core_resolve_target.bats | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index b85dd8d..bb39fac 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -351,8 +351,8 @@ EOF _tsv_escape_field() { local value="$1" value=${value//\\/\\\\} - value=${value//$'\t'/\\\\t} - value=${value//$'\n'/\\\\n} + value=${value//$'\t'/$'\\t'} + value=${value//$'\n'/$'\\n'} printf "%s" "$value" } @@ -488,8 +488,7 @@ EOF unpack_target() { local escaped_path escaped_branch local IFS=$'\t' - # shellcheck disable=SC2162 - read _ctx_is_main escaped_path escaped_branch <<< "$1" + read -r _ctx_is_main escaped_path escaped_branch <<< "$1" _ctx_worktree_path=$(_tsv_unescape_field "$escaped_path") _ctx_branch=$(_tsv_unescape_field "$escaped_branch") } diff --git a/tests/core_resolve_target.bats b/tests/core_resolve_target.bats index 9f4fb51..f48b15f 100644 --- a/tests/core_resolve_target.bats +++ b/tests/core_resolve_target.bats @@ -68,6 +68,14 @@ teardown() { [ "$_ctx_branch" = "main" ] } +@test "resolved target escaping round-trips tabs newlines and backslashes" { + local value="/tmp/path"$'\t'"with"$'\n'"chars\\tail" + local escaped + escaped=$(_tsv_escape_field "$value") + + [ "$(_tsv_unescape_field "$escaped")" = "$value" ] +} + @test "resolve_worktree sets context globals" { create_test_worktree "ctx-test" resolve_worktree "ctx-test" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" From b230dd7f56b9dd383e6aa197068ea809c47d9b49 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Mon, 4 May 2026 19:40:51 -0700 Subject: [PATCH 7/7] Address CodeRabbit review: escape inventory paths --- lib/commands/clean.sh | 4 +-- lib/commands/list.sh | 8 ++--- lib/core.sh | 62 ++++++++++++++++++---------------- tests/core_resolve_target.bats | 25 ++++++++++++++ 4 files changed, 64 insertions(+), 35 deletions(-) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 9fe47f0..a0226f8 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -149,10 +149,10 @@ _clean_merged() { is_main="${line#is_main }" ;; "path "*) - dir="${line#path }" + dir=$(_tsv_unescape_field "${line#path }") ;; "branch "*) - branch="${line#branch }" + branch=$(_tsv_unescape_field "${line#branch }") ;; esac done </dev/null) || return 0 - +_parse_worktree_records() { + local repo_root_canonical="$1" + local delimiter="$2" local wt_path="" wt_branch="" wt_detached=0 wt_locked=0 wt_prunable=0 + local field - local line - while IFS= read -r line; do - case "$line" in + while IFS= read -r -d "$delimiter" field; do + case "$field" in "") _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" wt_path="" @@ -261,13 +252,13 @@ list_worktree_records() { wt_locked=0 wt_prunable=0 fi - wt_path="${line#worktree }" + wt_path="${field#worktree }" ;; "branch refs/heads/"*) - wt_branch="${line#branch refs/heads/}" + wt_branch="${field#branch refs/heads/}" ;; "branch "*) - wt_branch="${line#branch }" + wt_branch="${field#branch }" ;; detached) wt_detached=1 @@ -279,13 +270,26 @@ list_worktree_records() { wt_prunable=1 ;; esac - done </dev/null 2>&1; then + _parse_worktree_records "$repo_root_canonical" "" < <(git -C "$repo_root" worktree list --porcelain -z 2>/dev/null) + else + _parse_worktree_records "$repo_root_canonical" $'\n' < <(git -C "$repo_root" worktree list --porcelain 2>/dev/null) + fi +} + # Get the status of a worktree from git # Usage: worktree_status worktree_path # Returns: status (ok, detached, locked, prunable, or missing) @@ -319,10 +323,10 @@ worktree_status() { is_main="${line#is_main }" ;; "path "*) - path="${line#path }" + path=$(_tsv_unescape_field "${line#path }") ;; "branch "*) - branch="${line#branch }" + branch=$(_tsv_unescape_field "${line#branch }") ;; "status "*) record_status="${line#status }" @@ -463,10 +467,10 @@ resolve_target() { is_main="${line#is_main }" ;; "path "*) - wt_path="${line#path }" + wt_path=$(_tsv_unescape_field "${line#path }") ;; "branch "*) - wt_branch="${line#branch }" + wt_branch=$(_tsv_unescape_field "${line#branch }") ;; esac done </dev/null 2>&1; then + skip "git worktree list --porcelain -z is not available" + fi + + local newline_path="${TEST_REPO}-external"$'\n'"newline" + git -C "$TEST_REPO" worktree add "$newline_path" -b newline-path --quiet + local expected_path + expected_path=$(cd "$newline_path" && pwd -P) + + local records escaped_path + escaped_path=$(_tsv_escape_field "$expected_path") + records=$(list_worktree_records "$TEST_REPO") + + [[ "$records" == *"path $escaped_path"$'\n'"branch newline-path"* ]] + + local status + status=$(worktree_status "$expected_path") + [ "$status" = "ok" ] + + resolve_worktree "newline-path" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" + [ "$_ctx_worktree_path" = "$expected_path" ] + [ "$_ctx_branch" = "newline-path" ] +} + # ── discover_repo_root from worktree ────────────────────────────────────────── @test "discover_repo_root returns main repo root when called from a worktree" {