@@ -432,6 +432,77 @@ export class URIHandlerService {
432432 }
433433 }
434434
435+ /**
436+ * Ensure the inviting peer's GitHub outbox is registered as a peer
437+ * remote on an already-cloned DreamNode. This is the reciprocal half
438+ * of the sovereignty handover: when both peers have invited each
439+ * other, each one's repo tracks the other's outbox as a fetch source.
440+ *
441+ * Peer remotes use the native `https://github.com/<owner>/<repo>` URL
442+ * (NOT `interbrain://` — that namespace is for `.gitmodules`, where
443+ * UUID indirection is needed; a peer remote is already a concrete,
444+ * known location). The remote is named after the owner.
445+ *
446+ * Returns true if a new peer remote was added, false if it was
447+ * already present (or the owner is the current user — you don't peer
448+ * with yourself).
449+ */
450+ private async ensurePeerRemote ( repoFullPath : string , owner : string , repoName : string ) : Promise < boolean > {
451+ try {
452+ const { exec } = require ( 'child_process' ) ;
453+ const { promisify } = require ( 'util' ) ;
454+ const execAsync = promisify ( exec ) ;
455+
456+ // Don't add a peer remote pointing at our own outbox.
457+ try {
458+ const { getSovereigntyService } = await import ( '../social-resonance-filter/services/sovereignty-service' ) ;
459+ const me = await getSovereigntyService ( ) . getCurrentUser ( ) ;
460+ if ( me && me . toLowerCase ( ) === owner . toLowerCase ( ) ) {
461+ return false ;
462+ }
463+ } catch {
464+ // Couldn't determine current user — proceed; worst case we
465+ // add a redundant remote, which is harmless.
466+ }
467+
468+ const peerUrl = `https://github.com/${ owner } /${ repoName } ` ;
469+ const { stdout : remotesOut } = await execAsync ( 'git remote -v' , { cwd : repoFullPath } ) ;
470+
471+ // Already have a remote pointing at this exact URL?
472+ if ( remotesOut . includes ( peerUrl ) ) {
473+ return false ;
474+ }
475+
476+ // Pick a remote name. Prefer the owner login; if that name is
477+ // taken by a *different* URL, suffix it.
478+ let remoteName = owner ;
479+ const existingNames = new Set (
480+ remotesOut . split ( '\n' )
481+ . map ( ( l : string ) => l . split ( '\t' ) [ 0 ] ?. trim ( ) )
482+ . filter ( Boolean )
483+ ) ;
484+ if ( existingNames . has ( remoteName ) ) {
485+ let n = 2 ;
486+ while ( existingNames . has ( `${ remoteName } -${ n } ` ) ) n ++ ;
487+ remoteName = `${ remoteName } -${ n } ` ;
488+ }
489+
490+ await execAsync ( `git remote add ${ remoteName } ${ peerUrl } ` , { cwd : repoFullPath } ) ;
491+ // Best-effort fetch so the peer's commits are immediately
492+ // visible to Check for Updates.
493+ try {
494+ await execAsync ( `git fetch ${ remoteName } ` , { cwd : repoFullPath } ) ;
495+ } catch ( fetchErr ) {
496+ console . warn ( `[URIHandler] peer remote ${ remoteName } added but fetch failed (non-fatal):` , fetchErr ) ;
497+ }
498+ console . log ( `[URIHandler] added peer remote "${ remoteName } " -> ${ peerUrl } ` ) ;
499+ return true ;
500+ } catch ( err ) {
501+ console . warn ( '[URIHandler] ensurePeerRemote failed (non-fatal):' , err ) ;
502+ return false ;
503+ }
504+ }
505+
435506 /**
436507 * Index a newly cloned node for semantic search
437508 */
@@ -650,11 +721,26 @@ export class URIHandlerService {
650721
651722 // Check if already exists - use Obsidian vault API
652723 if ( await this . app . vault . adapter . exists ( repoName ) ) {
724+ // The DreamNode is already in this vault — but the invite is
725+ // still meaningful: it's the *reciprocal* half of the
726+ // sovereignty handover. If Alice accepted Bob's invite, then
727+ // Bob sends his link back, accepting it should register
728+ // Alice's outbox (`github.com/<owner>/<repo>`) as a peer
729+ // remote so Bob can follow Alice's updates too.
730+ //
731+ // Only truly skip if that peer remote is already present.
732+ const peerAdded = await this . ensurePeerRemote ( destinationPath , owner , repoName ) ;
653733 if ( ! silent ) {
654- new Notice ( `DreamNode "${ repoName } " already cloned!` ) ;
734+ if ( peerAdded ) {
735+ new Notice ( `Now following ${ owner } 's "${ repoName } "` ) ;
736+ } else {
737+ new Notice ( `Already following ${ owner } 's "${ repoName } "` ) ;
738+ }
655739 await this . autoFocusNode ( repoName , silent ) ;
656740 }
657- return 'skipped' ;
741+ // 'success' when we wired a new peer, 'skipped' when nothing
742+ // changed — keeps the caller's accounting honest.
743+ return peerAdded ? 'success' : 'skipped' ;
658744 }
659745
660746 if ( ! silent ) {
@@ -852,10 +938,18 @@ export class URIHandlerService {
852938 const isRadicleId = identifier . startsWith ( 'rad:' ) ;
853939 const isGitHubUrl = identifier . includes ( 'github.com/' ) ;
854940
855- // Normalize GitHub URL if needed (remove protocol, .git suffix)
856- const normalizedGitHubUrl = isGitHubUrl
857- ? identifier . replace ( / ^ h t t p s ? : \/ \/ / , '' ) . replace ( / \. g i t $ / , '' )
858- : null ;
941+ // For a GitHub identifier, the cloned DreamNode lives at vault path
942+ // `<repo>` — that's literally how cloneFromGitHub names the
943+ // destination dir. So match on repoPath, NOT githubRepoUrl: under
944+ // rc.21 we deliberately stopped writing .udd.githubRepoUrl on clone
945+ // (it would be the sender's URL, and the sovereignty handover
946+ // repoints origin to the recipient's own outbox anyway), so
947+ // githubRepoUrl is undefined on freshly-cloned nodes.
948+ let githubRepoName : string | null = null ;
949+ if ( isGitHubUrl ) {
950+ const m = identifier . match ( / g i t h u b \. c o m \/ [ ^ / ] + \/ ( [ ^ / \s ] + ) / ) ;
951+ if ( m ) githubRepoName = m [ 1 ] . replace ( / \. g i t $ / , '' ) ;
952+ }
859953
860954 for ( const node of allNodes ) {
861955 // Check Radicle ID (available on DreamNode from vault scan)
@@ -864,10 +958,17 @@ export class URIHandlerService {
864958 return node ;
865959 }
866960
867- // Check GitHub URL (available on DreamNode from vault scan)
961+ // Check GitHub identifier: cloned node's repoPath === <repo>.
962+ if ( githubRepoName && node . repoPath === githubRepoName ) {
963+ ( node as any ) . uuid = node . id ;
964+ return node ;
965+ }
966+
967+ // Fallback: legacy nodes that still carry githubRepoUrl in .udd.
868968 if ( isGitHubUrl && node . githubRepoUrl ) {
869969 const normalizedUddUrl = node . githubRepoUrl . replace ( / ^ h t t p s ? : \/ \/ / , '' ) . replace ( / \. g i t $ / , '' ) ;
870- if ( normalizedUddUrl === normalizedGitHubUrl ) {
970+ const normalizedIdentifier = identifier . replace ( / ^ h t t p s ? : \/ \/ / , '' ) . replace ( / \. g i t $ / , '' ) ;
971+ if ( normalizedUddUrl === normalizedIdentifier ) {
871972 ( node as any ) . uuid = node . id ;
872973 return node ;
873974 }
0 commit comments