From f85e0f40dbbf8e32a52c1d3cf67402b803acec79 Mon Sep 17 00:00:00 2001 From: adamwett Date: Sun, 28 Jun 2026 19:48:26 -0400 Subject: [PATCH 1/2] 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/2] 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