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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- `push-branch.sh` can merge the pull request's base into the branch just before it pushes, when the branch is behind the base and both changed overlapping paths since the merge-base. A push that waited in a rate gate while the base moved is then not refused by a pre-push stale-base check. The base is fetched from the remote that is the PR's base repository; with none, the step is skipped. The overlap rule is the base ref's `scripts/check-stale-base-overlap.sh --check <remote>/<base>` when the base has one, else the paths both sides changed; nothing in the pushed head is run. The merge is `git merge --no-edit`, never a rebase. It is signed when `commit.gpgsign` is true or HEAD is signed. A conflict aborts the merge, pushes nothing and exits 4. The step is off by default. Turn it on with `sync_stale_base: true` in `docs/conventions/source-control.yaml` on the base repository's default branch, or `--sync-stale-base` for one push; `--no-sync-stale-base` turns it off for one push. A lane push never runs it. The `create` and `ready` references describe the new step and exit code.
4 changes: 4 additions & 0 deletions docs/conventions/source-control.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
# grace window. Landing them here is the lane's tracked adoption. .claude/source-control.md keeps
# the same four values until the cleanup slice deletes it; change both while it exists.
pr_open_state: ready
# sync_stale_base: push-branch.sh merges the base into a branch behind it on paths
# scripts/check-stale-base-overlap.sh calls overlapping, just before the push, so a push that
# waited in the rate gate while main moved is not refused by the pre-push stale-base check.
sync_stale_base: true
pr_body_required_sections:
- Summary
- Fix
Expand Down
11 changes: 11 additions & 0 deletions plugins/source-control/reference/config-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ reads the file itself, through a script where the key names one below.
`userConfig` option declares `ready` as its default, so a substituted `ready` is reported as the
default level (the two give the same result) and a substituted `draft` as the `userConfig`
level.
- `sync_stale_base`: `true` or `false`, whether
[`push-branch.sh`](../skills/pull-request/scripts/push-branch.sh) merges the pull request's
base into a branch behind it on overlapping paths just before it pushes (the script header
holds the base, overlap, signing and skip rules, and exit 4 for a conflict). Resolution, lowest
first: the default, `false`; `sync_stale_base` in `docs/conventions/source-control.yaml` at the
base repository's default branch, as the clone last fetched it, with no network read (without
`--pr`, the forge is asked for the base repository only when a fetched remote turns it on);
`--sync-stale-base` or `--no-sync-stale-base` on the call. No `userConfig` option. Neither the
setting nor the overlap script is read from the branch being pushed: a head must not switch on a
step that runs a script, or choose that script. A file the parser rejects, or another value,
reads as `false`. A lane push never syncs.
- `dialect`: the regex dialect `subject_pattern` and `pr_title_pattern` are written in, default
`posix-ere`. No skill here reads it. The guardrails enforcement resolver does, and any other value
turns its enforcement off with a diagnostic; see the
Expand Down
5 changes: 5 additions & 0 deletions plugins/source-control/schemas/source-control.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
"default": "ready",
"description": "State /source-control:pull-request create leaves a pull request in. ready runs /source-control:pull-request ready on the new pull request, which flips it after its gates; draft leaves it a draft for that action to flip later."
},
"sync_stale_base": {
"type": "boolean",
"default": false,
"description": "When true, push-branch.sh merges the pull request's base into a branch that is behind it on overlapping paths just before it pushes, using the repository's scripts/check-stale-base-overlap.sh as the overlap rule when the checkout has one. A merge, never a rebase; a conflict aborts it and pushes nothing (exit 4). --sync-stale-base and --no-sync-stale-base override it for one push."
},
"dialect": {
"type": "string",
"minLength": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ value and its unset fallback.
depend on `git checkout <headRefName>` to reach the branch: when it is locked by a sibling
worktree that command dead-ends (`fatal: '<branch>' is already used by worktree at ...`). Once
the head assertion holds, push through pull-request's `push-branch.sh --dir <checkout> --pr <N>`
and nothing else: it pushes the checkout's `HEAD:refs/heads/<headRefName>`. Inside a lane (a
and nothing else: it pushes the checkout's `HEAD:refs/heads/<headRefName>`. Attended, with
`sync_stale_base` on, it may first add one base-merge commit whose first parent is that asserted
`HEAD`, or exit 4 on a conflict with nothing pushed, which stops the push like any refusal
([create.md](../../pull-request/reference/create.md) §2.4.1). Inside a lane (a
`[bot]` login) it hands the push to `lane-push.sh --pr <N>` as the target's
`merge.agent-identity` (the loop-lane convention's "Lane write identity"), which reads the PR's
head repository and branch from the forge and pushes to `https://github.com/<owner>/<repo>.git`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,9 @@ def setUp(self) -> None:
self.root = pathlib.Path(tempfile.mkdtemp(prefix="check-code-"))
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.git("init", "-q")
# Repo-local, as in test_prune_babysit_worktrees.py: a global
# commit.gpgsign would sign every fixture commit with the machine's key.
self.git("config", "commit.gpgsign", "false")
self.commits: dict[str, str] = {}
for name, path in (
("init", "plugins/source-control/skills/babysit-prs/scripts/a.py"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,9 @@ def setUp(self) -> None:
self.git(self.root, "init", "-q")
self.git(self.root, "config", "user.email", "t@example.com")
self.git(self.root, "config", "user.name", "t")
# Repo-local, as in test_prune_babysit_worktrees.py: a global
# commit.gpgsign would sign every fixture commit with the machine's key.
self.git(self.root, "config", "commit.gpgsign", "false")
self.green = self.commit(
{pm.PLAN_SCRIPT: self.PLAN, "tree.txt": "green"}, "green"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ Persist chosen line(s) into `${CLOSES_LINE}`. NEVER wrap a closing keyword in an
bash "<skill-dir>/scripts/push-branch.sh" --dir "<checkout>" || exit 1
```

With `sync_stale_base` on ([config-resolution.md](../../../reference/config-resolution.md#yaml-keys-docsconventionssource-controlyaml)),
`push-branch.sh` first merges the base into a branch that is behind it on overlapping paths, so a
push that waited in a rate gate while the base moved is not refused by a pre-push stale-base
check; it reports the merge on stderr. Exit 4 means that merge conflicted and was aborted and
nothing was pushed: resolve it with `/source-control:resolve-conflicts` (merge the base by hand),
then push again.

Derive PR title from the commit subject, shaped to satisfy the resolved subject/title convention (the ladder in [SKILL.md](../SKILL.md): layered source-control config → project convention → Conventional Commits default). Build body with `${CLOSES_LINE}` at top, followed by the resolved section scaffold and a config-gated attribution line.

**Resolve the required section scaffold first.** Run `bash "<plugin-root>/lib/config-root.sh" classify` before reading any layer: for `home` or `non-repo`, team and overlay are not applicable and only the user-global layer is read ([../../../reference/config-resolution.md](../../../reference/config-resolution.md), "The three layers"). Read `pr_body_required_sections` across the applicable layers per [../../../reference/config-resolution.md](../../../reference/config-resolution.md) (per-key override: a winning layer's value is taken whole, never merged with an earlier layer's). The team layer is `docs/conventions/source-control.yaml`: `bash "<plugin-root>/lib/convention-key.sh" layer "<repo-root>" pr_body_required_sections` prints `yaml`, `legacy-md` or `none`, and `list` or `value` prints the YAML's heading list or keyword. Only when it prints `legacy-md` is the key read from the legacy markdown file's `## pr_body_required_sections` section, the compatibility read kept for one release. Either team file is a layer only when git tracks it, as in `parse-branch-issue.sh` and `/source-control:commit`: check `git ls-files --error-unmatch -- ":/docs/conventions/source-control.yaml"` before trusting `layer`, `list` or `value`, and `git ls-files --error-unmatch -- ":/.claude/source-control.md"` before the legacy read, since `convention-key.sh` does not check trackedness itself. An untracked or gitignored team file is skipped with a note saying so; when only the YAML is untracked, the key is read from the legacy file's section if that file is tracked and carries it. The value is a heading list or one of three keywords, each a resolved value that overrides a lower layer the same way a list would (never an absence; parallel to `trailer_policy`/`pr_body_attribution`):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,11 @@ on github.com (an SSH host alias or an enterprise host refuses). Inside a lane
`lane-push.sh` instead, as the `merge.agent-identity` the repository configures, and refuses when
the session does not act as it, or when `--pr` names a fork head and no `--dir <checkout>` names
the checkout to push (loop-lane convention, "Lane write identity"). For a fork head in a lane, add
`--dir` with the PR's checkout.
`--dir` with the PR's checkout. With `sync_stale_base` on, a base that moved on overlapping paths
since 2.5.2 is merged in before the push (see [create.md](create.md) §2.4.1, exit 4 on a
conflict), and its stderr names the new HEAD. That merge commit is not the head the verify gate
recorded, so the head wait below can time out and the flip refuses, leaving the PR a draft; run
`ready` again.

```bash
bash "<skill-dir>/scripts/push-branch.sh" --pr "$PR_NUMBER" || exit 1 # only when 2.5.3 committed anything
Expand Down
Loading
Loading