diff --git a/.agents/skills/worktree/scripts/lib/help.sh b/.agents/skills/worktree/scripts/lib/help.sh index 3c8780e2d4..3ed4bd2650 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. 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. + 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 e0fae1fcb5..ccc22e3db8 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 @@ -74,30 +75,80 @@ 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 negation tmp - 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" - negation="!$path/" - mkdir -p "$(dirname "$info_exclude")" + entry="/$path" + negation="!/$path/" + # 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 "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || exit 1 + printf '.' 2>/dev/null + )" || return 1 + filtered="${filtered%.}" + fi - # 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 + 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 - grep -qxF "$path" "$info_exclude" 2>/dev/null || echo "$path" >> "$info_exclude" - [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" + 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" + 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 + 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. # @@ -154,7 +205,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 @@ -374,7 +425,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. @@ -390,7 +441,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 @@ -1031,7 +1082,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 @@ -1065,7 +1116,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 @@ -1113,7 +1164,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/changelog.d/worktree/fixed/root-excludes.md b/changelog.d/worktree/fixed/root-excludes.md new file mode 100644 index 0000000000..a89c12ea41 --- /dev/null +++ b/changelog.d/worktree/fixed/root-excludes.md @@ -0,0 +1 @@ +- 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 3c8780e2d4..3ed4bd2650 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. 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. + 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 e0fae1fcb5..ccc22e3db8 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 @@ -74,30 +75,80 @@ 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 negation tmp - 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" - negation="!$path/" - mkdir -p "$(dirname "$info_exclude")" + entry="/$path" + negation="!/$path/" + # 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 "$negation" -- "$info_exclude" 2>/dev/null || rc=$? + [[ "$rc" -le 1 ]] || exit 1 + printf '.' 2>/dev/null + )" || return 1 + filtered="${filtered%.}" + fi - # 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 + 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 - grep -qxF "$path" "$info_exclude" 2>/dev/null || echo "$path" >> "$info_exclude" - [[ "$has_tracked" == true ]] && echo "$negation" >> "$info_exclude" + 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" + 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 + 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. # @@ -154,7 +205,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 @@ -374,7 +425,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. @@ -390,7 +441,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 @@ -1031,7 +1082,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 @@ -1065,7 +1116,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 @@ -1113,7 +1164,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 5cd1d90582..29976ef483 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. @@ -64,6 +48,8 @@ ROOT="" MAIN="" WT="" ENTRY="" +CHECK_PERMISSIONS=false +SOURCE_ABSENT=false make_repo() { mkdir -p "$MAIN" @@ -71,6 +57,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 +102,31 @@ 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" + ;; + 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" + 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:}" @@ -150,6 +163,8 @@ build() { MAIN="$ROOT/main" WT="" ENTRY="" + CHECK_PERMISSIONS=false + SOURCE_ABSENT=false mkdir -p "$ROOT" for word in "$@"; do step "$word" @@ -203,6 +218,24 @@ 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 + 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,16 +261,21 @@ 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 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=- ' +if [[ "${WORKTREE_EXCLUDE_MATRIX_ONLY:-}" != 1 ]]; then echo "=== the exclude entry of a configured symlink path ===" n=0 while IFS= read -r row; do @@ -255,9 +293,386 @@ 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 [[ "$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" 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; 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 ;; +esac +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 + 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:-}" == 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)) + 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 + 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) + : >"$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 + ;; + 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-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 + 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-content|content-after-copy) operation=content ;; esac + local -a args=("$route" "$WT") + if [[ "$route" == create ]]; then args=(create new-outcome); fi + (cd "$MAIN" && + if [[ "$outcome" == missing || "$fault" == initial-content ]]; then umask 027; 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=$? + 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" + 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 + 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 rule. Git itself proves an existing bare rule stays effective. + 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" + 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: existing bare rule stays effective" + 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" + 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 +} + +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|first-write +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 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 + 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. +CONTROLS="" +if [[ "${WORKTREE_EXCLUDE_CONTROL:-}" != 1 ]]; then CONTROLS='anchor +permissions'; fi +if [[ "${WORKTREE_EXCLUDE_OUTCOME_CONTROL:-}" != 1 && "${WORKTREE_EXCLUDE_SKIP_OUTCOMES:-}" != 1 ]]; then + CONTROLS+=$'\n''lookup +preserve-existing +empty +read +directory +temporary-file +content +content-second +permissions-status +replacement +cleanup +initial-content +first-write +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"' + ;; + preserve-existing) + matrix_only=0 + 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 + 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 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' + 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-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:-}" ]]; 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" + [[ ! -L "$writer" ]] || { echo 'control writer is a symlink' >&2; exit 2; } + matches=0 + while IFS= read -r line || [[ -n "$line" ]]; do + 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 "$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 + 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" +fi echo printf 'pass: %d fail: %d\n' "$PASS" "$FAIL" 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