Repository navigation
Commit 96cf32b
fix(pm): make the rerun os-regen-merge prescribes able to perform step 2 (#18941)
Fixes #18895
Clause-②: no
`scripts/pm/os-regen-merge.sh` told the operator, on every conflict
exit, to resolve and rerun
it "to redo the generated-artifact half", and there was no tree state in
which that rerun could
perform step 2. The run that starts the merge now records the three shas
it already reads before
step 1, and the rerun reads them back.
## Reproduced first — the card filed a reading, deliberately not a run
The card is explicit that the rerun was never executed and that a fix
validated against its prose
would be validated against a reading. So the first thing built was a
fixture harness that runs the
**unmodified** script (`origin/main`, md5
`2b146f4a8e7a68614d0893d081a1105d`) through the whole
operator sequence: run it, resolve the conflict, commit, rerun.
Each fixture is a real bare origin plus a clone with a driver
registered, carrying **both halves of
one merge** — they pull step 2 in opposite directions:
- `gen/mixed.txt` — both sides move it, the driver text-merges it and it
**CONFLICTS**, so the
operator sees both sides and merges them by hand;
- `gen/deferred.txt` — both sides move it, the driver **DEFERS**: exit
0, keeps ours, says nothing.
Main's side is gone, and nothing downstream reports it. Step 2 against
the pre-merge base is the
only thing that puts it back.
### OLD code, one fixture per conflict-exit branch
| branch taken | the exit's instruction | rerun, tree dirty | rerun,
resolution committed |
|---|---|---|---|
| class 1 only (non-generated) | "then rerun this script" | exit 1,
`working tree not clean` | **exit 0**, 0 sides taken, main's side still
dropped |
| class 2 only (MIXED routed) | "then continue with step 4" | exit 1,
`working tree not clean` | **exit 0**, 0 sides taken, main's side still
dropped |
| class 1 + class 2 | "then rerun this script" | exit 1, `working tree
not clean` | **exit 0**, 0 sides taken, main's side still dropped |
| class 3 (NOT_DRIVER_MANAGED) | "then rerun this script" | exit 1,
`working tree not clean` | **exit 0**, 0 sides taken, main's side still
dropped |
Verbatim, from the class-1+2 run on the old script:
```
[run 1] exit 1
[run 1] conflict headline: x merge stopped on conflicts in BOTH non-generated and generated files:
[run 1] says 'rerun this script': 1
[run 1] gen/silent.txt = silent v1-BRANCH main's side present: 0
[leg A: rerun while the tree is dirty] exit 1 :: x working tree not clean - commit or drop local changes first
[leg B: commit the resolution, rerun on a clean tree] exit 0
[leg B] merge-base HEAD origin/main = d62ed07 ; origin/main = d62ed07
[leg B] 'TAKING main s side' notices: 0
[leg B] 'KEEPING the branch s bytes' notices: 2
[leg B] main's side restored in worktree: 0
[leg B] main's side restored in HEAD: 0
[control] by-hand step 2 against the pre-merge base d3055bd:
[control] gen/silent.txt = silent v1-MAIN
```
The control line is the whole point: the repair was **always
available**, and only the base was
wrong. `merge-base HEAD origin/main` equals `origin/main` on that line —
the card's mechanism,
observed.
### The two branches the card left unmeasured, and one it did not count
The card asked whether `:699` and `:737` reach the same dead end by the
same route. **They do, and
the route is not a property of any branch**: every conflict exit returns
the operator to the same
preamble, which refuses a dirty tree and derives a wrong base from a
clean one. The branch only
decides which text prints before `exit 1`.
One correction to the card's count, measured: there are **four**
conflict exits, not three, and the
fourth (class 2 only) did not say "rerun this script" at all — it said
"continue with step 4". It
reaches the same place by **omitting** step 2 rather than by prescribing
an inert one, which is
strictly quieter. All four now carry one shared instruction, emitted
from one function.
### NEW code, the same four fixtures
```
[run 1] exit 1 (unchanged: step 1 still stops on the conflict)
[run 1] says 'rerun this script': 1 (all four branches, one shared text)
[leg A: rerun while the tree is dirty] exit 1 (unchanged: the dirty refusal still holds)
[leg B: commit the resolution, rerun] exit 0
[leg B] first line: -> RERUN: the merge committed in HEAD is the one this worktree recorded
[leg B] 'TAKING main s side' notices: 1 (the DEFERRED path, and only it)
[leg B] main's side restored in worktree: 1
[leg B] main's side restored in HEAD: 1
```
and the operator's hand resolution of the conflicted path survives:
```
gen/mixed.txt in HEAD: RESOLVED both intents
record: phase=done base=9596489... branch_tip=31872de... main_tip=f2b78b5...
second run: -> step 2 is already discharged for the merge in HEAD - nothing to merge and nothing to redo (exit 0)
```
## What the record is, and what it is not
It is **not** the `--base` flag the header refuses. Nothing is supplied
by the operator and nothing
is guessed: the run that starts the merge writes the base, its own
pre-merge tip and the
`origin/main` tip it is about to merge into
`$GIT_DIR/os-regen-merge-base`, and the rerun reads
them back. It is the same base, at the only moment it can still be read.
A base it cannot read is
one it **refuses to invent**.
Three findings changed the shape the dispatch suggested, each measured:
1. **The rerun must not re-read `origin/main` at all.** The suggested
detection asked whether
`git merge-base HEAD origin/main` equals `origin/main`'s tip. In this
repo `origin/main` is a
**shared ref** a sibling's fetch advances, so between the conflict and
the rerun it can move, and
that test then answers "not a rerun" for a merge that plainly happened.
Containment is asked
against the **recorded** main tip instead, and step 2 restores from that
pinned sha. A moved
`origin/main` prints a notice and is not merged in — the rerun repairs
the recorded merge only.
2. **A completed run marks its record `done` instead of deleting it.**
"Leave nothing behind" and
"refuse when no base is recorded" cannot both hold: a clean completion
and a rerun nobody
performed leave trees that read **identically** (clean, HEAD contains
`origin/main`, step 2
provably inert). Deleted, the refusal would fire on a finished merge.
Marked, the settled tree
reports "already discharged" and exits 0 exactly as it always did —
pinned as a case.
3. **The conflict exit records which paths it left conflicted.** Without
that, the rerun's step 2
takes main's side of the MIXED path the operator just hand-merged —
reverting the resolution the
message asked for. A conflicted path is one git could **not** resolve
silently, so no side was
dropped there; step 2 chooses a side only where the driver chose one
without saying so. Both
directions are pinned, because a fixture with only one of these paths
would pass with the other's
rule deleted.
Refusals, all non-zero, all printing the record: a **missing** record in
the rerun tree (and it
computes the by-hand step 2 from the merge commit's own two parents,
which is what the PR 18752
round did by hand), a record whose branch tip is **not an ancestor** of
HEAD, and a record naming a
commit this repository does not have.
```
x HEAD already contains origin/main and NO pre-merge base is recorded - refusing to run
step 2 against a base that is knowably wrong.
...
HEAD is a MERGE COMMIT, so both sides are still readable from it: ...
base=$(git merge-base 9d58a6f5... cab1efd9...)
git restore --source=cab1efd9... -- (every path in BOTH lists) # tree only, never --staged
```
`scripts/regen-artifacts.mjs`, `scripts/git-merge-regen.mjs` and
`scripts/check-regen-pending.mjs`
are read, never edited. The driver's `os-regen-pending` marker is theirs
and is neither written nor
read here; the header says so. Both files live in `$GIT_DIR` for the
same reason — in a linked
worktree that is `.git/worktrees/NAME`, so parallel agents never read
each other's merge.
## Verification
- `bash scripts/pm/os-regen-merge.sh --self-test`: **79 cases before,
122 after**, all pass, both
measured in-tree (the baseline copy run out of tree fails case 9c on a
relative ledger path, which
is the harness, not the script). Case 10 drives the prescribed sequence
end to end; case 10b is
the **discriminating mutation** — neutralising the single
`rr_mode="$(rr_classify)"` call site
restores the old behaviour exactly, and the same fixture then reads exit
0, zero sides taken, and
main's side still dropped in the commit. The card is reproducible on
demand, in the tree, forever.
- Derived gate families: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`
from this worktree, **23 commands, all exit 0**, each captured with
redirect-then-`$?`.
`--ran` reconciliation: `23 derived famil(ies) accounted for - 23 run, 0
NOT-MEASURED (a DERIVED
zero - all 23 recorded an exit code and none of them is 3)`. The set
includes
`check:bash32-floor`, `check:self-test-wired`,
`check:self-test-workflow-commands`,
`check:scripts-symbol-anchors`, `check:entry-guard`,
`check:parse-guard`, `check:nul-bytes`.
- No workspace package is touched, so there is no dependency-closure
build and no package test or
typecheck owed. No tracked test file names this script: its
`--self-test` is its suite.
- `pnpm lint` narrowed, and the narrowing is a **measurement** rather
than a skip, at `752a3abff`:
the population read from `eslint.config.mjs` itself is
`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` (plus narrower TS-only
blocks); `--format json`
over the one changed file returns 1 entry with 0 errors and the single
message
`File ignored because no matching configuration was supplied.` — i.e.
**0 files linted**; and the
config declares no `parserOptions.project` and no typed rules (its own
comment at line 328 says
so), so type-aware linting is off and this diff cannot move the verdict
on any untouched file.
- Control-character self-scan over the changed file: no match
(`check:nul-bytes` green as well).
- `skip-changeset` measured rather than assumed: the root package is
`private: true`, and across the
76 published `packages/*` manifests, **0** `files[]` entries reach
`scripts/pm`. Nothing publishes.
## Reader test
An operator who hits a class-2 conflict, resolves it, commits and reruns
as the message says now
gets step 2 against the pre-merge base: main's dropped side is restored
and committed, their own
resolution is left alone, and step 4's regeneration then re-derives the
generated artifact on a
known-good base — carrying both sides, as PR 18752's round showed a
correct step 2 buys.
## Acceptance notes
- The branch is 3 commits behind `origin/main` on four disjoint paths
(`content/docs/data-modeling/analytics.mdx`,
`content/docs/ui/dashboards.mdx`,
`packages/spec/scripts/check-duration-unit-keys.ts`,
`scripts/pm/check-widening-tells.mjs`),
none of them this file, none generated, so the joint-breakage re-check
is scoped to nothing and
the merge was deliberately not taken. Re-deriving the gate families
after a fresh fetch returns
the identical 23 commands. Declared, not skipped.
- Step 2 was also fixed to restore from a **pinned sha** rather than the
`origin/main` ref on the
plain path too, since a sibling's fetch can advance that shared ref
mid-run. The self-test's
source-spelling pin moved with it: it now pins the non-staging verb
against that variable and
keeps both staging spellings as absences.
- **noted, not filed:** the `lint.yml` step comment describing this
self-test says "five small
fixture repos to pin 23 cases". That was already stale before this PR
(the tree's own count was 79
across nine fixture builders) and is now 122. It is prose in a workflow
comment with no gate
reading it, and no PR or person is routed to that file by this change —
successor: none. Left as
it is rather than widening this diff into `.github/workflows/`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 39abe1d commit 96cf32b
1 file changed
Lines changed: 632 additions & 21 deletions
0 commit comments