From 5749ed914f76dd532cb224018b9ce4d7f39bbd88 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:13:52 +0000 Subject: [PATCH 1/7] fix(KEN-3935): anchor worktree exclusions at the root --- .agents/skills/worktree/scripts/lib/help.sh | 8 +- .agents/skills/worktree/scripts/lib/links.sh | 36 +++--- changelog.d/worktree/fixed/root-excludes.md | 1 + skills/worktree/scripts/lib/help.sh | 8 +- skills/worktree/scripts/lib/links.sh | 36 +++--- .../tests/worktree_exclude_tracked_paths.sh | 114 +++++++++++++----- skills/worktree/tests/worktree_path_safety.sh | 4 +- 7 files changed, 139 insertions(+), 68 deletions(-) create mode 100644 changelog.d/worktree/fixed/root-excludes.md diff --git a/.agents/skills/worktree/scripts/lib/help.sh b/.agents/skills/worktree/scripts/lib/help.sh index 3c8780e2d4..c9322cbfcb 100644 --- a/.agents/skills/worktree/scripts/lib/help.sh +++ b/.agents/skills/worktree/scripts/lib/help.sh @@ -780,11 +780,13 @@ Symlink entries that shadow tracked content: info/exclude entries: The ignore entry setup writes for each symlinked path goes into the COMMON - git dir's info/exclude, which every checkout reads. When the path holds - tracked content, setup follows the entry with '!/' (a trailing-slash + git dir's info/exclude, which every checkout reads. The entry is '/', + anchored at the repository root so nested names stay visible. When the path + holds tracked content, setup follows the entry with '!//' (a trailing-slash pattern matches a real directory but NOT a symlink pointing at one). Runtime-only paths keep the plain entry. The shape is re-evaluated on every - create/fix-links against both indexes, either one enough. + create/fix-links against both indexes, either one enough. Each pass removes + exact legacy '' and '!/' lines and preserves other patterns. Git-hook auto-repair: create and fix-links install shared post-checkout, post-merge, and diff --git a/.agents/skills/worktree/scripts/lib/links.sh b/.agents/skills/worktree/scripts/lib/links.sh index e0fae1fcb5..b93fbf1a66 100644 --- a/.agents/skills/worktree/scripts/lib/links.sh +++ b/.agents/skills/worktree/scripts/lib/links.sh @@ -40,7 +40,7 @@ split_worktree_config_words() { read -r -a WORKTREE_CONFIG_WORDS <<<"$value" } -# Idempotently append a worktree-relative path to the COMMON git-dir's +# Idempotently append a root-anchored path to the COMMON git-dir's # info/exclude so git treats the symlink we lay down as ignored content. # Required because .gitignore patterns with a trailing slash (e.g. `.agents/`) # match only real directories, not symlinks pointing at directories — without @@ -53,17 +53,18 @@ split_worktree_config_words() { # That shared file is the problem this function has to work around. The # entry is meant for a SYMLINK in a worktree, but it lands in a file every # checkout of the repo reads — including main, where the same path is a REAL -# directory. A bare `harness` entry therefore marks that whole directory ignored +# directory. A `/harness` entry therefore marks that whole directory ignored # in main, and `git add harness/tracked-file` starts refusing with "The # following paths are ignored by one of your .gitignore files" while `git # status` still lists the file as modified. It outlives the worktree, too. # # The fix uses the very asymmetry documented above: a TRAILING-SLASH pattern # matches a real directory but NOT a symlink pointing at one. So when the path -# has tracked content, follow the bare entry with `!/`, which re-includes +# has tracked content, follow the entry with `!//`, which re-includes # the real directory in main while leaving the worktree's symlink ignored. Order # matters — gitignore takes the LAST matching pattern — so the negation is -# always rewritten after the bare entry. +# always rewritten after the entry. A leading slash keeps a root link's rule +# from hiding new files under a nested directory with the same name. # # The negation is written ONLY when the path actually holds tracked files. # Runtime-only paths (the common case — kendex's own `.agents` mirror is hidden @@ -77,24 +78,27 @@ split_worktree_config_words() { exclude_from_worktree_index() { local wt="$1" path="$2" has_tracked="${3:-false}" [[ -z "$path" ]] && return 0 - local common_dir info_exclude negation tmp + local common_dir info_exclude entry negation common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 0 info_exclude="$common_dir/info/exclude" - negation="!$path/" + entry="/$path" + negation="!/$path/" mkdir -p "$(dirname "$info_exclude")" - # Drop any existing negation first: when it is still wanted it gets appended - # below, which also guarantees it sits AFTER the bare entry. - if [[ -f "$info_exclude" ]] && grep -qxF "$negation" "$info_exclude" 2>/dev/null; then - tmp="$info_exclude.wt-tmp.$$" - if grep -vxF "$negation" "$info_exclude" > "$tmp" 2>/dev/null; then - mv "$tmp" "$info_exclude" - else - rm -f "$tmp" 2>/dev/null - fi + # Remove exact legacy lines and move the current negation after the entry. + # Other patterns may belong to another tool and must stay in place. + if [[ -f "$info_exclude" ]]; then + ( + local tmp rc=0 + tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 + trap 'rm -f -- "$tmp"' EXIT + grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? + [[ "$rc" -le 1 ]] || exit "$rc" + mv -- "$tmp" "$info_exclude" + ) || return 1 fi - grep -qxF "$path" "$info_exclude" 2>/dev/null || echo "$path" >> "$info_exclude" + grep -qxF -- "$entry" "$info_exclude" 2>/dev/null || echo "$entry" >> "$info_exclude" [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" return 0 } diff --git a/changelog.d/worktree/fixed/root-excludes.md b/changelog.d/worktree/fixed/root-excludes.md new file mode 100644 index 0000000000..4cdaf5c76c --- /dev/null +++ b/changelog.d/worktree/fixed/root-excludes.md @@ -0,0 +1 @@ +- Keep new files in nested directories visible to Git when their names match a configured root symlink. Worktree setup and link repair migrate old exclusions to root-anchored patterns. diff --git a/skills/worktree/scripts/lib/help.sh b/skills/worktree/scripts/lib/help.sh index 3c8780e2d4..c9322cbfcb 100644 --- a/skills/worktree/scripts/lib/help.sh +++ b/skills/worktree/scripts/lib/help.sh @@ -780,11 +780,13 @@ Symlink entries that shadow tracked content: info/exclude entries: The ignore entry setup writes for each symlinked path goes into the COMMON - git dir's info/exclude, which every checkout reads. When the path holds - tracked content, setup follows the entry with '!/' (a trailing-slash + git dir's info/exclude, which every checkout reads. The entry is '/', + anchored at the repository root so nested names stay visible. When the path + holds tracked content, setup follows the entry with '!//' (a trailing-slash pattern matches a real directory but NOT a symlink pointing at one). Runtime-only paths keep the plain entry. The shape is re-evaluated on every - create/fix-links against both indexes, either one enough. + create/fix-links against both indexes, either one enough. Each pass removes + exact legacy '' and '!/' lines and preserves other patterns. Git-hook auto-repair: create and fix-links install shared post-checkout, post-merge, and diff --git a/skills/worktree/scripts/lib/links.sh b/skills/worktree/scripts/lib/links.sh index e0fae1fcb5..b93fbf1a66 100644 --- a/skills/worktree/scripts/lib/links.sh +++ b/skills/worktree/scripts/lib/links.sh @@ -40,7 +40,7 @@ split_worktree_config_words() { read -r -a WORKTREE_CONFIG_WORDS <<<"$value" } -# Idempotently append a worktree-relative path to the COMMON git-dir's +# Idempotently append a root-anchored path to the COMMON git-dir's # info/exclude so git treats the symlink we lay down as ignored content. # Required because .gitignore patterns with a trailing slash (e.g. `.agents/`) # match only real directories, not symlinks pointing at directories — without @@ -53,17 +53,18 @@ split_worktree_config_words() { # That shared file is the problem this function has to work around. The # entry is meant for a SYMLINK in a worktree, but it lands in a file every # checkout of the repo reads — including main, where the same path is a REAL -# directory. A bare `harness` entry therefore marks that whole directory ignored +# directory. A `/harness` entry therefore marks that whole directory ignored # in main, and `git add harness/tracked-file` starts refusing with "The # following paths are ignored by one of your .gitignore files" while `git # status` still lists the file as modified. It outlives the worktree, too. # # The fix uses the very asymmetry documented above: a TRAILING-SLASH pattern # matches a real directory but NOT a symlink pointing at one. So when the path -# has tracked content, follow the bare entry with `!/`, which re-includes +# has tracked content, follow the entry with `!//`, which re-includes # the real directory in main while leaving the worktree's symlink ignored. Order # matters — gitignore takes the LAST matching pattern — so the negation is -# always rewritten after the bare entry. +# always rewritten after the entry. A leading slash keeps a root link's rule +# from hiding new files under a nested directory with the same name. # # The negation is written ONLY when the path actually holds tracked files. # Runtime-only paths (the common case — kendex's own `.agents` mirror is hidden @@ -77,24 +78,27 @@ split_worktree_config_words() { exclude_from_worktree_index() { local wt="$1" path="$2" has_tracked="${3:-false}" [[ -z "$path" ]] && return 0 - local common_dir info_exclude negation tmp + local common_dir info_exclude entry negation common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 0 info_exclude="$common_dir/info/exclude" - negation="!$path/" + entry="/$path" + negation="!/$path/" mkdir -p "$(dirname "$info_exclude")" - # Drop any existing negation first: when it is still wanted it gets appended - # below, which also guarantees it sits AFTER the bare entry. - if [[ -f "$info_exclude" ]] && grep -qxF "$negation" "$info_exclude" 2>/dev/null; then - tmp="$info_exclude.wt-tmp.$$" - if grep -vxF "$negation" "$info_exclude" > "$tmp" 2>/dev/null; then - mv "$tmp" "$info_exclude" - else - rm -f "$tmp" 2>/dev/null - fi + # Remove exact legacy lines and move the current negation after the entry. + # Other patterns may belong to another tool and must stay in place. + if [[ -f "$info_exclude" ]]; then + ( + local tmp rc=0 + tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 + trap 'rm -f -- "$tmp"' EXIT + grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? + [[ "$rc" -le 1 ]] || exit "$rc" + mv -- "$tmp" "$info_exclude" + ) || return 1 fi - grep -qxF "$path" "$info_exclude" 2>/dev/null || echo "$path" >> "$info_exclude" + grep -qxF -- "$entry" "$info_exclude" 2>/dev/null || echo "$entry" >> "$info_exclude" [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" return 0 } diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index 5cd1d90582..ec0a5297b9 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -1,23 +1,7 @@ #!/usr/bin/env bash -# The shape of the info/exclude entry a configured symlink path gets. The entry -# lands in the COMMON git dir's info/exclude, which every checkout reads, -# including main, where the same path is a real directory: a bare entry alone -# marked that directory ignored in main, so `git add` of a tracked file under -# it refused with git's ignore complaint while status still listed the file -# as modified, and the entry outlived the worktree. When the path holds -# tracked content the bare entry is followed by `!/`: a trailing-slash -# pattern matches a real directory but not a symlink to one, so main regains -# the directory and the worktree's link stays ignored. A runtime-only path -# keeps the plain entry (kendex's own .agents mirror is hidden in main by that -# entry alone). The shape is recomputed on every pass, so it follows a path -# that gains or loses tracked content, and lines other tools wrote stay. -# One table, a row per scenario: the fixture is a word list of steps that -# builds a checkout with its bare origin, the configured entry's content and -# any worktree the row needs, the command runs from the checkout, and the row -# pins its exit status, its stdout, its stderr and what is left: the exclude -# file's lines, git's status of main, whether main can stage every tracked -# file under the entry (git's own refusal, first line), and every entry of -# the worktree with git's status of it. +# The common info/exclude hides root links without hiding nested names. +# Each row drives create or fix-links, then checks both checkouts and the +# common exclude file. Disposable writer mutations must make this suite fail. set -euo pipefail # The fixtures' origin is a local path that names no GitHub repository, so # the suite names the default branch. @@ -71,6 +55,8 @@ make_repo() { git -C "$MAIN" config user.email test@example.com git -C "$MAIN" config user.name Test git -C "$MAIN" config commit.gpgsign false + git -C "$MAIN" config gc.auto 0 + git -C "$MAIN" config maintenance.auto false printf 'base\n' >"$MAIN/base.txt" git -C "$MAIN" add base.txt git -C "$MAIN" commit -q -m base @@ -114,6 +100,19 @@ step() { must git -C "$MAIN" add .env.local commit_push runtime ;; + # A nested directory shares the root runtime link's name. + nested-entry) + mkdir -p "$MAIN/nested/runtime" + printf 'tracked\n' >"$MAIN/nested/runtime/keep.md" + must git -C "$MAIN" add nested/runtime/keep.md + commit_push nested + ;; + legacy-lines) + printf '%s\n' "$ENTRY" "!$ENTRY/" "$ENTRY" "!$ENTRY/" >"$MAIN/.git/info/exclude" + ;; + foreign-patterns) + printf '%s\n' '**/.claude/*' 'nested/runtime/ignored' '/runtime/cache' '!runtime/sub/' >>"$MAIN/.git/info/exclude" + ;; # A file under the entry becomes tracked, or the tracked file stops being. track:*) printf 'now-tracked\n' >"$MAIN/${1#track:}" @@ -203,6 +202,19 @@ run() { read -r -a argv <<<"${1///$WT}" if [[ "${argv[0]}" == create ]]; then WT="$ROOT/trees/${argv[1]}"; fi (cd "$MAIN" && LC_ALL=C "$WORKTREE_SCRIPT" "${argv[@]}" >"$ROOT/out" 2>"$ROOT/err") || rc=$? + if [[ -d "$MAIN/nested/runtime" ]]; then + local repo fresh_status staged root_ignored root_staged + for repo in "$MAIN" "$WT"; do + printf 'new\n' >"$repo/nested/runtime/new.md" + fresh_status="$(git -C "$repo" status --porcelain --untracked-files=all -- nested/runtime/new.md)" + must git -C "$repo" add -A + staged="$(git -C "$repo" diff --cached --name-only)" + root_ignored=0 + git -C "$repo" check-ignore -q -- runtime || root_ignored=$? + root_staged="$(git -C "$repo" ls-files -- runtime)" + printf 'nested-status=%s staged=%s root-ignored=%s root-staged=%s ' "$fresh_status" "$staged" "$root_ignored" "${root_staged:--}" + done + fi printf 'rc=%s out=%s err=%s %s' "$rc" "$(alias_text <"$ROOT/out")" "$(alias_text <"$ROOT/err")" "$(state)" } @@ -228,14 +240,18 @@ out_text() { # --- the rows --------------------------------------------------------------------- # label|fixture|command|rc|out|err|state -ROWS='an entry with tracked content gets the bare line and the negation after it, and main stages its tracked file|repo tracked-entry edit:harness/skills/tool.md|create tracked-check|0|wt:tracked-check|-|exclude=harness,!harness/,harness/state.json main= M harness/skills/tool.md stage=0:add '\''harness/skills/tool.md'\'' wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- -the entry'\''s lines stay after the worktree is removed|repo tracked-entry create:tracked-check|remove tracked-check|0|removed:tracked-check|deleted:tracked-check|exclude=harness,!harness/,harness/state.json main=- stage=0:- wt=gone -a runtime-only entry keeps the plain line, and main does not see the runtime tree|repo runtime-entry|create clean-check|0|wt:clean-check|-|exclude=runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- -the negation appears once the entry gains a tracked file, the link becomes per-child links, and main stages it|repo runtime-entry create:clean-check track:runtime/sub/keep.md|fix-links |0|restored:clean-check|-|exclude=runtime,!runtime/,runtime/state.json,runtime/sub/x.json main=- stage=0:- wt=.env.local,base.txt,runtime/state.json->
/runtime/state.json,runtime/sub/x.json->
/runtime/sub/x.json wt-status=- -a repeated pass rewrites the negation after the lines, once|repo tracked-entry create:tracked-check|fix-links |0|restored:tracked-check|-|exclude=harness,harness/state.json,!harness/ main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- -the negation stays while the worktree'\''s own index still tracks the file main dropped|repo tracked-entry create:tracked-check untrack:harness/skills/tool.md|fix-links |0|restored:tracked-check|-|exclude=harness,harness/state.json,!harness/ main=?? harness/ stage=- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- -the negation goes once neither index tracks a file under the entry|repo tracked-entry create:tracked-check untrack:harness/skills/tool.md sync-wt|fix-links |0|restored:tracked-check|-|exclude=harness,harness/state.json main=- stage=- wt=.env.local,.gitignore,base.txt,harness->
/harness wt-status=- -a line another tool wrote into the exclude file stays, before ours|repo tracked-entry foreign-line|create tracked-check|0|wt:tracked-check|-|exclude=**/.claude/*,harness,!harness/,harness/state.json main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +ROWS='an entry with tracked content gets the anchored line and the negation after it, and main stages its tracked file|repo tracked-entry edit:harness/skills/tool.md|create tracked-check|0|wt:tracked-check|-|exclude=/harness,!/harness/,/harness/state.json main= M harness/skills/tool.md stage=0:add '\''harness/skills/tool.md'\'' wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +the entry'\''s lines stay after the worktree is removed|repo tracked-entry create:tracked-check|remove tracked-check|0|removed:tracked-check|deleted:tracked-check|exclude=/harness,!/harness/,/harness/state.json main=- stage=0:- wt=gone +a runtime-only entry keeps the anchored line, and main does not see the runtime tree|repo runtime-entry|create clean-check|0|wt:clean-check|-|exclude=/runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- +the negation appears once the entry gains a tracked file, the link becomes per-child links, and main stages it|repo runtime-entry create:clean-check track:runtime/sub/keep.md|fix-links |0|restored:clean-check|-|exclude=/runtime,!/runtime/,/runtime/state.json,/runtime/sub/x.json main=- stage=0:- wt=.env.local,base.txt,runtime/state.json->
/runtime/state.json,runtime/sub/x.json->
/runtime/sub/x.json wt-status=- +a repeated pass rewrites the negation after the lines, once|repo tracked-entry create:tracked-check|fix-links |0|restored:tracked-check|-|exclude=/harness,/harness/state.json,!/harness/ main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +the negation stays while the worktree'\''s own index still tracks the file main dropped|repo tracked-entry create:tracked-check untrack:harness/skills/tool.md|fix-links |0|restored:tracked-check|-|exclude=/harness,/harness/state.json,!/harness/ main=?? harness/ stage=- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +the negation goes once neither index tracks a file under the entry|repo tracked-entry create:tracked-check untrack:harness/skills/tool.md sync-wt|fix-links |0|restored:tracked-check|-|exclude=/harness,/harness/state.json main=- stage=- wt=.env.local,.gitignore,base.txt,harness->
/harness wt-status=- +a line another tool wrote into the exclude file stays, before ours|repo tracked-entry foreign-line|create tracked-check|0|wt:tracked-check|-|exclude=**/.claude/*,/harness,!/harness/,/harness/state.json main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +a root runtime link leaves new files in a nested tracked namesake visible and stageable|repo runtime-entry nested-entry|create nested-check|0|wt:nested-check|-|exclude=/runtime main=A nested/runtime/new.md stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md,runtime->
/runtime wt-status=A nested/runtime/new.md +fix-links removes duplicate bare lines from a legacy-only exclude file|repo runtime-entry create:legacy-check legacy-lines|fix-links |0|restored:legacy-check|-|exclude=/runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- +fix-links migrates bare lines and preserves other patterns while nested files stay stageable|repo runtime-entry nested-entry create:legacy-check legacy-lines foreign-patterns|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,nested/runtime/ignored,/runtime/cache,!runtime/sub/,/runtime main=A nested/runtime/new.md stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md,runtime->
/runtime wt-status=A nested/runtime/new.md +fix-links migrates a tracked entry and keeps the negation last|repo tracked-entry create:legacy-check legacy-lines foreign-line|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,/harness,!/harness/ main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- ' echo "=== the exclude entry of a configured symlink path ===" @@ -255,10 +271,52 @@ while IFS= read -r row; do printf '%s => %s\n' "$label" "$(run "$command")" continue fi - assert_eq "$(run "$command")" "rc=$rc out=$(out_text "$out") err=$(err_text "$err") $want_state" "$label" + prefix="" + if [[ -d "$MAIN/nested/runtime" ]]; then + prefix='nested-status=?? nested/runtime/new.md staged=nested/runtime/new.md root-ignored=0 root-staged=- ' + prefix="$prefix$prefix" + fi + assert_eq "$(run "$command")" "${prefix}rc=$rc out=$(out_text "$out") err=$(err_text "$err") $want_state" "$label" done <<<"$ROWS" [[ "$((PASS + FAIL))" -gt 0 ]] || { echo "no row was asserted (a probe run renders rows instead)" >&2; exit 2; } +# Each control edits one writer operation in a disposable script tree. +# The child suite uses its own repositories and skips this control loop. +if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then + CONTROLS='anchor +migration' + while IFS= read -r control; do + case "$control" in + anchor) + old=' entry="/$path"' + new=' entry="$path"' + ;; + migration) + old=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' + new=' grep -vxF -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' + ;; + esac + scripts="$TMP_ROOT/control-$control" + cp -R -- "${WORKTREE_SCRIPT%/*}" "$scripts" + writer="$scripts/lib/links.sh" + [[ ! -L "$writer" ]] || { echo 'control writer is a symlink' >&2; exit 2; } + matches=0 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "$old" ]]; then + line="$new" + matches=$((matches + 1)) + fi + printf '%s\n' "$line" + done <"$writer" >"$scripts/mutated" + [[ "$matches" -eq 1 ]] && ! cmp -s -- "$writer" "$scripts/mutated" || { echo 'control edit did not change one match' >&2; exit 2; } + mv -- "$scripts/mutated" "$writer" + control_rc=0 + WORKTREE_EXCLUDE_CONTROL=1 WORKTREE_SCRIPT="$scripts/worktree" "$BASH" "$TEST_DIR/worktree_exclude_tracked_paths.sh" >"$scripts/result" 2>&1 || control_rc=$? + assert_eq "$control_rc" 1 "must-fail: $control writer mutation" + if [[ "$control_rc" != 1 ]]; then cat "$scripts/result"; fi + done <<<"$CONTROLS" +fi + echo printf 'pass: %d fail: %d\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/skills/worktree/tests/worktree_path_safety.sh b/skills/worktree/tests/worktree_path_safety.sh index 56ef96364a..05547c143d 100755 --- a/skills/worktree/tests/worktree_path_safety.sh +++ b/skills/worktree/tests/worktree_path_safety.sh @@ -247,9 +247,9 @@ the same path as both a symlink and a copy is refused naming the conflict|repo c a copy through a parent that is already a symlink is refused naming the symlink, and main'"'"'s file stands|repo wt:issue-follow config-file-untracked wt-config-linked copies-config-file|fix-links /trees/issue-follow|1|-|through-symlink:/trees/issue-follow:config+not-restored:/trees/issue-follow|files=main/base.txt:base,main/config/local.txt:main-config,trees/issue-follow/base.txt:base,trees/issue-follow/config->
/config branches=issue-follow,main index=H base.txt exclude=- a copy over a leaf that is already a symlink is refused naming the symlink, and main'"'"'s file stands|repo wt:issue-leaf config-file-untracked wt-config-file-linked copies-config-file|fix-links /trees/issue-leaf|1|-|through-symlink:/trees/issue-leaf:config/local.txt+not-restored:/trees/issue-leaf|files=main/base.txt:base,main/config/local.txt:main-config,trees/issue-leaf/base.txt:base,trees/issue-leaf/config/local.txt->
/config/local.txt branches=issue-leaf,main index=H base.txt exclude=- a configured copy leaves a branch-owned tracked child unchanged|repo wt:issue-copy-descendants wt-config-child main-config-copy copies-config|fix-links /trees/issue-copy-descendants|0|restored:/trees/issue-copy-descendants|-|files=main/base.txt:base,main/config:main-copy,trees/issue-copy-descendants/base.txt:base,trees/issue-copy-descendants/config/config:branch-owned branches=issue-copy-descendants,main index=H base.txt,H config/config exclude=- tracked-copy=branch-owned:branch-owned:clean -must-fail: an exact-only ownership guard overwrites the branch-owned tracked child|repo wt:issue-copy-descendants wt-config-child main-config-copy copies-config unfixed-copy-descendants|fix-links /trees/issue-copy-descendants|0|restored:/trees/issue-copy-descendants|-|files=main/base.txt:base,main/config:main-copy,trees/issue-copy-descendants/base.txt:base,trees/issue-copy-descendants/config/config:main-copy branches=issue-copy-descendants,main index=H base.txt,H config/config exclude=config tracked-copy=branch-owned:main-copy:dirty +must-fail: an exact-only ownership guard overwrites the branch-owned tracked child|repo wt:issue-copy-descendants wt-config-child main-config-copy copies-config unfixed-copy-descendants|fix-links /trees/issue-copy-descendants|0|restored:/trees/issue-copy-descendants|-|files=main/base.txt:base,main/config:main-copy,trees/issue-copy-descendants/base.txt:base,trees/issue-copy-descendants/config/config:main-copy branches=issue-copy-descendants,main index=H base.txt,H config/config exclude=/config tracked-copy=branch-owned:main-copy:dirty a glob metacharacter in a setup path is refused before pathname expansion|repo glob-dir wt:issue-glob mkdirs-glob|fix-links /trees/issue-glob|1|-|invalid-mkdirs:tmp/*+not-restored:/trees/issue-glob|files=main/base.txt:base,main/tmp/expanded/,trees/issue-glob/base.txt:base branches=issue-glob,main index=H base.txt exclude=- -a file symlink replaces a leaf that is a symlink to a directory without dereferencing it|repo tool-file wt:issue-file-link wt-tool-links-outside symlinks-tool|fix-links /trees/issue-file-link|0|restored:/trees/issue-file-link|-|files=main/base.txt:base,main/tool:main-tool,outside-dir/,trees/issue-file-link/base.txt:base,trees/issue-file-link/tool->
/tool branches=issue-file-link,main index=H base.txt,h tool exclude=tool,!tool/ +a file symlink replaces a leaf that is a symlink to a directory without dereferencing it|repo tool-file wt:issue-file-link wt-tool-links-outside symlinks-tool|fix-links /trees/issue-file-link|0|restored:/trees/issue-file-link|-|files=main/base.txt:base,main/tool:main-tool,outside-dir/,trees/issue-file-link/base.txt:base,trees/issue-file-link/tool->
/tool branches=issue-file-link,main index=H base.txt,h tool exclude=/tool,!/tool/ a file symlink refuses to delete a leaf that is a directory, leaving the index flags and the shared excludes alone|repo tool-file wt:issue-file-dir wt-tool-is-dir symlinks-tool|fix-links /trees/issue-file-dir|1|-|non-file:tool:file+unhealthy:/trees/issue-file-dir:tool|files=main/base.txt:base,main/tool:main-tool,trees/issue-file-dir/base.txt:base,trees/issue-file-dir/tool/preserved.txt:keep branches=issue-file-dir,main index=H base.txt,H tool exclude=- a relative symlink refuses to delete a leaf that is a directory|repo wt:issue-relative-dir wt-local-link-is-dir relative-local-link|fix-links /trees/issue-relative-dir|1|-|non-file:local-link:relative+unhealthy:/trees/issue-relative-dir:local-link|files=main/base.txt:base,trees/issue-relative-dir/base.txt:base,trees/issue-relative-dir/local-link/preserved.txt:keep-relative branches=issue-relative-dir,main index=H base.txt exclude=- fix-links refuses another repository'"'"'s worktree|repo other-repo|fix-links /foreign/issue-foreign|1|-|unregistered:/foreign/issue-foreign:restore links in it|files=foreign/issue-foreign/base.txt:base,main/base.txt:base,other/main/base.txt:base branches=main index=- exclude=- foreign=issue-foreign From d567424ce89d3566075cb4f8b42b5537fbaa0872 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:38:43 +0000 Subject: [PATCH 2/7] fix(KEN-3935): preserve shared exclusion permissions Address PR review --- .agents/skills/worktree/scripts/lib/links.sh | 2 ++ skills/worktree/scripts/lib/links.sh | 2 ++ .../tests/worktree_exclude_tracked_paths.sh | 22 ++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.agents/skills/worktree/scripts/lib/links.sh b/.agents/skills/worktree/scripts/lib/links.sh index b93fbf1a66..c1c197ed1a 100644 --- a/.agents/skills/worktree/scripts/lib/links.sh +++ b/.agents/skills/worktree/scripts/lib/links.sh @@ -92,6 +92,8 @@ exclude_from_worktree_index() { local tmp rc=0 tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 trap 'rm -f -- "$tmp"' EXIT + # Shared repositories need the replacement to keep the original access. + cp -p -- "$info_exclude" "$tmp" || exit 1 grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? [[ "$rc" -le 1 ]] || exit "$rc" mv -- "$tmp" "$info_exclude" diff --git a/skills/worktree/scripts/lib/links.sh b/skills/worktree/scripts/lib/links.sh index b93fbf1a66..c1c197ed1a 100644 --- a/skills/worktree/scripts/lib/links.sh +++ b/skills/worktree/scripts/lib/links.sh @@ -92,6 +92,8 @@ exclude_from_worktree_index() { local tmp rc=0 tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 trap 'rm -f -- "$tmp"' EXIT + # Shared repositories need the replacement to keep the original access. + cp -p -- "$info_exclude" "$tmp" || exit 1 grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? [[ "$rc" -le 1 ]] || exit "$rc" mv -- "$tmp" "$info_exclude" diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index ec0a5297b9..53e116fdea 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -48,6 +48,7 @@ ROOT="" MAIN="" WT="" ENTRY="" +CHECK_PERMISSIONS=false make_repo() { mkdir -p "$MAIN" @@ -113,6 +114,12 @@ step() { foreign-patterns) printf '%s\n' '**/.claude/*' 'nested/runtime/ignored' '/runtime/cache' '!runtime/sub/' >>"$MAIN/.git/info/exclude" ;; + shared-exclude) + must chmod 664 "$MAIN/.git/info/exclude" + mkdir -p "$MAIN/.claude" + printf 'private\n' >"$MAIN/.claude/private-note" + CHECK_PERMISSIONS=true + ;; # A file under the entry becomes tracked, or the tracked file stops being. track:*) printf 'now-tracked\n' >"$MAIN/${1#track:}" @@ -149,6 +156,7 @@ build() { MAIN="$ROOT/main" WT="" ENTRY="" + CHECK_PERMISSIONS=false mkdir -p "$ROOT" for word in "$@"; do step "$word" @@ -202,6 +210,11 @@ run() { read -r -a argv <<<"${1///$WT}" if [[ "${argv[0]}" == create ]]; then WT="$ROOT/trees/${argv[1]}"; fi (cd "$MAIN" && LC_ALL=C "$WORKTREE_SCRIPT" "${argv[@]}" >"$ROOT/out" 2>"$ROOT/err") || rc=$? + if [[ "$CHECK_PERMISSIONS" == true ]]; then + local shared_mode + shared_mode="$(find "$MAIN/.git/info/exclude" -perm 0664 -print)" + printf 'shared-mode=%s ' "${shared_mode:+preserved}" + fi if [[ -d "$MAIN/nested/runtime" ]]; then local repo fresh_status staged root_ignored root_staged for repo in "$MAIN" "$WT"; do @@ -252,6 +265,7 @@ a root runtime link leaves new files in a nested tracked namesake visible and st fix-links removes duplicate bare lines from a legacy-only exclude file|repo runtime-entry create:legacy-check legacy-lines|fix-links |0|restored:legacy-check|-|exclude=/runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- fix-links migrates bare lines and preserves other patterns while nested files stay stageable|repo runtime-entry nested-entry create:legacy-check legacy-lines foreign-patterns|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,nested/runtime/ignored,/runtime/cache,!runtime/sub/,/runtime main=A nested/runtime/new.md stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md,runtime->
/runtime wt-status=A nested/runtime/new.md fix-links migrates a tracked entry and keeps the negation last|repo tracked-entry create:legacy-check legacy-lines foreign-line|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,/harness,!/harness/ main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +fix-links keeps shared exclusion permissions and foreign rules|repo runtime-entry create:shared-check foreign-line shared-exclude|fix-links |0|restored:shared-check|-|exclude=/runtime,**/.claude/* main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- ' echo "=== the exclude entry of a configured symlink path ===" @@ -272,6 +286,7 @@ while IFS= read -r row; do continue fi prefix="" + if [[ "$CHECK_PERMISSIONS" == true ]]; then prefix='shared-mode=preserved '; fi if [[ -d "$MAIN/nested/runtime" ]]; then prefix='nested-status=?? nested/runtime/new.md staged=nested/runtime/new.md root-ignored=0 root-staged=- ' prefix="$prefix$prefix" @@ -284,7 +299,8 @@ done <<<"$ROWS" # The child suite uses its own repositories and skips this control loop. if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then CONTROLS='anchor -migration' +migration +permissions' while IFS= read -r control; do case "$control" in anchor) @@ -295,6 +311,10 @@ migration' old=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' new=' grep -vxF -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' ;; + permissions) + old=' cp -p -- "$info_exclude" "$tmp" || exit 1' + new=' cp -- "$info_exclude" "$tmp" || exit 1' + ;; esac scripts="$TMP_ROOT/control-$control" cp -R -- "${WORKTREE_SCRIPT%/*}" "$scripts" From f17193794db5a113f640f18c8b1e1bf4be3a2313 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:48:16 +0000 Subject: [PATCH 3/7] fix(KEN-3935): retain exclusion update outcomes Address PR comments --- .agents/skills/worktree/scripts/lib/links.sh | 94 ++++-- skills/worktree/scripts/lib/links.sh | 94 ++++-- .../tests/worktree_exclude_tracked_paths.sh | 304 +++++++++++++++++- 3 files changed, 429 insertions(+), 63 deletions(-) diff --git a/.agents/skills/worktree/scripts/lib/links.sh b/.agents/skills/worktree/scripts/lib/links.sh index c1c197ed1a..008bf917b9 100644 --- a/.agents/skills/worktree/scripts/lib/links.sh +++ b/.agents/skills/worktree/scripts/lib/links.sh @@ -75,35 +75,79 @@ split_worktree_config_words() { # # Other tools write to this file too (Claude Code writes its own `**/.claude/*` # entries), so only our own exact lines are ever written or removed. -exclude_from_worktree_index() { +exclude_from_worktree_index() ( local wt="$1" path="$2" has_tracked="${3:-false}" [[ -z "$path" ]] && return 0 - local common_dir info_exclude entry negation - common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 0 + local common_dir info_exclude entry negation tmp="" candidate="" filtered="" rc=0 exists=false + local failure=read operation=git-common-directory result=0 + trap ' + result=$? + if [[ -n "$tmp" ]] && ! rm -f -- "$tmp" 2>/dev/null; then + if [[ "$result" -eq 0 ]]; then result=1; failure=write; operation=cleanup; fi + fi + if [[ "$result" -ne 0 ]]; then + worktree_message "exclude-$failure-failed" "path=$wt/$path operation=$operation" "Could not complete the exclusion update for $wt/$path during $operation." >&2 + fi + exit "$result" + ' EXIT + common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 1 + [[ -n "$common_dir" ]] || return 1 info_exclude="$common_dir/info/exclude" entry="/$path" negation="!/$path/" - mkdir -p "$(dirname "$info_exclude")" - # Remove exact legacy lines and move the current negation after the entry. # Other patterns may belong to another tool and must stay in place. - if [[ -f "$info_exclude" ]]; then - ( - local tmp rc=0 - tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 - trap 'rm -f -- "$tmp"' EXIT - # Shared repositories need the replacement to keep the original access. - cp -p -- "$info_exclude" "$tmp" || exit 1 - grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? - [[ "$rc" -le 1 ]] || exit "$rc" - mv -- "$tmp" "$info_exclude" - ) || return 1 + operation=filter + if [[ -e "$info_exclude" || -L "$info_exclude" ]]; then + [[ -f "$info_exclude" && ! -L "$info_exclude" ]] || return 1 + exists=true + # The sentinel retains final newlines; grep's empty result is successful. + filtered="$( + grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || exit 1 + printf '.' 2>/dev/null + )" || return 1 + filtered="${filtered%.}" fi - grep -qxF -- "$entry" "$info_exclude" 2>/dev/null || echo "$entry" >> "$info_exclude" - [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" + operation=entry-inspection + grep -qxF -- "$entry" <<<"$filtered" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || return 1 + if [[ "$rc" -eq 1 ]]; then + [[ -z "$filtered" || "$filtered" == *$'\n' ]] || filtered+=$'\n' + filtered+="$entry"$'\n' + fi + if [[ "$has_tracked" == true ]]; then + [[ -z "$filtered" || "$filtered" == *$'\n' ]] || filtered+=$'\n' + filtered+="$negation"$'\n' + fi + + failure=write; operation=directory + mkdir -p -- "${info_exclude%/*}" 2>/dev/null || return 1 + operation=temporary-file + candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)" || return 1 + tmp="$candidate" + operation=content + printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 + if [[ "$exists" == true ]]; then + failure=read; operation=comparison; rc=0 + cmp -s -- "$tmp" "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || return 1 + [[ "$rc" -ne 0 ]] || return 0 + failure=write; operation=permissions + # Shared repositories need the replacement to keep the original access. + cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1 + operation=content + printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 + else + # A first write uses the caller's creation mask, as a regular file does. + operation=permissions + chmod '=rw' -- "$tmp" 2>/dev/null || return 1 + fi + operation=replacement + mv -- "$tmp" "$info_exclude" 2>/dev/null || return 1 return 0 -} +) # Replace "$2" with a symlink to "$1" without ever leaving "$2" absent. # @@ -160,7 +204,7 @@ copy_ignore_file_into_worktree() { local wt="$1" path="$2" local src="$PROJECT_ROOT/$path" dst="$wt/$path" tmp="" err="" effect="" ensure_worktree_path_safe "$wt" "$path" false || return 1 - exclude_from_worktree_index "$wt" "$path" false + exclude_from_worktree_index "$wt" "$path" false || return 1 ignore_file_copy_matches "$src" "$dst" && return 0 if [[ -d "$dst" && ! -L "$dst" ]]; then worktree_message ignore-copy-directory "$wt/$path" "Warning: '$path' in $wt is a directory where main's .gitignore should be copied; leaving it alone." >&2 @@ -380,7 +424,7 @@ symlink_into_worktree() { return 1 fi # Whether this path holds tracked content decides the SHAPE of the exclude - # entry: a path with tracked files needs the `!/` negation so the + # entry: a path with tracked files needs the `!//` negation so the # real directory in the main checkout stays stageable. Covers both the file # entry and the directory's contents, so it is computed once here rather than # separately in each branch below. @@ -396,7 +440,7 @@ symlink_into_worktree() { [[ -n "$(git -C "$PROJECT_ROOT" ls-files -- "$path" "$path/" 2>/dev/null || true)" ]]; then tracked_here=true fi - exclude_from_worktree_index "$wt" "$path" "$tracked_here" + exclude_from_worktree_index "$wt" "$path" "$tracked_here" || return 1 if [[ -f "$src" ]]; then # File symlink — if the path is tracked in the worktree (a project that @@ -1037,7 +1081,7 @@ setup_worktree_links() { for path in ${WORKTREE_CONFIG_WORDS[@]+"${WORKTREE_CONFIG_WORDS[@]}"}; do path="$(normalize_worktree_config_path WORKTREE_MKDIRS "$path")" || return 1 mkdir_inside_worktree "$wt" "$path" || return 1 - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 done # Project-configured symlinks @@ -1071,7 +1115,7 @@ setup_worktree_links() { continue fi ensure_worktree_path_safe "$wt" "$path" true || return 1 - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 # Checked, not left to implicit set -e: fix-links calls this function # on the left of ||, which disables errexit for everything inside it. # Unchecked, a failure here would return whatever the next command @@ -1119,7 +1163,7 @@ setup_worktree_links() { worktree_message relative-link-failed "$wt/$path" "Error: could not create relative symlink '$path' -> '$target' in $wt." >&2 return 1 fi - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 done # Shared dependencies come from main's install; a pnpm workspace install diff --git a/skills/worktree/scripts/lib/links.sh b/skills/worktree/scripts/lib/links.sh index c1c197ed1a..008bf917b9 100644 --- a/skills/worktree/scripts/lib/links.sh +++ b/skills/worktree/scripts/lib/links.sh @@ -75,35 +75,79 @@ split_worktree_config_words() { # # Other tools write to this file too (Claude Code writes its own `**/.claude/*` # entries), so only our own exact lines are ever written or removed. -exclude_from_worktree_index() { +exclude_from_worktree_index() ( local wt="$1" path="$2" has_tracked="${3:-false}" [[ -z "$path" ]] && return 0 - local common_dir info_exclude entry negation - common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 0 + local common_dir info_exclude entry negation tmp="" candidate="" filtered="" rc=0 exists=false + local failure=read operation=git-common-directory result=0 + trap ' + result=$? + if [[ -n "$tmp" ]] && ! rm -f -- "$tmp" 2>/dev/null; then + if [[ "$result" -eq 0 ]]; then result=1; failure=write; operation=cleanup; fi + fi + if [[ "$result" -ne 0 ]]; then + worktree_message "exclude-$failure-failed" "path=$wt/$path operation=$operation" "Could not complete the exclusion update for $wt/$path during $operation." >&2 + fi + exit "$result" + ' EXIT + common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 1 + [[ -n "$common_dir" ]] || return 1 info_exclude="$common_dir/info/exclude" entry="/$path" negation="!/$path/" - mkdir -p "$(dirname "$info_exclude")" - # Remove exact legacy lines and move the current negation after the entry. # Other patterns may belong to another tool and must stay in place. - if [[ -f "$info_exclude" ]]; then - ( - local tmp rc=0 - tmp="$(mktemp "$info_exclude.wt-tmp.XXXXXX")" || exit 1 - trap 'rm -f -- "$tmp"' EXIT - # Shared repositories need the replacement to keep the original access. - cp -p -- "$info_exclude" "$tmp" || exit 1 - grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$? - [[ "$rc" -le 1 ]] || exit "$rc" - mv -- "$tmp" "$info_exclude" - ) || return 1 + operation=filter + if [[ -e "$info_exclude" || -L "$info_exclude" ]]; then + [[ -f "$info_exclude" && ! -L "$info_exclude" ]] || return 1 + exists=true + # The sentinel retains final newlines; grep's empty result is successful. + filtered="$( + grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || exit 1 + printf '.' 2>/dev/null + )" || return 1 + filtered="${filtered%.}" fi - grep -qxF -- "$entry" "$info_exclude" 2>/dev/null || echo "$entry" >> "$info_exclude" - [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" + operation=entry-inspection + grep -qxF -- "$entry" <<<"$filtered" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || return 1 + if [[ "$rc" -eq 1 ]]; then + [[ -z "$filtered" || "$filtered" == *$'\n' ]] || filtered+=$'\n' + filtered+="$entry"$'\n' + fi + if [[ "$has_tracked" == true ]]; then + [[ -z "$filtered" || "$filtered" == *$'\n' ]] || filtered+=$'\n' + filtered+="$negation"$'\n' + fi + + failure=write; operation=directory + mkdir -p -- "${info_exclude%/*}" 2>/dev/null || return 1 + operation=temporary-file + candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)" || return 1 + tmp="$candidate" + operation=content + printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 + if [[ "$exists" == true ]]; then + failure=read; operation=comparison; rc=0 + cmp -s -- "$tmp" "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || return 1 + [[ "$rc" -ne 0 ]] || return 0 + failure=write; operation=permissions + # Shared repositories need the replacement to keep the original access. + cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1 + operation=content + printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 + else + # A first write uses the caller's creation mask, as a regular file does. + operation=permissions + chmod '=rw' -- "$tmp" 2>/dev/null || return 1 + fi + operation=replacement + mv -- "$tmp" "$info_exclude" 2>/dev/null || return 1 return 0 -} +) # Replace "$2" with a symlink to "$1" without ever leaving "$2" absent. # @@ -160,7 +204,7 @@ copy_ignore_file_into_worktree() { local wt="$1" path="$2" local src="$PROJECT_ROOT/$path" dst="$wt/$path" tmp="" err="" effect="" ensure_worktree_path_safe "$wt" "$path" false || return 1 - exclude_from_worktree_index "$wt" "$path" false + exclude_from_worktree_index "$wt" "$path" false || return 1 ignore_file_copy_matches "$src" "$dst" && return 0 if [[ -d "$dst" && ! -L "$dst" ]]; then worktree_message ignore-copy-directory "$wt/$path" "Warning: '$path' in $wt is a directory where main's .gitignore should be copied; leaving it alone." >&2 @@ -380,7 +424,7 @@ symlink_into_worktree() { return 1 fi # Whether this path holds tracked content decides the SHAPE of the exclude - # entry: a path with tracked files needs the `!/` negation so the + # entry: a path with tracked files needs the `!//` negation so the # real directory in the main checkout stays stageable. Covers both the file # entry and the directory's contents, so it is computed once here rather than # separately in each branch below. @@ -396,7 +440,7 @@ symlink_into_worktree() { [[ -n "$(git -C "$PROJECT_ROOT" ls-files -- "$path" "$path/" 2>/dev/null || true)" ]]; then tracked_here=true fi - exclude_from_worktree_index "$wt" "$path" "$tracked_here" + exclude_from_worktree_index "$wt" "$path" "$tracked_here" || return 1 if [[ -f "$src" ]]; then # File symlink — if the path is tracked in the worktree (a project that @@ -1037,7 +1081,7 @@ setup_worktree_links() { for path in ${WORKTREE_CONFIG_WORDS[@]+"${WORKTREE_CONFIG_WORDS[@]}"}; do path="$(normalize_worktree_config_path WORKTREE_MKDIRS "$path")" || return 1 mkdir_inside_worktree "$wt" "$path" || return 1 - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 done # Project-configured symlinks @@ -1071,7 +1115,7 @@ setup_worktree_links() { continue fi ensure_worktree_path_safe "$wt" "$path" true || return 1 - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 # Checked, not left to implicit set -e: fix-links calls this function # on the left of ||, which disables errexit for everything inside it. # Unchecked, a failure here would return whatever the next command @@ -1119,7 +1163,7 @@ setup_worktree_links() { worktree_message relative-link-failed "$wt/$path" "Error: could not create relative symlink '$path' -> '$target' in $wt." >&2 return 1 fi - exclude_from_worktree_index "$wt" "$path" + exclude_from_worktree_index "$wt" "$path" || return 1 done # Shared dependencies come from main's install; a pnpm workspace install diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index 53e116fdea..caed3e75e4 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -116,6 +116,7 @@ step() { ;; shared-exclude) must chmod 664 "$MAIN/.git/info/exclude" + printf '%s\n' "$ENTRY" >>"$MAIN/.git/info/exclude" mkdir -p "$MAIN/.claude" printf 'private\n' >"$MAIN/.claude/private-note" CHECK_PERMISSIONS=true @@ -268,6 +269,7 @@ fix-links migrates a tracked entry and keeps the negation last|repo tracked-entr fix-links keeps shared exclusion permissions and foreign rules|repo runtime-entry create:shared-check foreign-line shared-exclude|fix-links |0|restored:shared-check|-|exclude=/runtime,**/.claude/* main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- ' +if [[ "${WORKTREE_EXCLUDE_MATRIX_ONLY:-}" != 1 ]]; then echo "=== the exclude entry of a configured symlink path ===" n=0 while IFS= read -r row; do @@ -294,27 +296,291 @@ while IFS= read -r row; do assert_eq "$(run "$command")" "${prefix}rc=$rc out=$(out_text "$out") err=$(err_text "$err") $want_state" "$label" done <<<"$ROWS" [[ "$((PASS + FAIL))" -gt 0 ]] || { echo "no row was asserted (a probe run renders rows instead)" >&2; exit 2; } +fi + +# Faults affect only the writer's dependency calls. Every other call uses the +# executable resolved before this fixture changes PATH. +outcome_dependencies() { + local tool resolved git_tool="" + mkdir -p "$TMP_ROOT/outcome-bin" + for tool in git grep cp cmp mkdir mktemp mv rm chmod; do + resolved="$(command -v "$tool")" || return 1 + [[ "$tool" != git ]] || git_tool="$resolved" + printf '#!%s\nREAL_TOOL=%q\nTOOL=%q\n' "$BASH" "$resolved" "$tool" >"$TMP_ROOT/outcome-bin/$tool" + cat >>"$TMP_ROOT/outcome-bin/$tool" <<'WRAPPER' +match=false +case "${WT_EXCLUDE_FAULT:-}:$TOOL" in + filter:grep) [[ "${1:-}" == -vxF && "${3:-}" == "$WT_EXCLUDE_PATH" ]] && match=true ;; + inspection:grep) [[ "$#" -eq 3 && "$1" == -qxF && "$3" == "/$WT_EXCLUDE_PATH" ]] && match=true ;; + comparison:cmp) [[ "${4:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; + permissions:cp) [[ "${1:-}" == -p && "${3:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; + directory:mkdir) [[ "${3:-}" == "${WT_EXCLUDE_FILE%/*}" ]] && match=true ;; + temporary-file:mktemp) [[ "${1:-}" == "$WT_EXCLUDE_FILE.wt-tmp.XXXXXX" ]] && match=true ;; + replacement:mv) [[ "${3:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; + cleanup:rm) [[ "${3:-}" == "$WT_EXCLUDE_FILE.wt-tmp."* ]] && match=true ;; + initial-permissions:chmod) [[ "${1:-}" == =rw && "${3:-}" == "$WT_EXCLUDE_FILE.wt-tmp."* ]] && match=true ;; +esac +if [[ "$match" == true ]]; then + printf '%s\n' "$TOOL" >>"$WT_EXCLUDE_TRACE" + exit 2 +fi +exec "$REAL_TOOL" "$@" +WRAPPER + chmod +x "$TMP_ROOT/outcome-bin/$tool" + done + printf 'REAL_GIT=%q\n' "$git_tool" >"$TMP_ROOT/outcome-bash-env" + cat >>"$TMP_ROOT/outcome-bash-env" <<'WRAPPER' +git() { + local frame + if [[ "${WT_EXCLUDE_FAULT:-}" == lookup && "$#" -eq 4 && "$1" == -C && "$2" == "$WT_EXCLUDE_TREE" && "$3" == rev-parse && "$4" == --git-common-dir ]]; then + for frame in "${FUNCNAME[@]}"; do + if [[ "$frame" == exclude_from_worktree_index ]]; then + builtin printf '%s\n' git >>"$WT_EXCLUDE_TRACE" + return 2 + fi + done + fi + command "$REAL_GIT" "$@" +} +printf() { + if [[ "${WT_EXCLUDE_FAULT:-}" == content && "${1:-}" == '%s' && "${2:-}" == *"/$WT_EXCLUDE_PATH"* ]]; then + builtin printf '%s\n' printf >>"$WT_EXCLUDE_TRACE" + return 2 + fi + builtin printf "$@" +} +WRAPPER +} + +outcome_case() { + local caller="$1" route="$2" outcome="$3" fault="${4:--}" rc=0 failed_path=runtime + local expected=0 error_kind=write operation="$fault" records="" trace="" excluded="" stage="" + build "outcome-$caller-$route-$outcome-$fault" repo nested-entry + step create:outcome-check + case "$caller" in + symlink|materialized) + mkdir -p "$MAIN/runtime" + printf 'state\n' >"$MAIN/runtime/state.json" + printf 'WORKTREE_SYMLINKS="runtime"\n' >>"$MAIN/.env.local" + if [[ "$caller" == materialized ]]; then mkdir -p "$WT/runtime"; fi + ;; + ignore-copy|child-link) + mkdir -p "$MAIN/runtime" + printf 'tracked\n' >"$MAIN/runtime/keep.md" + must git -C "$MAIN" add runtime/keep.md + commit_push runtime + printf 'WORKTREE_SYMLINKS="runtime"\n' >>"$MAIN/.env.local" + if [[ "$caller" == ignore-copy ]]; then + printf 'state.json\n' >"$MAIN/runtime/.gitignore" + failed_path=runtime/.gitignore + else + printf 'state\n' >"$MAIN/runtime/cache" + failed_path=runtime/cache + fi + ;; + mkdir) printf 'WORKTREE_MKDIRS="runtime"\n' >>"$MAIN/.env.local" ;; + copy) + printf 'state\n' >"$MAIN/runtime" + printf 'WORKTREE_COPIES="runtime"\n' >>"$MAIN/.env.local" + ;; + relative) printf 'WORKTREE_RELATIVE_SYMLINKS="runtime=base.txt"\n' >>"$MAIN/.env.local" ;; + *) echo "unknown outcome caller: $caller" >&2; exit 2 ;; + esac + case "$outcome" in + written|failed) + printf '%s\n' "$failed_path" >"$MAIN/.git/info/exclude" + if [[ "$caller" == ignore-copy || "$caller" == child-link ]]; then printf '%s\n' /runtime '!/runtime/' >>"$MAIN/.git/info/exclude"; fi + ;; + unchanged) + printf '/%s\n' "$failed_path" >"$MAIN/.git/info/exclude" + if [[ "$caller" == ignore-copy || "$caller" == child-link ]]; then printf '%s\n' /runtime '!/runtime/' >>"$MAIN/.git/info/exclude"; fi + ;; + missing) rm -f "$MAIN/.git/info/exclude" ;; + *) echo "unknown writer outcome: $outcome" >&2; exit 2 ;; + esac + if [[ "$fault" == initial-permissions ]]; then rm -f "$MAIN/.git/info/exclude"; fi + if [[ "$route" == repair-links && "$caller" == symlink && "$outcome" == unchanged ]]; then + must ln -s "$MAIN/runtime" "$WT/runtime" + fi + if [[ "$route" == create ]]; then WT="$ROOT/trees/new-outcome"; fi + : >"$ROOT/dependency-trace" + if [[ "$outcome" == failed ]]; then expected=1; fi + case "$fault" in lookup|filter|inspection|comparison) error_kind=read ;; esac + case "$fault" in lookup) operation=git-common-directory ;; inspection) operation=entry-inspection ;; initial-permissions) operation=permissions ;; esac + local -a args=("$route" "$WT") + if [[ "$route" == create ]]; then args=(create new-outcome); fi + (cd "$MAIN" && PATH="$TMP_ROOT/outcome-bin:$PATH" BASH_ENV="$TMP_ROOT/outcome-bash-env" \ + WT_EXCLUDE_FAULT="$fault" WT_EXCLUDE_TREE="$WT" WT_EXCLUDE_PATH="$failed_path" \ + WT_EXCLUDE_FILE="$MAIN/.git/info/exclude" WT_EXCLUDE_TRACE="$ROOT/dependency-trace" \ + "$WORKTREE_SCRIPT" "${args[@]}" >"$ROOT/out" 2>"$ROOT/err") || rc=$? + assert_eq "$rc" "$expected" "$caller $route $outcome $fault: command status" + trace="$(cat "$ROOT/dependency-trace")" + if [[ "$outcome" == failed ]]; then + records="$(awk -v key="worktree-exclude-$error_kind-failed:" -v operation="operation=$operation" '$1 == key && $0 ~ operation { print $1 }' "$ROOT/err")" + assert_eq "$records" "worktree-exclude-$error_kind-failed:" "$caller $route $fault: writer diagnosis" + assert_eq "${trace:+reached}" reached "$caller $route $fault: dependency reached" + records="$(awk '/^worktree-(links-restored|links-repaired):/ || /^\// { print }' "$ROOT/out" "$ROOT/err")" + assert_eq "$records" '' "$caller $route $fault: no successful command result" + if [[ "$route" == fix-links ]]; then + records="$(awk '$1 == "worktree-links-unrestored:" { print $1 }' "$ROOT/err")" + assert_eq "$records" worktree-links-unrestored: "$caller $fault: setup failure retained" + fi + # Cleanup can fail after a completed replacement; other failures keep the + # original legacy rule. Git itself proves the nested file remains hidden. + if [[ "$fault" == initial-permissions ]]; then + assert_eq "$(test -e "$MAIN/.git/info/exclude" && printf present || printf absent)" absent "$caller $route $fault: no incomplete first write" + elif [[ "$fault" != cleanup ]]; then + excluded="$(grep -xF -- "$failed_path" "$MAIN/.git/info/exclude")" || true + assert_eq "$excluded" "$failed_path" "$caller $route $fault: legacy rule retained" + if [[ "$failed_path" == runtime ]]; then + printf 'new\n' >"$MAIN/nested/runtime/new.md" + stage="$(git -C "$MAIN" status --porcelain -- nested/runtime/new.md)" + assert_eq "$stage" '' "$caller $route $fault: hidden file cannot imply success" + fi + fi + else + records="$(awk '/^worktree-exclude-(read|write)-failed:/ { print }' "$ROOT/err")" + assert_eq "$records" '' "$caller $route $outcome: no writer error" + if [[ "$outcome" == unchanged ]]; then + assert_eq "$trace" '' "$caller $route unchanged: no replacement dependency" + fi + excluded="$(grep -xF -- "/$failed_path" "$MAIN/.git/info/exclude")" || true + assert_eq "$excluded" "/$failed_path" "$caller $route $outcome: anchored result" + fi +} + +if [[ "${WORKTREE_EXCLUDE_SKIP_OUTCOMES:-}" != 1 ]]; then + outcome_dependencies + if [[ -z "${WORKTREE_EXCLUDE_SELECT_FAULT:-}" || "$WORKTREE_EXCLUDE_SELECT_FAULT" == empty ]]; then + # Configured commands skip empty entries. Exercise the writer's empty-path + # contract directly without giving it a valid repository to fall back on. + empty_rc=0 + "$BASH" -c 'source "$1/lib/messages.sh"; source "$1/lib/links.sh"; exclude_from_worktree_index "$2" ""' "$BASH" "${WORKTREE_SCRIPT%/*}" "$TMP_ROOT/absent-tree" >"$TMP_ROOT/empty-out" 2>"$TMP_ROOT/empty-err" || empty_rc=$? + assert_eq "$empty_rc" 0 'empty path: succeeds without a Git-directory read' + assert_eq "$(cat "$TMP_ROOT/empty-out" "$TMP_ROOT/empty-err")" '' 'empty path: quiet unchanged result' + fi + OUTCOMES='symlink|fix-links|written|- +ignore-copy|fix-links|written|- +mkdir|fix-links|written|- +copy|fix-links|written|- +relative|fix-links|written|- +symlink|fix-links|unchanged|permissions +ignore-copy|fix-links|unchanged|permissions +mkdir|fix-links|unchanged|permissions +copy|fix-links|unchanged|permissions +relative|fix-links|unchanged|permissions +symlink|fix-links|missing|- +symlink|create|written|- +symlink|repair-links|written|- +symlink|repair-links|unchanged|lookup' + for caller in symlink ignore-copy mkdir copy relative child-link; do + OUTCOMES+=$'\n'"$caller|fix-links|failed|permissions"$'\n'"$caller|fix-links|failed|filter" + done + for caller in ignore-copy mkdir copy relative; do + OUTCOMES+=$'\n'"$caller|create|failed|permissions" + done + for route in create repair-links; do + OUTCOMES+=$'\n'"symlink|$route|failed|permissions"$'\n'"symlink|$route|failed|lookup" + done + OUTCOMES+=$'\n''ignore-copy|repair-links|failed|permissions +ignore-copy|repair-links|failed|filter +child-link|repair-links|failed|permissions +materialized|repair-links|failed|permissions' + for fault in lookup inspection comparison directory temporary-file content replacement cleanup initial-permissions; do + OUTCOMES+=$'\n'"symlink|fix-links|failed|$fault" + done + while IFS='|' read -r caller route outcome fault; do + if [[ -n "${WORKTREE_EXCLUDE_SELECT_FAULT:-}" && " $WORKTREE_EXCLUDE_SELECT_FAULT " != *" $fault "* ]]; then continue; fi + outcome_case "$caller" "$route" "$outcome" "$fault" + done <<<"$OUTCOMES" +fi + + # Each control edits one writer operation in a disposable script tree. # The child suite uses its own repositories and skips this control loop. -if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then - CONTROLS='anchor +CONTROLS="" +if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then CONTROLS='anchor migration -permissions' +permissions'; fi +if [[ "${WORKTREE_EXCLUDE_OUTCOME_CONTROL:-}" != 1 && "${WORKTREE_EXCLUDE_SKIP_OUTCOMES:-}" != 1 ]]; then + CONTROLS+=$'\n''lookup +empty +read +directory +temporary-file +content +permissions-status +replacement +cleanup +initial-permissions +callers' +fi +if [[ -n "$CONTROLS" ]]; then while IFS= read -r control; do + [[ -n "$control" ]] || continue + expected_matches=1 + select_fault="" + matrix_only=1 case "$control" in anchor) + matrix_only=0 old=' entry="/$path"' new=' entry="$path"' ;; migration) - old=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' - new=' grep -vxF -e "$negation" -- "$info_exclude" > "$tmp" || rc=$?' + matrix_only=0 + old=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' + new=' grep -vxF -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' ;; permissions) - old=' cp -p -- "$info_exclude" "$tmp" || exit 1' - new=' cp -- "$info_exclude" "$tmp" || exit 1' + matrix_only=0 + old=' cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1' + new=' cp -- "$info_exclude" "$tmp" 2>/dev/null || return 1' + ;; + lookup) + old=' common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 1' + new=' common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null)" || return 0' + select_fault=lookup + ;; + empty) + old=' [[ -z "$path" ]] && return 0' + new=' [[ -z "$path" ]] && :' + select_fault=empty + ;; + read) expected_matches=3; select_fault='filter inspection comparison' ;; + directory) + old=' mkdir -p -- "${info_exclude%/*}" 2>/dev/null || return 1' + new=' mkdir -p -- "${info_exclude%/*}" 2>/dev/null' + select_fault=directory ;; + temporary-file) + old=' candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)" || return 1' + new=' candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)"' + select_fault=temporary-file + ;; + content) expected_matches=2; select_fault=content ;; + permissions-status) + old=' cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1' + new=' cp -p -- "$info_exclude" "$tmp" 2>/dev/null' + select_fault=permissions + ;; + replacement) + old=' mv -- "$tmp" "$info_exclude" 2>/dev/null || return 1' + new=' mv -- "$tmp" "$info_exclude" 2>/dev/null' + select_fault=replacement + ;; + cleanup) + old=' if [[ "$result" -eq 0 ]]; then result=1; failure=write; operation=cleanup; fi' + new=' if [[ "$result" -eq 0 ]]; then result=0; failure=write; operation=cleanup; fi' + select_fault=cleanup + ;; + initial-permissions) + old=' chmod '\''=rw'\'' -- "$tmp" 2>/dev/null || return 1' + new=' chmod '\''=rw'\'' -- "$tmp" 2>/dev/null' + select_fault=initial-permissions + ;; + callers) expected_matches=5; select_fault='permissions filter' ;; esac scripts="$TMP_ROOT/control-$control" cp -R -- "${WORKTREE_SCRIPT%/*}" "$scripts" @@ -322,16 +588,28 @@ permissions' [[ ! -L "$writer" ]] || { echo 'control writer is a symlink' >&2; exit 2; } matches=0 while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" == "$old" ]]; then - line="$new" - matches=$((matches + 1)) - fi + matched=false + case "$control" in + read) + if [[ "$line" == *'[[ "$rc" -le 1 ]] || '* ]]; then line="${line%%[[*}:"; matched=true; fi + ;; + content) + if [[ "$line" == *"printf '%s'"*'"$filtered" 2>/dev/null > "$tmp" || return 1' ]]; then line="${line% || return 1}"; matched=true; fi + ;; + callers) + if [[ "$line" == *'exclude_from_worktree_index "$wt" "$path"'*' || return 1' ]]; then line="${line% || return 1}"; matched=true; fi + ;; + *) if [[ "$line" == "$old" ]]; then line="$new"; matched=true; fi ;; + esac + if [[ "$matched" == true ]]; then matches=$((matches + 1)); fi printf '%s\n' "$line" done <"$writer" >"$scripts/mutated" - [[ "$matches" -eq 1 ]] && ! cmp -s -- "$writer" "$scripts/mutated" || { echo 'control edit did not change one match' >&2; exit 2; } + [[ "$matches" -eq "$expected_matches" ]] && ! cmp -s -- "$writer" "$scripts/mutated" || { echo 'control edit did not change one match' >&2; exit 2; } mv -- "$scripts/mutated" "$writer" control_rc=0 - WORKTREE_EXCLUDE_CONTROL=1 WORKTREE_SCRIPT="$scripts/worktree" "$BASH" "$TEST_DIR/worktree_exclude_tracked_paths.sh" >"$scripts/result" 2>&1 || control_rc=$? + skip_outcomes=0 + [[ "$matrix_only" == 1 ]] || skip_outcomes=1 + WORKTREE_EXCLUDE_CONTROL=1 WORKTREE_EXCLUDE_OUTCOME_CONTROL=1 WORKTREE_EXCLUDE_SKIP_OUTCOMES="$skip_outcomes" WORKTREE_EXCLUDE_MATRIX_ONLY="$matrix_only" WORKTREE_EXCLUDE_SELECT_FAULT="$select_fault" WORKTREE_SCRIPT="$scripts/worktree" "$BASH" "$TEST_DIR/worktree_exclude_tracked_paths.sh" >"$scripts/result" 2>&1 || control_rc=$? assert_eq "$control_rc" 1 "must-fail: $control writer mutation" if [[ "$control_rc" != 1 ]]; then cat "$scripts/result"; fi done <<<"$CONTROLS" From 3abe511fb38525103de970d6449764383a99fd77 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:26:05 +0000 Subject: [PATCH 4/7] test(KEN-3935): cover the second exclusion content write Address PR comments --- .../tests/worktree_exclude_tracked_paths.sh | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index caed3e75e4..afddc187a1 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -324,12 +324,18 @@ if [[ "$match" == true ]]; then printf '%s\n' "$TOOL" >>"$WT_EXCLUDE_TRACE" exit 2 fi +if [[ "${WT_EXCLUDE_FAULT:-}" == content-after-copy && "$TOOL" == cp && "${1:-}" == -p && "${3:-}" == "$WT_EXCLUDE_FILE" ]]; then + "$REAL_TOOL" "$@" || exit $? + printf '%s\n' permission-copy >>"$WT_EXCLUDE_TRACE" || exit $? + exit 0 +fi exec "$REAL_TOOL" "$@" WRAPPER chmod +x "$TMP_ROOT/outcome-bin/$tool" done printf 'REAL_GIT=%q\n' "$git_tool" >"$TMP_ROOT/outcome-bash-env" cat >>"$TMP_ROOT/outcome-bash-env" <<'WRAPPER' +WT_EXCLUDE_CONTENT_WRITES=0 git() { local frame if [[ "${WT_EXCLUDE_FAULT:-}" == lookup && "$#" -eq 4 && "$1" == -C && "$2" == "$WT_EXCLUDE_TREE" && "$3" == rev-parse && "$4" == --git-common-dir ]]; then @@ -343,7 +349,17 @@ git() { command "$REAL_GIT" "$@" } printf() { - if [[ "${WT_EXCLUDE_FAULT:-}" == content && "${1:-}" == '%s' && "${2:-}" == *"/$WT_EXCLUDE_PATH"* ]]; then + if [[ ( "${WT_EXCLUDE_FAULT:-}" == content || "${WT_EXCLUDE_FAULT:-}" == content-after-copy ) && "${1:-}" == '%s' && "${2:-}" == *"/$WT_EXCLUDE_PATH"* ]]; then + if [[ "$WT_EXCLUDE_FAULT" == content-after-copy ]]; then + WT_EXCLUDE_CONTENT_WRITES=$((WT_EXCLUDE_CONTENT_WRITES + 1)) + if [[ "$WT_EXCLUDE_CONTENT_WRITES" -eq 1 ]]; then + builtin printf "$@" || return $? + builtin printf '%s\n' content-first >>"$WT_EXCLUDE_TRACE" || return $? + return 0 + fi + builtin printf '%s\n' content-second >>"$WT_EXCLUDE_TRACE" || return $? + return 2 + fi builtin printf '%s\n' printf >>"$WT_EXCLUDE_TRACE" return 2 fi @@ -406,7 +422,7 @@ outcome_case() { : >"$ROOT/dependency-trace" if [[ "$outcome" == failed ]]; then expected=1; fi case "$fault" in lookup|filter|inspection|comparison) error_kind=read ;; esac - case "$fault" in lookup) operation=git-common-directory ;; inspection) operation=entry-inspection ;; initial-permissions) operation=permissions ;; esac + case "$fault" in lookup) operation=git-common-directory ;; inspection) operation=entry-inspection ;; initial-permissions) operation=permissions ;; content-after-copy) operation=content ;; esac local -a args=("$route" "$WT") if [[ "$route" == create ]]; then args=(create new-outcome); fi (cd "$MAIN" && PATH="$TMP_ROOT/outcome-bin:$PATH" BASH_ENV="$TMP_ROOT/outcome-bash-env" \ @@ -419,6 +435,9 @@ outcome_case() { records="$(awk -v key="worktree-exclude-$error_kind-failed:" -v operation="operation=$operation" '$1 == key && $0 ~ operation { print $1 }' "$ROOT/err")" assert_eq "$records" "worktree-exclude-$error_kind-failed:" "$caller $route $fault: writer diagnosis" assert_eq "${trace:+reached}" reached "$caller $route $fault: dependency reached" + if [[ "$fault" == content-after-copy ]]; then + assert_eq "$trace" $'content-first\npermission-copy\ncontent-second' "$caller $route $fault: earlier write and permission copy succeeded" + fi records="$(awk '/^worktree-(links-restored|links-repaired):/ || /^\// { print }' "$ROOT/out" "$ROOT/err")" assert_eq "$records" '' "$caller $route $fault: no successful command result" if [[ "$route" == fix-links ]]; then @@ -486,7 +505,7 @@ symlink|repair-links|unchanged|lookup' ignore-copy|repair-links|failed|filter child-link|repair-links|failed|permissions materialized|repair-links|failed|permissions' - for fault in lookup inspection comparison directory temporary-file content replacement cleanup initial-permissions; do + for fault in lookup inspection comparison directory temporary-file content content-after-copy replacement cleanup initial-permissions; do OUTCOMES+=$'\n'"symlink|fix-links|failed|$fault" done while IFS='|' read -r caller route outcome fault; do @@ -510,6 +529,7 @@ read directory temporary-file content +content-second permissions-status replacement cleanup @@ -559,7 +579,12 @@ if [[ -n "$CONTROLS" ]]; then new=' candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)"' select_fault=temporary-file ;; - content) expected_matches=2; select_fault=content ;; + content) expected_matches=2; select_fault='content content-after-copy' ;; + content-second) + old=' printf '\''%s'\'' "$filtered" 2>/dev/null > "$tmp" || return 1' + new=' printf '\''%s'\'' "$filtered" 2>/dev/null > "$tmp"' + select_fault=content-after-copy + ;; permissions-status) old=' cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1' new=' cp -p -- "$info_exclude" "$tmp" 2>/dev/null' @@ -582,6 +607,13 @@ if [[ -n "$CONTROLS" ]]; then ;; callers) expected_matches=5; select_fault='permissions filter' ;; esac + if [[ -n "${WORKTREE_EXCLUDE_SELECT_FAULT:-}" && "$matrix_only" == 1 ]]; then + selected=false + for fault in $select_fault; do + if [[ " $WORKTREE_EXCLUDE_SELECT_FAULT " == *" $fault "* ]]; then selected=true; fi + done + [[ "$selected" == true ]] || continue + fi scripts="$TMP_ROOT/control-$control" cp -R -- "${WORKTREE_SCRIPT%/*}" "$scripts" writer="$scripts/lib/links.sh" From 7997bda3e52973e523f88d6596559862797b3b16 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:49 +0000 Subject: [PATCH 5/7] fix(KEN-3935): cut legacy exclusion migration Address PR comments --- .agents/skills/worktree/scripts/lib/help.sh | 8 ++-- .agents/skills/worktree/scripts/lib/links.sh | 6 +-- changelog.d/worktree/fixed/root-excludes.md | 2 +- skills/worktree/scripts/lib/help.sh | 8 ++-- skills/worktree/scripts/lib/links.sh | 6 +-- .../tests/worktree_exclude_tracked_paths.sh | 38 ++++++++++++------- 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/.agents/skills/worktree/scripts/lib/help.sh b/.agents/skills/worktree/scripts/lib/help.sh index c9322cbfcb..3ed4bd2650 100644 --- a/.agents/skills/worktree/scripts/lib/help.sh +++ b/.agents/skills/worktree/scripts/lib/help.sh @@ -780,13 +780,13 @@ Symlink entries that shadow tracked content: info/exclude entries: The ignore entry setup writes for each symlinked path goes into the COMMON - git dir's info/exclude, which every checkout reads. The entry is '/', - anchored at the repository root so nested names stay visible. When the path + git dir's info/exclude, which every checkout reads. New entries use '/'. + The leading slash limits the entry to the repository root. When the path holds tracked content, setup follows the entry with '!//' (a trailing-slash pattern matches a real directory but NOT a symlink pointing at one). Runtime-only paths keep the plain entry. The shape is re-evaluated on every - create/fix-links against both indexes, either one enough. Each pass removes - exact legacy '' and '!/' lines and preserves other patterns. + create/fix-links against both indexes, either one enough. Existing bare + patterns and other rules are preserved. Git-hook auto-repair: create and fix-links install shared post-checkout, post-merge, and diff --git a/.agents/skills/worktree/scripts/lib/links.sh b/.agents/skills/worktree/scripts/lib/links.sh index 008bf917b9..354619b1c6 100644 --- a/.agents/skills/worktree/scripts/lib/links.sh +++ b/.agents/skills/worktree/scripts/lib/links.sh @@ -95,15 +95,15 @@ exclude_from_worktree_index() ( info_exclude="$common_dir/info/exclude" entry="/$path" negation="!/$path/" - # Remove exact legacy lines and move the current negation after the entry. - # Other patterns may belong to another tool and must stay in place. + # Move only the current anchored negation after the entry. + # Bare rules and other patterns stay in place. operation=filter if [[ -e "$info_exclude" || -L "$info_exclude" ]]; then [[ -f "$info_exclude" && ! -L "$info_exclude" ]] || return 1 exists=true # The sentinel retains final newlines; grep's empty result is successful. filtered="$( - grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + grep -vxF -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? [[ "$rc" -le 1 ]] || exit 1 printf '.' 2>/dev/null )" || return 1 diff --git a/changelog.d/worktree/fixed/root-excludes.md b/changelog.d/worktree/fixed/root-excludes.md index 4cdaf5c76c..a89c12ea41 100644 --- a/changelog.d/worktree/fixed/root-excludes.md +++ b/changelog.d/worktree/fixed/root-excludes.md @@ -1 +1 @@ -- Keep new files in nested directories visible to Git when their names match a configured root symlink. Worktree setup and link repair migrate old exclusions to root-anchored patterns. +- Write new Git ignore rules for worktree links with a leading slash so they do not match nested directories with the same name. diff --git a/skills/worktree/scripts/lib/help.sh b/skills/worktree/scripts/lib/help.sh index c9322cbfcb..3ed4bd2650 100644 --- a/skills/worktree/scripts/lib/help.sh +++ b/skills/worktree/scripts/lib/help.sh @@ -780,13 +780,13 @@ Symlink entries that shadow tracked content: info/exclude entries: The ignore entry setup writes for each symlinked path goes into the COMMON - git dir's info/exclude, which every checkout reads. The entry is '/', - anchored at the repository root so nested names stay visible. When the path + git dir's info/exclude, which every checkout reads. New entries use '/'. + The leading slash limits the entry to the repository root. When the path holds tracked content, setup follows the entry with '!//' (a trailing-slash pattern matches a real directory but NOT a symlink pointing at one). Runtime-only paths keep the plain entry. The shape is re-evaluated on every - create/fix-links against both indexes, either one enough. Each pass removes - exact legacy '' and '!/' lines and preserves other patterns. + create/fix-links against both indexes, either one enough. Existing bare + patterns and other rules are preserved. Git-hook auto-repair: create and fix-links install shared post-checkout, post-merge, and diff --git a/skills/worktree/scripts/lib/links.sh b/skills/worktree/scripts/lib/links.sh index 008bf917b9..354619b1c6 100644 --- a/skills/worktree/scripts/lib/links.sh +++ b/skills/worktree/scripts/lib/links.sh @@ -95,15 +95,15 @@ exclude_from_worktree_index() ( info_exclude="$common_dir/info/exclude" entry="/$path" negation="!/$path/" - # Remove exact legacy lines and move the current negation after the entry. - # Other patterns may belong to another tool and must stay in place. + # Move only the current anchored negation after the entry. + # Bare rules and other patterns stay in place. operation=filter if [[ -e "$info_exclude" || -L "$info_exclude" ]]; then [[ -f "$info_exclude" && ! -L "$info_exclude" ]] || return 1 exists=true # The sentinel retains final newlines; grep's empty result is successful. filtered="$( - grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + grep -vxF -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$? [[ "$rc" -le 1 ]] || exit 1 printf '.' 2>/dev/null )" || return 1 diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index afddc187a1..4f869f793f 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -49,6 +49,7 @@ MAIN="" WT="" ENTRY="" CHECK_PERMISSIONS=false +SOURCE_ABSENT=false make_repo() { mkdir -p "$MAIN" @@ -111,12 +112,17 @@ step() { legacy-lines) printf '%s\n' "$ENTRY" "!$ENTRY/" "$ENTRY" "!$ENTRY/" >"$MAIN/.git/info/exclude" ;; + bare-line) printf '%s\n' "$ENTRY" >"$MAIN/.git/info/exclude" ;; + source-absent) + must mv -- "$MAIN/$ENTRY" "$ROOT/moved-source" + SOURCE_ABSENT=true + ;; foreign-patterns) printf '%s\n' '**/.claude/*' 'nested/runtime/ignored' '/runtime/cache' '!runtime/sub/' >>"$MAIN/.git/info/exclude" ;; shared-exclude) must chmod 664 "$MAIN/.git/info/exclude" - printf '%s\n' "$ENTRY" >>"$MAIN/.git/info/exclude" + printf '!/%s/\n' "$ENTRY" >>"$MAIN/.git/info/exclude" mkdir -p "$MAIN/.claude" printf 'private\n' >"$MAIN/.claude/private-note" CHECK_PERMISSIONS=true @@ -158,6 +164,7 @@ build() { WT="" ENTRY="" CHECK_PERMISSIONS=false + SOURCE_ABSENT=false mkdir -p "$ROOT" for word in "$@"; do step "$word" @@ -263,9 +270,8 @@ the negation stays while the worktree'\''s own index still tracks the file main the negation goes once neither index tracks a file under the entry|repo tracked-entry create:tracked-check untrack:harness/skills/tool.md sync-wt|fix-links |0|restored:tracked-check|-|exclude=/harness,/harness/state.json main=- stage=- wt=.env.local,.gitignore,base.txt,harness->
/harness wt-status=- a line another tool wrote into the exclude file stays, before ours|repo tracked-entry foreign-line|create tracked-check|0|wt:tracked-check|-|exclude=**/.claude/*,/harness,!/harness/,/harness/state.json main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- a root runtime link leaves new files in a nested tracked namesake visible and stageable|repo runtime-entry nested-entry|create nested-check|0|wt:nested-check|-|exclude=/runtime main=A nested/runtime/new.md stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md,runtime->
/runtime wt-status=A nested/runtime/new.md -fix-links removes duplicate bare lines from a legacy-only exclude file|repo runtime-entry create:legacy-check legacy-lines|fix-links |0|restored:legacy-check|-|exclude=/runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- -fix-links migrates bare lines and preserves other patterns while nested files stay stageable|repo runtime-entry nested-entry create:legacy-check legacy-lines foreign-patterns|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,nested/runtime/ignored,/runtime/cache,!runtime/sub/,/runtime main=A nested/runtime/new.md stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md,runtime->
/runtime wt-status=A nested/runtime/new.md -fix-links migrates a tracked entry and keeps the negation last|repo tracked-entry create:legacy-check legacy-lines foreign-line|fix-links |0|restored:legacy-check|-|exclude=**/.claude/*,/harness,!/harness/ main=- stage=0:- wt=.env.local,.gitignore,base.txt,harness/skills/tool.md,harness/state.json->
/harness/state.json wt-status=- +fix-links preserves existing bare rules and negations while adding the anchored entry|repo runtime-entry create:cut-check legacy-lines foreign-patterns|fix-links |0|restored:cut-check|-|exclude=runtime,!runtime/,runtime,!runtime/,**/.claude/*,nested/runtime/ignored,/runtime/cache,!runtime/sub/,/runtime main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- +creation with an absent source leaves existing bare rules effective|repo runtime-entry nested-entry bare-line source-absent|create absent-source|0|wt:absent-source|-|exclude=runtime main=- stage=- wt=.env.local,base.txt,nested/runtime/keep.md,nested/runtime/new.md wt-status=- fix-links keeps shared exclusion permissions and foreign rules|repo runtime-entry create:shared-check foreign-line shared-exclude|fix-links |0|restored:shared-check|-|exclude=/runtime,**/.claude/* main=- stage=- wt=.env.local,base.txt,runtime->
/runtime wt-status=- ' @@ -291,6 +297,7 @@ while IFS= read -r row; do if [[ "$CHECK_PERMISSIONS" == true ]]; then prefix='shared-mode=preserved '; fi if [[ -d "$MAIN/nested/runtime" ]]; then prefix='nested-status=?? nested/runtime/new.md staged=nested/runtime/new.md root-ignored=0 root-staged=- ' + if [[ "$SOURCE_ABSENT" == true ]]; then prefix='nested-status= staged= root-ignored=0 root-staged=- '; fi prefix="$prefix$prefix" fi assert_eq "$(run "$command")" "${prefix}rc=$rc out=$(out_text "$out") err=$(err_text "$err") $want_state" "$label" @@ -310,7 +317,7 @@ outcome_dependencies() { cat >>"$TMP_ROOT/outcome-bin/$tool" <<'WRAPPER' match=false case "${WT_EXCLUDE_FAULT:-}:$TOOL" in - filter:grep) [[ "${1:-}" == -vxF && "${3:-}" == "$WT_EXCLUDE_PATH" ]] && match=true ;; + filter:grep) [[ "${1:-}" == -vxF && "${3:-}" == "!/$WT_EXCLUDE_PATH/" ]] && match=true ;; inspection:grep) [[ "$#" -eq 3 && "$1" == -qxF && "$3" == "/$WT_EXCLUDE_PATH" ]] && match=true ;; comparison:cmp) [[ "${4:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; permissions:cp) [[ "${1:-}" == -p && "${3:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; @@ -403,7 +410,11 @@ outcome_case() { *) echo "unknown outcome caller: $caller" >&2; exit 2 ;; esac case "$outcome" in - written|failed) + written) + : >"$MAIN/.git/info/exclude" + if [[ "$caller" == ignore-copy || "$caller" == child-link ]]; then printf '%s\n' /runtime '!/runtime/' >>"$MAIN/.git/info/exclude"; fi + ;; + failed) printf '%s\n' "$failed_path" >"$MAIN/.git/info/exclude" if [[ "$caller" == ignore-copy || "$caller" == child-link ]]; then printf '%s\n' /runtime '!/runtime/' >>"$MAIN/.git/info/exclude"; fi ;; @@ -445,16 +456,16 @@ outcome_case() { assert_eq "$records" worktree-links-unrestored: "$caller $fault: setup failure retained" fi # Cleanup can fail after a completed replacement; other failures keep the - # original legacy rule. Git itself proves the nested file remains hidden. + # original rule. Git itself proves an existing bare rule stays effective. if [[ "$fault" == initial-permissions ]]; then assert_eq "$(test -e "$MAIN/.git/info/exclude" && printf present || printf absent)" absent "$caller $route $fault: no incomplete first write" elif [[ "$fault" != cleanup ]]; then excluded="$(grep -xF -- "$failed_path" "$MAIN/.git/info/exclude")" || true - assert_eq "$excluded" "$failed_path" "$caller $route $fault: legacy rule retained" + assert_eq "$excluded" "$failed_path" "$caller $route $fault: existing rule retained" if [[ "$failed_path" == runtime ]]; then printf 'new\n' >"$MAIN/nested/runtime/new.md" stage="$(git -C "$MAIN" status --porcelain -- nested/runtime/new.md)" - assert_eq "$stage" '' "$caller $route $fault: hidden file cannot imply success" + assert_eq "$stage" '' "$caller $route $fault: existing bare rule stays effective" fi fi else @@ -520,10 +531,10 @@ fi # The child suite uses its own repositories and skips this control loop. CONTROLS="" if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then CONTROLS='anchor -migration permissions'; fi if [[ "${WORKTREE_EXCLUDE_OUTCOME_CONTROL:-}" != 1 && "${WORKTREE_EXCLUDE_SKIP_OUTCOMES:-}" != 1 ]]; then CONTROLS+=$'\n''lookup +preserve-existing empty read directory @@ -548,10 +559,11 @@ if [[ -n "$CONTROLS" ]]; then old=' entry="/$path"' new=' entry="$path"' ;; - migration) + preserve-existing) matrix_only=0 - old=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' - new=' grep -vxF -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' + select_fault=cut-boundary + old=' grep -vxF -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' + new=' grep -vxF -e "$path" -e "!$path/" -e "$negation" -- "$info_exclude" 2>/dev/null || rc=$?' ;; permissions) matrix_only=0 From 865f17855652c9a08093c187971fdf08bba29ea7 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:26:04 +0000 Subject: [PATCH 6/7] fix(KEN-3935): cut temporary first exclusion writes Address CI failure through the authorized first-write scope cut. --- .agents/skills/worktree/scripts/lib/links.sh | 9 ++-- skills/worktree/scripts/lib/links.sh | 9 ++-- .../tests/worktree_exclude_tracked_paths.sh | 46 +++++++++++++------ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/.agents/skills/worktree/scripts/lib/links.sh b/.agents/skills/worktree/scripts/lib/links.sh index 354619b1c6..ccc22e3db8 100644 --- a/.agents/skills/worktree/scripts/lib/links.sh +++ b/.agents/skills/worktree/scripts/lib/links.sh @@ -124,6 +124,11 @@ exclude_from_worktree_index() ( failure=write; operation=directory mkdir -p -- "${info_exclude%/*}" 2>/dev/null || return 1 + if [[ "$exists" == false ]]; then + operation=content + printf '%s' "$filtered" 2>/dev/null >> "$info_exclude" || return 1 + return 0 + fi operation=temporary-file candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)" || return 1 tmp="$candidate" @@ -139,10 +144,6 @@ exclude_from_worktree_index() ( cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1 operation=content printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 - else - # A first write uses the caller's creation mask, as a regular file does. - operation=permissions - chmod '=rw' -- "$tmp" 2>/dev/null || return 1 fi operation=replacement mv -- "$tmp" "$info_exclude" 2>/dev/null || return 1 diff --git a/skills/worktree/scripts/lib/links.sh b/skills/worktree/scripts/lib/links.sh index 354619b1c6..ccc22e3db8 100644 --- a/skills/worktree/scripts/lib/links.sh +++ b/skills/worktree/scripts/lib/links.sh @@ -124,6 +124,11 @@ exclude_from_worktree_index() ( failure=write; operation=directory mkdir -p -- "${info_exclude%/*}" 2>/dev/null || return 1 + if [[ "$exists" == false ]]; then + operation=content + printf '%s' "$filtered" 2>/dev/null >> "$info_exclude" || return 1 + return 0 + fi operation=temporary-file candidate="$(mktemp "$info_exclude.wt-tmp.XXXXXX" 2>/dev/null)" || return 1 tmp="$candidate" @@ -139,10 +144,6 @@ exclude_from_worktree_index() ( cp -p -- "$info_exclude" "$tmp" 2>/dev/null || return 1 operation=content printf '%s' "$filtered" 2>/dev/null > "$tmp" || return 1 - else - # A first write uses the caller's creation mask, as a regular file does. - operation=permissions - chmod '=rw' -- "$tmp" 2>/dev/null || return 1 fi operation=replacement mv -- "$tmp" "$info_exclude" 2>/dev/null || return 1 diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index 4f869f793f..735f23340d 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -310,7 +310,7 @@ fi outcome_dependencies() { local tool resolved git_tool="" mkdir -p "$TMP_ROOT/outcome-bin" - for tool in git grep cp cmp mkdir mktemp mv rm chmod; do + for tool in git grep cp cmp mkdir mktemp mv rm; do resolved="$(command -v "$tool")" || return 1 [[ "$tool" != git ]] || git_tool="$resolved" printf '#!%s\nREAL_TOOL=%q\nTOOL=%q\n' "$BASH" "$resolved" "$tool" >"$TMP_ROOT/outcome-bin/$tool" @@ -325,7 +325,6 @@ case "${WT_EXCLUDE_FAULT:-}:$TOOL" in temporary-file:mktemp) [[ "${1:-}" == "$WT_EXCLUDE_FILE.wt-tmp.XXXXXX" ]] && match=true ;; replacement:mv) [[ "${3:-}" == "$WT_EXCLUDE_FILE" ]] && match=true ;; cleanup:rm) [[ "${3:-}" == "$WT_EXCLUDE_FILE.wt-tmp."* ]] && match=true ;; - initial-permissions:chmod) [[ "${1:-}" == =rw && "${3:-}" == "$WT_EXCLUDE_FILE.wt-tmp."* ]] && match=true ;; esac if [[ "$match" == true ]]; then printf '%s\n' "$TOOL" >>"$WT_EXCLUDE_TRACE" @@ -356,6 +355,10 @@ git() { command "$REAL_GIT" "$@" } printf() { + if [[ "${WT_EXCLUDE_FAULT:-}" == initial-content && "${1:-}" == '%s' && "${2:-}" == *"/$WT_EXCLUDE_PATH"* ]]; then + builtin printf '%s\n' printf >>"$WT_EXCLUDE_TRACE" + return 2 + fi if [[ ( "${WT_EXCLUDE_FAULT:-}" == content || "${WT_EXCLUDE_FAULT:-}" == content-after-copy ) && "${1:-}" == '%s' && "${2:-}" == *"/$WT_EXCLUDE_PATH"* ]]; then if [[ "$WT_EXCLUDE_FAULT" == content-after-copy ]]; then WT_EXCLUDE_CONTENT_WRITES=$((WT_EXCLUDE_CONTENT_WRITES + 1)) @@ -425,7 +428,7 @@ outcome_case() { missing) rm -f "$MAIN/.git/info/exclude" ;; *) echo "unknown writer outcome: $outcome" >&2; exit 2 ;; esac - if [[ "$fault" == initial-permissions ]]; then rm -f "$MAIN/.git/info/exclude"; fi + if [[ "$fault" == initial-content ]]; then rm -f "$MAIN/.git/info/exclude"; fi if [[ "$route" == repair-links && "$caller" == symlink && "$outcome" == unchanged ]]; then must ln -s "$MAIN/runtime" "$WT/runtime" fi @@ -433,10 +436,13 @@ outcome_case() { : >"$ROOT/dependency-trace" if [[ "$outcome" == failed ]]; then expected=1; fi case "$fault" in lookup|filter|inspection|comparison) error_kind=read ;; esac - case "$fault" in lookup) operation=git-common-directory ;; inspection) operation=entry-inspection ;; initial-permissions) operation=permissions ;; content-after-copy) operation=content ;; esac + case "$fault" in lookup) operation=git-common-directory ;; inspection) operation=entry-inspection ;; initial-content|content-after-copy) operation=content ;; esac local -a args=("$route" "$WT") if [[ "$route" == create ]]; then args=(create new-outcome); fi - (cd "$MAIN" && PATH="$TMP_ROOT/outcome-bin:$PATH" BASH_ENV="$TMP_ROOT/outcome-bash-env" \ + (cd "$MAIN" && + if [[ "$outcome" == missing || "$fault" == initial-content ]]; then umask 027; fi + if [[ "$outcome" == missing ]]; then export POSIXLY_CORRECT=1; fi + PATH="$TMP_ROOT/outcome-bin:$PATH" BASH_ENV="$TMP_ROOT/outcome-bash-env" \ WT_EXCLUDE_FAULT="$fault" WT_EXCLUDE_TREE="$WT" WT_EXCLUDE_PATH="$failed_path" \ WT_EXCLUDE_FILE="$MAIN/.git/info/exclude" WT_EXCLUDE_TRACE="$ROOT/dependency-trace" \ "$WORKTREE_SCRIPT" "${args[@]}" >"$ROOT/out" 2>"$ROOT/err") || rc=$? @@ -457,8 +463,9 @@ outcome_case() { fi # Cleanup can fail after a completed replacement; other failures keep the # original rule. Git itself proves an existing bare rule stays effective. - if [[ "$fault" == initial-permissions ]]; then - assert_eq "$(test -e "$MAIN/.git/info/exclude" && printf present || printf absent)" absent "$caller $route $fault: no incomplete first write" + if [[ "$fault" == initial-content ]]; then + excluded="$(grep -xF -- "/$failed_path" "$MAIN/.git/info/exclude" 2>/dev/null)" || true + assert_eq "$excluded" '' "$caller $route $fault: no completed first write" elif [[ "$fault" != cleanup ]]; then excluded="$(grep -xF -- "$failed_path" "$MAIN/.git/info/exclude")" || true assert_eq "$excluded" "$failed_path" "$caller $route $fault: existing rule retained" @@ -476,6 +483,9 @@ outcome_case() { fi excluded="$(grep -xF -- "/$failed_path" "$MAIN/.git/info/exclude")" || true assert_eq "$excluded" "/$failed_path" "$caller $route $outcome: anchored result" + if [[ "$outcome" == missing ]]; then + assert_eq "$(find "$MAIN/.git/info/exclude" -perm 0640 -print)" "$MAIN/.git/info/exclude" "$caller $route $outcome: caller creation mask" + fi fi } @@ -499,7 +509,7 @@ ignore-copy|fix-links|unchanged|permissions mkdir|fix-links|unchanged|permissions copy|fix-links|unchanged|permissions relative|fix-links|unchanged|permissions -symlink|fix-links|missing|- +symlink|fix-links|missing|first-write symlink|create|written|- symlink|repair-links|written|- symlink|repair-links|unchanged|lookup' @@ -516,7 +526,7 @@ symlink|repair-links|unchanged|lookup' ignore-copy|repair-links|failed|filter child-link|repair-links|failed|permissions materialized|repair-links|failed|permissions' - for fault in lookup inspection comparison directory temporary-file content content-after-copy replacement cleanup initial-permissions; do + for fault in lookup inspection comparison directory temporary-file content content-after-copy replacement cleanup initial-content; do OUTCOMES+=$'\n'"symlink|fix-links|failed|$fault" done while IFS='|' read -r caller route outcome fault; do @@ -544,7 +554,8 @@ content-second permissions-status replacement cleanup -initial-permissions +initial-content +first-write callers' fi if [[ -n "$CONTROLS" ]]; then @@ -612,14 +623,19 @@ if [[ -n "$CONTROLS" ]]; then new=' if [[ "$result" -eq 0 ]]; then result=0; failure=write; operation=cleanup; fi' select_fault=cleanup ;; - initial-permissions) - old=' chmod '\''=rw'\'' -- "$tmp" 2>/dev/null || return 1' - new=' chmod '\''=rw'\'' -- "$tmp" 2>/dev/null' - select_fault=initial-permissions + initial-content) + old=' printf '\''%s'\'' "$filtered" 2>/dev/null >> "$info_exclude" || return 1' + new=' printf '\''%s'\'' "$filtered" 2>/dev/null >> "$info_exclude"' + select_fault=initial-content + ;; + first-write) + old=' printf '\''%s'\'' "$filtered" 2>/dev/null >> "$info_exclude" || return 1' + new=' printf '\''%s'\'' "$filtered" 2>/dev/null >> /dev/null || return 1' + select_fault=first-write ;; callers) expected_matches=5; select_fault='permissions filter' ;; esac - if [[ -n "${WORKTREE_EXCLUDE_SELECT_FAULT:-}" && "$matrix_only" == 1 ]]; then + if [[ -n "${WORKTREE_EXCLUDE_SELECT_FAULT:-}" ]]; then selected=false for fault in $select_fault; do if [[ " $WORKTREE_EXCLUDE_SELECT_FAULT " == *" $fault "* ]]; then selected=true; fi From 44d87570abeb18fa40b1dd9c6e30cab9f9b4f545 Mon Sep 17 00:00:00 2001 From: "vanillagreen-fleet-lanes[bot]" <328550061+vanillagreen-fleet-lanes[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:42:52 +0000 Subject: [PATCH 7/7] test(KEN-3935): keep first-write fixture in Bash mode Address PR review --- skills/worktree/tests/worktree_exclude_tracked_paths.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/worktree/tests/worktree_exclude_tracked_paths.sh b/skills/worktree/tests/worktree_exclude_tracked_paths.sh index 735f23340d..29976ef483 100755 --- a/skills/worktree/tests/worktree_exclude_tracked_paths.sh +++ b/skills/worktree/tests/worktree_exclude_tracked_paths.sh @@ -441,7 +441,6 @@ outcome_case() { if [[ "$route" == create ]]; then args=(create new-outcome); fi (cd "$MAIN" && if [[ "$outcome" == missing || "$fault" == initial-content ]]; then umask 027; fi - if [[ "$outcome" == missing ]]; then export POSIXLY_CORRECT=1; fi PATH="$TMP_ROOT/outcome-bin:$PATH" BASH_ENV="$TMP_ROOT/outcome-bash-env" \ WT_EXCLUDE_FAULT="$fault" WT_EXCLUDE_TREE="$WT" WT_EXCLUDE_PATH="$failed_path" \ WT_EXCLUDE_FILE="$MAIN/.git/info/exclude" WT_EXCLUDE_TRACE="$ROOT/dependency-trace" \