Skip to content

Commit c88943f

Browse files
committed
Use registered worktree inventory
1 parent 32dc340 commit c88943f

8 files changed

Lines changed: 276 additions & 94 deletions

File tree

lib/commands/clean.sh

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ _clean_should_skip() {
7272
_clean_merged() {
7373
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:-}"
7474

75+
# base_dir and prefix are kept for the helper contract. Merged cleanup uses
76+
# Git's registry so nested registered worktrees are processed directly.
77+
: "$base_dir" "$prefix"
78+
7579
log_step "Checking for worktrees with merged PRs/MRs..."
7680

7781
local provider
@@ -84,12 +88,14 @@ _clean_merged() {
8488
local removed=0 skipped=0
8589
local main_branch
8690
main_branch=$(current_branch "$repo_root")
91+
local records
92+
records=$(list_worktree_records "$repo_root")
8793

88-
for dir in "$base_dir/${prefix}"*; do
89-
[ -d "$dir" ] || continue
94+
local is_main dir branch _status
95+
while IFS=$'\t' read -r is_main dir branch _status; do
96+
[ -z "$dir" ] && continue
97+
[ "$is_main" = "1" ] && continue
9098

91-
local branch
92-
branch=$(current_branch "$dir") || true
9399
local branch_tip
94100
branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)
95101

@@ -134,7 +140,9 @@ _clean_merged() {
134140
skipped=$((skipped + 1))
135141
fi
136142
fi
137-
done
143+
done <<EOF
144+
$records
145+
EOF
138146

139147
echo ""
140148
if [ "$dry_run" -eq 1 ]; then

lib/commands/list.sh

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,27 @@ cmd_list() {
99

1010
resolve_repo_context || exit 1
1111

12-
local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix"
12+
local repo_root="$_ctx_repo_root"
13+
local records
14+
records=$(list_worktree_records "$repo_root")
1315

1416
# Machine-readable output (porcelain)
1517
if [ "$porcelain" -eq 1 ]; then
1618
# Output: path<tab>branch<tab>status
17-
local branch status
18-
branch=$(current_branch "$repo_root")
19-
status=$(worktree_status "$repo_root")
20-
printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status"
19+
local is_main path branch status linked_rows=""
20+
while IFS=$'\t' read -r is_main path branch status; do
21+
[ -z "$path" ] && continue
22+
if [ "$is_main" = "1" ]; then
23+
printf "%s\t%s\t%s\n" "$path" "$branch" "$status"
24+
else
25+
linked_rows="${linked_rows}${path}"$'\t'"${branch}"$'\t'"${status}"$'\n'
26+
fi
27+
done <<EOF
28+
$records
29+
EOF
2130

22-
if [ -d "$base_dir" ]; then
23-
# Find all worktree directories and output: path<tab>branch<tab>status
24-
# Exclude the base directory itself to avoid matching when prefix is empty
25-
find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do
26-
# Skip the base directory itself
27-
[ "$dir" = "$base_dir" ] && continue
28-
local branch status
29-
branch=$(current_branch "$dir")
30-
[ -z "$branch" ] && branch="(detached)"
31-
status=$(worktree_status "$dir")
32-
printf "%s\t%s\t%s\n" "$dir" "$branch" "$status"
33-
done | LC_ALL=C sort -k2,2
31+
if [ -n "$linked_rows" ]; then
32+
printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k2,2 -k1,1
3433
fi
3534
return 0
3635
fi
@@ -41,24 +40,26 @@ cmd_list() {
4140
printf "%-30s %s\n" "BRANCH" "PATH"
4241
printf "%-30s %s\n" "------" "----"
4342

44-
# Always show repo root first
45-
local branch
46-
branch=$(current_branch "$repo_root")
47-
printf "%-30s %s\n" "$branch [main repo]" "$repo_root"
43+
local is_main path branch status linked_rows=""
44+
while IFS=$'\t' read -r is_main path branch status; do
45+
[ -z "$path" ] && continue
46+
if [ "$is_main" = "1" ]; then
47+
printf "%-30s %s\n" "$branch [main repo]" "$path"
48+
else
49+
linked_rows="${linked_rows}${branch}"$'\t'"${path}"$'\n'
50+
fi
51+
done <<EOF
52+
$records
53+
EOF
4854

49-
# Show worktrees sorted by branch name
50-
if [ -d "$base_dir" ]; then
51-
find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do
52-
# Skip the base directory itself
53-
[ "$dir" = "$base_dir" ] && continue
54-
local branch
55-
branch=$(current_branch "$dir")
56-
[ -z "$branch" ] && branch="(detached)"
57-
printf "%-30s %s\n" "$branch" "$dir"
58-
done | LC_ALL=C sort -k1,1
55+
if [ -n "$linked_rows" ]; then
56+
printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2 | while IFS=$'\t' read -r branch path; do
57+
[ -z "$path" ] && continue
58+
printf "%-30s %s\n" "$branch" "$path"
59+
done
5960
fi
6061

6162
echo ""
6263
echo ""
6364
echo "Tip: Use 'git gtr list --porcelain' for machine-readable output"
64-
}
65+
}

lib/core.sh

Lines changed: 130 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -190,49 +190,118 @@ current_branch() {
190190
printf "%s" "$branch"
191191
}
192192

193+
_worktree_record_status() {
194+
local detached="$1" locked="$2" prunable="$3"
195+
196+
if [ "$locked" -eq 1 ]; then
197+
printf "locked"
198+
elif [ "$prunable" -eq 1 ]; then
199+
printf "prunable"
200+
elif [ "$detached" -eq 1 ]; then
201+
printf "detached"
202+
else
203+
printf "ok"
204+
fi
205+
}
206+
207+
_emit_worktree_record() {
208+
local repo_root="$1"
209+
local wt_path="$2"
210+
local wt_branch="$3"
211+
local wt_detached="$4"
212+
local wt_locked="$5"
213+
local wt_prunable="$6"
214+
215+
[ -z "$wt_path" ] && return 0
216+
217+
local is_main=0 branch="$wt_branch" status
218+
[ "$wt_path" = "$repo_root" ] && is_main=1
219+
[ -z "$branch" ] && branch="(detached)"
220+
status=$(_worktree_record_status "$wt_detached" "$wt_locked" "$wt_prunable")
221+
222+
printf "%s\t%s\t%s\t%s\n" "$is_main" "$wt_path" "$branch" "$status"
223+
}
224+
225+
# List registered git worktrees for a repository.
226+
# Usage: list_worktree_records repo_root
227+
# Output: is_main<TAB>path<TAB>branch<TAB>status
228+
list_worktree_records() {
229+
local repo_root="$1"
230+
local repo_root_canonical
231+
repo_root_canonical=$(canonicalize_path "$repo_root" || printf "%s" "$repo_root")
232+
233+
local porcelain_output
234+
235+
porcelain_output=$(git -C "$repo_root" worktree list --porcelain 2>/dev/null) || return 0
236+
237+
local wt_path="" wt_branch="" wt_detached=0 wt_locked=0 wt_prunable=0
238+
239+
local line
240+
while IFS= read -r line; do
241+
case "$line" in
242+
"")
243+
_emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable"
244+
wt_path=""
245+
wt_branch=""
246+
wt_detached=0
247+
wt_locked=0
248+
wt_prunable=0
249+
;;
250+
"worktree "*)
251+
if [ -n "$wt_path" ]; then
252+
_emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable"
253+
wt_branch=""
254+
wt_detached=0
255+
wt_locked=0
256+
wt_prunable=0
257+
fi
258+
wt_path="${line#worktree }"
259+
;;
260+
"branch refs/heads/"*)
261+
wt_branch="${line#branch refs/heads/}"
262+
;;
263+
"branch "*)
264+
wt_branch="${line#branch }"
265+
;;
266+
detached)
267+
wt_detached=1
268+
;;
269+
locked*)
270+
wt_locked=1
271+
;;
272+
prunable*)
273+
wt_prunable=1
274+
;;
275+
esac
276+
done <<EOF
277+
$porcelain_output
278+
EOF
279+
280+
_emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable"
281+
}
282+
193283
# Get the status of a worktree from git
194284
# Usage: worktree_status worktree_path
195285
# Returns: status (ok, detached, locked, prunable, or missing)
196286
worktree_status() {
197287
local target_path="$1"
198-
local porcelain_output
199-
local in_section=0
288+
local target_path_canonical
289+
target_path_canonical=$(canonicalize_path "$target_path" || printf "%s" "$target_path")
290+
200291
local status="ok"
201292
local found=0
293+
local repo_root
294+
repo_root=$(_resolve_main_repo_root) || return 1
202295

203-
# Parse git worktree list --porcelain line by line
204-
porcelain_output=$(git worktree list --porcelain 2>/dev/null)
205-
206-
while IFS= read -r line; do
207-
# Check if this is the start of our target worktree
208-
if [ "$line" = "worktree $target_path" ]; then
209-
in_section=1
296+
local is_main path branch record_status
297+
while IFS=$'\t' read -r is_main path branch record_status; do
298+
if [ "$path" = "$target_path" ] || [ "$path" = "$target_path_canonical" ]; then
210299
found=1
211-
continue
212-
fi
213-
214-
# If we're in the target section, check for status lines
215-
if [ "$in_section" -eq 1 ]; then
216-
# Empty line marks end of section
217-
if [ -z "$line" ]; then
218-
break
219-
fi
220-
221-
# Check for status indicators (priority: locked > prunable > detached)
222-
case "$line" in
223-
locked*)
224-
status="locked"
225-
;;
226-
prunable*)
227-
[ "$status" = "ok" ] && status="prunable"
228-
;;
229-
detached)
230-
[ "$status" = "ok" ] && status="detached"
231-
;;
232-
esac
300+
status="$record_status"
301+
break
233302
fi
234303
done <<EOF
235-
$porcelain_output
304+
$(list_worktree_records "$repo_root")
236305
EOF
237306

238307
# If worktree not found in git's list
@@ -292,22 +361,15 @@ resolve_target() {
292361
fi
293362

294363
# Last resort: ask git for all worktrees (catches non-gtr-managed worktrees)
295-
local wt_path wt_branch
296-
while IFS= read -r line; do
297-
case "$line" in
298-
"worktree "*) wt_path="${line#worktree }" ;;
299-
"branch "*)
300-
wt_branch="${line#branch refs/heads/}"
301-
if [ "$wt_branch" = "$identifier" ]; then
302-
local is_main=0
303-
[ "$wt_path" = "$repo_root" ] && is_main=1
304-
printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch"
305-
return 0
306-
fi
307-
;;
308-
"") wt_path="" ; wt_branch="" ;;
309-
esac
310-
done < <(git -C "$repo_root" worktree list --porcelain 2>/dev/null)
364+
local is_main wt_path wt_branch _wt_status
365+
while IFS=$'\t' read -r is_main wt_path wt_branch _wt_status; do
366+
if [ "$wt_branch" = "$identifier" ]; then
367+
printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch"
368+
return 0
369+
fi
370+
done <<EOF
371+
$(list_worktree_records "$repo_root")
372+
EOF
311373

312374
log_error "Worktree not found for branch: $identifier"
313375
return 1
@@ -549,13 +611,24 @@ resolve_repo_context() {
549611
list_worktree_branches() {
550612
local base_dir="$1"
551613
local prefix="$2"
552-
553-
[ ! -d "$base_dir" ] && return 0
554-
555-
for dir in "$base_dir/${prefix}"*; do
556-
[ -d "$dir" ] || continue
557-
local branch
558-
branch=$(current_branch "$dir")
559-
[ -n "$branch" ] && echo "$branch"
560-
done
614+
local repo_root
615+
repo_root=$(_resolve_main_repo_root) || return 0
616+
617+
# base_dir and prefix are kept for the public helper contract. Worktree
618+
# discovery itself comes from Git's registry so nested registered worktrees
619+
# are included and arbitrary parent directories are ignored.
620+
: "$base_dir" "$prefix"
621+
622+
local records
623+
records=$(list_worktree_records "$repo_root")
624+
625+
local is_main path branch status
626+
while IFS=$'\t' read -r is_main path branch status; do
627+
[ "$is_main" = "1" ] && continue
628+
[ -z "$branch" ] && continue
629+
[ "$branch" = "(detached)" ] && continue
630+
printf "%s\n" "$branch"
631+
done <<EOF
632+
$records
633+
EOF
561634
}

tests/cmd_clean.bats

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ teardown() {
145145
[ ! -d "$TEST_WORKTREES_DIR/merged-force" ]
146146
}
147147

148+
@test "cmd_clean --merged uses nested registered worktree path" {
149+
mkdir -p "$TEST_WORKTREES_DIR/jsmith"
150+
git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet
151+
152+
_clean_detect_provider() { printf "github"; }
153+
ensure_provider_cli() { return 0; }
154+
check_branch_merged() {
155+
[ "$2" = "jsmith/my-feature" ]
156+
}
157+
run_hooks_in() {
158+
printf "preRemove:%s\n" "$2"
159+
return 0
160+
}
161+
run_hooks() { return 0; }
162+
163+
run cmd_clean --merged --force --yes
164+
[ "$status" -eq 0 ]
165+
[ ! -d "$TEST_WORKTREES_DIR/jsmith/my-feature" ]
166+
[[ "$output" == *"preRemove:$TEST_WORKTREES_DIR/jsmith/my-feature"* ]]
167+
[ ! -e "$TEST_WORKTREES_DIR/jsmith/.git" ]
168+
}
169+
148170
@test "cmd_clean --merged --to filters by target ref" {
149171
create_test_worktree "merged-to-main"
150172
create_test_worktree "merged-to-feature"

tests/cmd_copy.bats

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ teardown() {
8080
[ -f "$TEST_WORKTREES_DIR/copy-target-2/.env" ]
8181
}
8282

83+
@test "cmd_copy --all copies to nested registered worktree only" {
84+
mkdir -p "$TEST_WORKTREES_DIR/jsmith"
85+
git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet
86+
87+
run cmd_copy --all -- ".env"
88+
[ "$status" -eq 0 ]
89+
[ -f "$TEST_WORKTREES_DIR/copy-target/.env" ]
90+
[ -f "$TEST_WORKTREES_DIR/jsmith/my-feature/.env" ]
91+
[ ! -e "$TEST_WORKTREES_DIR/jsmith/.env" ]
92+
}
93+
8394
@test "cmd_copy --all copies configured includeDirs to all worktrees" {
8495
create_test_worktree "copy-target-2"
8596
mkdir -p "$TEST_REPO/.zed"

0 commit comments

Comments
 (0)