Repository navigation
adopt-project.py: check and execute iterate a plan's follow_ups: without asking for a list, so a hand-edited plan with follow_ups: 5 (or true) ends check in a TypeError: ... is not iterable traceback, and execute in the same one after both leg repositories are created and pushed #293
Description
(lane openRepoShape-2, 2026-10-12T00:11:30Z)
Found by this lane's adversarial review of PR #270 at aed9190 (the fix for #255), its follow-up FU2. It is the same on main d94d155, where it was reproduced again today. On main execute pushes both legs before it raises. At #270's head execute reads follow_ups: in _plan_values, before any leg exists (the review's :2962), so nothing is created there and the PR improves the execute half; the check half is unchanged (_print_what_will_happen, :1992 at that head). Not claimed.
What is wrong. plan writes follow_ups: as a block list. check and execute read it back with plan.get("follow_ups", []) and loop over it, with no test that it is a list. A hand-edited plan where follow_ups: is a truthy scalar (5, true, 1.5) ends both in TypeError: 'int' object is not iterable (or 'bool', 'float'), exit 1. check is the command that exists to say whether a plan is sound, and it ends in a traceback, with no FINDING naming the key. execute is worse on main: the read happens in the split-commit step, after _build_the_legs has created and pushed both leg repositories (two real repositories on the GitHub route), so the run dies with the legs left behind and no split branch.
# $U: a checkout of openRepoShape at d94d155 (git-filter-repo on PATH). $SRC: a three-file git repository (src/app.py, docs/spec.md, README.md), one commit. Local remotes only; no network.
python3 "$U/adopt-project.py" plan --source "$SRC" --project Northwind --org testorg --elected-by 'T H' --elected-on 2026-09-02 --out "$P"
python3 - "$P" <<'PY' # replace the follow_ups block with a scalar
import re, sys
t = open(sys.argv[1], encoding="utf-8").read()
t, n = re.subn(r"^follow_ups:\n(?: - .*\n)*", "follow_ups: 5\n", t, count=1, flags=re.M); assert n == 1
open(sys.argv[1], "w", encoding="utf-8", newline="\n").write(t)
PY
python3 "$U/adopt-project.py" check --plan "$P"
# exit 1, after the `triad`, names and `WHAT WILL HAPPEN` lines:
# adopt-project.py line 2222, in cmd_check / line 2185, in _print_what_will_happen: for item in plan.get("follow_ups", []):
# TypeError: 'int' object is not iterable
python3 "$U/adopt-project.py" execute --plan "$P" --yes --local-remote-dir "$R/remotes" --work-dir "$R/work"
# exit 1
# adopt-project.py line 3049, in cmd_execute / line 2977, in _commit_the_split: follow_ups = [str(f) for f in plan.get("follow_ups", [])]
# TypeError: 'int' object is not iterable
git --git-dir="$R/remotes/Northwind-spec.git" rev-parse --short main # dc3f11f (both legs hold a `main`)
git --git-dir="$R/remotes/Northwind-code.git" rev-parse --short main # 02e2626
git -C "$SRC" rev-parse --verify adopt/three-repo-shape # fatal: no split branch
python3 "$U/adopt-project.py" execute ... (again) # exit 2, REFUSED leg-remote-exists: <R>/remotes/Northwind-spec.git already existsthe plan's follow_ups: |
check |
execute |
|---|---|---|
5 |
exit 1, TypeError: 'int' at :2185 |
exit 1, TypeError: 'int' at :2977, both legs pushed, no split branch |
true |
exit 1, TypeError: 'bool' |
exit 1, TypeError: 'bool', both legs pushed, no split branch |
abc |
exit 0, prints three lines follow-up: a, b, c |
exit 0, adoption verified; the split commit's message ends FOLLOW-UPS, which this commit does NOT do: then - a, - b, - c |
{a: 1} |
exit 0, prints follow-up: a |
exit 0 |
[], or the key bare (controls) |
exit 0, no follow-up lines | exit 0 |
The string and mapping rows do not crash, but they are the same unchecked read: a string is iterated character by character.
Why. Two readers, neither guarded. _print_what_will_happen (adopt-project.py:2162, called from cmd_check at :2222) has for item in plan.get("follow_ups", []): at :2185. _commit_the_split (:2977, called from cmd_execute at :3049, after _build_the_legs and _create_leg_remotes) has follow_ups = [str(f) for f in plan.get("follow_ups", [])]. A value of [] or no value falls through to the default, so only a truthy non-iterable scalar crashes. cmd_execute does call _refuse_what_check_finds (:3012) before any leg exists (#168), but its list of findings (_entry_findings, _leg_findings, the coverage findings) does not look at follow_ups, so there is nothing for it to refuse.
What it should do. The plan is untrusted input (cmd_execute's own comment at :3006). check reports a plan whose follow_ups: is present and is not a list of strings as a FINDING that names the key and what it holds, exit 1, and it is on the list execute refuses through _refuse_what_check_finds, so execute exits 2 before any leg exists. A string or a mapping is refused too: iterated, it is not what the plan writer wrote. An empty list and a bare key stay valid.
How to test. In tests/test_adopt_execute_refusals.py, beside test_execute_refuses_each_class_and_creates_no_leg (:347) and with its helpers (write_edited, check_findings, run_check, no_leg_was_created): parametrise over follow_ups: 5, true, abc and {a: 1}:
checkexits 1 with a finding that namesfollow_upsand noTraceback;executeexits 2 with the same, noTraceback, and no*-spec.gitor*-code.gitunder the local remote directory;- controls:
[]and a barefollow_ups:still passcheckandexecute.
Reverting the guard fails the first two.
Related: #270 and #255 (the PR whose review found this; at its head the execute read is before any leg, so the leg-pushing half shows only on main); #196 (check admitting what execute refuses, the general class); #188 (a failure after both legs are pushed prints no notice of what exists, and the re-run meets leg-remote-exists); #262 and #263 (the same shape for other plan inputs: check passes, execute dies after the legs are pushed).
🤖 Generated with Claude Code
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T11:00:51Z, for #293
Logged in
lanes/log/openRepoShape-2.mdatef0cfa18c3e6d618bb710d19db811662b0bf9f08.The three reads (lane-collision-protocol Rule 1):
Merge origin/main (a983e6a) into fix/adopt-check-hand-edited-plan, af…
Merge #356's head dbfad70 into fix/adopt-plan-field-types, after #356'…
Merge main (3ef059c) into fix/adopt-check-hand-edited-plan, after #314 …
Merge main (580d7df) into fix/adopt-check-hand-edited-plan, after #445 …
Merge origin/main 965420b into fix/adopt-root-kept-symlinks: `_entry_…
Merge main 965420b into fix/adopt-plan-quote-yaml-keywords (#181)
Merge origin/main (cac6bb2: #293/#294 plan field findings) into fix/a…
templates: `project.yaml`, the leg pins and `family.yaml` quote their…
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…
Merge origin/main (de428f5) into fix/418-adopt-submodule-url-answers