diff --git a/README.md b/README.md index 03adf33..18a32ea 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 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) git gtr config add gtr.hook.postCd "source ./vars.sh" diff --git a/completions/_git-gtr b/completions/_git-gtr index ac3c6b5..f7d905d 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 (Git 2.36+)]' \ + '--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..0725d0e 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 (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 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..280739f 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,33 @@ 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:** + +- 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 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. + +--- + [Back to README](../README.md) | [Configuration](configuration.md) | [Troubleshooting](troubleshooting.md) diff --git a/docs/configuration.md b/docs/configuration.md index 25c81b5..00053b9 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 +``` + +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: + +```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 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. + +--- + ## 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..a04b6f5 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,41 @@ 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 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 [ "$native_sparse_supported" -eq 1 ] && cfg_bool gtr.sparse.inherit true; then + sparse_inherit=1 + fi + + 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") + 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="" + 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 local folder_name if [ -n "$folder_override" ]; then @@ -172,7 +211,24 @@ 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 + # 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 + + if [ -n "$sparse_source" ]; 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 diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 9af8711..2eba45b 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 (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) --name Custom folder name suffix (appended after branch name) diff --git a/lib/config.sh b/lib/config.sh index 0cc842c..77ec269 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" @@ -193,28 +194,62 @@ 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, +# and 3 when the installed Git does not recognize a requested config option. +_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 + [ "$status" -eq 129 ] && return 3 + 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") - - if [ -z "$value" ]; then - value="$default" - fi + local key="$1" default="${2:-false}" source candidate value status + local file_key="" config_file="" + local args=() + + for source in worktree local file global system; do + candidate="$key" + case "$source" in + worktree) args=(--worktree) ;; + local) args=(--local) ;; + file) + 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") + ;; + global) args=(--global) ;; + system) args=(--system) ;; + esac + if value=$(_cfg_read_bool "$candidate" "${args[@]}"); then + [ "$value" = "true" ] + return + else + status=$? + [ "$source" = "worktree" ] && [ "$status" -eq 3 ] && continue + [ "$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 4f5bdad..af608e2 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,21 +580,238 @@ _worktree_add_tracked() { fi _try_worktree_add "$wt_path" "" \ "Worktree created tracking $remote/$branch_name" \ + "$add_source" \ "$@" "$branch_name" } +# Normalize a remote-tracking ref only when its prefix names a configured remote. +# 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="${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, + # 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" 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") + + # 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/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 + [ -n "$branch_candidate" ] || return 0 + records=$(list_worktree_records "$repo_root") + + local path="" branch="" line + local matched_path="" matched_current="" matched_count=0 + while IFS= read -r line; do + case "$line" in + "") + 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="" + ;; + "path "*) path=$(_tsv_unescape_field "${line#path }") ;; + "branch "*) branch=$(_tsv_unescape_field "${line#branch }") ;; + esac + done < +_worktree_sparse_enabled() { + local wt="$1" + [ -n "$wt" ] || return 1 + local enabled + enabled=$(git -C "$wt" config --bool core.sparseCheckout 2>/dev/null || true) + [ "$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; +# 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 status current + + # Prefer the worktree holding from_ref, but only if it is sparse-enabled. + 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. + 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 +} + +# 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) || return 1 + version="${version#git version }" + version="${version%% *}" + major="${version%%.*}" + minor="${version#*.}" + minor="${minor%%.*}" + [ -n "$major" ] || return 1 + case "$major$minor" in + *[!0-9]* | "") return 1 ;; + esac + [ "$major" -gt "$required_major" ] && return 0 + [ "$major" -eq "$required_major" ] && [ "$minor" -ge "$required_minor" ] && return 0 + return 1 +} + +# Git 2.36+ natively copies sparse patterns and worktree config on worktree add. +_git_supports_sparse_inheritance() { + _git_version_at_least 2 36 +} + +# 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 _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 + + 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 + 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] [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 @@ -600,6 +819,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" @@ -629,9 +849,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" @@ -643,6 +865,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" @@ -653,6 +876,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 @@ -660,16 +884,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 f324d54..98aee44 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 (Git 2.36+)]' \ + '--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 (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 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..a46bc94 100644 --- a/templates/.gtrconfig.example +++ b/templates/.gtrconfig.example @@ -69,3 +69,10 @@ # 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; 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..7e5d23f 100644 --- a/tests/config.bats +++ b/tests/config.bats @@ -79,6 +79,25 @@ 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 +} + +@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" { diff --git a/tests/sparse.bats b/tests/sparse.bats new file mode 100644 index 0000000..695b1ad --- /dev/null +++ b/tests/sparse.bats @@ -0,0 +1,479 @@ +#!/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 + git -C "$TEST_REPO" tag base + + result=$(_worktree_path_for_ref 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" { + 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 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" ] +} + +@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 "cmd_create inherits a cone from the base worktree" { + source_gtr_commands + make_sparse_worktree "$TEST_WORKTREES_DIR/base" base apps/web packages + + 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 "$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 "$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 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" + 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/apps/api" ] +} + +@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 + + 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) + [ "$(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 falls back to a full checkout when source patterns are missing" { + source_gtr_commands + 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 + + 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-missing-patterns" + [ -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 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" { + 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 --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 + 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 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 + 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 --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 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 + + 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" ] +}