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