docs: record the day's dry run, and the three things it changed in the plan

Phase 1 of docs/curated-to-mantra.md, run on 2026-09-13 on top of Phase 0, on a
scratch worktree that landed nothing. The plan asked for exactly this -- redo
the dry run the day you land, because the numbers drift -- and it was right to:
the upstream had moved, the pipeline as written could not do phased landings,
and the library pin turned out to have to move backwards. All three are now in
the plan, in the Phase 1 section, as a record beside the plan rather than a
rewrite of it.

**The replay lands by cherry-pick in topological order, not by
`rebase --rebase-merges`, and the reason is a property of git rather than a
preference.** The plan's phases are cuts through a graph with four merges in
it. Rebasing a cut whose merges have one parent in an earlier cut recreates
each such merge against the *pre*-replay parent -- the commit in the rewritten
source branch, not the one already landed -- and drags the unrebased lineage in
beside the rebased one. A single whole-range rebase, which is what the plan's
dry run measured, never meets this because every parent is inside the range.
So: the ordinary commits are picked one at a time, the merge commits land as
nothing, and their content, which is only ever conflict resolutions, is folded
in where the conflicts actually surface. The driver that does it is
docs/scripts/curated-replay.py, added here; every commit it lands carries a
`Pulled-From: curated/curated@<sha>` trailer stamped by the rewrite, and a
paragraph naming any resolution made on the way in.

**At a branch join the join files are taken exactly as upstream's own merge
left them, and a union was tried and rejected three times before that rule
was reached.** A union -- keep both sides of the conflict -- is the obvious
resolution for two branches appending strings to the same file, and it was
wrong three ways, each caught by the exactness check against the rewritten
tip and none of them visible in a passing build. It duplicates lines both
sides already hold when a conflict hunk widens (thirteen string keys, twice).
It never applies the other side's deletions, so the two copy-suggestion
strings that fcc19f95 removes survived. And, the one that took longest to see,
a join resolved in a different *order* from upstream's merge leaves every later
commit that edits the block unable to find its base, so its deletions fail
silently while its additions land -- which is why the second fix still left
the same two strings behind. The rule that survives: files whose lines are
unique by construction (string keys, imports, route registrations, table rows)
get a line-set three-way merge over diff3 hunks -- ours, minus what theirs
deleted from base, plus what theirs added that the file does not already hold
anywhere -- and never at a join, where the file upstream's merge produced is
the answer. The driver's docstring says all of this so the next reader does
not rediscover it.

**The library pin goes back to 59c11ed for Phases 3 and 4 and forward again
in Phase 5, and the compiler was asked before deciding.** The nsec line was
written against lightning-kmp-app 59c11ed and writes through its
`NostrKeyManager`; 84cc44c, Mantra's pin, renamed that class to a read-only
`LegacyNostrKeysFile`. Reasoning said the Phase 3 cut would not compile against
84cc44c; the trial worktree was put at that cut and built against it, and
produced eight `Unresolved reference 'NostrKeyManager'` errors in
IdentityWriter.kt. So 366b0177 moves the pin to 59c11ed, whose nested
lightning-kmp -> bitcoin-kmp -> secp256k1 chain is the same commit as
84cc44c's and rebuilds nothing native, and 5efeae76 brings it to 84cc44c --
not to the 01962f3 it named upstream, which is a branch commit since rebased
onto the library's master and no longer fetchable, but whose tree 84cc44c
reproduces exactly. Every commit on the branch will compile against the pin it
records, which is what upstream's history had and a fixed pin would have
thrown away. Alternatives rejected: adapting the Phase 3 commits to the
renamed class (rewriting upstream's work on the way in, and inventing an
intermediate state nobody built) and landing Phases 3 to 5 as one unit whose
inner commits do not build (bisect would hate it, and so would review).

**The upstream moved, and the new line is the next pull rather than part of
this one.** curated/curated went from 86cb876b, which the plan was written
against, to 29027f2b: ten commits for several profiles on one device, one of
which (759199f2) adds schema version 20, the fork's first migration. They are
recorded and left out on purpose; a migration landing on Mantra's database
deserves its own decision, and the plan's own principle -- pull the whole
non-brand tree so the next pull is a replay -- says how that decision goes
once it is made.

The trial, with the committed driver: 30 commits replayed (4, 8, 6, 12), 21
clean and 9 with a resolution note, 0 stuck; the driver's own rerun from the
Phase 0 tip reproduces the tree and improves on the hand-run trial by the one
README line the old union had wrongly kept; residual diff against the
rewritten 86cb876b exactly the known set -- Phase 0's prose, 39fb64b6's files,
808a3459's three, this document and its scripts, the logo.
:composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean;
:composeApp:jvmTest 1,039 tests, 0 failures; :composeApp:testDebugUnitTest
530 tests, 0 failures; :composeApp:m3Audit all budgets met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-13 11:57:48 +02:00
parent ba0830a3d4
commit 2a02fbc14f
2 changed files with 196 additions and 0 deletions

View File

@@ -456,6 +456,54 @@ touched a seam; a non-empty exactness diff means a new brand token the normalise
not know. Delete the worktree and the `tmp/` branches when done; the script recreates
them in under two minutes.
**Run on 2026-09-13, after Phase 0, and it changed the plan in three places.** The
record, so the next pull starts from what happened rather than from what was planned:
*The upstream moved first.* `curated/curated` was `86cb876b` when the plan was written
and `29027f2b` when Phase 1 ran: ten more commits, a "multiple profiles on a device"
line, one of which (`759199f2`) adds schema version 20 — the fork's first migration.
They are outside this plan's scope and are the next pull; the schema change is why
they get a decision of their own rather than a ride on this one.
*Landing is by cherry-pick in topological order, not by `rebase --rebase-merges`.* The
phased cuts cannot be expressed as rebases: a recreated merge whose other parent was
replayed in an earlier phase would be pointed at the *pre*-replay commit, dragging the
unrebased lineage in beside the rebased one. So the ordinary commits are picked one by
one, the four merge commits land as nothing, and their content — the resolutions — is
folded in where the conflicts actually surface: at each branch join the join files
(`strings.xml`, `MantraNavHost.kt`, `CuratedSuggestionListScreen.kt`) are taken exactly
as upstream's own merge left them, and the two files the merges edited outside their
conflicts are folded into the back-button commit. Every landed commit carries a
`Pulled-From: curated/curated@<sha>` trailer, stamped by the rewrite, and a paragraph
naming any resolution made on the way in. The driver is
[`docs/scripts/curated-replay.py`](./scripts/curated-replay.py).
*The library pin goes backwards for Phases 3 and 4, on purpose.* The nsec line was
written against lightning-kmp-app `59c11ed`, whose `NostrKeyManager` it writes through;
`84cc44c` — Mantra's pin — renamed that class to a read-only `LegacyNostrKeysFile`, and
the compiler says so at the Phase 3 cut (`Unresolved reference 'NostrKeyManager'` in
`IdentityWriter.kt`, eight errors). So `366b0177` moves the pin back to `59c11ed`, whose
nested chain is identical, and `5efeae76` brings it forward again — to `84cc44c` rather
than the `01962f3` it named upstream, which is an unfetchable branch commit whose tree
`84cc44c` reproduces exactly. Every commit on the branch compiles against the pin it
records, which is the property upstream's history had and a fixed pin would have lost.
*Three ways a "keep both sides" resolution is wrong,* each caught by the exactness
check and each now handled: it duplicates lines both sides already hold when a hunk
widens; it never applies the other side's deletions (the copy-suggestion strings
`fcc19f95` removes survived one attempt); and, subtlest, a join resolved in a different
*order* from upstream's merge leaves later commits unable to find the block they edit,
so their deletions silently miss. Hence: conflicts are picked with `diff3` markers and
resolved as a line-set three-way merge for files whose lines are unique by
construction, and never at a join, where upstream's merge is the answer.
The numbers, on the trial worktree: 30 commits replayed (4, 8, 6, 12), 0 stuck, 9
carrying a resolution note; residual diff against the rewritten `86cb876b` exactly
the known set (Phase 0's prose, `39fb64b6`'s files, `808a3459`'s three, this document
and its scripts); `compileDebugKotlinAndroid` and `compileKotlinJvm` clean;
`jvmTest` 1,039 tests, `testDebugUnitTest` 530, both 0 failures; `m3Audit` all budgets
met.
### Phase 2 — The group's identity, and the lists' read side
Cut at `392b90b8'`: `4c0ed0c1`, `334e6dd1`, `930d37c8`, `392b90b8`, with `808a3459`

148
docs/scripts/curated-replay.py Executable file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Land rewritten Curated commits on a Mantra branch, one cherry-pick at a time.
The companion of curated-unbrand.py, and the second half of docs/curated-to-mantra.md's
pipeline as it was actually run. The rewrite (filter-branch with curated-unbrand.py as the
tree filter and a `Pulled-From: curated/curated@<sha>` trailer stamped by its message
filter) leaves a branch `tmp/curated-src` in Mantra's names; this replays commits off it
by their *original* sha, in the order you give, onto the current branch of <worktree>.
curated-replay.py pick <worktree> <orig-sha>... cherry-pick each, resolving by the rules below
curated-replay.py check <worktree> <orig-cut-sha> files differing from the rewritten cut
Resolutions are rules, and anything the rules do not cover stops the run with the commit
aborted, so a new conflict is a decision rather than a guess. What the rules encode:
* a branch join -- the first commit off one side of an upstream merge landing on top of
the other side -- is resolved by taking the join files exactly as upstream's own merge
left them (TAKE_FROM). Not a union: a union orders the two appended blocks its own way,
and every later commit that edits the block then misses its base and its deletions
silently fail.
* files whose lines are unique by construction (string keys, imports, route
registrations, table rows) get a line-set three-way merge over diff3 hunks (UNION):
ours, minus what theirs deleted from base, plus what theirs added that the file does
not already hold anywhere.
* the library pin follows the commit being replayed (PIN), because each upstream commit
compiled against the pin it recorded; `5efeae76` named a branch commit that no longer
exists and is mapped to the master commit with the identical tree.
* a handful of (commit, file) pairs where one side simply wins (THEIRS, DELETE), each
with the reason written into the commit.
Every resolution is appended to the landed commit's message as a paragraph, ahead of the
trailers, so the branch explains itself.
"""
import os, re, subprocess, sys
STRINGS = 'composeApp/src/commonMain/composeResources/values/strings.xml'
NAVHOST = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt'
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/'
GITLINK = 'lightning-kmp-app'
UNION = {STRINGS, NAVHOST, 'docs/README.md'}
PIN = {
'366b0177': '59c11ed926ac05c820b98afb4cc961a5a3506310', # the nsec line was written against 59c11ed's NostrKeyManager
'5efeae76': '84cc44c855e3d173e26016b807c449153a3af597', # upstream: 01962f3, a rebased-away branch commit with this tree
'00c36ec5': '84cc44c855e3d173e26016b807c449153a3af597',
}
TAKE_FROM = { # (orig, path) -> the original merge commit whose rewritten tree supplies the file
('1e52fc8f', STRINGS): 'c8de3a1f', ('1e52fc8f', NAVHOST): 'c8de3a1f',
('0634487e', UI + 'CuratedSuggestionListScreen.kt'): 'fedbe724',
('0634487e', STRINGS): 'fedbe724', ('0634487e', NAVHOST): 'fedbe724',
}
AFTER = { # orig -> files folded in after the pick, from the merge that edited them outside its conflicts
'0634487e': [(UI + 'AcceptCuratedSuggestionScreen.kt', 'fedbe724'), (UI + 'BroadcastGroupSignedEventScreen.kt', 'fedbe724')],
}
THEIRS = {
('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt'),
('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt'),
('e3cc23ae', UI + 'KeyRecoveryScreen.kt'),
}
THEIRS_WHY = {
'42f3a697': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served",
'e3cc23ae': 'the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it',
}
DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')}
def main():
mode, wt, args = sys.argv[1], sys.argv[2], sys.argv[3:]
def git(*a, check=True, **kw):
return subprocess.run(['git', *a], cwd=wt, capture_output=True, text=True, check=check, **kw)
rewritten = {}
for line in git('log', '--format=%H %(trailers:key=Pulled-From,valueonly)', 'origin/mantra..tmp/curated-src').stdout.splitlines():
parts = line.split()
if len(parts) == 2: rewritten[parts[1].split('@')[-1][:8]] = parts[0]
rw = lambda orig: rewritten[orig[:8]]
if mode == 'check':
print(git('diff', '--name-only', rw(args[0]), 'HEAD').stdout, end=''); return
def union_resolve(path):
p = os.path.join(wt, path); lines = open(p, encoding='utf-8').read().split('\n')
state = None; outside = set()
for l in lines:
if l.startswith('<<<<<<< '): state = 'in'; continue
if l.startswith('>>>>>>> '): state = None; continue
if state is None: outside.add(l)
out = []; state = None; ours = base = theirs = None
for l in lines:
if l.startswith('<<<<<<< '): state = 'ours'; ours, base, theirs = [], [], []; continue
if l.startswith('||||||| ') and state == 'ours': state = 'base'; continue
if l == '=======' and state in ('ours', 'base'): state = 'theirs'; continue
if l.startswith('>>>>>>> ') and state == 'theirs':
removed = {b for b in base if b not in theirs and b.strip()}
added = [t for t in theirs if t not in base]
kept = [o for o in ours if o not in removed]
have = outside | set(kept)
out.extend(kept); out.extend(a for a in added if a not in have or a.strip() == ''); state = None; continue
if state == 'ours': ours.append(l)
elif state == 'base': base.append(l)
elif state == 'theirs': theirs.append(l)
else: out.append(l)
open(p, 'w', encoding='utf-8').write('\n'.join(out))
def amend_note(note):
msg = git('log', '-1', '--format=%B').stdout.rstrip('\n').split('\n'); i = len(msg)
while i > 0 and re.match(r'^[A-Za-z-]+: ', msg[i - 1]): i -= 1
body, trailers = msg[:i], msg[i:]
while body and body[-1] == '': body.pop()
subprocess.run(['git', 'commit', '-q', '--amend', '-F', '-'], cwd=wt, input='\n'.join(body + ['', note, ''] + trailers) + '\n', text=True, check=True)
for orig in args:
o8 = orig[:8]; sha = rw(o8); subject = git('log', '-1', '--format=%s', sha).stdout.strip(); notes = []
r = git('-c', 'merge.conflictStyle=diff3', 'cherry-pick', '--allow-empty', sha, check=False)
if r.returncode != 0:
stuck = []
for f in git('diff', '--name-only', '--diff-filter=U').stdout.split():
if f == GITLINK and o8 in PIN:
git('update-index', '--cacheinfo', f'160000,{PIN[o8]},{GITLINK}'); notes.append(f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against')
elif (o8, f) in TAKE_FROM:
src = TAKE_FROM[(o8, f)]; open(os.path.join(wt, f), 'w').write(git('show', f'{rw(src)}:{f}').stdout); git('add', f)
notes.append(f'{os.path.basename(f)}: taken as the original merge {src} left it, this being the branch join')
elif (o8, f) in THEIRS:
git('checkout', '--theirs', '--', f); git('add', f); notes.append(f'{os.path.basename(f)}: {THEIRS_WHY[o8]}')
elif (o8, f) in DELETE:
git('rm', '-q', '-f', f); notes.append(f'{os.path.basename(f)}: deleted, as this commit deletes it upstream')
elif f in UNION:
union_resolve(f); git('add', f); notes.append(f'{os.path.basename(f)}: line-set three-way merge, both sides\' additions kept and this commit\'s deletions applied')
else:
stuck.append(f)
if stuck:
git('cherry-pick', '--abort', check=False); sys.exit(f'STUCK at {o8} {subject}: no rule for {stuck}')
r2 = subprocess.run(['git', '-c', 'core.editor=true', 'cherry-pick', '--continue'], cwd=wt, capture_output=True, text=True)
if r2.returncode != 0: sys.exit(f'STUCK continuing {o8}: {r2.stderr.strip()[:300]}')
for path, src in AFTER.get(o8, []):
open(os.path.join(wt, path), 'w').write(git('show', f'{rw(src)}:{path}').stdout); git('add', path)
notes.append(f'{os.path.basename(path)}: brought to the state the original merge {src} left it in, an edit that merge made outside its conflicts')
if o8 == '5efeae76':
git('update-index', '--cacheinfo', f"160000,{PIN[o8]},{GITLINK}")
notes.append('gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library\'s master as 84cc44c with an identical tree')
if notes:
git('commit', '-q', '--amend', '--no-edit', check=False)
amend_note('Replayed onto Mantra by docs/curated-to-mantra.md: ' + '; '.join(notes) + '.')
print(f"{o8} {'resolved' if notes else 'clean':8s} {subject[:80]}")
if __name__ == '__main__':
if len(sys.argv) < 4 or sys.argv[1] not in ('pick', 'check'): sys.exit(__doc__)
main()