diff --git a/AGENTS.md b/AGENTS.md index 9b04b048..a55dfc6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,8 +77,10 @@ sequence, and relay their per-repository lines rather than summarising them. clone with `--ff-only` and REFUSES BY NAME one that is dirty or on a feature branch, skipping it and leaving it exactly as it is — and not running the verb in it either. It also refuses a root whose LEG sits on a feature - branch, because that root's own `make bootstrap` would walk the leg back - onto its tracking branch; the superproject looks clean in that state, which + branch, or anywhere but the branch that root's own `make bootstrap` places + it on — its row's own `branch:` in `project.yaml`, else the root's + `tracking_branch` — because bootstrap would walk the leg onto that branch; + the superproject looks clean in that state, which is why the refusal names the leg. Never `git reset`, `git stash` or `git checkout -f` — and never `git checkout main` in a leg — to make the next run succeed: that is the work the refusal exists to protect. diff --git a/park b/park index 132ce6db..f4c0000a 100755 --- a/park +++ b/park @@ -486,6 +486,68 @@ legs_of() { esac done <"$root/project.yaml" } + +# The branch a leg's OWN row declares for it: `leg_branch_of ` +# prints the `branch:` of the `legs:` row whose `path:` is , and +# NOTHING when that row declares none, or no row has the path. It is the +# value `scripts/bootstrap.py` puts first — `str(leg.get("branch") or branch)` +# — so a leg is placed on its own `branch:` and on the root's +# `tracking_branch:` only when its row has no `branch:` of its own; a caller +# falls back to `tracking_branch_of` on an empty answer, which is that `or`. +# +# Read the way `legs_of` reads the same rows — `role:` before `path:`, the +# assembly row and a row with no role skipped — except that `branch:` may sit +# on either side of `path:` in its row, so the answer comes the moment the +# row has shown both, and a row that does not open with `- role:` clears what +# the row before it left. A leg is found by its path SPELLED EITHER WAY: the +# caller may hold `.gitmodules`' `spec` (`status`) or the manifest's own text +# (`resume`), and `./spec` and `spec/` are one leg, so a leading `./` and a +# trailing `/` come off both sides before they are compared. +# +# WHAT IS NO BRANCH: an empty value, `null`, `Null`, `NULL` or `~`, once a +# trailing ` # comment` is cut off the value (a branch name holds no space, so +# ` #` cannot be inside one). The loader is not matched to the letter: it keeps +# a QUOTED `"null"` and `'~'` as branches and reads `false` and `0` as no +# branch, while this does the opposite for both. No real branch is named any +# of those, so nothing here chases the difference. +# +# shellcheck disable=SC2329 # `park` never asks where a leg belongs; +# `resume` and `status` do. +leg_branch_of() { + local root="$1" want="$2" line role="" path="" branch="" + [ -n "$want" ] || return 0 + [ -f "$root/project.yaml" ] || return 0 + want="${want#./}" + want="${want%/}" + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in + ' - role:'*) + role="$(trim_unquote "${line#*:}")" + path="" + branch="" + ;; + ' - '*) role=""; path=""; branch="" ;; + ' path:'*) + path="$(trim_unquote "${line#*:}")" + path="${path#./}" + path="${path%/}" + [ -n "$role" ] || path="" + [ "$role" != "assembly" ] || path="" + ;; + ' branch:'*) + branch="${line#*:}" + branch="$(trim_unquote "${branch%%[[:space:]]#*}")" + case "$branch" in null | Null | NULL | '~') branch="" ;; esac + ;; + [![:space:]]*) role=""; path=""; branch="" ;; + esac + if [ -n "$path" ] && [ "$path" = "$want" ] && [ -n "$branch" ]; then + printf '%s\n' "$branch" + return 0 + fi + done <"$root/project.yaml" +} # --- END shared estate resolver --------------------------------------------- # The legs AS MOUNTED, out of `.gitmodules` — the same file `make bootstrap` @@ -533,8 +595,11 @@ report_repos() { branch="$(tracking_branch_of "$ESTATE_DIR/$member")" printf 'root\t%s\t%s\n' "$branch" "$ESTATE_DIR/$member" while IFS= read -r leg; do - # A LEG'S branch is the ROOT's `tracking_branch:` — that is - # the one value `scripts/bootstrap.py` places every leg on. + # A LEG'S branch here is the ROOT's `tracking_branch:`, which + # is NOT what `scripts/bootstrap.py` places a leg on when its + # row declares its own `branch:` (`leg_branch_of`): such a + # leg's unpushed commits are counted against the wrong + # branch (#242). printf 'leg\t%s\t%s\n' "$branch" "$ESTATE_DIR/$member/$leg" done < <(submodule_paths "$ESTATE_DIR/$member") done < <(family_members "$ESTATE_ROOT/family.yaml") diff --git a/resume b/resume index 92fd4303..e053140d 100755 --- a/resume +++ b/resume @@ -98,8 +98,9 @@ With no the estate around the current directory is refreshed. WHAT RESUME NEVER DOES: it resets nothing and it moves nobody's HEAD. A clone that is dirty, or on a branch that is not its tracking branch, is REFUSED BY NAME and skipped - it is neither fast-forwarded, nor bootstrapped, nor given -the verb - and so is a root one of whose LEGS is off its tracking branch, -because that root's own `make bootstrap` would move the leg. The rest of the +the verb - and so is a root one of whose LEGS is off the branch that root's own +`make bootstrap` places it on (its row's `branch:`, else the root's +`tracking_branch`), because bootstrap would move the leg. The rest of the estate still comes back. USAGE } @@ -469,6 +470,68 @@ legs_of() { esac done <"$root/project.yaml" } + +# The branch a leg's OWN row declares for it: `leg_branch_of ` +# prints the `branch:` of the `legs:` row whose `path:` is , and +# NOTHING when that row declares none, or no row has the path. It is the +# value `scripts/bootstrap.py` puts first — `str(leg.get("branch") or branch)` +# — so a leg is placed on its own `branch:` and on the root's +# `tracking_branch:` only when its row has no `branch:` of its own; a caller +# falls back to `tracking_branch_of` on an empty answer, which is that `or`. +# +# Read the way `legs_of` reads the same rows — `role:` before `path:`, the +# assembly row and a row with no role skipped — except that `branch:` may sit +# on either side of `path:` in its row, so the answer comes the moment the +# row has shown both, and a row that does not open with `- role:` clears what +# the row before it left. A leg is found by its path SPELLED EITHER WAY: the +# caller may hold `.gitmodules`' `spec` (`status`) or the manifest's own text +# (`resume`), and `./spec` and `spec/` are one leg, so a leading `./` and a +# trailing `/` come off both sides before they are compared. +# +# WHAT IS NO BRANCH: an empty value, `null`, `Null`, `NULL` or `~`, once a +# trailing ` # comment` is cut off the value (a branch name holds no space, so +# ` #` cannot be inside one). The loader is not matched to the letter: it keeps +# a QUOTED `"null"` and `'~'` as branches and reads `false` and `0` as no +# branch, while this does the opposite for both. No real branch is named any +# of those, so nothing here chases the difference. +# +# shellcheck disable=SC2329 # `park` never asks where a leg belongs; +# `resume` and `status` do. +leg_branch_of() { + local root="$1" want="$2" line role="" path="" branch="" + [ -n "$want" ] || return 0 + [ -f "$root/project.yaml" ] || return 0 + want="${want#./}" + want="${want%/}" + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in + ' - role:'*) + role="$(trim_unquote "${line#*:}")" + path="" + branch="" + ;; + ' - '*) role=""; path=""; branch="" ;; + ' path:'*) + path="$(trim_unquote "${line#*:}")" + path="${path#./}" + path="${path%/}" + [ -n "$role" ] || path="" + [ "$role" != "assembly" ] || path="" + ;; + ' branch:'*) + branch="${line#*:}" + branch="$(trim_unquote "${branch%%[[:space:]]#*}")" + case "$branch" in null | Null | NULL | '~') branch="" ;; esac + ;; + [![:space:]]*) role=""; path=""; branch="" ;; + esac + if [ -n "$path" ] && [ "$path" = "$want" ] && [ -n "$branch" ]; then + printf '%s\n' "$branch" + return 0 + fi + done <"$root/project.yaml" +} # --- END shared estate resolver --------------------------------------------- # --- the user config and the workspace repository --------------------------- @@ -678,25 +741,48 @@ pull_ff_only() { # THE LEGS, BEFORE A ROOT IS BOOTSTRAPPED (F3 of the review on #83). A root's # own `make bootstrap` — `templates/assembly-root/scripts/bootstrap.py`, -# `checkout_tracking_branch` — runs `git checkout ` in a leg whose -# local tracking-branch tip equals the pin, and `checkout -b ` -# in a leg that has no local one. Either MOVES A LEG'S HEAD. A leg sitting on -# a clean feature branch at the pin makes the superproject clean, so the dirty -# and off-branch guards above see nothing and say nothing — and `resume` used -# to walk that leg off `001-my-feature` onto `main` and exit 0. +# `checkout_tracking_branch` — MOVES A LEG'S HEAD onto the branch it places the +# leg on: `git checkout ` in a leg whose local tip equals the +# pin, `checkout -b ` in a leg that has no local one and, since +# openRepoShape v1.1.0, a fast-forward of a BEHIND the pin to the pin +# and a checkout of it — from that branch, from a HEAD detached at the pin, or +# from ANOTHER branch (opensoft/openRepoShape#475, and opensoft/openRepoShape#603 +# → opensoft/openRepoShape#615) — and a move of a AHEAD of the pin, +# with no commit of its own, back to the pin from a HEAD detached at it. A leg +# sitting on a clean feature branch at the pin makes the superproject clean, +# so the dirty and off-branch guards above see nothing and say nothing — and +# `resume` used to walk that leg off `001-my-feature` onto `main` and exit 0. # -# So the legs are asked first, and a root with one off its tracking branch is -# REFUSED BY NAME and neither pulled nor bootstrapped. DETACHED AT THE PIN is -# not a refusal: that is the state a fresh clone arrives in and precisely what -# bootstrap exists to fix. +# WHICH IS THE LEG'S OWN (#235). `bootstrap.py` places a leg on +# `str(leg.get("branch") or branch)`: the `branch:` its row in the root's +# `project.yaml` declares for that leg when it declares one, and the root's +# `tracking_branch` ONLY when the row has none (`leg_branch_of`, then +# `tracking_branch_of`, above). Every leg held to the ROOT's branch lets a leg +# declaring `branch: develop` be moved onto `develop` while `resume` exits 0, +# and refuses a leg that is already on `develop`. +# +# So the legs are asked first, and a root with one off the branch bootstrap +# would put it on is REFUSED BY NAME and neither pulled nor bootstrapped, +# whichever of the two that branch comes from, and the refusal says which. +# DETACHED AT THE PIN is not a refusal: that is the state a fresh clone arrives +# in and precisely what bootstrap exists to fix. root_legs_ok() { - local label="$1" root="$2" branch leg head pin at + local label="$1" root="$2" branch leg want kind from head pin at branch="$(tracking_branch_of "$root")" while IFS= read -r leg; do [ -n "$leg" ] || continue [ -e "$root/$leg/.git" ] || continue + want="$(leg_branch_of "$root" "$leg")" + if [ -n "$want" ]; then + kind="its own branch" + from="the \`branch:\` of its row in project.yaml" + else + want="$branch" + kind="its tracking branch" + from="the root's \`tracking_branch\`" + fi head="$(git -C "$root/$leg" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')" - [ "$head" != "$branch" ] || continue + [ "$head" != "$want" ] || continue if [ "$head" = "HEAD" ]; then pin="$(git -C "$root" rev-parse "HEAD:$leg" 2>/dev/null || true)" at="$(git -C "$root/$leg" rev-parse HEAD 2>/dev/null || true)" @@ -706,10 +792,10 @@ root_legs_ok() { head="detached at ${at:-?}, and the pin is ${pin:-?}" fi REFUSED=$((REFUSED + 1)) - say " REFUSED $label: leg $leg is on '$head', not its tracking branch '$branch';" + say " REFUSED $label: leg $leg is on '$head', not $kind '$want' ($from);" say " \`make bootstrap\` would move it - nothing was touched." say " look: git -C $root/$leg status --short --branch" - say " then: finish or park that leg's work, put it back on $branch, and re-run." + say " then: finish or park that leg's work, put it back on $want, and re-run." return 1 done < <(legs_of "$root") return 0 diff --git a/status b/status index bb5c2559..16067fdc 100755 --- a/status +++ b/status @@ -607,6 +607,68 @@ legs_of() { esac done <"$root/project.yaml" } + +# The branch a leg's OWN row declares for it: `leg_branch_of ` +# prints the `branch:` of the `legs:` row whose `path:` is , and +# NOTHING when that row declares none, or no row has the path. It is the +# value `scripts/bootstrap.py` puts first — `str(leg.get("branch") or branch)` +# — so a leg is placed on its own `branch:` and on the root's +# `tracking_branch:` only when its row has no `branch:` of its own; a caller +# falls back to `tracking_branch_of` on an empty answer, which is that `or`. +# +# Read the way `legs_of` reads the same rows — `role:` before `path:`, the +# assembly row and a row with no role skipped — except that `branch:` may sit +# on either side of `path:` in its row, so the answer comes the moment the +# row has shown both, and a row that does not open with `- role:` clears what +# the row before it left. A leg is found by its path SPELLED EITHER WAY: the +# caller may hold `.gitmodules`' `spec` (`status`) or the manifest's own text +# (`resume`), and `./spec` and `spec/` are one leg, so a leading `./` and a +# trailing `/` come off both sides before they are compared. +# +# WHAT IS NO BRANCH: an empty value, `null`, `Null`, `NULL` or `~`, once a +# trailing ` # comment` is cut off the value (a branch name holds no space, so +# ` #` cannot be inside one). The loader is not matched to the letter: it keeps +# a QUOTED `"null"` and `'~'` as branches and reads `false` and `0` as no +# branch, while this does the opposite for both. No real branch is named any +# of those, so nothing here chases the difference. +# +# shellcheck disable=SC2329 # `park` never asks where a leg belongs; +# `resume` and `status` do. +leg_branch_of() { + local root="$1" want="$2" line role="" path="" branch="" + [ -n "$want" ] || return 0 + [ -f "$root/project.yaml" ] || return 0 + want="${want#./}" + want="${want%/}" + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in + ' - role:'*) + role="$(trim_unquote "${line#*:}")" + path="" + branch="" + ;; + ' - '*) role=""; path=""; branch="" ;; + ' path:'*) + path="$(trim_unquote "${line#*:}")" + path="${path#./}" + path="${path%/}" + [ -n "$role" ] || path="" + [ "$role" != "assembly" ] || path="" + ;; + ' branch:'*) + branch="${line#*:}" + branch="$(trim_unquote "${branch%%[[:space:]]#*}")" + case "$branch" in null | Null | NULL | '~') branch="" ;; esac + ;; + [![:space:]]*) role=""; path=""; branch="" ;; + esac + if [ -n "$path" ] && [ "$path" = "$want" ] && [ -n "$branch" ]; then + printf '%s\n' "$branch" + return 0 + fi + done <"$root/project.yaml" +} # --- END shared estate resolver --------------------------------------------- # --- reading one repository ------------------------------------------------- @@ -652,18 +714,20 @@ declared_legs() { --get-regexp '^submodule\..*\.path$' 2>/dev/null || true) } -# `labelbranchpath` per repository this estate reads — THE SAME -# ENUMERATION `park`'s not-carried report makes, with two rows it does not -# need: `missing`, for a member with no working clone beside the holder, and -# `unmounted`, for a leg declared and not there. Both are things out of sync -# with the estate as recorded, which is what this command is for. +# `labelbranchpath` per repository this estate reads — `park`'s +# not-carried report's enumeration, with two rows it does not need: `missing`, +# for a member with no working clone beside the holder, and `unmounted`, for a +# leg declared and not there. Both are things out of sync with the estate as +# recorded, which is what this command is for. A LEG'S BRANCH COLUMN DIFFERS +# from `park`'s: here it is the branch the leg is placed on (`leg_branch_of`), +# where `park`'s report gives every leg the root's (#242). # # `members/` INSIDE A FAMILY HOLDER IS DELIBERATELY NOT HERE, for # park's reason: that copy is pinned and DETACHED, for `bootstrap` and # `validate`; nobody works in it. The working clone BESIDE the holder is # where a person works, so it is what is read. estate_repos() { - local member branch state leg + local member branch state leg legbranch if [ "$ESTATE_KIND" = "family" ]; then printf 'holder\t%s\t%s\n' "$(tracking_branch_of "$ESTATE_ROOT")" \ "$ESTATE_ROOT" @@ -678,9 +742,11 @@ estate_repos() { printf 'root\t%s\t%s\n' "$branch" "$ESTATE_DIR/$member" while IFS=$'\t' read -r state leg; do [ -n "$leg" ] || continue - # A LEG'S branch is the ROOT's `tracking_branch:` — the one - # value `scripts/bootstrap.py` places every leg on. - printf '%s\t%s\t%s\n' "$state" "$branch" "$ESTATE_DIR/$member/$leg" + # A LEG'S branch is the `branch:` its own row declares, else + # the ROOT's `tracking_branch:` — what `scripts/bootstrap.py` + # places it on (#235). + legbranch="$(leg_branch_of "$ESTATE_DIR/$member" "$leg")" + printf '%s\t%s\t%s\n' "$state" "${legbranch:-$branch}" "$ESTATE_DIR/$member/$leg" done < <(declared_legs "$ESTATE_DIR/$member") done < <(family_members "$ESTATE_ROOT/family.yaml") return 0 @@ -689,7 +755,8 @@ estate_repos() { printf 'root\t%s\t%s\n' "$branch" "$ESTATE_ROOT" while IFS=$'\t' read -r state leg; do [ -n "$leg" ] || continue - printf '%s\t%s\t%s\n' "$state" "$branch" "$ESTATE_ROOT/$leg" + legbranch="$(leg_branch_of "$ESTATE_ROOT" "$leg")" + printf '%s\t%s\t%s\n' "$state" "${legbranch:-$branch}" "$ESTATE_ROOT/$leg" done < <(declared_legs "$ESTATE_ROOT") } @@ -869,7 +936,7 @@ read_worktrees() { done < <(g "$repo" worktree list --porcelain 2>/dev/null; printf '\n') } -# A LEG is read against its PIN, not against a branch of its own: the root's +# A LEG is read against its PIN, not against its branch's tip: the root's # HEAD records a gitlink for it, `make bootstrap` places it there, and # `scripts/bump-leg.py` is the one way the pin moves. So: a checkout that is # not the gitlink has moved without being pinned; a gitlink with commits on @@ -886,13 +953,20 @@ read_leg() { head="$(g "$leg" rev-parse -q --verify HEAD 2>/dev/null || true)" # A LEG ON A FEATURE BRANCH is the state `resume` refuses a whole root # for — that root's own `make bootstrap` would walk the leg back onto - # its tracking branch — and the superproject looks clean in it, which is - # why the leg's own row has to say so, pin or no pin. A leg DETACHED at - # its pin is a submodule's normal state and is not a finding; one on its - # tracking branch is where `make bootstrap` leaves it. + # its branch — and the superproject looks clean in it, which is why the + # leg's own row has to say so, pin or no pin. A leg DETACHED at its pin + # is a submodule's normal state and is not a finding; one on its branch + # is where `make bootstrap` leaves it. THAT BRANCH IS THE LEG'S: the + # caller passes the `branch:` the leg's own row declares, else the root's + # `tracking_branch` (#235), and a leg that declares one is named so, + # because "not main" would be the wrong branch to send a person back to. IFS=$'\t' read -r kind ref < <(head_of "$leg") if [ "$kind" = "branch" ] && [ "$ref" != "$branch" ]; then - finding "on branch $ref, not $branch (\`resume\` refuses a root whose leg sits on a feature branch)" + if [ -n "$(leg_branch_of "$root" "$rel")" ]; then + finding "on branch $ref, not $branch, the \`branch:\` this leg's own row declares in project.yaml (\`resume\` refuses a root whose leg sits on any other branch, because \`make bootstrap\` would move it)" + else + finding "on branch $ref, not $branch (\`resume\` refuses a root whose leg sits on a feature branch)" + fi fi if [ -z "$gitlink" ]; then finding "not pinned: the root's HEAD records no gitlink at $rel" diff --git a/tests/test_park_resume_commands.py b/tests/test_park_resume_commands.py index 8cf874fe..6b61944e 100644 --- a/tests/test_park_resume_commands.py +++ b/tests/test_park_resume_commands.py @@ -402,7 +402,9 @@ def standalone_manifest(project: str) -> str: LEG_MEMBER = "Charlie" -def seed_leg_member(base: Path) -> tuple[Path, Path]: +def seed_leg_member(base: Path, member: str = LEG_MEMBER, + leg_branch: str | None = None, code_leg: bool = False, + spec_path: str = "spec") -> tuple[Path, Path]: """A bare project WITH A LEG, carrying the REAL `scripts/bootstrap.py`. THE GUARD MUST GUARD THE REAL THING (F3 of the review on #83). The other @@ -411,26 +413,49 @@ def seed_leg_member(base: Path) -> tuple[Path, Path]: This one ships `templates/assembly-root/scripts/bootstrap.py` itself — the file whose `checkout_tracking_branch` runs `git checkout ` in a leg whose branch tip equals the pin — so the test is about that code. + + `leg_branch` makes the spec leg's row declare `branch: `, the + leg's OWN branch (#235), which `bootstrap.py` places the leg on instead of + the root's `tracking_branch`. The leg's remote then has that branch too, + at the pin, so a leg can sit on it and `bootstrap.py` can check it out. + `code_leg` adds a second leg, `code`, whose row declares no branch of its + own, so one root holds a leg that does and one that does not. `spec_path` + is the TEXT of the spec row's `path:`, for a manifest that spells it + `"./spec"` or `spec/` over a `.gitmodules` that says `spec`. """ - leg_seed = base / "seed" / f"{LEG_MEMBER}-spec" + leg_seed = base / "seed" / f"{member}-spec" leg_seed.mkdir(parents=True) (leg_seed / "spec.md").write_text("# the spec leg\n", encoding="utf-8") git("init", "-q", "-b", "main", ".", cwd=leg_seed) commit_all(leg_seed, "the leg") - leg_bare = base / "remotes" / f"{LEG_MEMBER}-spec.git" + if leg_branch is not None: + git("branch", leg_branch, cwd=leg_seed) + leg_bare = base / "remotes" / f"{member}-spec.git" leg_bare.parent.mkdir(exist_ok=True) git("clone", "-q", "--bare", str(leg_seed), str(leg_bare), cwd=base) - - seed = base / "seed" / LEG_MEMBER + code_bare = base / "remotes" / f"{member}-code.git" + code_row = "" + if code_leg: + code_seed = base / "seed" / f"{member}-code" + code_seed.mkdir(parents=True) + (code_seed / "code.md").write_text("# the code leg\n", encoding="utf-8") + git("init", "-q", "-b", "main", ".", cwd=code_seed) + commit_all(code_seed, "the code leg") + git("clone", "-q", "--bare", str(code_seed), str(code_bare), cwd=base) + code_row = (f" - role: code\n repository: {ORG}/{member}-code\n" + " path: code\n") + + seed = base / "seed" / member (seed / "scripts").mkdir(parents=True) + own_branch = "" if leg_branch is None else f" branch: {leg_branch}\n" (seed / "project.yaml").write_text( "schema_version: 1\nkind: project-manifest\n" - f"id: {LEG_MEMBER.lower()}\nname: \"{LEG_MEMBER}\"\n" + f"id: {member.lower()}\nname: \"{member}\"\n" "tracking_branch: main\nlegs:\n" - f" - role: assembly\n repository: {ORG}/{LEG_MEMBER}\n" + f" - role: assembly\n repository: {ORG}/{member}\n" ' path: "."\n' - f" - role: spec\n repository: {ORG}/{LEG_MEMBER}-spec\n" - " path: spec\n", encoding="utf-8") + f" - role: spec\n repository: {ORG}/{member}-spec\n" + f" path: {spec_path}\n{own_branch}{code_row}", encoding="utf-8") shutil.copy2(UPSTREAM / "templates" / "assembly-root" / "Makefile", seed / "Makefile") shutil.copy2(UPSTREAM / "templates" / "assembly-root" / ".gitignore", @@ -440,13 +465,16 @@ def seed_leg_member(base: Path) -> tuple[Path, Path]: shutil.copy2(UPSTREAM / "scripts" / "repo_shape.py", seed / "scripts" / "repo_shape.py") for verb in ("park", "resume"): - write_stub(seed, verb, label=LEG_MEMBER) + write_stub(seed, verb, label=member) git("init", "-q", "-b", "main", ".", cwd=seed) commit_all(seed, "seed") git("-c", "protocol.file.allow=always", "submodule", "add", "-q", str(leg_bare), "spec", cwd=seed) + if code_leg: + git("-c", "protocol.file.allow=always", "submodule", "add", "-q", + str(code_bare), "code", cwd=seed) commit_all(seed, "the leg, pinned") - bare = base / "remotes" / f"{LEG_MEMBER}.git" + bare = base / "remotes" / f"{member}.git" git("clone", "-q", "--bare", str(seed), str(bare), cwd=base) return bare, leg_bare @@ -1851,6 +1879,303 @@ def test_a_leg_detached_at_the_pin_is_not_a_refusal(legged, home, remotes): assert f"{LEG_MEMBER}.sh ran with:" in result.stdout +# --- F3, again (#235): the branch a leg is held to is the LEG's own -------- + +#: A member like `Charlie`, except that its spec leg's row declares +#: `branch: develop`. +OWN_BRANCH_MEMBER = "Delta" + + +def clone_own_branch_member(home, tmp_path, **seed) -> dict: + """`legged`, for a root whose spec leg declares `branch: develop`. + + Built per test under `tmp_path` rather than into the session's `remotes`, + so the leg has a `develop` to sit on only where a test asks for one. The + leg arrives DETACHED at the pin, as every fresh clone's does, and `main` + and `develop` are both the pin. `seed` goes to `seed_leg_member`. + """ + base = tmp_path / "own-branch-remotes" + base.mkdir() + seed.setdefault("leg_branch", "develop") + bare, leg_bare = seed_leg_member(base, OWN_BRANCH_MEMBER, **seed) + workspace = seed_workspace( + base, "legged", + {f"workspaces/{ORG}/{OWN_BRANCH_MEMBER.lower()}.yaml": + standalone_manifest(OWN_BRANCH_MEMBER)}) + config = home / ".agents" / "workspace.yaml" + config.parent.mkdir(parents=True) + config.write_text("repository: tester/legged\npath: ~/projects/legged\n", + encoding="utf-8") + git("clone", "-q", str(workspace), str(home / "projects" / "legged"), + cwd=home) + root = home / "projects" / OWN_BRANCH_MEMBER + git("-c", "protocol.file.allow=always", "clone", "-q", + "--recurse-submodules", str(bare), str(root), cwd=home / "projects") + return {"root": root, "leg": root / "spec", "code": root / "code", + "dir": base / "remotes", "bare": bare, "leg_bare": leg_bare} + + +@pytest.fixture +def legged_own_branch(home, tmp_path) -> dict: + return clone_own_branch_member(home, tmp_path) + + +def test_a_leg_on_its_own_declared_branch_is_not_refused(legged_own_branch, + home): + """The guard compared every leg with the ROOT's `tracking_branch`, so a + leg sitting on the `branch:` its own row declares was refused as "not its + tracking branch 'main'" — where it belongs, and where `make bootstrap` + leaves it. + """ + leg = legged_own_branch["leg"] + git("checkout", "-q", "-b", "develop", cwd=leg) + head = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + assert git("status", "--porcelain", cwd=legged_own_branch["root"] + ).stdout == "", "fixture: the superproject is clean" + + result = run(RESUME, OWN_BRANCH_MEMBER, home=home, + env=offline(legged_own_branch)) + assert result.returncode == 0, result.stdout + result.stderr + assert "REFUSED" not in result.stdout, result.stdout + assert "not its tracking branch" not in result.stdout + assert f"{OWN_BRANCH_MEMBER}.sh ran with:" in result.stdout, ( + "a root whose leg is where it belongs is given the verb") + assert git("rev-parse", "--abbrev-ref", + "HEAD", cwd=leg).stdout.strip() == "develop" + assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() == head + + +def test_a_leg_off_its_own_declared_branch_refuses_the_root_and_is_not_moved( + legged_own_branch, home): + """THE MOVE THE GUARD EXISTS TO REFUSE, for a leg the old guard let + through. The leg sits on `main`, the ROOT's `tracking_branch`, while its + row declares `branch: develop` and a local `develop` is AT the pin — so + the root's own `make bootstrap` checks `develop` out, and `resume` used to + report that as success (exit 0) with the leg's HEAD moved. + """ + root, leg = legged_own_branch["root"], legged_own_branch["leg"] + git("branch", "-q", "develop", cwd=leg) + git("checkout", "-q", "main", cwd=leg) + head = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + assert git("status", "--porcelain", cwd=root).stdout == "", ( + "fixture: the superproject is clean, which is the whole point") + + result = run(RESUME, OWN_BRANCH_MEMBER, home=home, + env=offline(legged_own_branch)) + assert result.returncode == 1, result.stdout + result.stderr + assert (f"REFUSED root {OWN_BRANCH_MEMBER}: leg spec is on 'main', " + "not its own branch 'develop' " + "(the `branch:` of its row in project.yaml);") in result.stdout + assert "`make bootstrap` would move it - nothing was touched." \ + in result.stdout + assert "put it back on develop, and re-run." in result.stdout + assert git("rev-parse", "--abbrev-ref", + "HEAD", cwd=leg).stdout.strip() == "main", ( + "the leg was walked onto its own branch") + assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() == head + assert f"{OWN_BRANCH_MEMBER}.sh ran with:" not in result.stdout, ( + "a refused root does not get the verb either") + + # The premise, so the refusal is not theatre: the root's REAL bootstrap, + # run by hand over what `resume` refused, is what moves the leg. + proc = subprocess.run([sys.executable, "scripts/bootstrap.py"], + cwd=str(root), capture_output=True, text=True, + check=False) + assert git("rev-parse", "--abbrev-ref", + "HEAD", cwd=leg).stdout.strip() == "develop", ( + "the premise of the guard is gone: bootstrap no longer places a leg " + "on its own branch\n" + proc.stdout + proc.stderr) + + +def test_a_leg_with_no_branch_of_its_own_is_held_to_the_roots_and_says_so( + legged, home, remotes): + """UNCHANGED for a row with no `branch:`: the root's `tracking_branch` is + where bootstrap puts that leg, so a leg off it is refused exactly as + before — and the refusal now says WHICH branch the one it names is. + """ + leg = legged["leg"] + git("checkout", "-q", "-b", "001-my-feature", cwd=leg) + head = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + + result = run(RESUME, LEG_MEMBER, home=home, env=offline(remotes)) + assert result.returncode == 1, result.stdout + result.stderr + assert (f"REFUSED root {LEG_MEMBER}: leg spec is on '001-my-feature', " + "not its tracking branch 'main' " + "(the root's `tracking_branch`);") in result.stdout + assert "put it back on main, and re-run." in result.stdout + assert git("rev-parse", "--abbrev-ref", + "HEAD", cwd=leg).stdout.strip() == "001-my-feature" + assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() == head + assert f"{LEG_MEMBER}.sh ran with:" not in result.stdout + + +#: `leg_branch_of`'s own ground, which the tests above only meet at +#: `branch: develop` on the line after `path: spec` (the review of #238, 1). +#: The rows after `legs:` of a manifest, the leg asked about, and the branch +#: `bootstrap.py`'s `leg.get("branch") or branch` would find in that row. +LEG_ASSEMBLY_ROW = (' - role: assembly\n repository: o/R\n' + ' path: "."\n') +LEG_BRANCH_CASES = [ + ("after-path", + " - role: spec\n path: spec\n branch: develop\n", "spec", "develop"), + ("before-path", + " - role: spec\n branch: develop\n path: spec\n", "spec", "develop"), + ("double-quoted", + ' - role: spec\n path: spec\n branch: "develop"\n', "spec", "develop"), + ("single-quoted", + " - role: spec\n path: spec\n branch: 'develop'\n", "spec", "develop"), + ("a-trailing-comment-is-cut", + " - role: spec\n path: spec\n branch: develop # own\n", "spec", + "develop"), + ("a-trailing-comment-after-quotes-is-cut", + ' - role: spec\n path: spec\n branch: "develop" # own\n', "spec", + "develop"), + ("an-empty-value-is-no-branch", + " - role: spec\n path: spec\n branch:\n", "spec", ""), + ("only-a-comment-is-no-branch", + " - role: spec\n path: spec\n branch: # none\n", "spec", ""), + ("null-is-no-branch", + " - role: spec\n path: spec\n branch: null\n", "spec", ""), + ("Null-is-no-branch", + " - role: spec\n path: spec\n branch: Null\n", "spec", ""), + ("tilde-is-no-branch", + " - role: spec\n path: spec\n branch: ~\n", "spec", ""), + ("a-row-with-none-of-its-own", + " - role: spec\n path: spec\n - role: code\n path: code\n" + " branch: elsewhere\n", "spec", ""), + ("another-rows-branch-is-not-this-rows", + " - role: spec\n path: spec\n - role: code\n path: code\n" + " branch: elsewhere\n", "code", "elsewhere"), + ("a-branch-before-the-next-row-is-not-the-next-rows", + " - role: spec\n branch: develop\n path: spec\n" + " - role: code\n path: code\n", "code", ""), + ("the-assembly-row-has-no-leg-branch", + LEG_ASSEMBLY_ROW + " branch: leak\n" + " - role: spec\n path: spec\n", "spec", ""), + ("paths-sharing-a-prefix-ask-the-exact-row", + " - role: spec\n path: spec\n branch: a\n" + " - role: other\n path: spec-extra\n branch: b\n", "spec", "a"), + ("paths-sharing-a-prefix-ask-the-exact-row-the-other-way", + " - role: other\n path: spec-extra\n branch: b\n" + " - role: spec\n path: spec\n branch: a\n", "spec", "a"), + ("a-longer-path-declaring-one-is-not-the-shorter", + " - role: spec\n path: spec\n" + " - role: other\n path: spec-extra\n branch: b\n", "spec", ""), + ("a-dot-slash-path-is-the-same-leg", + ' - role: spec\n path: "./spec"\n branch: develop\n', "spec", + "develop"), + ("a-trailing-slash-path-is-the-same-leg", + " - role: spec\n path: spec/\n branch: develop\n", "spec", "develop"), + ("a-dot-slash-leg-asked-for-is-the-same-leg", + " - role: spec\n path: spec\n branch: develop\n", "./spec", + "develop"), + ("a-row-that-does-not-open-with-a-role-leaks-nothing", + " - role: spec\n path: spec\n - path: other\n branch: leak\n", + "spec", ""), +] + + +def shared_function(script: Path, name: str) -> str: + """One top-level function of the shared resolver, as written in `script`.""" + text = script.read_text(encoding="utf-8") + start = text.index(f"\n{name}() {{\n") + 1 + return text[start:text.index("\n}\n", start) + 3] + + +@pytest.mark.parametrize("rows,want,expected", + [case[1:] for case in LEG_BRANCH_CASES], + ids=[case[0] for case in LEG_BRANCH_CASES]) +def test_leg_branch_of_reads_a_rows_own_branch_the_way_bootstrap_does( + tmp_path, rows, want, expected): + """`leg_branch_of` is what `resume` and `status` ask where a leg belongs, + and `scripts/bootstrap.py` is what it has to agree with: a leg goes on + `str(leg.get("branch") or branch)`, so the answer is the row's own + `branch:` or NOTHING. The code is the real one, `trim_unquote` and + `leg_branch_of` cut out of `resume` and run in `bash`; the byte-for-byte + tests keep the other two copies the same.""" + root = tmp_path / "root" + root.mkdir() + (root / "project.yaml").write_text( + "schema_version: 1\nkind: project-manifest\ntracking_branch: main\n" + "legs:\n" + (rows if LEG_ASSEMBLY_ROW in rows else LEG_ASSEMBLY_ROW + rows), + encoding="utf-8") + functions = tmp_path / "functions.sh" + functions.write_text(shared_function(RESUME, "trim_unquote") + + shared_function(RESUME, "leg_branch_of"), + encoding="utf-8") + proc = subprocess.run( + ["bash", "-c", 'source "$1"; leg_branch_of "$2" "$3"', "_", + str(functions), str(root), want], + capture_output=True, text=True, check=False, input="") + assert proc.returncode == 0, proc.stderr + assert proc.stdout == (f"{expected}\n" if expected else ""), ( + f"asked for {want!r}, answered {proc.stdout!r}") + + +def test_each_leg_of_one_root_is_held_to_its_own_branch(home, tmp_path): + """A root with a leg that declares `branch: develop` and one that does + not: the first is where it belongs on `develop` and the second on the + ROOT's `main`, and `resume` refuses neither. Put the second on `develop` + and it is the one refused, by name, while the first is left alone.""" + estate = clone_own_branch_member(home, tmp_path, code_leg=True) + root, spec, code = estate["root"], estate["leg"], estate["code"] + git("checkout", "-q", "-b", "develop", cwd=spec) + + result = run(RESUME, OWN_BRANCH_MEMBER, home=home, env=offline(estate)) + assert result.returncode == 0, result.stdout + result.stderr + assert "REFUSED" not in result.stdout, result.stdout + assert f"{OWN_BRANCH_MEMBER}.sh ran with:" in result.stdout + assert git("rev-parse", "--abbrev-ref", "HEAD", + cwd=spec).stdout.strip() == "develop" + assert git("rev-parse", "--abbrev-ref", "HEAD", + cwd=code).stdout.strip() == "main", "bootstrap placed it" + + git("checkout", "-q", "-b", "develop", cwd=code) + heads = {leg: git("rev-parse", "HEAD", cwd=leg).stdout.strip() + for leg in (spec, code)} + assert git("status", "--porcelain", cwd=root).stdout == "" + result = run(RESUME, OWN_BRANCH_MEMBER, home=home, env=offline(estate)) + assert result.returncode == 1, result.stdout + result.stderr + assert (f"REFUSED root {OWN_BRANCH_MEMBER}: leg code is on 'develop', " + "not its tracking branch 'main' " + "(the root's `tracking_branch`);") in result.stdout + assert "leg spec is on" not in result.stdout, ( + "the leg on its own declared branch was refused too") + for leg, head in heads.items(): + assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() == head + assert git("rev-parse", "--abbrev-ref", "HEAD", + cwd=leg).stdout.strip() == "develop" + + +@pytest.mark.parametrize("spelled", ['"./spec"', "spec/"], + ids=["dot-slash", "trailing-slash"]) +def test_a_leg_spelled_dot_slash_or_with_a_slash_is_one_leg_to_status_and_resume( + home, tmp_path, spelled): + """`status` finds a leg by `.gitmodules`' path and `resume` by the + manifest's own text, and `./spec` and `spec/` are `spec` to validate- + manifest. A leg on `main` whose row declares `branch: develop` is the one + both must name, whichever way the row spells its path (the review of + #238, 2) — `status` read it as a leg on its tracking branch.""" + estate = clone_own_branch_member(home, tmp_path, spec_path=spelled) + leg = estate["leg"] + git("checkout", "-q", "main", cwd=leg) + head = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + + status = run(STATUS, OWN_BRANCH_MEMBER, home=home) + assert status.returncode == 1, status.stdout + status.stderr + assert f" leg develop {leg}" in status.stdout, status.stdout + assert (" - on branch main, not develop, the `branch:` this leg's own " + "row declares in project.yaml") in status.stdout + + resume = run(RESUME, OWN_BRANCH_MEMBER, home=home, env=offline(estate)) + assert resume.returncode == 1, resume.stdout + resume.stderr + assert (f"REFUSED root {OWN_BRANCH_MEMBER}: leg {spelled.strip(chr(34))} " + "is on 'main', not its own branch 'develop'") in resume.stdout + assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() == head + assert git("rev-parse", "--abbrev-ref", "HEAD", + cwd=leg).stdout.strip() == "main" + + # --- F4: two checkouts of one workspace are not two orgs ------------------- def test_two_checkouts_of_one_workspace_are_not_two_orgs(remotes, home): diff --git a/tests/test_status_command.py b/tests/test_status_command.py index b5c1b5bc..c6e0f4e3 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -138,13 +138,17 @@ def home(tmp_path) -> Path: return target -def seed_root_with_leg(base: Path, name: str) -> dict: +def seed_root_with_leg(base: Path, name: str, + leg_branch: str | None = None) -> dict: """Bare remotes for ONE standalone root that mounts ONE leg. The leg has two commits so a checkout can be moved one back and be genuinely away from its pin; the root pins the leg's tip. No Makefile and no overlay: `status` runs nothing, so a root here is a `project.yaml`, a `.gitmodules` and git. + + `leg_branch` makes the leg's row declare `branch: `, its OWN + branch (#235), and gives the leg's remote that branch at the pin. """ leg_seed = base / "seed" / f"{name}-spec" leg_seed.mkdir(parents=True) @@ -153,15 +157,18 @@ def seed_root_with_leg(base: Path, name: str) -> dict: commit_all(leg_seed, "the leg") (leg_seed / "more.md").write_text("more spec\n", encoding="utf-8") commit_all(leg_seed, "the leg moved on") + if leg_branch is not None: + git("branch", leg_branch, cwd=leg_seed) leg_bare = base / "remotes" / f"{name}-spec.git" leg_bare.parent.mkdir(parents=True, exist_ok=True) git("clone", "-q", "--bare", str(leg_seed), str(leg_bare), cwd=base) seed = base / "seed" / name seed.mkdir(parents=True) + own_branch = "" if leg_branch is None else f" branch: {leg_branch}\n" (seed / "project.yaml").write_text( - ROOT_WITH_LEG_YAML.format(id=name.lower(), name=name, org=ORG), - encoding="utf-8") + ROOT_WITH_LEG_YAML.format(id=name.lower(), name=name, org=ORG) + + own_branch, encoding="utf-8") (seed / "README.md").write_text(f"# {name}\n", encoding="utf-8") git("init", "-q", "-b", "main", ".", cwd=seed) commit_all(seed, "seed") @@ -470,6 +477,48 @@ def test_a_leg_on_a_feature_branch_at_its_pin_is_a_finding(atlas, home): assert result.returncode == 0, result.stdout + result.stderr +def test_a_leg_off_its_own_declared_branch_is_a_finding_that_names_it( + home, tmp_path): + """#235: the leg's row declares `branch: develop`, which `make bootstrap` + places it on instead of the root's `tracking_branch`, so a leg sitting on + `main` — the ROOT's branch, and at its pin — is the leg `resume` refuses. + The finding names `develop`, says it is the leg's own, and the row and the + reads under it are about `develop`, not `main`.""" + base = tmp_path / "own-branch-remotes" + base.mkdir() + root = clone_root(home, seed_root_with_leg(base, "Atlas", "develop"), + "Atlas") + leg = root / "spec" + git("checkout", "-q", "main", cwd=leg) + result = run(STATUS, "Atlas", home=home) + assert result.returncode == 1, result.stdout + result.stderr + assert f" leg develop {leg}" in result.stdout, ( + "the leg's row names the branch the leg is read against") + assert (" - on branch main, not develop, the `branch:` this leg's own " + "row declares in project.yaml (`resume` refuses a root whose leg " + "sits on any other branch, because `make bootstrap` would move " + "it)") in result.stdout + assert "not main" not in result.stdout + assert "1 finding(s) in 2 repositories" in result.stdout + + +def test_a_leg_on_its_own_declared_branch_is_not_a_finding(home, tmp_path): + """The other direction of #235: a leg on the `branch:` its row declares is + where `make bootstrap` leaves it, not "on branch develop, not main".""" + base = tmp_path / "own-branch-remotes" + base.mkdir() + root = clone_root(home, seed_root_with_leg(base, "Atlas", "develop"), + "Atlas") + leg = root / "spec" + git("checkout", "-q", "-b", "develop", "origin/develop", cwd=leg) + result = run(STATUS, "Atlas", home=home) + assert result.returncode == 0, result.stdout + result.stderr + assert "on branch" not in result.stdout, result.stdout + assert (" at its pin, which is origin/develop's tip as of the last " + "fetch; clean") in result.stdout + assert "0 finding(s) in 2 repositories" in result.stdout + + def test_a_pin_behind_its_legs_origin_after_the_leg_fetched(atlas, home, status_remotes): leg = atlas / "spec"