diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index bcd69bd8..6865de7c 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -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@` 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` diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py new file mode 100755 index 00000000..f0ac19dd --- /dev/null +++ b/docs/scripts/curated-replay.py @@ -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@` 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 . + + curated-replay.py pick ... cherry-pick each, resolving by the rules below + curated-replay.py check 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()