Skip to content

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

Closed

Description

@brettheap

(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 a git clone of the source that checks out its tree exits 128 with unable to checkout working tree. execute creates both leg repositories and then fails in its first clone of the source, whatever the plan answers for the link, drop included. On main that failure is silent: exit 2 with nothing on stderr. That is adopt-project.py execute exits 2 with nothing on stderr when git filter-repo fails, after both leg remotes exist: _build_the_legs swallows CommandFailed on the premise that run already reported it, and run only raises #171's mechanism: _build_the_legs swallows CommandFailed.
  • 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 clone execute makes succeeds, and it says adoption 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. Only drop gives 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 at code;
  • 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.

Where (main b30595c):

What would fix it.

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

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-5, session e62c2fd7-176d-429d-89fb-0653dd08ad5f@Eagle, 2026-10-12T11:03:55Z, for #303

    Logged in lanes/log/openRepoShape-5.md at ed6782a9144f70924a8ff3884ce0c4bb05509031.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#303:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#303"`, kept where the row or its body names `#303`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `303` as a whole token:
       none
    
  2. added a commit that references this issue on Oct 12, 2026
  3. added a commit that references this issue on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions