Files
mantra-kmp/docs/scripts/curated-replay.py
T

184 lines
12 KiB
Python
Raw Normal View History

#!/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; docs/curated-to-mantra-profiles.md is the second pull it
served, and added the one rule that pull needed. 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. DELETE is also how a sweep over screens
lands on a tree that no longer has some of them: the modify/delete conflict on each
removed screen resolves as "still deleted".
* a commit of which Mantra wants only some files (PARTIAL): the diff restricted to those
paths is applied and committed under the original message, and the note names what
was left out. For a commit half in a removed line and half in a kept one.
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'),
('2f420230', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.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',
'2f420230': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision)",
}
DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} | {
# the extended-FAB sweep touches twenty screens; these seven were taken out with lines B, D and H
('ca6c16be', UI + f) for f in (
'AcceptCuratedSuggestionScreen.kt', 'AddGroupPostScreen.kt', 'EditGroupCuratedSchemaScreen.kt',
'EditGroupNostrProfileScreen.kt', 'EditGroupRelaysScreen.kt', 'GroupCuratedEntriesScreen.kt',
'ProposeGroupEventScreen.kt',
)
}
VM = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/'
PARTIAL = { # orig -> (paths to take, why the rest stays out)
'd2a4298d': ([
'composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt',
UI + 'BroadcastGroupSignedEventScreen.kt',
UI + 'widgets/RelayOutcomeLabel.kt',
VM + 'BroadcastGroupSignedEventViewModel.kt',
], "only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. "
"The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md)"),
}
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 = []
if o8 in PARTIAL:
paths, why = PARTIAL[o8]
patch = git('diff', '--binary', f'{sha}^', sha, '--', *paths).stdout
r = subprocess.run(['git', 'apply', '--index', '--3way'], cwd=wt, input=patch, capture_output=True, text=True)
if r.returncode != 0: sys.exit(f'STUCK at {o8} {subject}: the partial pick did not apply: {r.stderr.strip()[:300]}')
git('commit', '-q', '-C', sha)
amend_note(f'Replayed onto Mantra by docs/curated-to-mantra.md, in part: {why}.')
print(f"{o8} partial {subject[:80]}"); continue
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 in PIN and git('ls-tree', 'HEAD', GITLINK).stdout.split()[2] != PIN[o8]:
# a populated submodule lets git resolve the gitlink by fast-forward and keep the newer pin;
# the pin has to follow the commit, since each compiles only against the library it named
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 o8 == '5efeae76'
else f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against (git had fast-forwarded it to the newer one)')
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()