Skip to content

resume's leg guard checks each leg against the root's tracking_branch, not the leg's own branch:, so it lets bootstrap move a leg (exit 0) and refuses a leg on its own branch #235

Closed

Description

@brettheap

What happens

resume asks every leg first. root_legs_ok (resume:692-712) is F3 of the review on #83: it refuses a root whose leg sits off its tracking branch, because the root's own make bootstrap would move that leg. But it takes the tracking branch from the ROOT: branch="$(tracking_branch_of "$root")" (resume:694), compared at resume:699. The root's bootstrap.py places each leg on THAT LEG'S OWN branch: when the row declares one, and on tracking_branch only otherwise. So for a leg that declares its own branch:, the guard reasons about the wrong branch, in both directions:

  • It lets a move through. resume exits 0 after bootstrap has moved the leg's HEAD. That is the move F3 exists to refuse.
  • It refuses a leg that is where it belongs. A leg on its own declared branch is refused as "not its tracking branch".

status has the same blind spot. It passes the root's $branch to read_leg (status:5547), whose finding at status:894 says "on branch X, not Y (resume refuses a root whose leg sits on a feature branch)".

Line numbers here are at main 2732d59. park, resume, status and tests/test_park_resume_commands.py are byte-identical at #233's head b52394d.

openRepoShape's side

Reproduction at both pins

Each run builds the same estate with the suite's own helpers (seed_workspace, standalone_manifest, run, offline from tests/test_park_resume_commands.py) and that pin's own templates/assembly-root/{Makefile,.gitignore,scripts/bootstrap.py} and scripts/repo_shape.py:

  • the root Delta has tracking_branch: main;
  • the spec leg's row declares branch: develop;
  • the leg's remote has c1 on develop and main, then c2 on main only, and c2 is the pin;
  • the superproject is clean;
  • every LANES_* and TMUX* variable is unset.
python3 probe_leg_branch.py <openRepoTools checkout> <openRepoShape clone> <empty work dir>
the leg before resume Delta at 7f84ca4 at 190133d (v1.1.0)
on main (the ROOT's branch) at the pin; local develop one commit BEHIND the pin exit 0, not refused; the leg stays on main. Bootstrap: [spec] spec: PIN 91a86c1689ae != branch develop tip a3b073e15bc1 exit 0, not refused; the leg ends on develop. Bootstrap: [spec] spec: branch develop fast-forwarded 9856f90a0779 -> 87c6196d63f5, the pin; origin/develop is 9856f90a0779, which the pin deliberately does NOT follow
on main at the pin; local develop AT the pin (variant: git branch develop HEAD in the leg) exit 0, not refused; the leg ends on develop. Bootstrap: [spec] spec: on develop at 0b9013cf0444 (branch tip == pin) exit 0, not refused; the leg ends on develop. Bootstrap: [spec] spec: on develop at d406a159c993 (branch tip == pin)
on develop, its OWN declared branch, at the pin (variant: git checkout -b develop in the leg) exit 1: REFUSED root Delta: leg spec is on 'develop', not its tracking branch 'main'; exit 1, the same refusal

Read across, the table says three things:

In every row nothing is lost: the leg's commits stay on their branches. What breaks is the guard's promise, that resume refuses rather than moves a leg's HEAD, and in row 3 a correct checkout is refused.

probe_leg_branch.py (from the review). Row 2's variant replaces line 84 with m.git("branch", "-q", "develop", "HEAD", cwd=leg); row 3's replaces lines 83-84 with m.git("checkout", "-q", "-b", "develop", cwd=leg) and also prints the REFUSED line.
#!/usr/bin/env python3
"""`resume` on a root whose spec leg declares its OWN `branch: develop`
(validate-manifest.py's `_leg_branch_findings`, #455: it "wins over
`tracking_branch` in `bootstrap.py`"), with the leg checked out on `main`,
the ROOT's tracking branch, and local `develop` one commit BEHIND the pin.

`root_legs_ok` compares the leg's HEAD with the ROOT's tracking branch, so
it lets the leg through; then the root's real `make bootstrap` runs. Built
once per pin, with that pin's own bootstrap.py and repo_shape.py.

Usage: probe_leg_branch.py <openRepoTools checkout> <openRepoShape clone> <work dir>
"""
import subprocess
import sys
from pathlib import Path

ORT, ORS, WORK = (Path(p).resolve() for p in sys.argv[1:4])
sys.path.insert(0, str(ORT / "tests"))
import test_park_resume_commands as m  # noqa: E402

PINS = {"7f84ca4": "7f84ca42ca86a8902928345109d2bf6bad87bd91",
        "190133d": "190133d5e704e380c430f87ca9c4e31746de0012"}
NAME = "Delta"


def show(rev, rel):
    return subprocess.run(["git", "show", f"{rev}:{rel}"], cwd=str(ORS),
                          capture_output=True, check=True).stdout


for label, rev in PINS.items():
    base = WORK / label
    base.mkdir(parents=True)
    # the leg: c1 on develop and main, then c2 on main only (the pin)
    leg_seed = base / "seed" / f"{NAME}-spec"
    leg_seed.mkdir(parents=True)
    (leg_seed / "spec.md").write_text("one\n", encoding="utf-8")
    m.git("init", "-q", "-b", "main", ".", cwd=leg_seed)
    m.commit_all(leg_seed, "c1")
    m.git("branch", "develop", cwd=leg_seed)
    (leg_seed / "spec.md").write_text("two\n", encoding="utf-8")
    m.commit_all(leg_seed, "c2, the pin")
    leg_bare = base / "remotes" / f"{NAME}-spec.git"
    leg_bare.parent.mkdir(parents=True)
    m.git("clone", "-q", "--bare", str(leg_seed), str(leg_bare), cwd=base)

    seed = base / "seed" / NAME
    (seed / "scripts").mkdir(parents=True)
    (seed / "project.yaml").write_text(
        "schema_version: 1\nkind: project-manifest\n"
        f"id: {NAME.lower()}\nname: \"{NAME}\"\ntracking_branch: main\nlegs:\n"
        f"  - role: assembly\n    repository: {m.ORG}/{NAME}\n    path: \".\"\n"
        f"  - role: spec\n    repository: {m.ORG}/{NAME}-spec\n    path: spec\n"
        "    branch: develop\n", encoding="utf-8")
    for rel, dest in (("templates/assembly-root/Makefile", "Makefile"),
                      ("templates/assembly-root/.gitignore", ".gitignore"),
                      ("templates/assembly-root/scripts/bootstrap.py", "scripts/bootstrap.py"),
                      ("scripts/repo_shape.py", "scripts/repo_shape.py")):
        (seed / dest).write_bytes(show(rev, rel))
    for verb in ("park", "resume"):
        m.write_stub(seed, verb, label=NAME)
    m.git("init", "-q", "-b", "main", ".", cwd=seed)
    m.commit_all(seed, "seed")
    m.git("-c", "protocol.file.allow=always", "submodule", "add", "-q",
          str(leg_bare), "spec", cwd=seed)
    m.commit_all(seed, "the leg, pinned at c2")
    bare = base / "remotes" / f"{NAME}.git"
    m.git("clone", "-q", "--bare", str(seed), str(bare), cwd=base)
    ws = m.seed_workspace(base, "legged",
                          {f"workspaces/{m.ORG}/{NAME.lower()}.yaml":
                           m.standalone_manifest(NAME)})

    home = base / "home"
    (home / "projects").mkdir(parents=True)
    (home / ".agents").mkdir()
    (home / ".agents" / "workspace.yaml").write_text(
        "repository: tester/legged\npath: ~/projects/legged\n", encoding="utf-8")
    m.git("clone", "-q", str(ws), str(home / "projects" / "legged"), cwd=home)
    root = home / "projects" / NAME
    m.git("-c", "protocol.file.allow=always", "clone", "-q",
          "--recurse-submodules", str(bare), str(root), cwd=home / "projects")
    leg = root / "spec"
    m.git("checkout", "-q", "main", cwd=leg)                  # on the ROOT's branch, at the pin
    m.git("branch", "-q", "develop", "origin/develop", cwd=leg)  # the leg's own branch, behind
    before = m.git("rev-parse", "--abbrev-ref", "HEAD", cwd=leg).stdout.strip()
    clean = m.git("status", "--porcelain", cwd=root).stdout == ""

    r = m.run(m.RESUME, NAME, home=home, env=m.offline({"dir": base / "remotes"}))
    after = m.git("rev-parse", "--abbrev-ref", "HEAD", cwd=leg).stdout.strip()
    print(f"[{label}] superproject clean before: {clean}; leg HEAD before: {before}")
    print(f"[{label}] resume exit {r.returncode}; refused the root: "
          f"{'REFUSED root' in r.stdout}; leg HEAD after: {after}")
    for line in r.stdout.splitlines():
        if line.strip().startswith("[spec]") or "left" in line and "branch" in line:
            print(f"[{label}]   bootstrap said: {line.strip()}")

Suggested change

  1. Read each leg's own branch, in the shared resolver. legs_of (resume:451-471, inside the shared estate resolver that park, resume and status carry byte for byte, resume:107-472) prints only path. A row's branch: can follow its path:, so the reader has to finish the row before it prints. Either:

    • print path<TAB>branch per row, with the branch empty when none is declared; or
    • add a leg_branch_of <root> <path> reader beside it.

    Either way, change all three copies together: test_the_three_commands_carry_the_same_resolver_byte_for_byte (tests/test_park_resume_commands.py:1370) holds them equal.

  2. Compare with the leg's branch. In root_legs_ok, compare each leg's HEAD with ${leg_branch:-$root_tracking_branch}, exactly as bootstrap.py:1064 chooses, and name that branch in the refusal. Do the same for status's read_leg (status:5547, :894).

  3. Refresh the comment. The comment above the guard (resume:679-691) still names only the two moves bootstrap made at 7f84ca4. At v1.1.0, step (a) also:

The test that would catch it

In tests/test_park_resume_commands.py, add a seed like seed_leg_member (:405) whose spec row declares branch: develop, built from the pinned bootstrap.py and repo_shape.py as that seed already is. Then add three cases, one per row of the table:

  • (1) on main, develop behind the pin: resume must REFUSE the root by name, and the leg must stay on main. Today, at the v1.1.0 pin, this case is red: exit 0, the leg on develop.
  • (2) on main, develop at the pin: the same assertions. Red at both pins today.
  • (3) on develop at the pin: resume must NOT refuse the root, and the verb must run. Red at both pins today: exit 1.

Also add a status case asserting the leg's finding names develop.

Found by the independent review of #233 (5560570028).

🤖 Generated with Claude Code

Activity

  1. brettheap commented on Oct 18, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoTools-1, session df82f553-7803-49c4-8ddf-3f9acdcb8ee0@Eagle, 2026-10-18T01:53:36Z, for #235

    Logged in lanes/log/openRepoTools-1.md at cf1ec741d49ca44f3d64bf236dd68dca6bebbfbc.

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

    1. existing `CLAIMED —` comments on opensoft/openRepoTools#235:
       none
    2. `gh pr list --repo opensoft/openRepoTools --state all --search "#235"`, kept where the row or its body names `#235`:
    233	Pin openRepoShape at v1.1.0, 190133d on its main: six of the eight mounted files moved (bootstrap places legs only where no commit is lost; the family Makefile refuses a holder without siblings.py); no test expectation moved	chore/openreposhape-pin-v1.0.1	OPEN
    3. `git ls-remote --heads git@github.com:opensoft/openRepoTools` branches naming `235` as a whole token:
       none
    
  2. added a commit that references this issue on Oct 18, 2026
  3. brettheap commented on Oct 18, 2026

    @brettheap
    ContributorAuthor

    RELEASED — lane openRepoTools-1, session df82f553-7803-49c4-8ddf-3f9acdcb8ee0@Eagle, 2026-10-18T09:32:36Z, for #235 — fixed by PR #238 (landed 14239b9); the issue is closed

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