Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions lib/commands/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -134,7 +140,9 @@ _clean_merged() {
skipped=$((skipped + 1))
fi
fi
done
done <<EOF
$records
EOF

echo ""
if [ "$dry_run" -eq 1 ]; then
Expand Down
65 changes: 33 additions & 32 deletions lib/commands/list.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,27 @@ cmd_list() {

resolve_repo_context || exit 1

local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix"
local repo_root="$_ctx_repo_root"
local records
records=$(list_worktree_records "$repo_root")

# Machine-readable output (porcelain)
if [ "$porcelain" -eq 1 ]; then
# Output: path<tab>branch<tab>status
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 <<EOF
$records
EOF

if [ -d "$base_dir" ]; then
# Find all worktree directories and output: path<tab>branch<tab>status
# 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
Expand All @@ -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 <<EOF
$records
EOF

# Show worktrees sorted by branch name
if [ -d "$base_dir" ]; then
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
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"
}
}
187 changes: 130 additions & 57 deletions lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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_main<TAB>path<TAB>branch<TAB>status
list_worktree_records() {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 <<EOF
$porcelain_output
EOF

_emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable"
}

# Get the status of a worktree from git
# Usage: worktree_status worktree_path
# Returns: status (ok, detached, locked, prunable, or missing)
worktree_status() {
local target_path="$1"
local porcelain_output
local in_section=0
local target_path_canonical
target_path_canonical=$(canonicalize_path "$target_path" || printf "%s" "$target_path")

local status="ok"
local found=0
local repo_root
repo_root=$(_resolve_main_repo_root) || return 1

# Parse git worktree list --porcelain line by line
porcelain_output=$(git worktree list --porcelain 2>/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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 <<EOF
$porcelain_output
$(list_worktree_records "$repo_root")
EOF

# If worktree not found in git's list
Expand Down Expand Up @@ -292,22 +361,15 @@ resolve_target() {
fi

# Last resort: ask git for all worktrees (catches non-gtr-managed worktrees)
local wt_path wt_branch
while IFS= read -r line; do
case "$line" in
"worktree "*) wt_path="${line#worktree }" ;;
"branch "*)
wt_branch="${line#branch refs/heads/}"
if [ "$wt_branch" = "$identifier" ]; then
local is_main=0
[ "$wt_path" = "$repo_root" ] && is_main=1
printf "%s\t%s\t%s\n" "$is_main" "$wt_path" "$wt_branch"
return 0
fi
;;
"") wt_path="" ; wt_branch="" ;;
esac
done < <(git -C "$repo_root" worktree list --porcelain 2>/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 <<EOF
$(list_worktree_records "$repo_root")
EOF

log_error "Worktree not found for branch: $identifier"
return 1
Expand Down Expand Up @@ -549,13 +611,24 @@ resolve_repo_context() {
list_worktree_branches() {
local base_dir="$1"
local prefix="$2"

[ ! -d "$base_dir" ] && return 0

for dir in "$base_dir/${prefix}"*; do
[ -d "$dir" ] || continue
local branch
branch=$(current_branch "$dir")
[ -n "$branch" ] && echo "$branch"
done
local repo_root
repo_root=$(_resolve_main_repo_root) || return 0

# base_dir and prefix are kept for the public helper contract. Worktree
# discovery itself comes from Git's registry so nested registered worktrees
# are included and arbitrary parent directories are ignored.
: "$base_dir" "$prefix"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
local records
records=$(list_worktree_records "$repo_root")

local is_main path branch status
while IFS=$'\t' read -r is_main path branch status; do
[ "$is_main" = "1" ] && continue
[ -z "$branch" ] && continue
[ "$branch" = "(detached)" ] && continue
printf "%s\n" "$branch"
done <<EOF
$records
EOF
}
22 changes: 22 additions & 0 deletions tests/cmd_clean.bats
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ teardown() {
[ ! -d "$TEST_WORKTREES_DIR/merged-force" ]
}

@test "cmd_clean --merged uses nested registered worktree path" {
mkdir -p "$TEST_WORKTREES_DIR/jsmith"
git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet

_clean_detect_provider() { printf "github"; }
ensure_provider_cli() { return 0; }
check_branch_merged() {
[ "$2" = "jsmith/my-feature" ]
}
run_hooks_in() {
printf "preRemove:%s\n" "$2"
return 0
}
run_hooks() { return 0; }

run cmd_clean --merged --force --yes
[ "$status" -eq 0 ]
[ ! -d "$TEST_WORKTREES_DIR/jsmith/my-feature" ]
[[ "$output" == *"preRemove:$TEST_WORKTREES_DIR/jsmith/my-feature"* ]]
[ ! -e "$TEST_WORKTREES_DIR/jsmith/.git" ]
}

@test "cmd_clean --merged --to filters by target ref" {
create_test_worktree "merged-to-main"
create_test_worktree "merged-to-feature"
Expand Down
11 changes: 11 additions & 0 deletions tests/cmd_copy.bats
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ teardown() {
[ -f "$TEST_WORKTREES_DIR/copy-target-2/.env" ]
}

@test "cmd_copy --all copies to nested registered worktree only" {
mkdir -p "$TEST_WORKTREES_DIR/jsmith"
git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet

run cmd_copy --all -- ".env"
[ "$status" -eq 0 ]
[ -f "$TEST_WORKTREES_DIR/copy-target/.env" ]
[ -f "$TEST_WORKTREES_DIR/jsmith/my-feature/.env" ]
[ ! -e "$TEST_WORKTREES_DIR/jsmith/.env" ]
}

@test "cmd_copy --all copies configured includeDirs to all worktrees" {
create_test_worktree "copy-target-2"
mkdir -p "$TEST_REPO/.zed"
Expand Down
Loading
Loading