From 615eb2a14aa107f0a416c28fd4ee231f33c3e660 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Thu, 13 Aug 2026 11:21:24 +0200 Subject: [PATCH] support both project config shapes instead of refusing one A project that versions its own .claude was refused adoption. It is now the second supported shape: the directory is left as it is, nothing is linked, and project-config/ goes unused, because that configuration already reaches both machines by the same pull that brings the code. A project with nothing tracked under .claude/ is adopted as before. Which shape applies is read from the checkout on every run rather than settled once. Whether git holds entries under .claude/ varies by branch and by machine, so a decision made at adoption time cannot describe a project whose branches differ. The defect this fixes is the ordering, not the refusal. Both scripts tested for an existing symlink first and reported "already satisfied", so a link created while .claude was untracked outlived the branch that tracks it: git reported every file under the link as deleted while Claude Code went on reading it, and nothing looked at the git state again. Both now read it before the symlink branch. That shadowing state is the one neither shape tolerates, so it is asserted on every side. server/lib/22-claude-config.sh defers with exact restore commands rather than checking paths back out into a project's working tree, laptop/adopt-project-config.sh refuses with the same commands, and verify.sh asserts no link shadows a project that versions its own config. The git pathspec carries a trailing slash so it matches entries under the directory. A bare .claude also matches an adopted symlink that was itself committed, which would read as a project versioning configuration it does not have; lib/common.test.sh covers that case along with the other three. --- laptop/adopt-project-config.sh | 49 +++++++++------ lib/common.test.sh | 111 +++++++++++++++++++++++++++++++++ server/lib/22-claude-config.sh | 50 +++++++++++---- sync-design.md | 47 +++++++++++--- verify.sh | 18 ++++++ 5 files changed, 234 insertions(+), 41 deletions(-) diff --git a/laptop/adopt-project-config.sh b/laptop/adopt-project-config.sh index 1ab607d..bf84a88 100755 --- a/laptop/adopt-project-config.sh +++ b/laptop/adopt-project-config.sh @@ -58,6 +58,37 @@ adopt() { local src="$project/.claude" dest="$CONFIG_ROOT/$name" + # Both shapes are supported, so a project that versions its own .claude is not + # a project this refuses — it is a project with nothing to adopt. The + # configuration already reaches both machines by the same pull that brings the + # code, and replacing the directory with a symlink would not centralise it but + # break it: git will not follow a symlink to reach a tracked path, so every + # file under it reports as deleted on both machines and committing that would + # remove them from the project. + # + # Read before the symlink branch below, because the two can coexist and that + # combination is the failure. The pathspec carries a trailing slash so it + # matches entries UNDER the directory; a bare `.claude` also matches an + # adopted symlink that was itself committed. + # + # Which shape applies is a property of this checkout, not of the project. + # Tracked entries under .claude come and go with the branch, so this is read + # on every invocation rather than settled at adoption time. + local tracked="" + if git -C "$project" rev-parse --git-dir >/dev/null 2>&1; then + tracked=$(git -C "$project" ls-files -- '.claude/' | head -3) + fi + + if [[ -n "$tracked" ]]; then + [[ -L "$src" ]] && die "$name versions its own .claude, and $src is a symlink shadowing it: +$tracked +git reports every one of those as deleted while Claude Code reads the link +instead. Restore the project's own directory: + rm '$src' && git -C '$project' checkout -- .claude" + info "nothing to adopt for $name: it versions its own .claude, which already reaches both machines by its own git" + return 0 + fi + # Already adopted is not an error: this must be safe to re-run over a list # that includes projects done on an earlier pass. if [[ -L "$src" ]]; then @@ -73,24 +104,6 @@ adopt() { [[ -e "$src" ]] || die "$src does not exist — nothing to adopt for $name" [[ -d "$src" ]] || die "$src is not a directory" - # A project that tracks its own .claude has already solved this problem: the - # configuration is versioned with the code and reaches both machines by the - # same pull that brings everything else. Replacing the directory with a - # symlink does not centralise it, it breaks it — git will not follow a symlink - # to reach a tracked path, so every file under it reports as deleted on both - # machines, and committing that would remove them from the project. - # - # The premise of this whole scheme is a .claude that is NOT reliably - # committed. Where the premise does not hold, neither does the scheme. - if git -C "$project" rev-parse --git-dir >/dev/null 2>&1; then - local tracked - tracked=$(git -C "$project" ls-files -- .claude | head -3) - [[ -n "$tracked" ]] && die "$name tracks its own .claude in its repository: -$tracked -That configuration already reaches both machines through the project's own git -history, and linking it away would report every tracked file as deleted. Leave -this project alone." - fi # Refuse rather than merge. Two directories of the same name are two different # configurations, and picking one silently loses the other (§1 criterion 8). [[ -e "$dest" ]] && die "$dest already exists; move or remove it before adopting $name" diff --git a/lib/common.test.sh b/lib/common.test.sh index 9e10e51..20dfdab 100755 --- a/lib/common.test.sh +++ b/lib/common.test.sh @@ -720,5 +720,116 @@ t "phase_2_deliver marks a working-tree delivery -dirty" "1" "$DIRTY_MARK" DIRTY_REFUSED=$(grep -cF '[[ "$remote_commit" == *-dirty ]]' bootstrap.sh) t "check_delivered_commit refuses a -dirty delivery outright" "1" "$DIRTY_REFUSED" +# ---- laptop/adopt-project-config.sh adopt(): the three shapes a project's +# .claude can be in +# +# Which shape applies is read fresh from the checkout on every run via +# `git -C "$project" ls-files -- '.claude/'`: a project that versions its own +# .claude has nothing to adopt, a symlink shadowing that tracked content is +# refused rather than silently overwritten, and everything else is adopted as +# before. adopt() cannot be sourced and called the way resolve_operator_key() +# is above — the script's top level parses args, loops over them, and calls +# finish(), so sourcing it would run that immediately in this shell rather +# than defining a function to call. Run as a subprocess instead, the same way +# it runs for real. +# +# ADOPT_HOME stands in for $HOME so the two-levels-below-$HOME check and +# $CONFIG_ROOT land under TD rather than the operator's real ~/misc or +# ~/.claude. It is resolved through `pwd -P` once, up front: mktemp -d +# returns a path through a symlink on macOS (/var -> /private/var), and +# adopt() resolves each project path the same way before comparing it against +# $HOME, so an unresolved ADOPT_HOME would fail that comparison on every case +# below for a reason that has nothing to do with what is under test. +ADOPT_HOME=$TD/adopt-home +mkdir -p "$ADOPT_HOME/misc" +ADOPT_HOME=$(cd "$ADOPT_HOME" && pwd -P) + +git_id() { + git -C "$1" config user.email test@example.invalid + git -C "$1" config user.name test +} + +# 1. tracked real directory: nothing to adopt, nothing touched +P1="$ADOPT_HOME/misc/tracked-dir" +mkdir -p "$P1/.claude" +: >"$P1/.claude/settings.json" +git -C "$P1" init -q +git_id "$P1" +git -C "$P1" add .claude/settings.json +git -C "$P1" commit -q -m "add claude config" + +OUT1=$(HOME="$ADOPT_HOME" bash laptop/adopt-project-config.sh "$P1" 2>&1) +RC1=$? +t "adopt: tracked .claude directory: exits 0" "0" "$RC1" +t "adopt: tracked .claude directory: left as a directory" "yes" \ + "$([[ -d $P1/.claude && ! -L $P1/.claude ]] && echo yes)" +t "adopt: tracked .claude directory: no project-config entry created" "absent" \ + "$([[ -e $ADOPT_HOME/.claude/project-config/tracked-dir ]] && echo present || echo absent)" +t "adopt: tracked .claude directory: message says nothing to adopt" "1" \ + "$(grep -c 'nothing to adopt' <<<"$OUT1")" + +# 2. tracked content shadowed by a symlink: refused, not overwritten +P2="$ADOPT_HOME/misc/shadowed" +mkdir -p "$P2/.claude" +: >"$P2/.claude/settings.json" +git -C "$P2" init -q +git_id "$P2" +git -C "$P2" add .claude/settings.json +git -C "$P2" commit -q -m "add claude config" +rm -rf "$P2/.claude" +ln -s "$TD/elsewhere" "$P2/.claude" + +OUT2=$(HOME="$ADOPT_HOME" bash laptop/adopt-project-config.sh "$P2" 2>&1) +RC2=$? +t "adopt: symlink shadowing tracked .claude: refuses rather than adopting" "1" "$RC2" +t "adopt: symlink shadowing tracked .claude: names the restore" "1" \ + "$(grep -cE "checkout -- \.claude" <<<"$OUT2")" + +# 3. untracked .claude: adopted exactly as before the two shapes existed +P3="$ADOPT_HOME/misc/untracked" +mkdir -p "$P3/.claude" +: >"$P3/.claude/settings.json" + +OUT3=$(HOME="$ADOPT_HOME" bash laptop/adopt-project-config.sh "$P3" 2>&1) +RC3=$? +t "adopt: untracked .claude: exits 0" "0" "$RC3" +# Exit 0 alone cannot tell adoption apart from the "nothing to adopt" path, +# which also succeeds and also leaves a project that looks untouched. +t "adopt: untracked .claude: reports an adoption, not a skip" "1" \ + "$(grep -c 'adopted untracked' <<<"$OUT3")" +t "adopt: untracked .claude: moved into project-config" "yes" \ + "$([[ -d $ADOPT_HOME/.claude/project-config/untracked ]] && echo yes)" +t "adopt: untracked .claude: a symlink left in its place" "yes" "$([[ -L $P3/.claude ]] && echo yes)" +t "adopt: untracked .claude: symlink resolves to the moved directory" "yes" \ + "$([[ "$(cd "$P3/.claude" && pwd -P)" == "$(cd "$ADOPT_HOME/.claude/project-config/untracked" && pwd -P)" ]] && echo yes)" + +# 4. THE CASE THE TRAILING SLASH EXISTS FOR: the only tracked .claude entry is +# the symlink itself — committed adopted state, not a project versioning its +# own configuration. `git ls-files -- '.claude/'` matches entries UNDER the +# directory, and a symlink has none, so this must fall through to the normal +# already-adopted handling rather than into the tracked branch above. A bare +# `.claude` pathspec would match the symlink entry and misread this case as +# shape 1. +P4="$ADOPT_HOME/misc/committed-symlink" +mkdir -p "$P4" +DEST4="$ADOPT_HOME/.claude/project-config/committed-symlink" +mkdir -p "$DEST4" +: >"$DEST4/settings.json" +ln -s "../../.claude/project-config/committed-symlink" "$P4/.claude" +git -C "$P4" init -q +git_id "$P4" +git -C "$P4" add .claude +git -C "$P4" commit -q -m "adopt project config" + +OUT4=$(HOME="$ADOPT_HOME" bash laptop/adopt-project-config.sh "$P4" 2>&1) +RC4=$? +t "adopt: committed symlink alone: exits 0" "0" "$RC4" +t "adopt: committed symlink alone: not read as versioning its own config" "0" \ + "$(grep -c 'versions its own' <<<"$OUT4")" +t "adopt: committed symlink alone: falls through to already-satisfied" "1" \ + "$(grep -c 'already satisfied' <<<"$OUT4")" +t "adopt: committed symlink alone: moved content untouched" "yes" \ + "$([[ -e $DEST4/settings.json ]] && echo yes)" + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] diff --git a/server/lib/22-claude-config.sh b/server/lib/22-claude-config.sh index 255115a..7e2862e 100755 --- a/server/lib/22-claude-config.sh +++ b/server/lib/22-claude-config.sh @@ -255,6 +255,43 @@ for cfg in "$DST"/project-config/*/; do link="$proj/.claude" want="../../.claude/project-config/$pname" + # Both shapes are supported, and which one a project uses is read from the + # checkout rather than decided once: a project that versions its own .claude + # keeps it, because that configuration already reaches both machines by the + # same pull that brings the code and there is nothing here to centralise; + # anything else is linked to the delivered payload below. + # + # The pathspec is `.claude/`, with the slash, so it matches entries UNDER the + # directory. A bare `.claude` also matches an adopted symlink that was itself + # committed, which would read as the project versioning a configuration it + # does not have. + # + # Observed on every run, and observed BEFORE the symlink branch below rather + # than after. Whether git holds entries under .claude is a property of the + # branch and the machine, not of the project, so a link created while they + # were untracked outlives the checkout that tracks them: the loop reported + # "already satisfied" and never looked again, while git reported every file + # under the link as deleted and an agent in that project watched its + # configuration disappear from git while Claude Code went on reading it. + tracked="" + [[ -d "$proj/.git" ]] && tracked=$(git -C "$proj" ls-files -- '.claude/' 2>/dev/null | head -3) + + if [[ -n "$tracked" && -L "$link" ]]; then + # Both shapes at once, which is the one state neither of them tolerates. + # Deferred rather than repaired: undoing it means checking paths back out + # into a project's working tree, further than anything else here reaches. + # The commands are exact so that it is one step and not a judgement call. + defer "$link" \ + "$pname versions its own .claude and the link is shadowing it, so git reports every tracked file under it as deleted: $tracked" \ + "rm '$link' && git -C '$proj' checkout -- .claude" + continue + fi + + if [[ -n "$tracked" ]]; then + info "$pname versions its own .claude; left as it is, and project-config/$pname goes unused on this box" + continue + fi + if [[ -L "$link" ]]; then if [[ "$(readlink "$link")" == "$want" ]]; then info "already satisfied: $proj/.claude -> $want" @@ -266,19 +303,6 @@ for cfg in "$DST"/project-config/*/; do continue fi - # Same refusal as laptop/adopt-project-config.sh, asserted again here because - # this side links from the delivered payload and would otherwise relink a - # project whose repository tracks its own .claude. git will not follow a - # symlink to reach a tracked path, so the link makes every file under it - # report as deleted, and an agent working in that project sees its - # configuration vanish from git while still being read. - if [[ -d "$proj/.git" ]] && [[ -n "$(git -C "$proj" ls-files -- .claude 2>/dev/null | head -1)" ]]; then - defer "$link" \ - "$pname tracks its own .claude in its repository, so it reaches both machines by git already; linking it away would report every tracked file as deleted" \ - "rm -rf '$DST/project-config/$pname' on the laptop's ~/.claude repository, and commit" - continue - fi - if [[ -d "$link" ]]; then # What blocks the replacement is divergence, not mere presence. A project # pushed from the laptop already has copies of everything the payload diff --git a/sync-design.md b/sync-design.md index a962e1c..51f4e05 100644 --- a/sync-design.md +++ b/sync-design.md @@ -95,16 +95,43 @@ which are per-machine by design and stay out of the repository either way. Linking those would centralise nothing while adding a link that can break, so they are left alone. `hezo` and `peek` are adopted. -**A project that tracks its own `.claude` is refused.** The premise here is a -project configuration that is *not* reliably committed; where a project versions -it with the code, it already reaches both machines by the same pull, and the -symlink does not centralise it but breaks it — git does not follow a symlink to -reach a tracked path, so every file under it reports as deleted on both machines -and committing that removes them from the project. `untense` was adopted before -this was understood, and an agent working on the box stopped rather than commit -a tree that had lost two skills. Both `laptop/adopt-project-config.sh` and -`server/lib/22-claude-config.sh` now refuse such a project, and the refusal is -asserted on both sides because either can create the link. +**A project that tracks its own `.claude` is a second supported shape, not a +refusal.** Two shapes coexist: + + versioned with the project git holds entries under .claude/; the + directory is left exactly as it is, nothing + is linked, and project-config/ (if any) + goes unused — configuration reaches both + machines by the same pull that brings the code + adopted nothing tracked under .claude/; the directory + moves to ~/.claude/project-config/ and + the project gets the relative symlink, as + described above + +**The shape is a property of the checkout, not of the project.** Whether git +holds entries under `.claude/` varies by branch and by machine, so both +`laptop/adopt-project-config.sh` and `server/lib/22-claude-config.sh` read it on +every run rather than settling it once at adoption time. A refusal decided once +could not describe a project whose branches differ. + +**The two shapes must never coexist.** A symlink laid over tracked files makes +git report every one of them as deleted while Claude Code goes on reading the +link — and from the link's side that state is indistinguishable from a correct +adoption. `untense` was adopted before this was understood, and an agent +working on the box stopped rather than commit a tree that had lost two skills. +The state arises when a link created while `.claude` was untracked outlives the +branch that tracks it, which is why both scripts now read the git state BEFORE +the symlink branch rather than after — testing after is what let it go +unnoticed. `server/lib/22-claude-config.sh` defers with exact restore commands +rather than checking paths back out into a project's working tree; +`laptop/adopt-project-config.sh` refuses with the same commands; `verify.sh` +asserts the absence of that shadowing for every project that versions its own +config. + +**The git pathspec carries a trailing slash** (`.claude/`), so it matches +entries UNDER the directory. A bare `.claude` also matches an adopted symlink +that was itself committed, which would read as a project versioning +configuration it does not have. ## The two pieces of work diff --git a/verify.sh b/verify.sh index 53d358b..1de1b23 100755 --- a/verify.sh +++ b/verify.sh @@ -179,6 +179,24 @@ for cfg in "$HOME"/.claude/project-config/*/; do pname=$(basename "$cfg") proj="$HOME/workspace/$pname" [[ -d "$proj" ]] || continue + + # A project that versions its own .claude is the other supported shape, not + # drift: its configuration reaches both machines by the project's own git, and + # the project-config entry simply goes unused here. The link assertion below + # does not apply to it — but the two shapes must not coexist, because a + # symlink laid over tracked files makes git report every one of them as + # deleted while Claude Code goes on reading the link. From the link's side + # that state is indistinguishable from a correct adoption, which is why it is + # asserted here rather than left to the assertion below to pass happily. + # + # Trailing slash on the pathspec: it must match entries UNDER the directory, + # where a bare `.claude` would also match an adopted symlink that was itself + # committed. + if [[ -n "$(git -C "$proj" ls-files -- '.claude/' 2>/dev/null | head -1)" ]]; then + check_true "$pname versions its own .claude, unshadowed" test ! -L "$proj/.claude" + continue + fi + check "project config linked: $pname" "$HOME/.claude/project-config/$pname" \ bash -c 'cd "$1/.claude" 2>/dev/null && pwd -P' _ "$proj" done -- 2.51.2