Repository navigation
adopt-project.py check passes a source whose tree holds a symlink with an empty target, so with symlinks on execute creates both leg repositories and then fails cloning the source (on main with exit 2 and nothing on stderr, #171), and with core.symlinks=false it says adoption verified over a leg or root that no clone with symlinks on can check out #303
Description
(lane openRepoShape-1, 2026-10-12T00:13:54Z)
Found by this lane's adversarial review of the #183 branch (fix/adopt-root-kept-symlinks at 04792d2), its N10. Pre-existing on main b30595c, wherever the link sits in the tree.
What is wrong. A source's tree can hold a symlink whose target is empty: a 120000 entry whose blob is the empty blob. plan and check never read a link's target, so check says plan ok. What execute does next depends on how git checks a symlink out where it runs:
- With symlinks on, git's default where it can make one, the first clone fails. Linux refuses a symlink to nothing (
symlink("")fails with ENOENT), so agit cloneof the source that checks out its tree exits 128 withunable to checkout working tree.executecreates both leg repositories and then fails in its first clone of the source, whatever the plan answers for the link,dropincluded. On main that failure is silent: exit 2 with nothing on stderr. That is adopt-project.pyexecuteexits 2 with nothing on stderr whengit filter-repofails, after both leg remotes exist:_build_the_legsswallowsCommandFailedon the premise thatrunalready reported it, andrunonly raises #171's mechanism:_build_the_legsswallowsCommandFailed. - With
core.symlinks=false, the adoption verifies over a repository that cannot be cloned. Git checks the link out as an empty plain file, so every cloneexecutemakes succeeds, and it saysadoption verified. The entry is pushed as it is, into whichever leg, or the root, the plan keeps it in. Every clone of that repository made with symlinks on then fails, and so does the assembly's recursive clone. Onlydropgives an assembly that clones.
With symlinks on:
SHAPE=<openRepoShape checkout>
export GIT_AUTHOR_NAME=T GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=T GIT_COMMITTER_EMAIL=t@example.invalid
LINK=docs/empty-link
TMP=$(mktemp -d)
git init -q -b main "$TMP/Thing" && cd "$TMP/Thing" || exit 1
mkdir src docs && printf 'print(1)\n' > src/app.py && printf '# Spec\n' > docs/spec.md
printf '# Thing\n' > README.md && git add -A
empty=$(git hash-object -w --stdin < /dev/null)
git update-index --add --cacheinfo "120000,$empty,$LINK" # a symlink whose target is ""
git commit -qm init && git ls-tree -r HEAD -- "$LINK"
python3 "$SHAPE/adopt-project.py" plan --source "$TMP/Thing" --project Northwind --org testorg \
--elected-by 'T H' --elected-on 2026-09-02 --out "$TMP/plan.yaml" >/dev/null
echo "plan exit $?"
python3 "$SHAPE/adopt-project.py" check --plan "$TMP/plan.yaml" | tail -1
python3 "$SHAPE/adopt-project.py" execute --plan "$TMP/plan.yaml" --yes \
--local-remote-dir "$TMP/remotes" --work-dir "$TMP/work" > "$TMP/out" 2> "$TMP/err"
echo "execute exit $?; stdout:"
cat "$TMP/out"
echo "stderr: $(wc -c < "$TMP/err") bytes"
cat "$TMP/err"
echo "leg repositories left: $(ls "$TMP/remotes" | tr '\n' ' ')"
git clone -q "$TMP/Thing" "$TMP/any-clone" 2> "$TMP/clone-err"; echo "a plain git clone of the source: exit $?"; head -2 "$TMP/clone-err"Observed on main b30595c (Linux, git 2.43.0; <T> is the temporary directory), and byte-identical on the #183 branch at 8f624aa:
120000 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 docs/empty-link
plan exit 0
plan ok
execute exit 2; stdout:
topics skipped for local remotes
creating the leg repositories
bare <T>/remotes/Northwind-spec.git
bare <T>/remotes/Northwind-code.git
stderr: 0 bytes
leg repositories left: Northwind-code.git Northwind-spec.git
a plain git clone of the source: exit 128
error: unable to create symlink docs/empty-link: No such file or directory
fatal: unable to checkout working tree
The #171 fix branch (fix/adopt-late-failure-notice, at e507c42) prints the same up to stderr, then reports the failure in full, but still only after both legs exist:
stderr: 1707 bytes
REFUSED extracting the spec leg. THE EXACT COMMAND WAS:
git -c protocol.file.allow=always clone -q <T>/Thing <T>/work/Northwind-spec
exit 128
--- output ---
error: unable to create symlink docs/empty-link: No such file or directory
fatal: unable to checkout working tree
...
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and neither leg is pushed. Once what failed above is fixed, re-run the adoption ...
With core.symlinks=false for the whole adoption, and then a clone with symlinks on, as anyone who clones the result makes it:
SHAPE=<openRepoShape checkout>
export GIT_AUTHOR_NAME=T GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=T GIT_COMMITTER_EMAIL=t@example.invalid
LINK=docs/empty-link
TMP=$(mktemp -d)
git init -q -b main "$TMP/Thing" && cd "$TMP/Thing" || exit 1
mkdir src docs && printf 'print(1)\n' > src/app.py && printf '# Spec\n' > docs/spec.md
printf '# Thing\n' > README.md && git add -A
empty=$(git hash-object -w --stdin < /dev/null)
git update-index --add --cacheinfo "120000,$empty,$LINK" # a symlink whose target is ""
git commit -qm init
( # the adoption runs where git checks a symlink out as a plain file
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.symlinks GIT_CONFIG_VALUE_0=false
python3 "$SHAPE/adopt-project.py" plan --source "$TMP/Thing" --project Northwind --org testorg \
--elected-by 'T H' --elected-on 2026-09-02 --out "$TMP/plan.yaml" >/dev/null
python3 "$SHAPE/adopt-project.py" check --plan "$TMP/plan.yaml" | tail -1
python3 "$SHAPE/adopt-project.py" execute --plan "$TMP/plan.yaml" --yes \
--local-remote-dir "$TMP/remotes" --work-dir "$TMP/work" > "$TMP/out" 2> "$TMP/err"
echo "execute exit $?: $(grep '^adoption verified' "$TMP/out")"
)
echo "the spec leg holds: $(git -C "$TMP/remotes/Northwind-spec.git" ls-tree -r main -- "$LINK")"
# whoever clones what was pushed, with symlinks on (git's default where it can make one)
git clone -q "$TMP/remotes/Northwind-spec.git" "$TMP/spec-clone" 2> "$TMP/spec-err"
echo "a clone of the spec leg: exit $?"; head -2 "$TMP/spec-err"
git -c protocol.file.allow=always clone -q --recurse-submodules -b adopt/three-repo-shape \
"$TMP/Thing" "$TMP/assembly" > "$TMP/rec" 2>&1
echo "the assembly's recursive clone: exit $?"
grep -E '^(error|fatal)' "$TMP/rec" | sed -E "s#$TMP#<T>#g; s#'[0-9a-f]{40}'#'<spec commit>'#"Observed on main b30595c, and byte-identical on the #183 branch at 8f624aa and the #171 fix branch at e507c42:
plan ok
execute exit 0: adoption verified: every source path is in exactly one place
the spec leg holds: 120000 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 docs/empty-link
a clone of the spec leg: exit 128
error: unable to create symlink docs/empty-link: No such file or directory
fatal: unable to checkout working tree
the assembly's recursive clone: exit 1
error: unable to create symlink docs/empty-link: No such file or directory
fatal: Unable to checkout '<spec commit>' in submodule path 'spec'
The same source with the link at the top level, empty-link (which plan asks about), was answered three ways on main. With core.symlinks=false, all three verify:
- answered
root, the split's own tip holds the link, and the assembly's recursive clone exits 128 at the root; - answered
code, the clone exits 1 atcode; - answered
drop, it exits 0.
With symlinks on, the root and drop answers both end like the first run: exit 2, nothing on stderr, and two empty leg repositories.
Such a link can come from a checkout with core.symlinks=false, where git writes each link as a plain file holding its target. Emptying that file and committing records a 120000 entry with the empty blob (checked with git 2.43.0).
Why it matters.
- With symlinks on, no adoption of such a source can succeed, whatever the plan answers: the first clone fails. Every attempt creates both leg repositories first. Against GitHub that is two real repositories per attempt, and the next attempt meets them.
- On main nothing at all is printed about why. Once adopt-project.py
executeexits 2 with nothing on stderr whengit filter-repofails, after both leg remotes exist:_build_the_legsswallowsCommandFailedon the premise thatrunalready reported it, andrunonly raises #171's fix lands, the run says why, but the legs are still made first. - With
core.symlinks=false, the run that verifies is the worse outcome. Itsadoption verifiedand itsNEXT:line hand over a repository that no clone with symlinks on can check out, and the first person to clone it finds out. - Either way the cause is in the source, readable from its tree before anything exists, and
plancould name it.
Where (main b30595c):
adopt-project.py:2490-2523,_extract_leg: itsgit cloneof the source (:2500) is the one that fails with symlinks on.adopt-project.py:2945-2970,_build_the_legs:except CommandFailed: return Noneat:2968-2969(adopt-project.pyexecuteexits 2 with nothing on stderr whengit filter-repofails, after both leg remotes exist:_build_the_legsswallowsCommandFailedon the premise thatrunalready reported it, andrunonly raises #171).cmd_executethen returns 2 at:3168-3169, after_create_leg_remotes(:3154).adopt-project.py:3300-3360,_verify: it accounts for blob shas, so with symlinks off the link's empty blob is accounted for like any file, andadoption verifiedis printed at:3356.planandchecksee a link only by its mode (SYMLINK_MODE,adopt-project.py:178;_kind_ofat:1391-1397names it for adopt-project.py: a leg's mount path must not be, hold or lie inside a path the assembly root holds, so--spec-path contractsno longer checks ok and dies staging the split after both legs are pushed, and--code-path project.yamlno longer saysadoption verifiedover an assembly with no manifest (#172) #198's mount-path rule) and never read its target. Main follows link targets only after the legs exist: in_where_it_landed(:3066) while staging the split, and in_SplitTree(:3522, for_shape_file_findingsat:3672) in_verify.- The materialize_assembly_root writes the shape files through a root-kept symlink to anywhere on disk, and crashes with a traceback on a link loop, after both legs are pushed #183 branch (8f624aa) reads the target of a root-kept link at or above a shape path, and refuses an empty one there: "Give a target in the source, or remove it there, and re-run
plan". It does not read a link no shape path runs through, so both runs above are unchanged on it.
What would fix it.
planreports a tracked symlink whose target is empty, and so doescheckfor a plan written earlier. They read the target from the tree (its blob), whatevercore.symlinkssays where they run. The remediation names the fix in the source: give the link a target, or remove it, and re-runplan.dropis not a fix, because with symlinks onexecute's clones still check the link out.executerefuses the same before any leg exists, through adopt-project.pyexecuterefuses onlyplan-unresolvedandplan-stale, so a plancheckrejects as uncovered or with a bad leg creates and pushes both legs before it fails verification #168's list.- The materialize_assembly_root writes the shape files through a root-kept symlink to anywhere on disk, and crashes with a traceback on a link loop, after both legs are pushed #183 branch's finding for a link at or above a shape path could become this general one.
- More generally, an entry git cannot check out where it runs is better found before the legs than by the first clone. That wider question can stay a note in this issue.
Related. #171: the silence. Its fix is on fix/adopt-late-failure-notice, the #188 branch, which also closes #188 and #177. #183 and #178: symlinks at or above a shape path. #168: execute refuses what check finds.
Not claimed.
🤖 Generated with Claude Code
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-5, session e62c2fd7-176d-429d-89fb-0653dd08ad5f@Eagle, 2026-10-12T11:03:55Z, for #303
Logged in
lanes/log/openRepoShape-5.mdated6782a9144f70924a8ff3884ce0c4bb05509031.The three reads (lane-collision-protocol Rule 1):
Merge origin/main (3ef059c, #314) into fix/adopt-check-link-and-manif…
docs/release-notes/v1.0.0.md: brought up to main 437a360, so the note…
Merge main 5a25387 into fix/adopt-plan-quote-yaml-keywords (#181)
adopt-project.py: a symlink with an empty target is left to #303's `p…
tests/test_adopt_root_symlinks.py: an empty-target link above `script…
Merge origin/main af01068 into fix/adopt-root-kept-symlinks: `_entry_…
docs/release-notes/v1.0.0.md: brought up to main d41cc29, so section …
Merge origin/main d41cc29 into fix/adopt-root-kept-symlinks: #435's t…
adopt-project.py: `check` follows each symlink the plan keeps where t…
adopt-project.py: a source symlink whose target begins with a NUL byt…