Skip to content

Commit 1b510ff

Browse files
adamwetthelizaga
andauthored
feat: inherit sparse-checkout in new worktrees (#186)
* 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. * 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 * fix: harden sparse-checkout inheritance * fix: support pre-2.20 config lookup * fix: align sparse sources with git refs --------- Co-authored-by: Tom Elizaga <tom.elizaga@gmail.com>
1 parent 6b48a5b commit 1b510ff

14 files changed

Lines changed: 930 additions & 38 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ git gtr config add gtr.copy.include "**/.env.example"
390390
# Run setup after creating worktrees
391391
git gtr config add gtr.hook.postCreate "npm install"
392392

393+
# Inherit sparse-checkout from the base worktree on Git 2.36+ (default: on)
394+
git gtr config set gtr.sparse.inherit true
395+
393396
# Re-source environment after gtr cd or gtr new --cd (runs in current shell)
394397
git gtr config add gtr.hook.postCd "source ./vars.sh"
395398

completions/_git-gtr

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ _git-gtr() {
6767
'--no-copy[Skip file copying]' \
6868
'--no-fetch[Skip git fetch]' \
6969
'--no-hooks[Skip post-create hooks]' \
70+
'--sparse[Inherit sparse-checkout from base worktree (Git 2.36+)]' \
71+
'--no-sparse[Force a full checkout]' \
7072
'--force[Allow same branch in multiple worktrees]' \
7173
'--name[Custom folder name suffix]:name:' \
7274
'--folder[Custom folder name (replaces default)]:folder:' \
@@ -187,15 +189,15 @@ _git-gtr() {
187189
'--local[Use local git config]' \
188190
'--global[Use global git config]' \
189191
'--system[Use system git config]' \
190-
'*: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)'
192+
'*: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)'
191193
;;
192194
set|add|unset)
193195
# Write operations only support --local and --global
194196
# (--system may require root or appropriate file permissions)
195197
_arguments \
196198
'--local[Use local git config]' \
197199
'--global[Use global git config]' \
198-
'*: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)'
200+
'*: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)'
199201
;;
200202
esac
201203
fi

completions/git-gtr.fish

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ complete -c git -n '__fish_git_gtr_using_command new' -l track -d 'Track mode' -
6666
complete -c git -n '__fish_git_gtr_using_command new' -l no-copy -d 'Skip file copying'
6767
complete -c git -n '__fish_git_gtr_using_command new' -l no-fetch -d 'Skip git fetch'
6868
complete -c git -n '__fish_git_gtr_using_command new' -l no-hooks -d 'Skip post-create hooks'
69+
complete -c git -n '__fish_git_gtr_using_command new' -l sparse -d 'Inherit sparse-checkout from base worktree (Git 2.36+)'
70+
complete -c git -n '__fish_git_gtr_using_command new' -l no-sparse -d 'Force a full checkout'
6971
complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same branch in multiple worktrees'
7072
complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r
7173
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 "
143145
gtr.ai.default 'Default AI tool'
144146
gtr.worktrees.dir 'Worktrees base directory'
145147
gtr.worktrees.prefix 'Worktree folder prefix'
148+
gtr.sparse.inherit 'gtr.sparse.inherit'
146149
gtr.defaultBranch 'Default branch'
147150
gtr.defaultRemote 'Default remote'
148151
gtr.provider 'Hosting provider (github, gitlab)'

completions/gtr.bash

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ _git_gtr() {
9999
new)
100100
# Complete flags
101101
if [[ "$cur" == -* ]]; then
102-
COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --force --name --folder --yes --editor -e --ai -a" -- "$cur"))
102+
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"))
103103
elif [ "$prev" = "--track" ]; then
104104
COMPREPLY=($(compgen -W "auto remote local none" -- "$cur"))
105105
fi
@@ -138,15 +138,15 @@ _git_gtr() {
138138
if [[ "$cur" == -* ]]; then
139139
COMPREPLY=($(compgen -W "--local --global --system" -- "$cur"))
140140
else
141-
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"))
141+
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"))
142142
fi
143143
;;
144144
set|add|unset)
145145
# Write operations only support --local and --global (--system requires root)
146146
if [[ "$cur" == -* ]]; then
147147
COMPREPLY=($(compgen -W "--local --global" -- "$cur"))
148148
else
149-
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"))
149+
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"))
150150
fi
151151
;;
152152
esac

docs/advanced-usage.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [CI/CD Integration](#cicd-integration)
1616
- [Multiple Worktrees Same Branch](#multiple-worktrees-same-branch)
1717
- [Parallel AI Development](#parallel-ai-development)
18+
- [Sparse-Checkout Inheritance](#sparse-checkout-inheritance)
1819

1920
---
2021

@@ -202,4 +203,33 @@ git gtr ai feature-auth-tests -- --message "Write integration tests"
202203

203204
---
204205

206+
## Sparse-Checkout Inheritance
207+
208+
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.
209+
210+
```bash
211+
# my-app is a sparse worktree checking out only apps/my-app + packages.
212+
# A feature branch off it inherits the same cone automatically:
213+
git gtr new my-app-feature-xyz --from my-app
214+
215+
# The new worktree contains only the inherited sparse slice:
216+
ls "$(git gtr go my-app-feature-xyz)"
217+
git -C "$(git gtr go my-app-feature-xyz)" sparse-checkout list
218+
219+
# Opt out for a single command (full checkout):
220+
git gtr new big-refactor --from my-app --no-sparse
221+
```
222+
223+
**How it works:**
224+
225+
- 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.
226+
- Git 2.17–2.35 keeps the existing full-checkout behavior. An explicit `--sparse` request prints a warning that inheritance requires Git 2.36+.
227+
- Controlled by `gtr.sparse.inherit` (default on). Use `--sparse` / `--no-sparse` to override per command.
228+
- Full-checkout repositories are unaffected — they always get a full checkout.
229+
230+
> [!NOTE]
231+
> 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.
232+
233+
---
234+
205235
[Back to README](../README.md) | [Configuration](configuration.md) | [Troubleshooting](troubleshooting.md)

docs/configuration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ echo "/.worktrees/" >> .gitignore
110110

111111
---
112112

113+
## Sparse-Checkout Settings
114+
115+
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.
116+
117+
```bash
118+
# Inherit sparse-checkout from the base worktree (default: true)
119+
gtr.sparse.inherit = true
120+
```
121+
122+
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.
123+
124+
Per-command overrides:
125+
126+
```bash
127+
# Force inheritance even if gtr.sparse.inherit is off
128+
git gtr new feature-xyz --from my-app --sparse
129+
130+
# Force a full checkout even if gtr.sparse.inherit is on
131+
git gtr new feature-xyz --from my-app --no-sparse
132+
```
133+
134+
> [!NOTE]
135+
> 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.
136+
137+
---
138+
113139
## Provider Settings
114140

115141
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:

lib/commands/create.sh

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ cmd_create() {
9494
--no-copy
9595
--no-fetch
9696
--no-hooks
97+
--sparse
98+
--no-sparse
9799
--yes
98100
--force
99101
--name: value
@@ -110,6 +112,8 @@ cmd_create() {
110112
local skip_copy="${_arg_no_copy:-0}"
111113
local skip_fetch="${_arg_no_fetch:-0}"
112114
local skip_hooks="${_arg_no_hooks:-0}"
115+
local sparse_flag="${_arg_sparse:-0}"
116+
local no_sparse_flag="${_arg_no_sparse:-0}"
113117
local yes_mode="${_arg_yes:-0}"
114118
local force="${_arg_force:-0}"
115119
local custom_name="${_arg_name:-}"
@@ -156,6 +160,41 @@ cmd_create() {
156160
# Determine from_ref with precedence: --from > --from-current > default
157161
from_ref=$(_create_resolve_from_ref "$from_ref" "$from_current" "$repo_root" "$remote")
158162

163+
# Decide whether to inherit sparse-checkout from the base worktree.
164+
# Precedence: --no-sparse > --sparse > gtr.sparse.inherit (default on).
165+
local sparse_inherit=0 native_sparse_supported=0
166+
_git_supports_sparse_inheritance && native_sparse_supported=1
167+
if [ "$no_sparse_flag" -eq 1 ]; then
168+
sparse_inherit=0
169+
elif [ "$sparse_flag" -eq 1 ]; then
170+
sparse_inherit=1
171+
elif [ "$native_sparse_supported" -eq 1 ] && cfg_bool gtr.sparse.inherit true; then
172+
sparse_inherit=1
173+
fi
174+
175+
local sparse_source="" no_checkout=0
176+
if [ "$sparse_inherit" -eq 1 ]; then
177+
if [ "$native_sparse_supported" -eq 1 ]; then
178+
sparse_source=$(_resolve_sparse_source "$from_ref")
179+
elif [ "$sparse_flag" -eq 1 ]; then
180+
log_warn "Sparse-checkout inheritance requires Git 2.36+ — creating a full checkout"
181+
fi
182+
if [ -z "$sparse_source" ] && [ "$sparse_flag" -eq 1 ] && [ "$native_sparse_supported" -eq 1 ]; then
183+
log_warn "No sparse-checkout source found for '$from_ref' — creating a full checkout"
184+
fi
185+
fi
186+
187+
# Git 2.36+ copies the caller's sparse settings during worktree add. Defer
188+
# checkout only when a sparse caller must produce a full checkout; ordinary
189+
# dense creation keeps the existing one-step path.
190+
local current_worktree=""
191+
if [ -z "$sparse_source" ] && [ "$native_sparse_supported" -eq 1 ]; then
192+
current_worktree=$(git rev-parse --show-toplevel 2>/dev/null || true)
193+
if _worktree_sparse_enabled "$current_worktree"; then
194+
no_checkout=1
195+
fi
196+
fi
197+
159198
# Construct folder name for display
160199
local folder_name
161200
if [ -n "$folder_override" ]; then
@@ -172,7 +211,24 @@ cmd_create() {
172211

173212
# Create the worktree
174213
local worktree_path
175-
if ! worktree_path=$(create_worktree "$base_dir" "$prefix" "$branch_name" "$from_ref" "$track_mode" "$skip_fetch" "$force" "$custom_name" "$folder_override" "$remote"); then
214+
# Only `git worktree add` uses sparse_source as its context; fetch and branch
215+
# resolution retain the caller's repository configuration.
216+
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
217+
exit 1
218+
fi
219+
220+
if [ -n "$sparse_source" ]; then
221+
if _worktree_is_sparse "$worktree_path"; then
222+
log_info "Inherited sparse-checkout from $sparse_source"
223+
else
224+
log_warn "Sparse-checkout inheritance was not applied — falling back to a full checkout"
225+
if ! _ensure_full_checkout "$worktree_path" 1; then
226+
log_error "Could not populate worktree at $worktree_path"
227+
exit 1
228+
fi
229+
fi
230+
elif [ "$no_checkout" -eq 1 ] && ! _ensure_full_checkout "$worktree_path" 1; then
231+
log_error "Could not populate full worktree at $worktree_path"
176232
exit 1
177233
fi
178234

lib/commands/help.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Options:
2323
--no-copy Skip file copying (gtr.copy.include patterns)
2424
--no-fetch Skip git fetch before creating
2525
--no-hooks Skip post-create hooks
26+
--sparse Inherit sparse-checkout from the base worktree (Git 2.36+)
27+
--no-sparse Force a full checkout (override gtr.sparse.inherit)
2628
--force Allow same branch in multiple worktrees
2729
(requires --name or --folder to distinguish them)
2830
--name <suffix> Custom folder name suffix (appended after branch name)

lib/config.sh

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ _CFG_KEY_MAP=(
107107
"gtr.ai.default|defaults.ai"
108108
"gtr.worktrees.dir|worktrees.dir"
109109
"gtr.worktrees.prefix|worktrees.prefix"
110+
"gtr.sparse.inherit|sparse.inherit"
110111
"gtr.defaultBranch|defaults.branch"
111112
"gtr.defaultRemote|defaults.remote"
112113
"gtr.provider|defaults.provider"
@@ -193,28 +194,62 @@ cfg_get_all() {
193194
esac
194195
}
195196

196-
# Get a boolean config value
197+
# Read one config source with Git's native boolean parser.
198+
# Returns 0 with true/false output, 1 when absent, 2 when present but invalid,
199+
# and 3 when the installed Git does not recognize a requested config option.
200+
_cfg_read_bool() {
201+
local key="$1" value status
202+
shift
203+
if value=$(git config "$@" --bool --get "$key" 2>/dev/null); then
204+
printf "%s" "$value"
205+
return 0
206+
else
207+
status=$?
208+
fi
209+
[ "$status" -eq 1 ] && return 1
210+
[ "$status" -eq 129 ] && return 3
211+
return 2
212+
}
213+
214+
# Get a boolean config value.
215+
# Precedence: worktree > local > .gtrconfig > global > system > default.
197216
# Usage: cfg_bool key [default]
198-
# Returns: 0 for true, 1 for false
217+
# Returns: 0 for true, 1 for false.
199218
cfg_bool() {
200-
local key="$1"
201-
local default="${2:-false}"
202-
local value
203-
204-
value=$(cfg_get "$key")
205-
206-
if [ -z "$value" ]; then
207-
value="$default"
208-
fi
219+
local key="$1" default="${2:-false}" source candidate value status
220+
local file_key="" config_file=""
221+
local args=()
222+
223+
for source in worktree local file global system; do
224+
candidate="$key"
225+
case "$source" in
226+
worktree) args=(--worktree) ;;
227+
local) args=(--local) ;;
228+
file)
229+
file_key=$(cfg_map_to_file_key "$key")
230+
[ -n "$file_key" ] || continue
231+
config_file=$(_gtrconfig_path)
232+
[ -f "$config_file" ] || continue
233+
candidate="$file_key"
234+
args=(-f "$config_file")
235+
;;
236+
global) args=(--global) ;;
237+
system) args=(--system) ;;
238+
esac
239+
if value=$(_cfg_read_bool "$candidate" "${args[@]}"); then
240+
[ "$value" = "true" ]
241+
return
242+
else
243+
status=$?
244+
[ "$source" = "worktree" ] && [ "$status" -eq 3 ] && continue
245+
[ "$status" -eq 2 ] && return 1
246+
fi
247+
done
209248

210-
case "$value" in
211-
true|yes|1|on)
212-
return 0
213-
;;
214-
false|no|0|off|*)
215-
return 1
216-
;;
249+
case "$default" in
250+
[tT][rR][uU][eE]|[yY][eE][sS]|1|[oO][nN]) return 0 ;;
217251
esac
252+
return 1
218253
}
219254

220255
# Convert scope name to git config flag

0 commit comments

Comments
 (0)