From f85e0f40dbbf8e32a52c1d3cf67402b803acec79 Mon Sep 17 00:00:00 2001 From: adamwett Date: Sun, 28 Jun 2026 19:48:26 -0400 Subject: [PATCH 1/5] feat: inherit sparse-checkout in new worktrees When creating a new worktree from one with sparse-checkout enabled, the new worktree inherits the cone pattern automatically. Controlled by gtr.sparse.inherit config (default on) and --sparse/--no-sparse flags. Adds reusable helpers for sparse-checkout replication. --- README.md | 3 + completions/_git-gtr | 6 +- completions/git-gtr.fish | 3 + completions/gtr.bash | 6 +- docs/advanced-usage.md | 29 ++++++ docs/configuration.md | 26 ++++++ lib/commands/create.sh | 32 ++++++- lib/commands/help.sh | 2 + lib/config.sh | 1 + lib/core.sh | 103 ++++++++++++++++++++- scripts/generate-completions.sh | 6 +- templates/.gtrconfig.example | 6 ++ tests/sparse.bats | 154 ++++++++++++++++++++++++++++++++ 13 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 tests/sparse.bats diff --git a/README.md b/README.md index 03adf33..502d135 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,9 @@ git gtr config add gtr.copy.include "**/.env.example" # Run setup after creating worktrees git gtr config add gtr.hook.postCreate "npm install" +# Inherit sparse-checkout from the base worktree (default: on; --no-sparse to opt out) +git gtr config set gtr.sparse.inherit true + # Re-source environment after gtr cd or gtr new --cd (runs in current shell) git gtr config add gtr.hook.postCd "source ./vars.sh" diff --git a/completions/_git-gtr b/completions/_git-gtr index ac3c6b5..ece9e33 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -67,6 +67,8 @@ _git-gtr() { '--no-copy[Skip file copying]' \ '--no-fetch[Skip git fetch]' \ '--no-hooks[Skip post-create hooks]' \ + '--sparse[Inherit sparse-checkout from base worktree]' \ + '--no-sparse[Force a full checkout]' \ '--force[Allow same branch in multiple worktrees]' \ '--name[Custom folder name suffix]:name:' \ '--folder[Custom folder name (replaces default)]:folder:' \ @@ -187,7 +189,7 @@ _git-gtr() { '--local[Use local git config]' \ '--global[Use global git config]' \ '--system[Use system git config]' \ - '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color)' + '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.sparse.inherit gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color)' ;; set|add|unset) # Write operations only support --local and --global @@ -195,7 +197,7 @@ _git-gtr() { _arguments \ '--local[Use local git config]' \ '--global[Use global git config]' \ - '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color)' + '*:config key:(gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.sparse.inherit gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color)' ;; esac fi diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index ca9e464..7ad432c 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -66,6 +66,8 @@ complete -c git -n '__fish_git_gtr_using_command new' -l track -d 'Track mode' - complete -c git -n '__fish_git_gtr_using_command new' -l no-copy -d 'Skip file copying' complete -c git -n '__fish_git_gtr_using_command new' -l no-fetch -d 'Skip git fetch' complete -c git -n '__fish_git_gtr_using_command new' -l no-hooks -d 'Skip post-create hooks' +complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree' +complete -c git -n '__fish_git_gtr_using_command new' -l no-sparse -d 'Force a full checkout' complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same branch in multiple worktrees' complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r complete -c git -n '__fish_git_gtr_using_command new' -l folder -d 'Custom folder name (replaces default)' -r @@ -143,6 +145,7 @@ complete -f -c git -n '__fish_git_gtr_using_command config' -a " gtr.ai.default 'Default AI tool' gtr.worktrees.dir 'Worktrees base directory' gtr.worktrees.prefix 'Worktree folder prefix' + gtr.sparse.inherit 'gtr.sparse.inherit' gtr.defaultBranch 'Default branch' gtr.defaultRemote 'Default remote' gtr.provider 'Hosting provider (github, gitlab)' diff --git a/completions/gtr.bash b/completions/gtr.bash index 329b06f..1819496 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -99,7 +99,7 @@ _git_gtr() { new) # Complete flags if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --editor -e --ai -a" -- "$cur")) elif [ "$prev" = "--track" ]; then COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi @@ -138,7 +138,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global --system" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.sparse.inherit gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color" -- "$cur")) fi ;; set|add|unset) @@ -146,7 +146,7 @@ _git_gtr() { if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--local --global" -- "$cur")) else - COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color" -- "$cur")) + COMPREPLY=($(compgen -W "gtr.copy.include gtr.copy.exclude gtr.copy.includeDirs gtr.copy.excludeDirs gtr.hook.postCreate gtr.hook.preRemove gtr.hook.postRemove gtr.hook.postCd gtr.editor.default gtr.editor.workspace gtr.ai.default gtr.worktrees.dir gtr.worktrees.prefix gtr.sparse.inherit gtr.defaultBranch gtr.defaultRemote gtr.provider gtr.ui.color" -- "$cur")) fi ;; esac diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 6d16336..65c2374 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -15,6 +15,7 @@ - [CI/CD Integration](#cicd-integration) - [Multiple Worktrees Same Branch](#multiple-worktrees-same-branch) - [Parallel AI Development](#parallel-ai-development) +- [Sparse-Checkout Inheritance](#sparse-checkout-inheritance) --- @@ -202,4 +203,32 @@ git gtr ai feature-auth-tests -- --message "Write integration tests" --- +## Sparse-Checkout Inheritance + +When working in a large monorepo, a base worktree often uses [sparse-checkout](https://git-scm.com/docs/git-sparse-checkout) to materialize only a slice of the tree. `gtr` carries that slice into the worktrees you branch off it, so feature worktrees stay lean instead of exploding into the full repo. + +```bash +# my-app is a sparse worktree checking out only apps/my-app + packages. +# A feature branch off it inherits the same cone automatically: +git gtr new my-app-feature-xyz --from my-app + +# The new worktree contains only the inherited sparse slice: +ls "$(git gtr go my-app-feature-xyz)" +git -C "$(git gtr go my-app-feature-xyz)" sparse-checkout list + +# Opt out for a single command (full checkout): +git gtr new big-refactor --from my-app --no-sparse +``` + +**How it works:** + +- gtr inspects the worktree holding the base ref (`--from`, falling back to the current worktree). If it has sparse-checkout enabled, the new worktree is created with `--no-checkout` and the same cone (or pattern set) is applied — the full tree is never written to disk. +- Controlled by `gtr.sparse.inherit` (default on). Use `--sparse` / `--no-sparse` to override per command. +- Full-checkout repositories are unaffected — they always get a full checkout. + +> [!NOTE] +> Sparse-checkout is per-worktree, not per-branch. Inheriting "from `my-app`" copies the live sparse settings of the `my-app` worktree, not anything stored on the branch itself. + +--- + [Back to README](../README.md) | [Configuration](configuration.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/configuration.md b/docs/configuration.md index 25c81b5..8216349 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,6 +110,32 @@ echo "/.worktrees/" >> .gitignore --- +## Sparse-Checkout Settings + +If the worktree you branch from uses [sparse-checkout](https://git-scm.com/docs/git-sparse-checkout) (e.g. a slice of a large monorepo), `gtr` can give the new worktree the same narrowed working tree instead of an expensive full checkout. + +```bash +# Inherit sparse-checkout from the base worktree (default: true) +gtr.sparse.inherit = true +``` + +When enabled, `git gtr new` looks at the worktree holding the base ref (the `--from` target, falling back to the current worktree). If that worktree has sparse-checkout on, the new worktree is created with `--no-checkout` and the same cone (or pattern set) is applied — so the full tree is never materialized. Full-checkout repositories are unaffected. + +Per-command overrides: + +```bash +# Force inheritance even if gtr.sparse.inherit is off +git gtr new feature-xyz --from my-app --sparse + +# Force a full checkout even if gtr.sparse.inherit is on +git gtr new feature-xyz --from my-app --no-sparse +``` + +> [!NOTE] +> Sparse-checkout is stored per-worktree, not per-branch. "Inherit from `my-app`" means inherit the live sparse settings of the `my-app` *worktree*. + +--- + ## Provider Settings The `clean --merged` and `clean --closed` commands auto-detect your hosting provider from the `origin` remote URL (`github.com` → GitHub, `gitlab.com` → GitLab). For self-hosted instances, set the provider explicitly: diff --git a/lib/commands/create.sh b/lib/commands/create.sh index 917861a..1a840f6 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -94,6 +94,8 @@ cmd_create() { --no-copy --no-fetch --no-hooks +--sparse +--no-sparse --yes --force --name: value @@ -110,6 +112,8 @@ cmd_create() { local skip_copy="${_arg_no_copy:-0}" local skip_fetch="${_arg_no_fetch:-0}" local skip_hooks="${_arg_no_hooks:-0}" + local sparse_flag="${_arg_sparse:-0}" + local no_sparse_flag="${_arg_no_sparse:-0}" local yes_mode="${_arg_yes:-0}" local force="${_arg_force:-0}" local custom_name="${_arg_name:-}" @@ -156,6 +160,27 @@ cmd_create() { # Determine from_ref with precedence: --from > --from-current > default from_ref=$(_create_resolve_from_ref "$from_ref" "$from_current" "$repo_root" "$remote") + # Decide whether to inherit sparse-checkout from the base worktree. + # Precedence: --no-sparse > --sparse > gtr.sparse.inherit (default on). + local sparse_inherit=0 + if [ "$no_sparse_flag" -eq 1 ]; then + sparse_inherit=0 + elif [ "$sparse_flag" -eq 1 ]; then + sparse_inherit=1 + elif cfg_bool gtr.sparse.inherit true; then + sparse_inherit=1 + fi + + local sparse_source="" no_checkout=0 + if [ "$sparse_inherit" -eq 1 ]; then + sparse_source=$(_resolve_sparse_source "$from_ref") + if [ -n "$sparse_source" ]; then + no_checkout=1 + elif [ "$sparse_flag" -eq 1 ]; then + log_warn "No sparse-checkout source found for '$from_ref' — creating a full checkout" + fi + fi + # Construct folder name for display local folder_name if [ -n "$folder_override" ]; then @@ -172,10 +197,15 @@ cmd_create() { # Create the worktree local worktree_path - if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override" "$remote"); then + if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override" "$remote" "$no_checkout"); then exit 1 fi + # Inherit sparse-checkout before copying so copied files land in the narrowed tree + if [ -n "$sparse_source" ]; then + apply_inherited_sparse "$worktree_path" "$sparse_source" || log_warn "Sparse-checkout inheritance incomplete" + fi + # Copy files based on patterns if [ "$skip_copy" -eq 0 ]; then _post_create_copy "$repo_root" "$worktree_path" diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 9af8711..63e81c9 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -23,6 +23,8 @@ Options: --no-copy Skip file copying (gtr.copy.include patterns) --no-fetch Skip git fetch before creating --no-hooks Skip post-create hooks + --sparse Inherit sparse-checkout from the base worktree + --no-sparse Force a full checkout (override gtr.sparse.inherit) --force Allow same branch in multiple worktrees (requires --name or --folder to distinguish them) --name Custom folder name suffix (appended after branch name) diff --git a/lib/config.sh b/lib/config.sh index 0cc842c..0881278 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -107,6 +107,7 @@ _CFG_KEY_MAP=( "gtr.ai.default|defaults.ai" "gtr.worktrees.dir|worktrees.dir" "gtr.worktrees.prefix|worktrees.prefix" + "gtr.sparse.inherit|sparse.inherit" "gtr.defaultBranch|defaults.branch" "gtr.defaultRemote|defaults.remote" "gtr.provider|defaults.provider" diff --git a/lib/core.sh b/lib/core.sh index 4f5bdad..abc9272 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -581,18 +581,118 @@ _worktree_add_tracked() { "$@" "$branch_name" } +# Find the worktree that has a given ref checked out. +# Parses `git worktree list --porcelain` and matches the branch name as given +# or with a leading remote segment stripped (so "origin/foo" matches "foo"). +# Usage: _worktree_path_for_ref +# Prints: worktree path on match, empty otherwise. +_worktree_path_for_ref() { + local ref="$1" + [ -n "$ref" ] || return 0 + + local ref_short="${ref##*/}" + local line wt_path="" branch + while IFS= read -r line; do + case "$line" in + "worktree "*) + wt_path="${line#worktree }" + ;; + "branch "*) + branch="${line#branch }" + branch="${branch#refs/heads/}" + if [ "$branch" = "$ref" ] || [ "$branch" = "$ref_short" ]; then + printf "%s" "$wt_path" + return 0 + fi + ;; + esac + done < <(git worktree list --porcelain 2>/dev/null || true) + return 0 +} + +# Resolve which worktree's sparse-checkout config a new worktree should inherit. +# Prefers the worktree holding from_ref, falling back to the current worktree. +# Only prints a path if that worktree actually has sparse-checkout enabled. +# Usage: _resolve_sparse_source +# Prints: source worktree path if sparse-enabled, empty otherwise. +_resolve_sparse_source() { + local from_ref="$1" + local src + + src=$(_worktree_path_for_ref "$from_ref") + if [ -z "$src" ]; then + src=$(git rev-parse --show-toplevel 2>/dev/null || true) + fi + [ -n "$src" ] || return 0 + + local enabled + enabled=$(git -C "$src" config --bool core.sparseCheckout 2>/dev/null || true) + [ "$enabled" = "true" ] || return 0 + + printf "%s" "$src" +} + +# Replicate a source worktree's sparse-checkout config into a new worktree. +# Handles both cone and non-cone modes. Materializes the cone from the index +# (the new worktree should have been created with --no-checkout). +# Usage: apply_inherited_sparse +# Returns: 0 on success; 1 (with warning) if any git step fails. +apply_inherited_sparse() { + local new_wt="$1" src_wt="$2" + + local cone + cone=$(git -C "$src_wt" config --bool core.sparseCheckoutCone 2>/dev/null || true) + + if [ "$cone" = "true" ]; then + local dirs=() dir + while IFS= read -r dir; do + [ -n "$dir" ] && dirs+=("$dir") + done < <(git -C "$src_wt" sparse-checkout list 2>/dev/null || true) + + if ! git -C "$new_wt" sparse-checkout init --cone >/dev/null 2>&1; then + log_warn "Could not enable sparse-checkout in new worktree" + return 1 + fi + if [ "${#dirs[@]}" -gt 0 ] && ! git -C "$new_wt" sparse-checkout set "${dirs[@]}" >/dev/null 2>&1; then + log_warn "Could not apply inherited sparse-checkout patterns" + return 1 + fi + else + if ! git -C "$new_wt" sparse-checkout init >/dev/null 2>&1; then + log_warn "Could not enable sparse-checkout in new worktree" + return 1 + fi + if ! git -C "$src_wt" sparse-checkout list 2>/dev/null \ + | git -C "$new_wt" sparse-checkout set --stdin >/dev/null 2>&1; then + log_warn "Could not apply inherited sparse-checkout patterns" + return 1 + fi + fi + + # Materialize the working tree (worktree was created with --no-checkout). + if ! git -C "$new_wt" checkout >/dev/null 2>&1; then + log_warn "Could not check out files into the new sparse worktree" + return 1 + fi + + log_info "Inherited sparse-checkout from $src_wt" + return 0 +} + # Create a new git worktree -# Usage: create_worktree base_dir prefix branch_name from_ref track_mode [skip_fetch] [force] [custom_name] [folder_override] [remote] +# Usage: create_worktree base_dir prefix branch_name from_ref track_mode [skip_fetch] [force] [custom_name] [folder_override] [remote] [no_checkout] # track_mode: auto, remote, local, or none # skip_fetch: 0 (default, fetch) or 1 (skip) # force: 0 (default, check branch) or 1 (allow same branch in multiple worktrees) # custom_name: optional custom name suffix (e.g., "backend" creates "feature-auth-backend") # folder_override: optional complete folder name override (replaces default naming) +# no_checkout: 0 (default) or 1 (pass --no-checkout, e.g. for deferred sparse-checkout) create_worktree() { local base_dir="$1" prefix="$2" branch_name="$3" from_ref="$4" local track_mode="${5:-auto}" skip_fetch="${6:-0}" force="${7:-0}" local custom_name="${8:-}" folder_override="${9:-}" local remote="${10:-$(resolve_default_remote)}" + local no_checkout="${11:-0}" local sanitized_name sanitized_name=$(_resolve_folder_name "$branch_name" "$custom_name" "$folder_override") || return 1 @@ -600,6 +700,7 @@ create_worktree() { local worktree_path="$base_dir/${prefix}${sanitized_name}" local force_args=() [ "$force" -eq 1 ] && force_args=(--force) + [ "$no_checkout" -eq 1 ] && force_args+=(--no-checkout) if [ -d "$worktree_path" ]; then log_error "Worktree $sanitized_name already exists at $worktree_path" diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index f324d54..12b688a 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -193,7 +193,7 @@ MIDDLE1 new) # Complete flags if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --editor -e --ai -a" -- "$cur")) elif [ "$prev" = "--track" ]; then COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi @@ -326,6 +326,8 @@ _git-gtr() { '--no-copy[Skip file copying]' \ '--no-fetch[Skip git fetch]' \ '--no-hooks[Skip post-create hooks]' \ + '--sparse[Inherit sparse-checkout from base worktree]' \ + '--no-sparse[Force a full checkout]' \ '--force[Allow same branch in multiple worktrees]' \ '--name[Custom folder name suffix]:name:' \ '--folder[Custom folder name (replaces default)]:folder:' \ @@ -545,6 +547,8 @@ complete -c git -n '__fish_git_gtr_using_command new' -l track -d 'Track mode' - complete -c git -n '__fish_git_gtr_using_command new' -l no-copy -d 'Skip file copying' complete -c git -n '__fish_git_gtr_using_command new' -l no-fetch -d 'Skip git fetch' complete -c git -n '__fish_git_gtr_using_command new' -l no-hooks -d 'Skip post-create hooks' +complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree' +complete -c git -n '__fish_git_gtr_using_command new' -l no-sparse -d 'Force a full checkout' complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same branch in multiple worktrees' complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r complete -c git -n '__fish_git_gtr_using_command new' -l folder -d 'Custom folder name (replaces default)' -r diff --git a/templates/.gtrconfig.example b/templates/.gtrconfig.example index 46d2f22..739da1a 100644 --- a/templates/.gtrconfig.example +++ b/templates/.gtrconfig.example @@ -69,3 +69,9 @@ # Set to "none" to disable workspace lookup entirely # workspace = project.code-workspace # workspace = none + +[sparse] + # Inherit sparse-checkout from the base worktree when creating a new one + # (default: true). Useful for monorepos where the base worktree checks out + # only a slice of the tree. Override per command with --sparse / --no-sparse. + # inherit = true diff --git a/tests/sparse.bats b/tests/sparse.bats new file mode 100644 index 0000000..5a5fce5 --- /dev/null +++ b/tests/sparse.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats + +# Tests for sparse-checkout inheritance helpers and decision logic. + +setup() { + load test_helper + source_gtr_libs + + # Disposable repo with a couple of top-level dirs so cone sparse is observable. + # Canonicalize the path so it matches what `git worktree list` reports + # (macOS /var is a symlink to /private/var). + TEST_REPO=$(cd "$(mktemp -d)" && pwd -P) + git -C "$TEST_REPO" init --quiet + git -C "$TEST_REPO" config user.name "Test User" + git -C "$TEST_REPO" config user.email "test@example.com" + mkdir -p "$TEST_REPO/apps/web" "$TEST_REPO/apps/api" "$TEST_REPO/packages" "$TEST_REPO/docs" + echo web > "$TEST_REPO/apps/web/file.txt" + echo api > "$TEST_REPO/apps/api/file.txt" + echo pkg > "$TEST_REPO/packages/file.txt" + echo doc > "$TEST_REPO/docs/file.txt" + git -C "$TEST_REPO" add -A + git -C "$TEST_REPO" commit -m init --quiet + + TEST_WORKTREES_DIR="${TEST_REPO}-worktrees" + cd "$TEST_REPO" || return 1 +} + +teardown() { + cd / 2>/dev/null || true + if [ -d "$TEST_REPO" ]; then + git -C "$TEST_REPO" worktree list --porcelain 2>/dev/null | while IFS= read -r line; do + case "$line" in + "worktree "*) + wt="${line#worktree }" + [ "$wt" = "$TEST_REPO" ] && continue + git -C "$TEST_REPO" worktree remove --force "$wt" 2>/dev/null || true + ;; + esac + done + fi + rm -rf "$TEST_REPO" "$TEST_WORKTREES_DIR" +} + +# Create a cone-sparse worktree on a branch checking out only the given dirs. +# Usage: make_sparse_worktree ... +make_sparse_worktree() { + local path="$1" branch="$2" + shift 2 + git -C "$TEST_REPO" worktree add --quiet -b "$branch" "$path" HEAD + git -C "$path" sparse-checkout init --cone >/dev/null + git -C "$path" sparse-checkout set "$@" >/dev/null +} + +@test "_worktree_path_for_ref finds the worktree holding a branch" { + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + result=$(_worktree_path_for_ref base) + [ "$result" = "$TEST_WORKTREES_DIR/base" ] +} + +@test "_worktree_path_for_ref matches remote-prefixed refs by short name" { + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + result=$(_worktree_path_for_ref origin/base) + [ "$result" = "$TEST_WORKTREES_DIR/base" ] +} + +@test "_worktree_path_for_ref returns empty for unknown refs" { + result=$(_worktree_path_for_ref does-not-exist) + [ -z "$result" ] +} + +@test "_resolve_sparse_source returns the base worktree when it is sparse" { + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + result=$(_resolve_sparse_source base) + [ "$result" = "$TEST_WORKTREES_DIR/base" ] +} + +@test "_resolve_sparse_source returns empty when base is not sparse" { + git -C "$TEST_REPO" worktree add --quiet -b plain "$TEST_WORKTREES_DIR/plain" HEAD + result=$(_resolve_sparse_source plain) + [ -z "$result" ] +} + +@test "_resolve_sparse_source falls back to current worktree" { + # No worktree holds 'origin/main'; cwd (main repo) is sparse + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/web >/dev/null + result=$(_resolve_sparse_source origin/main) + [ "$result" = "$TEST_REPO" ] + # restore full checkout for teardown safety + git -C "$TEST_REPO" sparse-checkout disable >/dev/null +} + +@test "apply_inherited_sparse replicates a cone into a new worktree" { + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + git -C "$TEST_REPO" worktree add --no-checkout --quiet -b feat "$TEST_WORKTREES_DIR/feat" base + + run apply_inherited_sparse "$TEST_WORKTREES_DIR/feat" "$TEST_WORKTREES_DIR/base" + [ "$status" -eq 0 ] + + # Config replicated + [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckout)" = "true" ] + [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckoutCone)" = "true" ] + + # Pattern list matches the source + src_list=$(git -C "$TEST_WORKTREES_DIR/base" sparse-checkout list) + new_list=$(git -C "$TEST_WORKTREES_DIR/feat" sparse-checkout list) + [ "$src_list" = "$new_list" ] + + # Working tree narrowed: cone dirs present, excluded dirs absent + [ -d "$TEST_WORKTREES_DIR/feat/apps/web" ] + [ -d "$TEST_WORKTREES_DIR/feat/packages" ] + [ ! -d "$TEST_WORKTREES_DIR/feat/apps/api" ] + [ ! -d "$TEST_WORKTREES_DIR/feat/docs" ] +} + +@test "cmd_create inherits sparse-checkout from --from base worktree" { + source_gtr_commands + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + + run cmd_create feat-xyz --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-xyz" + [ "$(git -C "$wt" config --bool core.sparseCheckout)" = "true" ] + [ -d "$wt/apps/web" ] + [ ! -d "$wt/apps/api" ] + [ ! -d "$wt/docs" ] +} + +@test "cmd_create --no-sparse forces a full checkout" { + source_gtr_commands + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + + run cmd_create feat-full --from base --no-sparse --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-full" + # Full checkout: everything present, sparse not enabled + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + +@test "cmd_create with non-sparse base produces a full checkout" { + source_gtr_commands + git -C "$TEST_REPO" worktree add --quiet -b plain "$TEST_WORKTREES_DIR/plain" HEAD + + run cmd_create feat-plain --from plain --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-plain" + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] +} From c0b439fddb3691642b1c31a2f319d523e968cd3a Mon Sep 17 00:00:00 2001 From: adamwett Date: Mon, 29 Jun 2026 16:30:55 -0400 Subject: [PATCH 2/5] fix(sparse): address CodeRabbit review feedback on sparse-checkout inheritance - Preserve slash-separated branch paths in _worktree_path_for_ref - Allow fallback to top-level worktree when matching worktree is not sparse - Add Git 2.25+ guard for sparse-checkout support with full checkout fallback - Fix non-cone mode to use init --no-cone (git defaults to cone mode) - Improve error handling: failed sparse inheritance now falls back to full checkout and hard-errors if that fails, instead of leaving --no-checkout worktree empty - Add tests for slash refs, non-cone inheritance, and sparse config precedence --- lib/commands/create.sh | 14 ++++++-- lib/core.sh | 75 ++++++++++++++++++++++++++++++++++-------- tests/sparse.bats | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/lib/commands/create.sh b/lib/commands/create.sh index 1a840f6..55acde6 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -201,9 +201,19 @@ cmd_create() { exit 1 fi - # Inherit sparse-checkout before copying so copied files land in the narrowed tree + # Inherit sparse-checkout before copying so copied files land in the narrowed tree. + # The worktree was created with --no-checkout, so a failed inheritance would leave + # it empty: fall back to a full checkout (and hard-fail if even that does not work) + # before the copy/hooks/success path continues. if [ -n "$sparse_source" ]; then - apply_inherited_sparse "$worktree_path" "$sparse_source" || log_warn "Sparse-checkout inheritance incomplete" + if ! apply_inherited_sparse "$worktree_path" "$sparse_source"; then + log_warn "Sparse-checkout inheritance failed — falling back to a full checkout" + git -C "$worktree_path" sparse-checkout disable >/dev/null 2>&1 || true + if ! git -C "$worktree_path" checkout >/dev/null 2>&1; then + log_error "Could not populate worktree at $worktree_path" + exit 1 + fi + fi fi # Copy files based on patterns diff --git a/lib/core.sh b/lib/core.sh index abc9272..de14202 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -583,14 +583,17 @@ _worktree_add_tracked() { # Find the worktree that has a given ref checked out. # Parses `git worktree list --porcelain` and matches the branch name as given -# or with a leading remote segment stripped (so "origin/foo" matches "foo"). +# or with a single leading remote segment stripped (so "origin/feature/auth" +# matches the branch "feature/auth", preserving the path after the remote). # Usage: _worktree_path_for_ref # Prints: worktree path on match, empty otherwise. _worktree_path_for_ref() { local ref="$1" [ -n "$ref" ] || return 0 - local ref_short="${ref##*/}" + # Strip only the first path segment (the remote name) so slash-separated + # branch names keep their prefix: "origin/feature/auth" -> "feature/auth". + local ref_no_remote="${ref#*/}" local line wt_path="" branch while IFS= read -r line; do case "$line" in @@ -600,7 +603,7 @@ _worktree_path_for_ref() { "branch "*) branch="${line#branch }" branch="${branch#refs/heads/}" - if [ "$branch" = "$ref" ] || [ "$branch" = "$ref_short" ]; then + if [ "$branch" = "$ref" ] || [ "$branch" = "$ref_no_remote" ]; then printf "%s" "$wt_path" return 0 fi @@ -610,36 +613,80 @@ _worktree_path_for_ref() { return 0 } +# Test whether a worktree has sparse-checkout enabled. +# Usage: _worktree_is_sparse +# Returns: 0 if sparse-checkout is on, 1 otherwise. +_worktree_is_sparse() { + local wt="$1" + [ -n "$wt" ] || return 1 + local enabled + enabled=$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || true) + [ "$enabled" = "true" ] +} + # Resolve which worktree's sparse-checkout config a new worktree should inherit. # Prefers the worktree holding from_ref, falling back to the current worktree. -# Only prints a path if that worktree actually has sparse-checkout enabled. +# Only prints a path if the chosen worktree actually has sparse-checkout enabled; +# a matching but non-sparse worktree does not short-circuit the fallback. # Usage: _resolve_sparse_source # Prints: source worktree path if sparse-enabled, empty otherwise. _resolve_sparse_source() { local from_ref="$1" local src + # Prefer the worktree holding from_ref, but only if it is sparse-enabled. src=$(_worktree_path_for_ref "$from_ref") - if [ -z "$src" ]; then - src=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$src" ] && _worktree_is_sparse "$src"; then + printf "%s" "$src" + return 0 fi - [ -n "$src" ] || return 0 - local enabled - enabled=$(git -C "$src" config --bool core.sparseCheckout 2>/dev/null || true) - [ "$enabled" = "true" ] || return 0 + # Otherwise fall back to the current/top-level worktree if it is sparse. + src=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$src" ] && _worktree_is_sparse "$src"; then + printf "%s" "$src" + return 0 + fi - printf "%s" "$src" + return 0 +} + +# Check whether the running git is new enough for `git sparse-checkout` +# (introduced in Git 2.25). +# Returns: 0 if supported, 1 otherwise. +_git_supports_sparse_checkout() { + local version major minor + version=$(git --version 2>/dev/null | awk '{print $3}') + major="${version%%.*}" + minor="${version#*.}" + minor="${minor%%.*}" + [ -n "$major" ] || return 1 + case "$major$minor" in + *[!0-9]* | "") return 1 ;; + esac + [ "$major" -gt 2 ] && return 0 + [ "$major" -eq 2 ] && [ "$minor" -ge 25 ] && return 0 + return 1 } # Replicate a source worktree's sparse-checkout config into a new worktree. # Handles both cone and non-cone modes. Materializes the cone from the index # (the new worktree should have been created with --no-checkout). +# Requires Git 2.25+ (`git sparse-checkout`); on older clients it falls back to +# a full checkout so the new worktree is still populated. # Usage: apply_inherited_sparse -# Returns: 0 on success; 1 (with warning) if any git step fails. +# Returns: 0 on success; 1 (with warning) if unsupported or any git step fails. apply_inherited_sparse() { local new_wt="$1" src_wt="$2" + # `git sparse-checkout` needs Git 2.25+. On older clients, materialize the + # worktree with a normal checkout rather than leaving it unpopulated. + if ! _git_supports_sparse_checkout; then + log_warn "git sparse-checkout requires Git 2.25+; creating a full checkout instead" + git -C "$new_wt" checkout >/dev/null 2>&1 || true + return 1 + fi + local cone cone=$(git -C "$src_wt" config --bool core.sparseCheckoutCone 2>/dev/null || true) @@ -658,7 +705,9 @@ apply_inherited_sparse() { return 1 fi else - if ! git -C "$new_wt" sparse-checkout init >/dev/null 2>&1; then + # Modern git defaults `init` to cone mode, so request --no-cone explicitly + # to preserve the source's raw (non-cone) patterns. + if ! git -C "$new_wt" sparse-checkout init --no-cone >/dev/null 2>&1; then log_warn "Could not enable sparse-checkout in new worktree" return 1 fi diff --git a/tests/sparse.bats b/tests/sparse.bats index 5a5fce5..4a1d55c 100644 --- a/tests/sparse.bats +++ b/tests/sparse.bats @@ -63,6 +63,12 @@ make_sparse_worktree() { [ "$result" = "$TEST_WORKTREES_DIR/base" ] } +@test "_worktree_path_for_ref preserves slash branch path after remote name" { + make_sparse_worktree "$TEST_WORKTREES_DIR/auth" feature/user-auth apps/web + result=$(_worktree_path_for_ref origin/feature/user-auth) + [ "$result" = "$TEST_WORKTREES_DIR/auth" ] +} + @test "_worktree_path_for_ref returns empty for unknown refs" { result=$(_worktree_path_for_ref does-not-exist) [ -z "$result" ] @@ -113,6 +119,33 @@ make_sparse_worktree() { [ ! -d "$TEST_WORKTREES_DIR/feat/docs" ] } +@test "apply_inherited_sparse replicates non-cone patterns into a new worktree" { + # Source uses non-cone (raw pattern) sparse-checkout. + git -C "$TEST_REPO" worktree add --quiet -b base "$TEST_WORKTREES_DIR/base" HEAD + git -C "$TEST_WORKTREES_DIR/base" sparse-checkout init --no-cone >/dev/null + git -C "$TEST_WORKTREES_DIR/base" sparse-checkout set "/apps/web/" "/docs/" >/dev/null + + git -C "$TEST_REPO" worktree add --no-checkout --quiet -b feat "$TEST_WORKTREES_DIR/feat" base + + run apply_inherited_sparse "$TEST_WORKTREES_DIR/feat" "$TEST_WORKTREES_DIR/base" + [ "$status" -eq 0 ] + + # Sparse enabled, but cone mode stays off (raw patterns, not cone dirs) + [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckout)" = "true" ] + [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckoutCone 2>/dev/null || echo false)" != "true" ] + + # Raw pattern list matches the source (applied via set --stdin) + src_list=$(git -C "$TEST_WORKTREES_DIR/base" sparse-checkout list) + new_list=$(git -C "$TEST_WORKTREES_DIR/feat" sparse-checkout list) + [ "$src_list" = "$new_list" ] + + # Working tree narrowed to the pattern dirs + [ -d "$TEST_WORKTREES_DIR/feat/apps/web" ] + [ -d "$TEST_WORKTREES_DIR/feat/docs" ] + [ ! -d "$TEST_WORKTREES_DIR/feat/apps/api" ] + [ ! -d "$TEST_WORKTREES_DIR/feat/packages" ] +} + @test "cmd_create inherits sparse-checkout from --from base worktree" { source_gtr_commands make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages @@ -141,6 +174,37 @@ make_sparse_worktree() { [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] } +@test "cmd_create respects gtr.sparse.inherit=false (no inheritance)" { + source_gtr_commands + git -C "$TEST_REPO" config gtr.sparse.inherit false + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + + run cmd_create feat-cfg --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-cfg" + # Inheritance disabled by config: full checkout, sparse not enabled + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + +@test "cmd_create --sparse overrides gtr.sparse.inherit=false" { + source_gtr_commands + git -C "$TEST_REPO" config gtr.sparse.inherit false + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + + run cmd_create feat-override --from base --sparse --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-override" + # --sparse beats the config: sparse inherited from the base worktree + [ "$(git -C "$wt" config --bool core.sparseCheckout)" = "true" ] + [ -d "$wt/apps/web" ] + [ ! -d "$wt/apps/api" ] + [ ! -d "$wt/docs" ] +} + @test "cmd_create with non-sparse base produces a full checkout" { source_gtr_commands git -C "$TEST_REPO" worktree add --quiet -b plain "$TEST_WORKTREES_DIR/plain" HEAD From d3e3885852305893af2b4d7c2338c92825944c91 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Fri, 10 Jul 2026 21:45:54 -0700 Subject: [PATCH 3/5] fix: harden sparse-checkout inheritance --- README.md | 2 +- completions/_git-gtr | 2 +- completions/git-gtr.fish | 2 +- docs/advanced-usage.md | 5 +- docs/configuration.md | 4 +- lib/commands/create.sh | 41 +++-- lib/commands/help.sh | 2 +- lib/config.sh | 64 ++++++-- lib/core.sh | 279 +++++++++++++++++++++----------- scripts/generate-completions.sh | 4 +- templates/.gtrconfig.example | 5 +- tests/config.bats | 9 ++ tests/sparse.bats | 207 +++++++++++++++++++++--- 13 files changed, 470 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 502d135..18a32ea 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ git gtr config add gtr.copy.include "**/.env.example" # Run setup after creating worktrees git gtr config add gtr.hook.postCreate "npm install" -# Inherit sparse-checkout from the base worktree (default: on; --no-sparse to opt out) +# Inherit sparse-checkout from the base worktree on Git 2.36+ (default: on) git gtr config set gtr.sparse.inherit true # Re-source environment after gtr cd or gtr new --cd (runs in current shell) diff --git a/completions/_git-gtr b/completions/_git-gtr index ece9e33..f7d905d 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -67,7 +67,7 @@ _git-gtr() { '--no-copy[Skip file copying]' \ '--no-fetch[Skip git fetch]' \ '--no-hooks[Skip post-create hooks]' \ - '--sparse[Inherit sparse-checkout from base worktree]' \ + '--sparse[Inherit sparse-checkout from base worktree (Git 2.36+)]' \ '--no-sparse[Force a full checkout]' \ '--force[Allow same branch in multiple worktrees]' \ '--name[Custom folder name suffix]:name:' \ diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 7ad432c..0725d0e 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -66,7 +66,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -l track -d 'Track mode' - complete -c git -n '__fish_git_gtr_using_command new' -l no-copy -d 'Skip file copying' complete -c git -n '__fish_git_gtr_using_command new' -l no-fetch -d 'Skip git fetch' complete -c git -n '__fish_git_gtr_using_command new' -l no-hooks -d 'Skip post-create hooks' -complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree' +complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree (Git 2.36+)' complete -c git -n '__fish_git_gtr_using_command new' -l no-sparse -d 'Force a full checkout' complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same branch in multiple worktrees' complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 65c2374..280739f 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -222,12 +222,13 @@ git gtr new big-refactor --from my-app --no-sparse **How it works:** -- gtr inspects the worktree holding the base ref (`--from`, falling back to the current worktree). If it has sparse-checkout enabled, the new worktree is created with `--no-checkout` and the same cone (or pattern set) is applied — the full tree is never written to disk. +- On Git 2.36+, gtr inspects the worktree holding the base ref (`--from`, falling back to the current worktree). If it has valid sparse-checkout settings, gtr creates the new worktree from that source so Git copies its patterns and per-worktree config before checkout — the full tree is never written to disk. +- Git 2.17–2.35 keeps the existing full-checkout behavior. An explicit `--sparse` request prints a warning that inheritance requires Git 2.36+. - Controlled by `gtr.sparse.inherit` (default on). Use `--sparse` / `--no-sparse` to override per command. - Full-checkout repositories are unaffected — they always get a full checkout. > [!NOTE] -> Sparse-checkout is per-worktree, not per-branch. Inheriting "from `my-app`" copies the live sparse settings of the `my-app` worktree, not anything stored on the branch itself. +> Sparse-checkout is per-worktree, not per-branch. Inheriting "from `my-app`" copies the live sparse settings and worktree-specific Git config of the `my-app` worktree, not anything stored on the branch itself. If the same branch is checked out more than once, gtr prefers the current matching worktree; otherwise it warns and creates a full checkout rather than choosing arbitrarily. --- diff --git a/docs/configuration.md b/docs/configuration.md index 8216349..00053b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -119,7 +119,7 @@ If the worktree you branch from uses [sparse-checkout](https://git-scm.com/docs/ gtr.sparse.inherit = true ``` -When enabled, `git gtr new` looks at the worktree holding the base ref (the `--from` target, falling back to the current worktree). If that worktree has sparse-checkout on, the new worktree is created with `--no-checkout` and the same cone (or pattern set) is applied — so the full tree is never materialized. Full-checkout repositories are unaffected. +On Git 2.36+, `git gtr new` looks at the worktree holding the base ref (the `--from` target, falling back to the current worktree). If that worktree has valid sparse-checkout settings, gtr runs worktree creation from that source so Git copies its patterns and per-worktree config before checkout. The full tree is never materialized. Git 2.17–2.35 retains full-checkout behavior; explicit `--sparse` requests print a version warning. Per-command overrides: @@ -132,7 +132,7 @@ git gtr new feature-xyz --from my-app --no-sparse ``` > [!NOTE] -> Sparse-checkout is stored per-worktree, not per-branch. "Inherit from `my-app`" means inherit the live sparse settings of the `my-app` *worktree*. +> Sparse-checkout is stored per-worktree, not per-branch. "Inherit from `my-app`" means inherit the live sparse settings and worktree-specific Git config of the `my-app` *worktree*. Ambiguous duplicate-branch worktrees fall back to a full checkout instead of choosing a source arbitrarily. --- diff --git a/lib/commands/create.sh b/lib/commands/create.sh index 55acde6..9c1d726 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -171,16 +171,29 @@ cmd_create() { sparse_inherit=1 fi - local sparse_source="" no_checkout=0 + local sparse_source="" no_checkout=0 native_sparse_supported=0 + _git_supports_sparse_inheritance && native_sparse_supported=1 if [ "$sparse_inherit" -eq 1 ]; then - sparse_source=$(_resolve_sparse_source "$from_ref") - if [ -n "$sparse_source" ]; then - no_checkout=1 + if [ "$native_sparse_supported" -eq 1 ]; then + sparse_source=$(_resolve_sparse_source "$from_ref") elif [ "$sparse_flag" -eq 1 ]; then + log_warn "Sparse-checkout inheritance requires Git 2.36+ — creating a full checkout" + fi + if [ -z "$sparse_source" ] && [ "$sparse_flag" -eq 1 ] && [ "$native_sparse_supported" -eq 1 ]; then log_warn "No sparse-checkout source found for '$from_ref' — creating a full checkout" fi fi + # Git 2.36+ copies the caller's sparse settings during worktree add. Defer + # checkout only when a sparse caller must produce a full checkout; ordinary + # dense creation keeps the existing one-step path. + local current_worktree + current_worktree=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -z "$sparse_source" ] && [ "$native_sparse_supported" -eq 1 ] \ + && _worktree_sparse_enabled "$current_worktree"; then + no_checkout=1 + fi + # Construct folder name for display local folder_name if [ -n "$folder_override" ]; then @@ -197,23 +210,25 @@ cmd_create() { # Create the worktree local worktree_path - if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override" "$remote" "$no_checkout"); then + # Only `git worktree add` uses sparse_source as its context; fetch and branch + # resolution retain the caller's repository configuration. + if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override" "$remote" "$no_checkout" "$sparse_source"); then exit 1 fi - # Inherit sparse-checkout before copying so copied files land in the narrowed tree. - # The worktree was created with --no-checkout, so a failed inheritance would leave - # it empty: fall back to a full checkout (and hard-fail if even that does not work) - # before the copy/hooks/success path continues. if [ -n "$sparse_source" ]; then - if ! apply_inherited_sparse "$worktree_path" "$sparse_source"; then - log_warn "Sparse-checkout inheritance failed — falling back to a full checkout" - git -C "$worktree_path" sparse-checkout disable >/dev/null 2>&1 || true - if ! git -C "$worktree_path" checkout >/dev/null 2>&1; then + if _worktree_is_sparse "$worktree_path"; then + log_info "Inherited sparse-checkout from $sparse_source" + else + log_warn "Sparse-checkout inheritance was not applied — falling back to a full checkout" + if ! _ensure_full_checkout "$worktree_path" 1; then log_error "Could not populate worktree at $worktree_path" exit 1 fi fi + elif [ "$no_checkout" -eq 1 ] && ! _ensure_full_checkout "$worktree_path" 1; then + log_error "Could not populate full worktree at $worktree_path" + exit 1 fi # Copy files based on patterns diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 63e81c9..2eba45b 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -23,7 +23,7 @@ Options: --no-copy Skip file copying (gtr.copy.include patterns) --no-fetch Skip git fetch before creating --no-hooks Skip post-create hooks - --sparse Inherit sparse-checkout from the base worktree + --sparse Inherit sparse-checkout from the base worktree (Git 2.36+) --no-sparse Force a full checkout (override gtr.sparse.inherit) --force Allow same branch in multiple worktrees (requires --name or --folder to distinguish them) diff --git a/lib/config.sh b/lib/config.sh index 0881278..4d5f570 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -194,28 +194,58 @@ cfg_get_all() { esac } -# Get a boolean config value +# Read one config source with Git's native boolean parser. +# Returns 0 with true/false output, 1 when absent, 2 when present but invalid. +_cfg_read_bool() { + local key="$1" value status + shift + if value=$(git config "$@" --bool --get "$key" 2>/dev/null); then + printf "%s" "$value" + return 0 + else + status=$? + fi + [ "$status" -eq 1 ] && return 1 + return 2 +} + +# Get a boolean config value. +# Precedence: worktree > local > .gtrconfig > global > system > default. # Usage: cfg_bool key [default] -# Returns: 0 for true, 1 for false +# Returns: 0 for true, 1 for false. cfg_bool() { - local key="$1" - local default="${2:-false}" - local value - - value=$(cfg_get "$key") + local key="$1" default="${2:-false}" source candidate value status + local file_key config_file + local args=() + file_key=$(cfg_map_to_file_key "$key") + config_file=$(_gtrconfig_path) - if [ -z "$value" ]; then - value="$default" - fi + for source in worktree local file global system; do + candidate="$key" + case "$source" in + worktree) args=(--worktree) ;; + local) args=(--local) ;; + file) + [ -n "$file_key" ] && [ -f "$config_file" ] || continue + candidate="$file_key" + args=(-f "$config_file") + ;; + global) args=(--global) ;; + system) args=(--system) ;; + esac + if value=$(_cfg_read_bool "$candidate" "${args[@]}"); then + [ "$value" = "true" ] + return + else + status=$? + [ "$status" -eq 2 ] && return 1 + fi + done - case "$value" in - true|yes|1|on) - return 0 - ;; - false|no|0|off|*) - return 1 - ;; + case "$default" in + [tT][rR][uU][eE]|[yY][eE][sS]|1|[oO][nN]) return 0 ;; esac + return 1 } # Convert scope name to git config flag diff --git a/lib/core.sh b/lib/core.sh index de14202..d003c21 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -507,21 +507,23 @@ resolve_worktree() { } # Try to create a worktree, handling the common log/add/report pattern. -# Usage: _try_worktree_add [git_worktree_add_args...] +# Usage: _try_worktree_add [git_worktree_add_args...] # Prints worktree path on success; returns 1 on failure (caller handles error). # Note: step_msg may be empty to skip the log_step call. _try_worktree_add() { - local wt_path="$1" step_msg="$2" ok_msg="$3" - shift 3 + local wt_path="$1" step_msg="$2" ok_msg="$3" add_source="$4" + shift 4 [ -n "$step_msg" ] && log_step "$step_msg" - if git worktree add "$wt_path" "$@" >&2; then - log_info "$ok_msg" - printf "%s" "$wt_path" - return 0 + if [ -n "$add_source" ]; then + git -C "$add_source" worktree add "$wt_path" "$@" >&2 || return 1 + else + git worktree add "$wt_path" "$@" >&2 || return 1 fi - return 1 + log_info "$ok_msg" + printf "%s" "$wt_path" + return 0 } # Build and validate folder name from branch/custom/override. @@ -566,11 +568,11 @@ _check_branch_refs() { } # Auto-track: create local tracking branch from remote if needed, then add worktree. -# Usage: _worktree_add_tracked [remote] [force_args...] +# Usage: _worktree_add_tracked [force_args...] # shellcheck disable=SC2317 # Called indirectly from create_worktree _worktree_add_tracked() { - local wt_path="$1" branch_name="$2" remote="${3:-$(resolve_default_remote)}" - shift 3 + local wt_path="$1" branch_name="$2" remote="${3:-$(resolve_default_remote)}" add_source="${4:-}" + shift 4 log_step "Branch '$branch_name' exists on $remote" if git branch --track "$branch_name" "$remote/$branch_name" >/dev/null 2>&1; then @@ -578,45 +580,130 @@ _worktree_add_tracked() { fi _try_worktree_add "$wt_path" "" \ "Worktree created tracking $remote/$branch_name" \ + "$add_source" \ "$@" "$branch_name" } -# Find the worktree that has a given ref checked out. -# Parses `git worktree list --porcelain` and matches the branch name as given -# or with a single leading remote segment stripped (so "origin/feature/auth" -# matches the branch "feature/auth", preserving the path after the remote). +# Normalize a remote-tracking ref only when its prefix names a configured remote. +# Usage: _remote_branch_for_ref +# Prints: local branch portion for a configured remote ref, empty otherwise. +_remote_branch_for_ref() { + local ref="$1" repo_root remote default_remote matched_remote="" + repo_root=$(_resolve_main_repo_root) || return 0 + case "$ref" in + refs/remotes/*) ref="${ref#refs/remotes/}" ;; + esac + + # The configured/default remote remains meaningful before the first fetch, + # when it may not yet appear in `git remote` (common in isolated tests too). + default_remote=$(resolve_default_remote) + case "$ref" in + "$default_remote/"*) matched_remote="$default_remote" ;; + esac + + while IFS= read -r remote; do + case "$ref" in + "$remote/"*) + if [ -z "$matched_remote" ] || [ "${#remote}" -gt "${#matched_remote}" ]; then + matched_remote="$remote" + fi + ;; + esac + done < <(git -C "$repo_root" remote 2>/dev/null || true) + [ -n "$matched_remote" ] && printf "%s" "${ref#"$matched_remote/"}" + return 0 +} + +# Find the worktree that has a given local or remote-tracking ref checked out. +# Exact local branch names always win; remote prefixes are stripped only when +# they match a configured remote. # Usage: _worktree_path_for_ref # Prints: worktree path on match, empty otherwise. _worktree_path_for_ref() { - local ref="$1" + local ref="$1" exact_ref remote_branch repo_root current_path records resolved_ref + local exact_blocks_remote=0 [ -n "$ref" ] || return 0 + repo_root=$(_resolve_main_repo_root) || return 0 + current_path=$(git rev-parse --show-toplevel 2>/dev/null || true) + [ -n "$current_path" ] && current_path=$(canonicalize_path "$current_path" || printf "%s" "$current_path") - # Strip only the first path segment (the remote name) so slash-separated - # branch names keep their prefix: "origin/feature/auth" -> "feature/auth". - local ref_no_remote="${ref#*/}" - local line wt_path="" branch + exact_ref="$ref" + case "$exact_ref" in + refs/heads/*) exact_ref="${exact_ref#refs/heads/}" ;; + esac + case "$ref" in + refs/heads/*|refs/tags/*) exact_blocks_remote=1 ;; + esac + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$exact_ref" \ + || git -C "$repo_root" show-ref --verify --quiet "refs/tags/$exact_ref"; then + exact_blocks_remote=1 + fi + resolved_ref=$(git -C "$repo_root" rev-parse --verify --symbolic-full-name "$ref" 2>/dev/null || true) + case "$resolved_ref" in + refs/remotes/*|"") ;; + refs/*) exact_blocks_remote=1 ;; + esac + if [ "$exact_blocks_remote" -eq 0 ]; then + remote_branch=$(_remote_branch_for_ref "$ref") + else + remote_branch="" + fi + records=$(list_worktree_records "$repo_root") + + local path="" branch="" line + local exact_path="" exact_current="" exact_count=0 + local remote_path="" remote_current="" remote_count=0 while IFS= read -r line; do case "$line" in - "worktree "*) - wt_path="${line#worktree }" - ;; - "branch "*) - branch="${line#branch }" - branch="${branch#refs/heads/}" - if [ "$branch" = "$ref" ] || [ "$branch" = "$ref_no_remote" ]; then - printf "%s" "$wt_path" - return 0 + "") + if [ -n "$path" ] && [ "$branch" = "$exact_ref" ]; then + exact_count=$((exact_count + 1)) + [ -z "$exact_path" ] && exact_path="$path" + [ -n "$current_path" ] && [ "$path" = "$current_path" ] && exact_current="$path" + fi + if [ -n "$path" ] && [ -n "$remote_branch" ] && [ "$branch" = "$remote_branch" ]; then + remote_count=$((remote_count + 1)) + [ -z "$remote_path" ] && remote_path="$path" + [ -n "$current_path" ] && [ "$path" = "$current_path" ] && remote_current="$path" fi + path="" + branch="" ;; + "path "*) path=$(_tsv_unescape_field "${line#path }") ;; + "branch "*) branch=$(_tsv_unescape_field "${line#branch }") ;; esac - done < <(git worktree list --porcelain 2>/dev/null || true) + done < -# Returns: 0 if sparse-checkout is on, 1 otherwise. -_worktree_is_sparse() { +# Test whether sparse-checkout is configured, even if its pattern file is bad. +# Usage: _worktree_sparse_enabled +_worktree_sparse_enabled() { local wt="$1" [ -n "$wt" ] || return 1 local enabled @@ -624,6 +711,21 @@ _worktree_is_sparse() { [ "$enabled" = "true" ] } +# Test whether a worktree has usable sparse-checkout settings. +# Usage: _worktree_is_sparse +_worktree_is_sparse() { + local wt="$1" + _worktree_sparse_enabled "$wt" || return 1 + + ( + cd "$wt" || exit 1 + local pattern_file + pattern_file=$(git rev-parse --git-path info/sparse-checkout 2>/dev/null) || exit 1 + [ -r "$pattern_file" ] || exit 1 + git sparse-checkout list >/dev/null 2>&1 + ) +} + # Resolve which worktree's sparse-checkout config a new worktree should inherit. # Prefers the worktree holding from_ref, falling back to the current worktree. # Only prints a path if the chosen worktree actually has sparse-checkout enabled; @@ -632,29 +734,41 @@ _worktree_is_sparse() { # Prints: source worktree path if sparse-enabled, empty otherwise. _resolve_sparse_source() { local from_ref="$1" - local src + local src status current # Prefer the worktree holding from_ref, but only if it is sparse-enabled. - src=$(_worktree_path_for_ref "$from_ref") + if src=$(_worktree_path_for_ref "$from_ref"); then + : + else + status=$? + [ "$status" -eq 2 ] && return 0 + fi if [ -n "$src" ] && _worktree_is_sparse "$src"; then printf "%s" "$src" return 0 fi + if [ -n "$src" ] && _worktree_sparse_enabled "$src"; then + log_warn "Sparse-checkout settings in $src are incomplete; falling back to a full checkout" + return 0 + fi # Otherwise fall back to the current/top-level worktree if it is sparse. - src=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [ -n "$src" ] && _worktree_is_sparse "$src"; then - printf "%s" "$src" + current=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$current" ] && _worktree_is_sparse "$current"; then + printf "%s" "$current" return 0 fi + if [ -n "$current" ] && [ "$current" != "$src" ] && _worktree_sparse_enabled "$current"; then + log_warn "Sparse-checkout settings in $current are incomplete; falling back to a full checkout" + fi return 0 } -# Check whether the running git is new enough for `git sparse-checkout` -# (introduced in Git 2.25). -# Returns: 0 if supported, 1 otherwise. -_git_supports_sparse_checkout() { +# Compare the running Git's major/minor version. +# Usage: _git_version_at_least +_git_version_at_least() { + local required_major="$1" required_minor="$2" local version major minor version=$(git --version 2>/dev/null | awk '{print $3}') major="${version%%.*}" @@ -664,84 +778,53 @@ _git_supports_sparse_checkout() { case "$major$minor" in *[!0-9]* | "") return 1 ;; esac - [ "$major" -gt 2 ] && return 0 - [ "$major" -eq 2 ] && [ "$minor" -ge 25 ] && return 0 + [ "$major" -gt "$required_major" ] && return 0 + [ "$major" -eq "$required_major" ] && [ "$minor" -ge "$required_minor" ] && return 0 return 1 } -# Replicate a source worktree's sparse-checkout config into a new worktree. -# Handles both cone and non-cone modes. Materializes the cone from the index -# (the new worktree should have been created with --no-checkout). -# Requires Git 2.25+ (`git sparse-checkout`); on older clients it falls back to -# a full checkout so the new worktree is still populated. -# Usage: apply_inherited_sparse -# Returns: 0 on success; 1 (with warning) if unsupported or any git step fails. -apply_inherited_sparse() { - local new_wt="$1" src_wt="$2" - - # `git sparse-checkout` needs Git 2.25+. On older clients, materialize the - # worktree with a normal checkout rather than leaving it unpopulated. - if ! _git_supports_sparse_checkout; then - log_warn "git sparse-checkout requires Git 2.25+; creating a full checkout instead" - git -C "$new_wt" checkout >/dev/null 2>&1 || true - return 1 - fi - - local cone - cone=$(git -C "$src_wt" config --bool core.sparseCheckoutCone 2>/dev/null || true) +# Git 2.36+ natively copies sparse patterns and worktree config on worktree add. +_git_supports_sparse_inheritance() { + _git_version_at_least 2 36 +} - if [ "$cone" = "true" ]; then - local dirs=() dir - while IFS= read -r dir; do - [ -n "$dir" ] && dirs+=("$dir") - done < <(git -C "$src_wt" sparse-checkout list 2>/dev/null || true) +# Ensure a worktree is fully populated. When populate is 1, checkout is forced +# because the worktree may have been created with --no-checkout. +# Usage: _ensure_full_checkout [populate] +_ensure_full_checkout() { + local wt="$1" populate="${2:-0}" - if ! git -C "$new_wt" sparse-checkout init --cone >/dev/null 2>&1; then - log_warn "Could not enable sparse-checkout in new worktree" - return 1 - fi - if [ "${#dirs[@]}" -gt 0 ] && ! git -C "$new_wt" sparse-checkout set "${dirs[@]}" >/dev/null 2>&1; then - log_warn "Could not apply inherited sparse-checkout patterns" - return 1 - fi - else - # Modern git defaults `init` to cone mode, so request --no-cone explicitly - # to preserve the source's raw (non-cone) patterns. - if ! git -C "$new_wt" sparse-checkout init --no-cone >/dev/null 2>&1; then - log_warn "Could not enable sparse-checkout in new worktree" - return 1 - fi - if ! git -C "$src_wt" sparse-checkout list 2>/dev/null \ - | git -C "$new_wt" sparse-checkout set --stdin >/dev/null 2>&1; then - log_warn "Could not apply inherited sparse-checkout patterns" + if _worktree_sparse_enabled "$wt"; then + if ! git -C "$wt" sparse-checkout disable >/dev/null 2>&1; then + log_warn "Could not disable sparse-checkout in $wt" return 1 fi + populate=1 fi - # Materialize the working tree (worktree was created with --no-checkout). - if ! git -C "$new_wt" checkout >/dev/null 2>&1; then - log_warn "Could not check out files into the new sparse worktree" + if [ "$populate" -eq 1 ] && ! git -C "$wt" checkout >/dev/null 2>&1; then + log_warn "Could not populate full checkout in $wt" return 1 fi - - log_info "Inherited sparse-checkout from $src_wt" return 0 } # Create a new git worktree -# Usage: create_worktree base_dir prefix branch_name from_ref track_mode [skip_fetch] [force] [custom_name] [folder_override] [remote] [no_checkout] +# Usage: create_worktree base_dir prefix branch_name from_ref track_mode [skip_fetch] [force] [custom_name] [folder_override] [remote] [no_checkout] [add_source] # track_mode: auto, remote, local, or none # skip_fetch: 0 (default, fetch) or 1 (skip) # force: 0 (default, check branch) or 1 (allow same branch in multiple worktrees) # custom_name: optional custom name suffix (e.g., "backend" creates "feature-auth-backend") # folder_override: optional complete folder name override (replaces default naming) # no_checkout: 0 (default) or 1 (pass --no-checkout, e.g. for deferred sparse-checkout) +# add_source: worktree path whose context should be used only for `git worktree add` create_worktree() { local base_dir="$1" prefix="$2" branch_name="$3" from_ref="$4" local track_mode="${5:-auto}" skip_fetch="${6:-0}" force="${7:-0}" local custom_name="${8:-}" folder_override="${9:-}" local remote="${10:-$(resolve_default_remote)}" local no_checkout="${11:-0}" + local add_source="${12:-}" local sanitized_name sanitized_name=$(_resolve_folder_name "$branch_name" "$custom_name" "$folder_override") || return 1 @@ -779,9 +862,11 @@ create_worktree() { _try_worktree_add "$worktree_path" \ "Creating worktree from remote branch $remote/$branch_name" \ "Worktree created tracking $remote/$branch_name" \ + "$add_source" \ "${force_args[@]}" -b "$branch_name" "$remote/$branch_name" && return 0 _try_worktree_add "$worktree_path" "" \ "Worktree created tracking $remote/$branch_name" \ + "$add_source" \ "${force_args[@]}" "$branch_name" && return 0 fi log_error "Remote branch $remote/$branch_name does not exist" @@ -793,6 +878,7 @@ create_worktree() { _try_worktree_add "$worktree_path" \ "Creating worktree from local branch $branch_name" \ "Worktree created with local branch $branch_name" \ + "$add_source" \ "${force_args[@]}" "$branch_name" && return 0 fi log_error "Local branch $branch_name does not exist" @@ -803,6 +889,7 @@ create_worktree() { _try_worktree_add "$worktree_path" \ "Creating new branch $branch_name from $from_ref" \ "Worktree created with new branch $branch_name" \ + "$add_source" \ "${force_args[@]}" -b "$branch_name" "$resolved_ref" && return 0 log_error "Failed to create worktree with new branch" return 1 @@ -810,16 +897,18 @@ create_worktree() { auto|*) if [ "$_wt_remote_exists" -eq 1 ] && [ "$_wt_local_exists" -eq 0 ]; then - _worktree_add_tracked "$worktree_path" "$branch_name" "$remote" "${force_args[@]}" && return 0 + _worktree_add_tracked "$worktree_path" "$branch_name" "$remote" "$add_source" "${force_args[@]}" && return 0 elif [ "$_wt_local_exists" -eq 1 ]; then _try_worktree_add "$worktree_path" \ "Using existing local branch $branch_name" \ "Worktree created with local branch $branch_name" \ + "$add_source" \ "${force_args[@]}" "$branch_name" && return 0 else _try_worktree_add "$worktree_path" \ "Creating new branch $branch_name from $from_ref" \ "Worktree created with new branch $branch_name" \ + "$add_source" \ "${force_args[@]}" -b "$branch_name" "$resolved_ref" && return 0 fi ;; diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index 12b688a..98aee44 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -326,7 +326,7 @@ _git-gtr() { '--no-copy[Skip file copying]' \ '--no-fetch[Skip git fetch]' \ '--no-hooks[Skip post-create hooks]' \ - '--sparse[Inherit sparse-checkout from base worktree]' \ + '--sparse[Inherit sparse-checkout from base worktree (Git 2.36+)]' \ '--no-sparse[Force a full checkout]' \ '--force[Allow same branch in multiple worktrees]' \ '--name[Custom folder name suffix]:name:' \ @@ -547,7 +547,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -l track -d 'Track mode' - complete -c git -n '__fish_git_gtr_using_command new' -l no-copy -d 'Skip file copying' complete -c git -n '__fish_git_gtr_using_command new' -l no-fetch -d 'Skip git fetch' complete -c git -n '__fish_git_gtr_using_command new' -l no-hooks -d 'Skip post-create hooks' -complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree' +complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree (Git 2.36+)' complete -c git -n '__fish_git_gtr_using_command new' -l no-sparse -d 'Force a full checkout' complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same branch in multiple worktrees' complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r diff --git a/templates/.gtrconfig.example b/templates/.gtrconfig.example index 739da1a..a46bc94 100644 --- a/templates/.gtrconfig.example +++ b/templates/.gtrconfig.example @@ -72,6 +72,7 @@ [sparse] # Inherit sparse-checkout from the base worktree when creating a new one - # (default: true). Useful for monorepos where the base worktree checks out - # only a slice of the tree. Override per command with --sparse / --no-sparse. + # (default: true; requires Git 2.36+). Useful for monorepos where the base + # worktree checks out only a slice of the tree. Override per command with + # --sparse / --no-sparse. # inherit = true diff --git a/tests/config.bats b/tests/config.bats index 8d85f61..6ed8743 100644 --- a/tests/config.bats +++ b/tests/config.bats @@ -79,6 +79,15 @@ teardown() { ! _cfg_is_known_key "gtr.nonexistent" } +# ── Boolean values ─────────────────────────────────────────────────────────── + +@test "cfg_bool uses Git boolean parsing for nonzero integers" { + setup_integration_repo + git -C "$TEST_REPO" config gtr.sparse.inherit 2 + + cfg_bool gtr.sparse.inherit false +} + # ── cfg_list deduplication ─────────────────────────────────────────────────── @test "_cfg_list_add_entry adds entry to result" { diff --git a/tests/sparse.bats b/tests/sparse.bats index 4a1d55c..404a4fa 100644 --- a/tests/sparse.bats +++ b/tests/sparse.bats @@ -69,6 +69,49 @@ make_sparse_worktree() { [ "$result" = "$TEST_WORKTREES_DIR/auth" ] } +@test "_worktree_path_for_ref uses the longest configured remote prefix" { + git -C "$TEST_REPO" config gtr.defaultRemote team + git -C "$TEST_REPO" remote add team/upstream https://example.com/upstream.git + make_sparse_worktree "$TEST_WORKTREES_DIR/auth" feature/user-auth apps/web + + result=$(_worktree_path_for_ref team/upstream/feature/user-auth) + [ "$result" = "$TEST_WORKTREES_DIR/auth" ] +} + +@test "_worktree_path_for_ref does not shorten an exact local slash ref" { + git -C "$TEST_REPO" branch feature/user-auth + make_sparse_worktree "$TEST_WORKTREES_DIR/short" user-auth apps/web + + result=$(_worktree_path_for_ref feature/user-auth) + [ -z "$result" ] +} + +@test "_worktree_path_for_ref does not reinterpret an exact branch or tag as remote" { + make_sparse_worktree "$TEST_WORKTREES_DIR/short" foo apps/web + git -C "$TEST_REPO" branch origin/foo + + result=$(_worktree_path_for_ref origin/foo) + [ -z "$result" ] + + git -C "$TEST_REPO" branch -D origin/foo >/dev/null + git -C "$TEST_REPO" tag origin/foo + result=$(_worktree_path_for_ref origin/foo) + [ -z "$result" ] +} + +@test "_resolve_sparse_source refuses an ambiguous duplicate branch" { + make_sparse_worktree "$TEST_WORKTREES_DIR/shared-one" shared apps/web + git -C "$TEST_REPO" worktree add --force --quiet "$TEST_WORKTREES_DIR/shared-two" shared + git -C "$TEST_WORKTREES_DIR/shared-two" sparse-checkout set docs >/dev/null + + run _worktree_path_for_ref shared + [ "$status" -eq 2 ] + [[ "$output" == *"source is ambiguous"* ]] + + result=$(_resolve_sparse_source shared) + [ -z "$result" ] +} + @test "_worktree_path_for_ref returns empty for unknown refs" { result=$(_worktree_path_for_ref does-not-exist) [ -z "$result" ] @@ -96,38 +139,71 @@ make_sparse_worktree() { git -C "$TEST_REPO" sparse-checkout disable >/dev/null } -@test "apply_inherited_sparse replicates a cone into a new worktree" { +@test "cmd_create inherits a cone from the base worktree" { + source_gtr_commands make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages - git -C "$TEST_REPO" worktree add --no-checkout --quiet -b feat "$TEST_WORKTREES_DIR/feat" base - run apply_inherited_sparse "$TEST_WORKTREES_DIR/feat" "$TEST_WORKTREES_DIR/base" + run cmd_create feat --from base --yes --no-fetch --no-hooks --no-copy [ "$status" -eq 0 ] + wt="$TEST_WORKTREES_DIR/feat" # Config replicated - [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckout)" = "true" ] - [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool core.sparseCheckoutCone)" = "true" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout)" = "true" ] + [ "$(git -C "$wt" config --bool core.sparseCheckoutCone)" = "true" ] # Pattern list matches the source src_list=$(git -C "$TEST_WORKTREES_DIR/base" sparse-checkout list) - new_list=$(git -C "$TEST_WORKTREES_DIR/feat" sparse-checkout list) + new_list=$(git -C "$wt" sparse-checkout list) [ "$src_list" = "$new_list" ] # Working tree narrowed: cone dirs present, excluded dirs absent + [ -d "$wt/apps/web" ] + [ -d "$wt/packages" ] + [ ! -d "$wt/apps/api" ] + [ ! -d "$wt/docs" ] +} + +@test "cmd_create inherits a dash-prefixed cone directory" { + source_gtr_commands + mkdir -p "$TEST_REPO/-app" + echo dash > "$TEST_REPO/-app/file.txt" + git -C "$TEST_REPO" add -A + git -C "$TEST_REPO" commit -m "add dash-prefixed directory" --quiet + + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base -- -app + + run cmd_create feat --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + [ -f "$TEST_WORKTREES_DIR/feat/-app/file.txt" ] + [ ! -d "$TEST_WORKTREES_DIR/feat/apps" ] +} + +@test "cmd_create preserves sparse-index mode" { + source_gtr_commands + git -C "$TEST_REPO" worktree add --quiet -b base "$TEST_WORKTREES_DIR/base" HEAD + if ! git -C "$TEST_WORKTREES_DIR/base" sparse-checkout init --cone --sparse-index >/dev/null 2>&1; then + skip "git does not support sparse-index mode" + fi + git -C "$TEST_WORKTREES_DIR/base" sparse-checkout set apps/web packages >/dev/null + if [ "$(git -C "$TEST_WORKTREES_DIR/base" config --bool index.sparse 2>/dev/null || true)" != "true" ]; then + skip "git did not enable sparse-index mode" + fi + + run cmd_create feat --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + [ "$(git -C "$TEST_WORKTREES_DIR/feat" config --bool index.sparse)" = "true" ] [ -d "$TEST_WORKTREES_DIR/feat/apps/web" ] - [ -d "$TEST_WORKTREES_DIR/feat/packages" ] [ ! -d "$TEST_WORKTREES_DIR/feat/apps/api" ] - [ ! -d "$TEST_WORKTREES_DIR/feat/docs" ] } -@test "apply_inherited_sparse replicates non-cone patterns into a new worktree" { +@test "cmd_create inherits non-cone patterns" { + source_gtr_commands # Source uses non-cone (raw pattern) sparse-checkout. git -C "$TEST_REPO" worktree add --quiet -b base "$TEST_WORKTREES_DIR/base" HEAD git -C "$TEST_WORKTREES_DIR/base" sparse-checkout init --no-cone >/dev/null git -C "$TEST_WORKTREES_DIR/base" sparse-checkout set "/apps/web/" "/docs/" >/dev/null - git -C "$TEST_REPO" worktree add --no-checkout --quiet -b feat "$TEST_WORKTREES_DIR/feat" base - - run apply_inherited_sparse "$TEST_WORKTREES_DIR/feat" "$TEST_WORKTREES_DIR/base" + run cmd_create feat --from base --yes --no-fetch --no-hooks --no-copy [ "$status" -eq 0 ] # Sparse enabled, but cone mode stays off (raw patterns, not cone dirs) @@ -146,18 +222,47 @@ make_sparse_worktree() { [ ! -d "$TEST_WORKTREES_DIR/feat/packages" ] } -@test "cmd_create inherits sparse-checkout from --from base worktree" { +@test "cmd_create falls back to a full checkout when source patterns are missing" { source_gtr_commands - make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + git -C "$TEST_REPO" worktree add --quiet -b base "$TEST_WORKTREES_DIR/base" HEAD + git -C "$TEST_WORKTREES_DIR/base" sparse-checkout init --no-cone >/dev/null + git -C "$TEST_WORKTREES_DIR/base" sparse-checkout set "/apps/web/" >/dev/null - run cmd_create feat-xyz --from base --yes --no-fetch --no-hooks --no-copy + pattern_file=$(git -C "$TEST_WORKTREES_DIR/base" rev-parse --git-path info/sparse-checkout) + rm -f "$pattern_file" + + run cmd_create feat-missing-patterns --from base --yes --no-fetch --no-hooks --no-copy [ "$status" -eq 0 ] + [[ "$output" != *"Inherited sparse-checkout"* ]] + [[ "$output" == *"falling back to a full checkout"* ]] - wt="$TEST_WORKTREES_DIR/feat-xyz" - [ "$(git -C "$wt" config --bool core.sparseCheckout)" = "true" ] + wt="$TEST_WORKTREES_DIR/feat-missing-patterns" [ -d "$wt/apps/web" ] - [ ! -d "$wt/apps/api" ] - [ ! -d "$wt/docs" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + +@test "cmd_create does not replace a broken base with the current sparse worktree" { + source_gtr_commands + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/web >/dev/null + git -C "$TEST_REPO" worktree add --quiet -b broken "$TEST_WORKTREES_DIR/broken" HEAD + git -C "$TEST_WORKTREES_DIR/broken" sparse-checkout set docs >/dev/null + + pattern_file=$(git -C "$TEST_WORKTREES_DIR/broken" rev-parse --git-path info/sparse-checkout) + rm -f "$pattern_file" + + run cmd_create feat-broken --from broken --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + [[ "$output" != *"Inherited sparse-checkout"* ]] + [[ "$output" == *"falling back to a full checkout"* ]] + + wt="$TEST_WORKTREES_DIR/feat-broken" + [ -d "$wt/apps/web" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] } @test "cmd_create --no-sparse forces a full checkout" { @@ -174,6 +279,21 @@ make_sparse_worktree() { [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] } +@test "cmd_create --no-sparse from the current sparse worktree creates a dense checkout" { + source_gtr_commands + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/web >/dev/null + + run cmd_create feat-current-full --from-current --no-sparse --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-current-full" + [ -d "$wt/apps/web" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + @test "cmd_create respects gtr.sparse.inherit=false (no inheritance)" { source_gtr_commands git -C "$TEST_REPO" config gtr.sparse.inherit false @@ -189,6 +309,39 @@ make_sparse_worktree() { [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] } +@test "cmd_create respects sparse.inherit=false from .gtrconfig" { + source_gtr_commands + git -C "$TEST_REPO" config -f "$TEST_REPO/.gtrconfig" sparse.inherit false + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/web >/dev/null + + run cmd_create feat-file-cfg --from-current --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-file-cfg" + [ -d "$wt/apps/web" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + +@test "worktree-local sparse config overrides .gtrconfig" { + source_gtr_commands + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/web >/dev/null + git -C "$TEST_REPO" config -f "$TEST_REPO/.gtrconfig" sparse.inherit true + git -C "$TEST_REPO" config --worktree gtr.sparse.inherit false + + run cmd_create feat-worktree-cfg --from-current --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-worktree-cfg" + [ -d "$wt/apps/web" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + @test "cmd_create --sparse overrides gtr.sparse.inherit=false" { source_gtr_commands git -C "$TEST_REPO" config gtr.sparse.inherit false @@ -205,6 +358,22 @@ make_sparse_worktree() { [ ! -d "$wt/docs" ] } +@test "cmd_create --sparse falls back to dense when Git lacks native inheritance" { + source_gtr_commands + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + _git_supports_sparse_inheritance() { return 1; } + + run cmd_create feat-old-git --from base --sparse --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + [[ "$output" == *"requires Git 2.36+"* ]] + + wt="$TEST_WORKTREES_DIR/feat-old-git" + [ -d "$wt/apps/web" ] + [ -d "$wt/apps/api" ] + [ -d "$wt/docs" ] + [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] +} + @test "cmd_create with non-sparse base produces a full checkout" { source_gtr_commands git -C "$TEST_REPO" worktree add --quiet -b plain "$TEST_WORKTREES_DIR/plain" HEAD From f9b808a41fd33e8c96abfffa9a43c0a32d99a168 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Fri, 10 Jul 2026 21:56:01 -0700 Subject: [PATCH 4/5] fix: support pre-2.20 config lookup --- lib/config.sh | 5 ++++- tests/config.bats | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/config.sh b/lib/config.sh index 4d5f570..3765e4c 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -195,7 +195,8 @@ cfg_get_all() { } # Read one config source with Git's native boolean parser. -# Returns 0 with true/false output, 1 when absent, 2 when present but invalid. +# Returns 0 with true/false output, 1 when absent, 2 when present but invalid, +# and 3 when the installed Git does not recognize a requested config option. _cfg_read_bool() { local key="$1" value status shift @@ -206,6 +207,7 @@ _cfg_read_bool() { status=$? fi [ "$status" -eq 1 ] && return 1 + [ "$status" -eq 129 ] && return 3 return 2 } @@ -238,6 +240,7 @@ cfg_bool() { return else status=$? + [ "$source" = "worktree" ] && [ "$status" -eq 3 ] && continue [ "$status" -eq 2 ] && return 1 fi done diff --git a/tests/config.bats b/tests/config.bats index 6ed8743..7e5d23f 100644 --- a/tests/config.bats +++ b/tests/config.bats @@ -88,6 +88,16 @@ teardown() { cfg_bool gtr.sparse.inherit false } +@test "cfg_bool skips an unsupported worktree config scope" { + _cfg_read_bool() { + [ "${2:-}" = "--worktree" ] && return 3 + [ "${2:-}" = "--local" ] && { printf "true"; return 0; } + return 1 + } + + cfg_bool gtr.sparse.inherit false +} + # ── cfg_list deduplication ─────────────────────────────────────────────────── @test "_cfg_list_add_entry adds entry to result" { From 853b43d0ab6db919884be21ca507514a2dd933e5 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Sat, 11 Jul 2026 00:10:24 -0700 Subject: [PATCH 5/5] fix: align sparse sources with git refs --- lib/commands/create.sh | 19 +++++---- lib/config.sh | 9 ++-- lib/core.sh | 95 ++++++++++++++++++------------------------ tests/sparse.bats | 94 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 68 deletions(-) diff --git a/lib/commands/create.sh b/lib/commands/create.sh index 9c1d726..a04b6f5 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -162,17 +162,17 @@ cmd_create() { # Decide whether to inherit sparse-checkout from the base worktree. # Precedence: --no-sparse > --sparse > gtr.sparse.inherit (default on). - local sparse_inherit=0 + local sparse_inherit=0 native_sparse_supported=0 + _git_supports_sparse_inheritance && native_sparse_supported=1 if [ "$no_sparse_flag" -eq 1 ]; then sparse_inherit=0 elif [ "$sparse_flag" -eq 1 ]; then sparse_inherit=1 - elif cfg_bool gtr.sparse.inherit true; then + elif [ "$native_sparse_supported" -eq 1 ] && cfg_bool gtr.sparse.inherit true; then sparse_inherit=1 fi - local sparse_source="" no_checkout=0 native_sparse_supported=0 - _git_supports_sparse_inheritance && native_sparse_supported=1 + local sparse_source="" no_checkout=0 if [ "$sparse_inherit" -eq 1 ]; then if [ "$native_sparse_supported" -eq 1 ]; then sparse_source=$(_resolve_sparse_source "$from_ref") @@ -187,11 +187,12 @@ cmd_create() { # Git 2.36+ copies the caller's sparse settings during worktree add. Defer # checkout only when a sparse caller must produce a full checkout; ordinary # dense creation keeps the existing one-step path. - local current_worktree - current_worktree=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [ -z "$sparse_source" ] && [ "$native_sparse_supported" -eq 1 ] \ - && _worktree_sparse_enabled "$current_worktree"; then - no_checkout=1 + local current_worktree="" + if [ -z "$sparse_source" ] && [ "$native_sparse_supported" -eq 1 ]; then + current_worktree=$(git rev-parse --show-toplevel 2>/dev/null || true) + if _worktree_sparse_enabled "$current_worktree"; then + no_checkout=1 + fi fi # Construct folder name for display diff --git a/lib/config.sh b/lib/config.sh index 3765e4c..77ec269 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -217,10 +217,8 @@ _cfg_read_bool() { # Returns: 0 for true, 1 for false. cfg_bool() { local key="$1" default="${2:-false}" source candidate value status - local file_key config_file + local file_key="" config_file="" local args=() - file_key=$(cfg_map_to_file_key "$key") - config_file=$(_gtrconfig_path) for source in worktree local file global system; do candidate="$key" @@ -228,7 +226,10 @@ cfg_bool() { worktree) args=(--worktree) ;; local) args=(--local) ;; file) - [ -n "$file_key" ] && [ -f "$config_file" ] || continue + file_key=$(cfg_map_to_file_key "$key") + [ -n "$file_key" ] || continue + config_file=$(_gtrconfig_path) + [ -f "$config_file" ] || continue candidate="$file_key" args=(-f "$config_file") ;; diff --git a/lib/core.sh b/lib/core.sh index d003c21..af608e2 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -585,13 +585,14 @@ _worktree_add_tracked() { } # Normalize a remote-tracking ref only when its prefix names a configured remote. -# Usage: _remote_branch_for_ref +# Usage: _remote_branch_for_ref [repo_root] # Prints: local branch portion for a configured remote ref, empty otherwise. _remote_branch_for_ref() { - local ref="$1" repo_root remote default_remote matched_remote="" - repo_root=$(_resolve_main_repo_root) || return 0 + local ref="$1" repo_root="${2:-}" remote default_remote matched_remote="" + [ -n "$repo_root" ] || repo_root=$(_resolve_main_repo_root) || return 0 case "$ref" in refs/remotes/*) ref="${ref#refs/remotes/}" ;; + remotes/*) ref="${ref#remotes/}" ;; esac # The configured/default remote remains meaningful before the first fetch, @@ -620,51 +621,48 @@ _remote_branch_for_ref() { # Usage: _worktree_path_for_ref # Prints: worktree path on match, empty otherwise. _worktree_path_for_ref() { - local ref="$1" exact_ref remote_branch repo_root current_path records resolved_ref - local exact_blocks_remote=0 + local ref="$1" repo_root current_path records resolved_ref branch_candidate [ -n "$ref" ] || return 0 repo_root=$(_resolve_main_repo_root) || return 0 current_path=$(git rev-parse --show-toplevel 2>/dev/null || true) [ -n "$current_path" ] && current_path=$(canonicalize_path "$current_path" || printf "%s" "$current_path") - exact_ref="$ref" - case "$exact_ref" in - refs/heads/*) exact_ref="${exact_ref#refs/heads/}" ;; - esac - case "$ref" in - refs/heads/*|refs/tags/*) exact_blocks_remote=1 ;; - esac - if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$exact_ref" \ - || git -C "$repo_root" show-ref --verify --quiet "refs/tags/$exact_ref"; then - exact_blocks_remote=1 - fi + # Let Git apply its own revision precedence and namespace shorthand rules. + # Tags and other non-branch refs deliberately have no owning worktree. resolved_ref=$(git -C "$repo_root" rev-parse --verify --symbolic-full-name "$ref" 2>/dev/null || true) case "$resolved_ref" in - refs/remotes/*|"") ;; - refs/*) exact_blocks_remote=1 ;; + refs/heads/*) + branch_candidate="${resolved_ref#refs/heads/}" + ;; + refs/remotes/*) + branch_candidate=$(_remote_branch_for_ref "$resolved_ref" "$repo_root") + ;; + refs/*) + return 0 + ;; + "") + # A commit-ish that resolves without a symbolic ref (for example a tag + # collision, SHA, or revision expression) has no unique owning worktree. + if git -C "$repo_root" rev-parse --verify "${ref}^{commit}" >/dev/null 2>&1; then + return 0 + fi + # Preserve configured-remote matching before the first fetch, when the + # remote-tracking ref does not exist yet. + branch_candidate=$(_remote_branch_for_ref "$ref" "$repo_root") + ;; esac - if [ "$exact_blocks_remote" -eq 0 ]; then - remote_branch=$(_remote_branch_for_ref "$ref") - else - remote_branch="" - fi + [ -n "$branch_candidate" ] || return 0 records=$(list_worktree_records "$repo_root") local path="" branch="" line - local exact_path="" exact_current="" exact_count=0 - local remote_path="" remote_current="" remote_count=0 + local matched_path="" matched_current="" matched_count=0 while IFS= read -r line; do case "$line" in "") - if [ -n "$path" ] && [ "$branch" = "$exact_ref" ]; then - exact_count=$((exact_count + 1)) - [ -z "$exact_path" ] && exact_path="$path" - [ -n "$current_path" ] && [ "$path" = "$current_path" ] && exact_current="$path" - fi - if [ -n "$path" ] && [ -n "$remote_branch" ] && [ "$branch" = "$remote_branch" ]; then - remote_count=$((remote_count + 1)) - [ -z "$remote_path" ] && remote_path="$path" - [ -n "$current_path" ] && [ "$path" = "$current_path" ] && remote_current="$path" + if [ -n "$path" ] && [ "$branch" = "$branch_candidate" ]; then + matched_count=$((matched_count + 1)) + [ -z "$matched_path" ] && matched_path="$path" + [ -n "$current_path" ] && [ "$path" = "$current_path" ] && matched_current="$path" fi path="" branch="" @@ -677,25 +675,12 @@ $records EOF - local selected_path selected_current selected_count selected_branch - if [ "$exact_count" -gt 0 ]; then - selected_path="$exact_path" - selected_current="$exact_current" - selected_count="$exact_count" - selected_branch="$exact_ref" - else - selected_path="$remote_path" - selected_current="$remote_current" - selected_count="$remote_count" - selected_branch="$remote_branch" - fi - - if [ -n "$selected_current" ]; then - printf "%s" "$selected_current" - elif [ "$selected_count" -eq 1 ]; then - printf "%s" "$selected_path" - elif [ "$selected_count" -gt 1 ]; then - log_warn "Multiple worktrees use branch '$selected_branch'; sparse-checkout source is ambiguous" + if [ -n "$matched_current" ]; then + printf "%s" "$matched_current" + elif [ "$matched_count" -eq 1 ]; then + printf "%s" "$matched_path" + elif [ "$matched_count" -gt 1 ]; then + log_warn "Multiple worktrees use branch '$branch_candidate'; sparse-checkout source is ambiguous" return 2 fi return 0 @@ -770,7 +755,9 @@ _resolve_sparse_source() { _git_version_at_least() { local required_major="$1" required_minor="$2" local version major minor - version=$(git --version 2>/dev/null | awk '{print $3}') + version=$(git --version 2>/dev/null) || return 1 + version="${version#git version }" + version="${version%% *}" major="${version%%.*}" minor="${version#*.}" minor="${minor%%.*}" diff --git a/tests/sparse.bats b/tests/sparse.bats index 404a4fa..695b1ad 100644 --- a/tests/sparse.bats +++ b/tests/sparse.bats @@ -53,14 +53,43 @@ make_sparse_worktree() { @test "_worktree_path_for_ref finds the worktree holding a branch" { make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + git -C "$TEST_REPO" tag base + result=$(_worktree_path_for_ref base) - [ "$result" = "$TEST_WORKTREES_DIR/base" ] + [ -z "$result" ] + + for ref in heads/base refs/heads/base; do + result=$(_worktree_path_for_ref "$ref") + [ "$result" = "$TEST_WORKTREES_DIR/base" ] + done } @test "_worktree_path_for_ref matches remote-prefixed refs by short name" { make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web result=$(_worktree_path_for_ref origin/base) [ "$result" = "$TEST_WORKTREES_DIR/base" ] + + git -C "$TEST_REPO" update-ref refs/remotes/origin/base HEAD + for ref in remotes/origin/base refs/remotes/origin/base; do + result=$(_worktree_path_for_ref "$ref") + [ "$result" = "$TEST_WORKTREES_DIR/base" ] + done + + make_sparse_worktree "$TEST_WORKTREES_DIR/literal-remote" remotes/origin/base docs + result=$(_worktree_path_for_ref remotes/origin/base) + [ -z "$result" ] + result=$(_worktree_path_for_ref refs/remotes/origin/base) + [ "$result" = "$TEST_WORKTREES_DIR/base" ] +} + +@test "_worktree_path_for_ref follows Git namespace shorthand resolution" { + make_sparse_worktree "$TEST_WORKTREES_DIR/heads-literal" heads/only apps/web + make_sparse_worktree "$TEST_WORKTREES_DIR/tags-literal" tags/only docs + + result=$(_worktree_path_for_ref heads/only) + [ "$result" = "$TEST_WORKTREES_DIR/heads-literal" ] + result=$(_worktree_path_for_ref tags/only) + [ "$result" = "$TEST_WORKTREES_DIR/tags-literal" ] } @test "_worktree_path_for_ref preserves slash branch path after remote name" { @@ -163,6 +192,43 @@ make_sparse_worktree() { [ ! -d "$wt/docs" ] } +@test "cmd_create uses a shorthand base ref's sparse worktree" { + source_gtr_commands + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/api >/dev/null + make_sparse_worktree "$TEST_WORKTREES_DIR/base source" base docs + git -C "$TEST_WORKTREES_DIR/base source" commit --allow-empty -m "advance base" --quiet + + run cmd_create feat-heads --from heads/base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-heads" + [ "$(git -C "$wt" rev-parse HEAD)" = "$(git -C "$TEST_WORKTREES_DIR/base source" rev-parse HEAD)" ] + [ "$(git -C "$wt" sparse-checkout list)" = "docs" ] + [ -d "$wt/docs" ] + [ ! -d "$wt/apps/api" ] + [[ "$output" == *"Inherited sparse-checkout from $TEST_WORKTREES_DIR/base source"* ]] +} + +@test "cmd_create keeps tag start points separate from same-named branch settings" { + source_gtr_commands + git -C "$TEST_REPO" sparse-checkout init --cone >/dev/null + git -C "$TEST_REPO" sparse-checkout set apps/api >/dev/null + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base docs + git -C "$TEST_WORKTREES_DIR/base" commit --allow-empty -m "advance base" --quiet + git -C "$TEST_REPO" tag base HEAD + + run cmd_create feat-tag --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + + wt="$TEST_WORKTREES_DIR/feat-tag" + [ "$(git -C "$wt" rev-parse HEAD)" = "$(git -C "$TEST_REPO" rev-parse 'refs/tags/base^{commit}')" ] + [ "$(git -C "$wt" sparse-checkout list)" = "apps/api" ] + [ -d "$wt/apps/api" ] + [ ! -d "$wt/docs" ] + [[ "$output" == *"Inherited sparse-checkout from $TEST_REPO"* ]] +} + @test "cmd_create inherits a dash-prefixed cone directory" { source_gtr_commands mkdir -p "$TEST_REPO/-app" @@ -374,6 +440,32 @@ make_sparse_worktree() { [ "$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || echo false)" != "true" ] } +@test "cmd_create skips sparse config lookup when Git cannot inherit" { + source_gtr_commands + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web + _git_supports_sparse_inheritance() { return 1; } + cfg_bool() { + printf "unexpected cfg_bool call\n" >&2 + return 1 + } + + run cmd_create feat-old-default --from base --yes --no-fetch --no-hooks --no-copy + [ "$status" -eq 0 ] + [[ "$output" != *"unexpected cfg_bool call"* ]] + [ -d "$TEST_WORKTREES_DIR/feat-old-default/apps/api" ] +} + +@test "_git_version_at_least parses vendor-suffixed versions" { + git() { + [ "$1" = "--version" ] || return 1 + printf "git version 2.36.6 (Vendor Git-1)\n" + } + + _git_version_at_least 2 36 + run _git_version_at_least 2 37 + [ "$status" -eq 1 ] +} + @test "cmd_create with non-sparse base produces a full checkout" { source_gtr_commands git -C "$TEST_REPO" worktree add --quiet -b plain "$TEST_WORKTREES_DIR/plain" HEAD