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:
@@ -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
148
docs/scripts/curated-replay.py
Executable 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()
|
||||
Reference in New Issue
Block a user