diff --git a/cli/__tests__/ship-branch.test.mts b/cli/__tests__/ship-branch.test.mts index 7e22f345..0bfb3597 100644 --- a/cli/__tests__/ship-branch.test.mts +++ b/cli/__tests__/ship-branch.test.mts @@ -1429,7 +1429,7 @@ describe('ship-branch.sh — untracked/gitignored gate configs are linked into t expect(r.stderr).not.toMatch(/\.search-code\/index\.db .*commit it/); }); - it('uses main-worktree gate inputs when shipping from a linked worktree', () => { + it('uses main-worktree gate inputs and classifies a symlinked cache without a fatal pathspec', () => { const hookBody = [ '[ -e guard.config.json ] && echo CONFIG_SEEN || echo CONFIG_MISSING', '[ -e .search-code/index.db ] && echo INDEX_SEEN || echo INDEX_MISSING', @@ -1455,12 +1455,11 @@ describe('ship-branch.sh — untracked/gitignored gate configs are linked into t dirs.push(linkedParent); const linked = join(linkedParent, 'checkout'); git(['worktree', 'add', '-q', '-b', 'linked-task', linked], { stdio: 'ignore' }); - mkdirSync(join(linked, '.search-code')); // interrupted/empty local index directory + execFileSync('ln', ['-s', join(dir, '.search-code'), join(linked, '.search-code')]); mkdirSync(join(linked, '.decisions')); // empty local projection must not hide populated main data mkdirSync(join(linked, '.fallow')); writeFileSync(join(linked, '.fallow/source'), 'linked'); // populated local override still wins writeFileSync(join(linked, 'note.txt'), 'hi\n'); - const r = spawnSync('/bin/bash', [scriptPath, 'feat/linked-gate-inputs', 't', 'note.txt'], { cwd: linked, input: 'b\n', @@ -1470,6 +1469,7 @@ describe('ship-branch.sh — untracked/gitignored gate configs are linked into t dropWorktree(git, r.stderr); expect(r.status, r.stderr).toBe(0); expect(r.stderr).toMatch(/\.search-code\/index\.db .*gitignored cache/); + expect(r.stderr).not.toMatch(/fatal: pathspec/); const log = readFileSync( join(linked, '.devkit/last-ship-gates-feat-linked-gate-inputs.log'), 'utf8', diff --git a/cli/lib/ship/link-gate-configs.sh b/cli/lib/ship/link-gate-configs.sh index ba32a873..77d4e546 100644 --- a/cli/lib/ship/link-gate-configs.sh +++ b/cli/lib/ship/link-gate-configs.sh @@ -60,12 +60,42 @@ is_review_projection_purpose() { [ "$1" = review ] || [ "$1" = review-baseline ] } +# gate_projection_source_is_ignored +# +# `git check-ignore ` refuses to traverse a symlinked directory inside a +# worktree. Gate inputs deliberately use that shape to share ignored caches from the main worktree, +# so resolve the source's parent physically and ask the worktree that owns those bytes instead. +gate_projection_source_is_ignored() { + local root=$1 source=$2 rel=$3 physical_parent physical_source owner='' candidate owner_rel + if ! physical_parent=$(cd -P "$(dirname "$source")" 2>/dev/null && pwd); then + git -C "$root" check-ignore -q -- "$rel" + return + fi + physical_source="$physical_parent/$(basename "$source")" + while IFS= read -r candidate; do + case "$physical_source" in + "$candidate"/*) + [ "${#candidate}" -gt "${#owner}" ] && owner=$candidate + ;; + esac + done < <( + git -C "$root" worktree list --porcelain 2>/dev/null | + awk '/^worktree /{print substr($0, 10)}' + ) + if [ -n "$owner" ]; then + owner_rel=${physical_source#"$owner"/} + git -C "$owner" check-ignore -q -- "$owner_rel" + else + git -C "$root" check-ignore -q -- "$rel" + fi +} + # link_untracked_gate_configs [purpose] link_untracked_gate_configs() { local wt=$1 root=$2 purpose=${3:-ship} emitter resolved rel line index_rel='' candidate_manifest='' local main_root='' candidate_root=$root source='' local projection_manifest=${DEVKIT_REVIEW_PROJECTION_MANIFEST:-} projection_tool='' - local linked=() candidates=() + local linked=() linked_sources=() candidates=() case "$purpose" in ship | review | review-baseline) ;; *) @@ -138,6 +168,7 @@ link_untracked_gate_configs() { for rel in "${candidates[@]}"; do [ -e "$root/$rel" ] && [ ! -e "$wt/$rel" ] && [ ! -L "$wt/$rel" ] || continue linked+=("$rel") + linked_sources+=("$root/$rel") done { if [ "${#linked[@]}" -gt 0 ]; then @@ -154,6 +185,7 @@ link_untracked_gate_configs() { mkdir -p "$wt/$(dirname "$rel")" ln -s "$source" "$wt/$rel" linked+=("$rel") + linked_sources+=("$source") done fi @@ -167,13 +199,15 @@ link_untracked_gate_configs() { else echo " linked into the gate worktree so gates match a normal commit (not defaults):" fi + local linked_index=0 for rel in "${linked[@]}"; do # `check-ignore -q` inside the `if` → its exit-1 "not ignored" is errexit-safe. - if git -C "$root" check-ignore -q "$rel"; then + if gate_projection_source_is_ignored "$root" "${linked_sources[$linked_index]}" "$rel"; then echo " - $rel (gitignored cache — normal)" else echo " - $rel (untracked — commit it so gates are consistent for everyone)" fi + linked_index=$((linked_index + 1)) done } >&2 return 0