Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions product/skills/land-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ guard before any write (label, thread-resolve, queue, merge).
Exit 0 means every check passed for that exact order. Exit 1 lists the failing
check per PR. The script never calls `gh pr list --head`.

Then check each PR is not superseded before rebasing it or resolving its
conflicts. A branch behind its base still lists differing files; that
listing does not say which way they differ. Fetch the trunk, then run the
catstack gate `scripts/check_branch_not_superseded.py` (the skill
directory links into the catstack checkout, so it sits three levels up
from the resolved skill path) and paste its output:

```sh
git fetch origin
python3 "$(realpath ~/.claude/skills/land-stack)/../../../scripts/check_branch_not_superseded.py" '#<number>' --base origin/<trunk>
```

- Exit 0, LIVE: the PR carries work the trunk lacks; rebase it if needed
and go on.
- Exit 3, SUPERSEDED: do not rebase or resolve conflicts, since that would
revert landed work. Close the PR with a comment naming the PRs that
superseded it (find them in `git log --oneline <merge-base>..origin/<trunk>`
over the files it touches).
- Exit 2 or any other code, UNCHECKED: the check did not run. Fix what it
names (fetch, unshallow, correct the ref) and rerun; never treat it as
LIVE.

3. **Land bottom-up.** Merge the bottom PR, wait for it to actually merge, then
retarget the next PR's base onto the trunk before merging it. Repeat up the
stack. A base change can report an unsettled/unknown mergeability state
Expand Down
47 changes: 47 additions & 0 deletions product/skills/land-stack/tests/test_superseded_gate_step.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations

import importlib.util
import re
import sys
import unittest
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parents[1]
SKILL = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
GATE = SKILL_DIR / ".." / ".." / ".." / "scripts" / "check_branch_not_superseded.py"


def load_gate():
spec = importlib.util.spec_from_file_location("check_branch_not_superseded", GATE)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


class TestSupersededGateStep(unittest.TestCase):
def test_gate_sits_where_the_skill_says(self):
self.assertIn("/../../../scripts/check_branch_not_superseded.py", SKILL)
self.assertTrue(GATE.resolve().is_file(), GATE)

def test_step_runs_before_any_rebase_or_landing(self):
gate_step = SKILL.index("check each PR is not superseded before rebasing")
self.assertLess(SKILL.index("2. **Verify with a guard before any write.**"), gate_step)
self.assertLess(gate_step, SKILL.index("3. **Land bottom-up.**"))

def test_exit_codes_in_prose_match_the_gate(self):
stated = {verdict: int(code) for code, verdict in re.findall(r"Exit (\d+)[^,\n]*, (LIVE|SUPERSEDED|UNCHECKED)", SKILL)}
self.assertEqual(stated, load_gate().EXIT_CODES)

def test_superseded_closes_instead_of_resolving(self):
superseded = SKILL[SKILL.index("SUPERSEDED: do not rebase"):SKILL.index("UNCHECKED: the check did not run")]
self.assertIn("Close the PR", superseded)
self.assertIn("naming the PRs that", superseded)

def test_unchecked_is_never_treated_as_live(self):
self.assertIn("never treat it as LIVE", " ".join(SKILL.split()))


if __name__ == "__main__":
unittest.main()
Loading
Loading