docs: plan the pull of Curated's work back into Mantra, and the tool that makes it mechanical
Curated (remote `curated`, curated/curated-kmp) forked from this repository atba26c0b1on 2026-09-09 and has landed thirty-nine commits since: a group's own nostr identity, curated lists over kinds 31888-31890, sign-in with a recovery phrase, an nsec or an npub, a back button on every pushed screen -- and two rebrands, 744 and 765 files each, underneath all of it. docs/curated-to-mantra.md is which of that Mantra wants, in what order, and how it lands under Mantra's names. docs/scripts/curated-unbrand.py is the rewrite the plan depends on, and docs/README.md gets the row. **The plan is measured, not argued, and that is its whole claim to be read.** The obvious way to write it was to reason from the commit list about what would conflict. Instead the method was run to the end on a scratch worktree before a sentence was written: Curated's history rewritten into Mantra's names, replayed onto origin/mantra with `rebase --rebase-merges`, built for both targets, and put through jvmTest, testDebugUnitTest and the m3 audit. Every number in the document is from that run -- 37 of 39 commits replay, 3 conflicts with known resolutions, 1,036 jvm tests and 530 common tests at 0 failures, all audit budgets met, Room regenerating nothing -- and the appendix records it so the next reader can tell a drifted number from a wrong method. A plan whose mechanics had not been tried would have been a list of hopes about seven hundred renamed files. **Mantra has not moved since the fork, and that decides the shape of the problem.** The merge-base of the two branches is origin/mantra itself, so `git merge curated/curated` is a fast-forward: it merges nothing and makes Mantra become Curare, icon, package rename and the deletion of Mantra's own library, dialects and projects sections included. The document says so before anything else because it is the one thing git does by default. It also means the pull is not a merge at all but a translation -- a Curated commit written in Mantra's vocabulary applies to Mantra as if it had been written there -- which is why a history rewrite followed by an ordinary rebase works, and cherry-picking the original commits (every path and every import line in every context wrong) does not. **808a3459, which drops the translation sections, is dropped rather than reverted afterwards, because that was measured too.** The first plan was to replay everything and restore the sections with a forward commit written against the final row layout. Trying the drop instead showed git merging 930d37c8, 1e52fc8f and 55664cc7's rewrite of ChatRoomDetailScreen around it without a conflict: the screen comes out as signing key, the five identity rows, Propose event, then Library, Dialects, Projects, then Subgroups, with `artifacts` and `dialects` still on the UI state, and the same 1,036 tests pass -- GroupSignedWorkRowsJvmTest only asserts that Subgroups is below the block. The one conflict is a modify/delete on a test 55664cc7 deletes anyway. A restore commit would have been Mantra owning a layout upstream never had. **Curated lists are pulled although they are not Mantra's product, and the reasoning is written out in full because it is the only real product question.** Excluding the five commits is not deleting five commits: the paste flow routes kind 31889 beside 0, 1 and 10002, broadcast seeds an entry's relays from its schema, the row restructure builds five rows of which two are these, the back-button sweep touches the suggestion screens, and 165 of the 306 strings added since the fork are theirs, interleaved. Carving that out means editing five other commits by hand and then owning a group screen that conflicts with upstream's on every future pull. Including it costs no schema, no migration and no query on a screen nobody opens. The principle underneath is stated as such: the method only stays cheap while Mantra's tree is a superset of Curated's non-brand tree, so every feature left out is a permanent seam. If product wants the two rows gone, that is two lines behind a constant, not surgery. **The brand line lands as one re-messaged commit of comments, and Torch is retired natively first.** Run through the rewrite, the two rebrand commits collapse from 744 and 765 files to sixteen and twenty-one, and what survives is the reasoning the rebrands added about the names they refused to change -- the comments on TWEAK_TAG, HOST_KEY_DERIVATION_TAG and Relays.ephemeral and the paragraph in shared-key-derivation.md. Mantra should have those; the next person to grep "mantra/" here is as likely to finish the rename as anyone upstream was. The rewritten d26cf6c7 is a different case: Mantra is still Torch in TorchTheme (116 references), UserAgent, two error strings, and an iOS config that builds Torch.app under a bundle id from two brands ago. That is Mantra's own debt and Phase 0 pays it as MantraTheme and "Mantra", so that CurareTheme maps onto a name that means something rather than onto the brand before last. Then d26cf6c7' is dropped in the replay, since its work is done -- the plan was made to say this explicitly after Phase 0 and Phase 2 were found to disagree about it. **The normaliser is a script in docs/scripts rather than a description, because three of its rules were wrong before they were right and prose would have hidden that.** `\b` does not fire inside snake_case, so `\bcurare\b` misses the string keys that carry the brand and they need look-around rules that treat `_` as a boundary. File names carry identifiers, so a path rule that only moves directories leaves MantraNavHost.kt and CurareNavHost.kt side by side with equal content. And "Curated" is two words -- the brand in generation one, the protocol's name at the tip (CuratedSchemaEvent, nostr/curated/, the queue's "Curated" mark) -- so the bare word is deliberately not a rule and only the generation-one identifiers are mapped, which is exact because the two commits written in that generation never use the word as a brand in code. Every rule matches a brand token and none matches a Mantra one, so the twelve Mantra* entities, the two mantra/ prefixes and ephemeral.mantra.press cannot be touched by construction; the check is that the entity-token count does not fall (433 before, 435 after, none lost) and that the rewritten tip compiles, which no missed identifier would survive. It has a tree mode for `filter-branch --tree-filter` and a patch mode for `format-patch` files; the consolidated script was re-run on a fresh export of the tip and reproduces the tree that was built and tested, to the byte, logo aside. **The phases are cuts through the graph, not lines of work, so no commit is replayed twice and the merges are recreated where they were.** The natural grouping is by feature line, but the lines interleave -- the nsec branch and the queue fork at the same commit and rejoin at c8de3a1f, the back-button branch joins the accept flow at fedbe724 -- and two of those merges carry hand resolutions that a flattened replay would silently lose. Each phase therefore replays every commit reachable from its cut that the previous phase did not, `--onto <mantra tip> <previous cut> <this cut>`, and the two conflicts and two reconcile files that the dry run found sit in Phase 4 with their resolutions written down as a runbook. The exactness check closes each phase: the diff between the replay and the rewritten tip must be exactly what was chosen to drop, and anything else is a rule that is wrong or a resolution that is, found there rather than in production. **Phase 6 says what the fork should become, because otherwise this document is needed again next week.** Every future upstream commit is written in to.curare.*, and each pull costs the dry run, the review and the prose -- affordable once, corrosive weekly. The options are given in the order they should be taken: converge the source package (a Kotlin package is not a brand, and the rebrands' own messages say the domain is Mantra* everywhere that matters), then invert the relationship so Curated is Mantra plus a thin brand-and-product series, and only failing both keep the normaliser maintained beside the code it maps. Two things already flow the other way and are named:39fb64b6, since Curated still carries the dead WalletManagerExtension.kt and one caller, and Phase 0's Torch retirement. **Several counts in the draft were checked against the data and corrected before they were committed.** The entity-token figure quoted from the rebrand's message (1,369) was measured with a different pattern and is replaced by this run's own (433 -> 435); "roughly 150 of 293" strings became 165 of 306; the back-button commit touches eight of Mantra's translation screens, not nineteen; and the claim that nothing in the fork added a DAO method was too strong -- three queries were added, and the statement now says no table and no migration, with 19.json byte-identical after a build as the evidence. The dry run, for the record: normaliser at the tip 812 files rewritten and 806 paths moved; filter-branch over 39 commits in 84 s; replay with the logo and 808a3459 dropped 37 commits, 3 conflicts, 2 reconcile files, residual diff exactly the logo plus the three files 808a3459 touched; :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 1,036 tests, 0 failures (Mantra alone: 733); :composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all budgets met with 12 adaptive uses and 2 navigation components. The scratch worktree and the tmp/ branches were removed afterwards; the script recreates them in under two minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
164
docs/scripts/curated-unbrand.py
Executable file
164
docs/scripts/curated-unbrand.py
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrite a tree, or a patch, from Curated's names into Mantra's.
|
||||
|
||||
Curated (curated/curated-kmp, brand "Curare" since e6aee792, "Curated" before it) forked from
|
||||
Mantra at ba26c0b1 and renamed the app twice: `press.mantra.*` -> `com.it.curated.*` ->
|
||||
`to.curare.*`, with the app-level identifiers (`MantraDatabase`, `MantraNavHost`, ...) and the
|
||||
generated-resources package following. Every feature commit on the fork is therefore written in
|
||||
one of those two vocabularies. This script maps both back to Mantra's, so a Curated commit can be
|
||||
replayed onto Mantra as an ordinary cherry-pick. docs/curated-to-mantra.md is the plan it serves.
|
||||
|
||||
What it must never touch, and by construction cannot: every rule below matches a *brand* token
|
||||
(`Curare`, `curare`, `com.it.curated`, a `Curated*` app identifier) and none matches a Mantra one.
|
||||
The twelve `Mantra*` Room entities, the two `mantra/` hash-tag prefixes, `wss://ephemeral.mantra.press`
|
||||
and the `curated` product vocabulary (`CuratedSchemaEvent`, `nostr/curated/`, the "Curated" queue
|
||||
mark) pass through unchanged. The bare word "Curated" is deliberately NOT a rule: in generation 1
|
||||
it was the brand, at the tip it is the protocol's name, and the two generation-1 feature commits
|
||||
never use it as a brand in code -- so mapping only the gen-1 *identifiers* is exact.
|
||||
|
||||
Modes
|
||||
tree <dir> rewrite a checked-out tree in place: contents, then paths (for filter-branch)
|
||||
patch <file>... rewrite git format-patch files in place: headers, paths and body alike
|
||||
|
||||
Check your work the way the plan does: the rewritten tip must compile and pass jvmTest, and the
|
||||
diff between a replay and the rewritten tip must be exactly the commits you chose to drop.
|
||||
"""
|
||||
import os, re, shutil, sys
|
||||
|
||||
# ---- content rules, applied in order. \b keeps `CurareApp` off `CurareApplication`. ----
|
||||
_RULES = [
|
||||
# generation 2: to.curare / Curare*
|
||||
(r'to\.curare\.', 'press.mantra.'),
|
||||
(r'to/curare/', 'press/mantra/'),
|
||||
(r'curare\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'),
|
||||
(r'\bCurareDatabaseConstructor\b', 'MantraDatabaseConstructor'),
|
||||
(r'\bCurareDatabaseJvmTest\b', 'MantraDatabaseJvmTest'),
|
||||
(r'\bCurareDatabase\b', 'MantraDatabase'),
|
||||
(r'\bCurareApplication\b', 'MantraApplication'),
|
||||
(r'\bCurareApp\b', 'MantraApp'),
|
||||
(r'\bCurareGlobal\b', 'MantraGlobal'),
|
||||
(r'\bcurareGlobal\b', 'mantraGlobal'),
|
||||
(r'\bCurareNavHost\b', 'MantraNavHost'),
|
||||
(r'\bCurareNavigationSuite\b', 'MantraNavigationSuite'),
|
||||
(r'\bCurareShapes\b', 'MantraShapes'),
|
||||
(r'\bCurareConverters\b', 'MantraConverters'),
|
||||
(r'\bdefaultCurareDir\b', 'defaultMantraDir'),
|
||||
(r'\bCurareTheme\b', 'TorchTheme'), # Mantra's theme is still TorchTheme; change here if Phase 0 renames it
|
||||
(r'Res\.string\.curare\b', 'Res.string.mantra'),
|
||||
(r'<string name="curare">Curare</string>', '<string name="mantra">Mantra</string>'),
|
||||
(r'const val APP_NAME = "Curare"', 'const val APP_NAME = "Torch"'), # and here
|
||||
(r'const val CLIENT_NAME = "Curare"', 'const val CLIENT_NAME = "Torch"'), # and here
|
||||
(r'APP_DIR_NAME = "curated"', 'APP_DIR_NAME = "mantra"'),
|
||||
(r'rootProject\.name = "Curare"', 'rootProject.name = "Mantra"'),
|
||||
(r'namespace = "to\.curare\.android"', 'namespace = "press.mantra.android"'),
|
||||
(r'applicationId = "to\.curare\.android"', 'applicationId = "press.mantra.android"'),
|
||||
# generation 1: com.it.curated / Curated* app identifiers (the two group-identity commits)
|
||||
(r'com\.it\.curated\.', 'press.mantra.'),
|
||||
(r'com/it/curated/', 'press/mantra/'),
|
||||
(r'curated\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'),
|
||||
(r'\bCuratedDatabaseConstructor\b', 'MantraDatabaseConstructor'),
|
||||
(r'\bCuratedDatabaseJvmTest\b', 'MantraDatabaseJvmTest'),
|
||||
(r'\bCuratedDatabase\b', 'MantraDatabase'),
|
||||
(r'\bCuratedApplication\b', 'MantraApplication'),
|
||||
(r'\bCuratedApp\b', 'MantraApp'),
|
||||
(r'\bCuratedGlobal\b', 'MantraGlobal'),
|
||||
(r'\bcuratedGlobal\b', 'mantraGlobal'),
|
||||
(r'\bCuratedNavHost\b', 'MantraNavHost'),
|
||||
(r'\bCuratedNavigationSuite\b', 'MantraNavigationSuite'),
|
||||
(r'\bCuratedShapes\b', 'MantraShapes'),
|
||||
(r'\bCuratedConverters\b', 'MantraConverters'),
|
||||
(r'\bdefaultCuratedDir\b', 'defaultMantraDir'),
|
||||
(r'\bCuratedTheme\b', 'TorchTheme'),
|
||||
(r'const val APP_NAME = "Curated"', 'const val APP_NAME = "Torch"'),
|
||||
(r'const val CLIENT_NAME = "Curated"', 'const val CLIENT_NAME = "Torch"'),
|
||||
(r'rootProject\.name = "Curated"', 'rootProject.name = "Mantra"'),
|
||||
(r'namespace = "com\.it\.curated\.android"', 'namespace = "press.mantra.android"'),
|
||||
(r'applicationId = "com\.it\.curated"', 'applicationId = "press.mantra.android"'),
|
||||
# ios: Mantra never left Torch / ac.aux.compose.Aux (see the plan's Phase 0)
|
||||
(r'PRODUCT_NAME=Curare\b', 'PRODUCT_NAME=Torch'), (r'PRODUCT_NAME=Curated\b', 'PRODUCT_NAME=Torch'),
|
||||
(r'PRODUCT_BUNDLE_IDENTIFIER=to\.curare\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'),
|
||||
(r'PRODUCT_BUNDLE_IDENTIFIER=com\.it\.curated\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'),
|
||||
(r'\bCurare\.app\b', 'Torch.app'), (r'\bCurated\.app\b', 'Torch.app'),
|
||||
# string keys carrying the brand: `_` is a word character, so \b never fires inside them
|
||||
(r'sign_in_is_not_yet_available_while_curare_is', 'sign_in_is_not_yet_available_while_mantra_is'),
|
||||
(r'sign_in_is_not_yet_available_while_curated_is', 'sign_in_is_not_yet_available_while_mantra_is'),
|
||||
(r'(?<![A-Za-z0-9])curare(?=_)', 'mantra'), (r'(?<=_)curare(?![A-Za-z0-9])', 'mantra'),
|
||||
# a package name in prose, without its trailing dot
|
||||
(r'to\.curare\b', 'press.mantra'), (r'com\.it\.curated\b', 'press.mantra'),
|
||||
# the brand word itself, in comments, string values and docs
|
||||
(r'\bCurare\b', 'Mantra'),
|
||||
(r'\bcurare\b', 'mantra'),
|
||||
]
|
||||
RULES = [(re.compile(p), r) for p, r in _RULES]
|
||||
|
||||
TEXT_EXT = {'.kt', '.kts', '.xml', '.md', '.py', '.sh', '.toml', '.properties', '.json', '.yml', '.yaml',
|
||||
'.txt', '.xcconfig', '.pbxproj', '.plist', '.swift', '.gitignore', '.gitmodules'}
|
||||
|
||||
|
||||
def normalise_text(s: str) -> str:
|
||||
for rx, rep in RULES:
|
||||
s = rx.sub(rep, s)
|
||||
return s
|
||||
|
||||
|
||||
def normalise_path(rel: str) -> str:
|
||||
"""Directories first, then the identifier rules over the whole path (file names carry them too)."""
|
||||
rel = (rel.replace('/to/curare/', '/press/mantra/').replace('/com/it/curated/', '/press/mantra/')
|
||||
.replace('to.curare.compose.database.CurareDatabase', 'press.mantra.compose.database.MantraDatabase')
|
||||
.replace('com.it.curated.compose.database.CuratedDatabase', 'press.mantra.compose.database.MantraDatabase'))
|
||||
return normalise_text(rel)
|
||||
|
||||
|
||||
def _is_text(path: str) -> bool:
|
||||
return os.path.splitext(path)[1] in TEXT_EXT or os.path.basename(path) in ('.gitmodules', 'gradlew')
|
||||
|
||||
|
||||
def rewrite_tree(root: str) -> None:
|
||||
rewritten = 0
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
if '/.git' in dirpath or dirpath.endswith('/.git'):
|
||||
continue
|
||||
for fn in filenames:
|
||||
p = os.path.join(dirpath, fn)
|
||||
if not _is_text(p) or os.path.islink(p):
|
||||
continue
|
||||
try:
|
||||
s = open(p, encoding='utf-8').read()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
t = normalise_text(s)
|
||||
if t != s:
|
||||
open(p, 'w', encoding='utf-8').write(t)
|
||||
rewritten += 1
|
||||
moves = []
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
if '/.git' in dirpath or dirpath.endswith('/.git'):
|
||||
continue
|
||||
for fn in filenames:
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
||||
new = normalise_path(rel)
|
||||
if new != rel:
|
||||
moves.append((rel, new))
|
||||
for rel, new in moves:
|
||||
dst = os.path.join(root, new)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.move(os.path.join(root, rel), dst)
|
||||
for dirpath, _, _ in os.walk(root, topdown=False):
|
||||
if dirpath != root and '/.git' not in dirpath and not os.listdir(dirpath):
|
||||
os.rmdir(dirpath)
|
||||
print(f'rewritten {rewritten} files, moved {len(moves)} paths', file=sys.stderr)
|
||||
|
||||
|
||||
def rewrite_patches(paths) -> None:
|
||||
for p in paths:
|
||||
s = open(p, encoding='utf-8').read()
|
||||
open(p, 'w', encoding='utf-8').write(normalise_text(s))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == 'tree':
|
||||
rewrite_tree(os.path.abspath(sys.argv[2]))
|
||||
elif len(sys.argv) >= 3 and sys.argv[1] == 'patch':
|
||||
rewrite_patches(sys.argv[2:])
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
Reference in New Issue
Block a user