Skip to content

Commit 18f5e97

Browse files
cloneMissingSubmodules: rc.21 port — read .gitmodules, clone sovereign
The old impl read `udd.submodules` expecting Radicle IDs. Under rc.21 that array is always empty; submodule info lives in `.gitmodules` keyed by `interbrain://<uuid>` URLs. Result: post-beacon-accept never cloned the supermodule's submodules to the vault root as sovereign nodes. Now parse `.gitmodules`, extract each submodule's `path` + UUID, and for each one that isn't already in the vault by UUID: - Derive candidate GitHub owners from the parent's git remotes (mirrors the helper's transitivity logic) - Try cloneFromGitHub(<owner>/<path>) against each — by rc.21 convention, a submodule named "Circle" lives at <owner>/Circle - First success wins; recurse for nested submodules End result: when Bob accepts Alice's Cylinder1 beacon, Cylinder1 arrives with its Square/Circle submodule subdirs populated AND sovereign Circle / Square land at the vault root, visible in holarchy navigation and clickable from DreamSong canvases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e2f95d commit 18f5e97

1 file changed

Lines changed: 100 additions & 35 deletions

File tree

src/features/dreamnode-updater/ui/cherry-pick-preview-modal.ts

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -705,65 +705,101 @@ export class CherryPickPreviewModal extends Modal {
705705
const clonedRepos: string[] = [];
706706

707707
try {
708-
const adapter = this.app.vault.adapter as any;
708+
const adapter = this.app.vault.adapter as { basePath?: string };
709709
const vaultPath = adapter.basePath || '';
710710
const path = require('path');
711711
const fs = require('fs').promises;
712712

713-
// Read the .udd file to get submodule Radicle IDs
714-
const uddPath = path.join(vaultPath, repoName, '.udd');
715-
let uddContent: string;
713+
// rc.21: read submodules from .gitmodules (URL-based) rather than
714+
// the legacy .udd.submodules array (which held Radicle IDs). Each
715+
// entry's URL is `interbrain://<uuid>`; the submodule's path under
716+
// the parent doubles as its sovereign repo name at the vault root.
717+
const gitmodulesPath = path.join(vaultPath, repoName, '.gitmodules');
718+
let gitmodulesContent: string;
716719
try {
717-
uddContent = await fs.readFile(uddPath, 'utf-8');
720+
gitmodulesContent = await fs.readFile(gitmodulesPath, 'utf-8');
718721
} catch {
719-
console.log(`[BeaconPreview] No .udd file found for ${repoName}, skipping submodule scan`);
722+
console.log(`[BeaconPreview] No .gitmodules in ${repoName}, skipping submodule scan`);
720723
return clonedRepos;
721724
}
722725

723-
const udd = JSON.parse(uddContent);
724-
const submoduleIds: string[] = udd.submodules || [];
726+
// Parse `[submodule "Name"]` blocks. We need both `path` and `url`.
727+
type ParsedSub = { name: string; path: string; uuid: string };
728+
const submodules: ParsedSub[] = [];
729+
const sectionRegex = /\[submodule "([^"]+)"\]([\s\S]*?)(?=\n\[|$)/g;
730+
let m: RegExpExecArray | null;
731+
while ((m = sectionRegex.exec(gitmodulesContent)) !== null) {
732+
const name = m[1];
733+
const body = m[2];
734+
const pathMatch = body.match(/^\s*path\s*=\s*(.+)$/m);
735+
const urlMatch = body.match(/^\s*url\s*=\s*interbrain:\/\/([0-9a-fA-F-]+)/m);
736+
if (pathMatch && urlMatch) {
737+
submodules.push({ name, path: pathMatch[1].trim(), uuid: urlMatch[1].trim() });
738+
}
739+
}
725740

726-
if (submoduleIds.length === 0) {
727-
console.log(`[BeaconPreview] No submodules in ${repoName}`);
741+
if (submodules.length === 0) {
742+
console.log(`[BeaconPreview] No interbrain:// submodules in ${repoName}/.gitmodules`);
728743
return clonedRepos;
729744
}
730745

731-
console.log(`[BeaconPreview] Found ${submoduleIds.length} submodule(s) in ${repoName}: ${submoduleIds.join(', ')}`);
746+
console.log(`[BeaconPreview] Found ${submodules.length} submodule(s) in ${repoName}: ${submodules.map(s => s.name).join(', ')}`);
732747

733-
// Get existing repos in vault to check what's already there
748+
// Existing top-level DreamNodes (by UUID — id === udd.uuid in the store).
734749
const store = useInterBrainStore.getState();
735-
const existingRadicleIds = new Set(
736-
Array.from(store.dreamNodes.values())
737-
.map(data => data.node.radicleId)
738-
.filter(id => id)
750+
const existingUuids = new Set(
751+
Array.from(store.dreamNodes.values()).map(data => data.node.id)
739752
);
740753

741754
const uriHandler = getURIHandlerService();
742755

743-
for (const radicleId of submoduleIds) {
744-
// Check if already exists by Radicle ID
745-
if (existingRadicleIds.has(radicleId)) {
746-
console.log(`[BeaconPreview] Submodule ${radicleId} already exists, skipping`);
756+
// Where to fetch this submodule from when the parent's transitivity
757+
// would have applied. We mirror the same logic the helper uses:
758+
// derive owner from one of the parent's GitHub remotes.
759+
const parentFullPath = path.join(vaultPath, repoName);
760+
const peerCandidates = await this.deriveOwnersFromGitRemotes(parentFullPath);
761+
if (peerCandidates.length === 0) {
762+
console.warn(`[BeaconPreview] No GitHub remotes on ${repoName} — can't derive submodule clone sources`);
763+
return clonedRepos;
764+
}
765+
766+
for (const sub of submodules) {
767+
if (existingUuids.has(sub.uuid)) {
768+
console.log(`[BeaconPreview] Submodule "${sub.name}" (uuid=${sub.uuid}) already in vault, skipping sovereign clone`);
747769
continue;
748770
}
749771

750-
// Clone the submodule
751-
console.log(`[BeaconPreview] Cloning missing submodule: ${radicleId}`);
752-
this.showProcessing(`Cloning submodule...`);
753-
754-
const result = await uriHandler.cloneFromRadicle(radicleId, true); // silent mode
755-
756-
if (result.status === 'success' && result.repoName) {
757-
console.log(`[BeaconPreview] Cloned submodule: ${radicleId} → "${result.repoName}"`);
758-
clonedRepos.push(result.repoName);
772+
// The submodule's repo name on each peer's outbox matches its
773+
// .gitmodules name by rc.21 convention (we ship Circle as
774+
// <owner>/Circle).
775+
let cloned: 'success' | 'skipped' | 'error' = 'error';
776+
let actualName = sub.name;
777+
for (const owner of peerCandidates) {
778+
const repoPath = `${owner}/${sub.name}`;
779+
console.log(`[BeaconPreview] Trying sovereign clone: ${repoPath}`);
780+
this.showProcessing(`Cloning ${sub.name}…`);
781+
try {
782+
const status = await uriHandler.cloneFromGitHub(repoPath, true);
783+
if (status === 'success' || status === 'skipped') {
784+
cloned = status;
785+
actualName = sub.name;
786+
break;
787+
}
788+
} catch (cloneError) {
789+
console.warn(`[BeaconPreview] cloneFromGitHub(${repoPath}) threw:`, cloneError);
790+
}
791+
}
759792

760-
// Recursively clone nested submodules
761-
const nestedClones = await this.cloneMissingSubmodules(result.repoName);
762-
clonedRepos.push(...nestedClones);
763-
} else if (result.status === 'skipped') {
764-
console.log(`[BeaconPreview] Submodule ${radicleId} already existed as "${result.repoName}"`);
793+
if (cloned === 'success') {
794+
console.log(`[BeaconPreview] Cloned "${actualName}" sovereign at vault root`);
795+
clonedRepos.push(actualName);
796+
// Recurse — newly arrived node may have its own submodules.
797+
const nested = await this.cloneMissingSubmodules(actualName);
798+
clonedRepos.push(...nested);
799+
} else if (cloned === 'skipped') {
800+
console.log(`[BeaconPreview] Submodule "${actualName}" already cloned at vault root`);
765801
} else {
766-
console.warn(`[BeaconPreview] Failed to clone submodule ${radicleId}`);
802+
console.warn(`[BeaconPreview] Failed to sovereign-clone "${sub.name}" from any peer`);
767803
}
768804
}
769805
} catch (error) {
@@ -773,6 +809,35 @@ export class CherryPickPreviewModal extends Modal {
773809
return clonedRepos;
774810
}
775811

812+
/**
813+
* Read a git repo's remotes and extract the GitHub owner from each.
814+
* Used to figure out where to fetch a sovereign submodule from.
815+
*/
816+
private async deriveOwnersFromGitRemotes(repoPath: string): Promise<string[]> {
817+
try {
818+
const { exec } = require('child_process');
819+
const { promisify } = require('util');
820+
const execAsync = promisify(exec);
821+
const { stdout } = await execAsync('git remote -v', { cwd: repoPath });
822+
const owners: string[] = [];
823+
const seen = new Set<string>();
824+
for (const line of (stdout as string).split('\n')) {
825+
// line: "<name>\t<url> (fetch|push)"
826+
const parts = line.split(/\s+/);
827+
if (parts.length < 2) continue;
828+
const url = parts[1];
829+
const m = url.match(/github\.com[/:]([^/]+)\//);
830+
if (m && !seen.has(m[1])) {
831+
seen.add(m[1]);
832+
owners.push(m[1]);
833+
}
834+
}
835+
return owners;
836+
} catch {
837+
return [];
838+
}
839+
}
840+
776841
/**
777842
* Preview a coherence beacon commit by opening the supermodule's DreamSong
778843
* (clones first if needed)

0 commit comments

Comments
 (0)