submodule-manager-service.ts
1 // Access Node.js modules directly in Electron context (following GitService pattern) 2 const { exec } = require('child_process'); 3 const { promisify } = require('util'); 4 const path = require('path'); 5 6 const execAsync = promisify(exec); 7 8 import { App } from 'obsidian'; 9 import { GitService } from './git-service'; 10 import { VaultService } from './vault-service'; 11 import { CanvasParserService, DependencyInfo, CanvasAnalysis } from './canvas-parser-service'; 12 import { UDDService } from './udd-service'; 13 import { RadicleService } from './radicle-service'; 14 15 export interface SubmoduleInfo { 16 name: string; 17 path: string; 18 url: string; 19 branch?: string; 20 } 21 22 export interface SubmoduleImportResult { 23 success: boolean; 24 submoduleName: string; 25 originalPath: string; 26 newPath: string; 27 error?: string; 28 alreadyExisted?: boolean; // Track if submodule was already present 29 } 30 31 export interface SyncResult { 32 canvasPath: string; 33 dreamNodePath: string; 34 submodulesImported: SubmoduleImportResult[]; 35 submodulesRemoved: string[]; // Names of submodules that were removed 36 pathsUpdated: Map<string, string>; 37 commitHash?: string; 38 error?: string; 39 success: boolean; 40 } 41 42 export class SubmoduleManagerService { 43 private gitService: GitService; 44 private vaultPath: string = ''; 45 46 constructor( 47 private app: App, 48 private vaultService: VaultService, 49 private canvasParser: CanvasParserService, 50 private radicleService: RadicleService 51 ) { 52 this.gitService = new GitService(app); 53 this.initializeVaultPath(app); 54 } 55 56 private initializeVaultPath(app: App): void { 57 // Get vault file system path for Node.js fs operations (same pattern as GitService) 58 const adapter = app.vault.adapter as { path?: string; basePath?: string }; 59 60 let vaultPath = ''; 61 if (typeof adapter.path === 'string') { 62 vaultPath = adapter.path; 63 } else if (typeof adapter.basePath === 'string') { 64 vaultPath = adapter.basePath; 65 } else if (adapter.path && typeof adapter.path === 'object') { 66 const pathObj = adapter.path as Record<string, string>; 67 vaultPath = pathObj.path || pathObj.basePath || ''; 68 } 69 70 this.vaultPath = vaultPath; 71 } 72 73 private getFullPath(repoPath: string): string { 74 if (!this.vaultPath) { 75 console.warn('SubmoduleManagerService: Vault path not initialized, using relative path'); 76 return repoPath; 77 } 78 return path.join(this.vaultPath, repoPath); 79 } 80 81 /** 82 * Get or initialize Radicle ID for a DreamNode repository 83 * Pattern from RadicleBatchInitService: Check .udd first, then git, then initialize if needed 84 */ 85 private async getOrInitializeRadicleId(repoPath: string): Promise<string | null> { 86 const fs = require('fs').promises; 87 const uddPath = path.join(repoPath, '.udd'); 88 89 try { 90 // STEP 1: Try reading Radicle ID from .udd file first 91 try { 92 const uddContent = await fs.readFile(uddPath, 'utf-8'); 93 const udd = JSON.parse(uddContent); 94 95 if (udd.radicleId) { 96 console.log(`SubmoduleManagerService: Found existing Radicle ID in .udd: ${udd.radicleId}`); 97 return udd.radicleId; 98 } 99 } catch (error) { 100 console.warn(`SubmoduleManagerService: Could not read .udd at ${uddPath}:`, error); 101 } 102 103 // STEP 2: No Radicle ID in .udd - check if repository is initialized anyway 104 const radicleId = await this.radicleService.getRadicleId(repoPath); 105 if (radicleId) { 106 // GAP DETECTED: Repository initialized but .udd doesn't have the ID - sync it 107 console.log(`SubmoduleManagerService: Found Radicle ID in git: ${radicleId}, writing to .udd...`); 108 try { 109 const uddContent = await fs.readFile(uddPath, 'utf-8'); 110 const udd = JSON.parse(uddContent); 111 udd.radicleId = radicleId; 112 await fs.writeFile(uddPath, JSON.stringify(udd, null, 2)); 113 console.log(`SubmoduleManagerService: Successfully synced Radicle ID to .udd`); 114 return radicleId; 115 } catch (writeError) { 116 console.warn(`SubmoduleManagerService: Could not write Radicle ID to .udd:`, writeError); 117 return radicleId; // Still return the ID even if write failed 118 } 119 } 120 121 // STEP 3: Repository not initialized - initialize it now 122 console.log(`SubmoduleManagerService: No Radicle ID found, initializing repository...`); 123 124 try { 125 // Get DreamNode directory name (PascalCase, no spaces) and UUID 126 const uddContent = await fs.readFile(uddPath, 'utf-8'); 127 const udd = JSON.parse(uddContent); 128 const directoryName = path.basename(repoPath); // Already PascalCase from existing system 129 const uuid = udd.uuid; 130 131 // Use UUID suffix to ensure uniqueness (avoids collision with deleted repos) 132 // Format: "DirectoryName-abc123" (first 7 chars of UUID) 133 const uniqueName = uuid ? `${directoryName}-${uuid.substring(0, 7)}` : directoryName; 134 135 console.log(`SubmoduleManagerService: Initializing with unique name: ${uniqueName}`); 136 137 // Initialize with rad init 138 await this.radicleService.init( 139 repoPath, 140 uniqueName, // name with UUID suffix for uniqueness 141 'DreamNode repository' // description 142 ); 143 144 // Get the newly created Radicle ID 145 const newRadicleId = await this.radicleService.getRadicleId(repoPath); 146 147 if (newRadicleId) { 148 console.log(`SubmoduleManagerService: Successfully initialized Radicle ID: ${newRadicleId}`); 149 150 // Write to .udd immediately 151 udd.radicleId = newRadicleId; 152 await fs.writeFile(uddPath, JSON.stringify(udd, null, 2)); 153 console.log(`SubmoduleManagerService: Wrote Radicle ID to .udd`); 154 155 return newRadicleId; 156 } 157 158 console.warn(`SubmoduleManagerService: Radicle init succeeded but could not retrieve ID`); 159 return null; 160 } catch (initError) { 161 // With unique names (Title-UUID), storage collisions should not occur 162 // If they do, it indicates a bug or external modification 163 if (initError instanceof Error && initError.message.startsWith('RADICLE_STORAGE_EXISTS:')) { 164 console.error(`SubmoduleManagerService: Unexpected storage collision despite unique naming!`); 165 console.error(`SubmoduleManagerService: This may indicate external Radicle modifications or a bug.`); 166 } 167 168 console.warn(`SubmoduleManagerService: Failed to initialize Radicle repository:`, initError); 169 return null; 170 } 171 172 } catch (error) { 173 console.error(`SubmoduleManagerService: Error getting/initializing Radicle ID:`, error); 174 return null; 175 } 176 } 177 178 /** 179 * Import a DreamNode as a git submodule 180 */ 181 async importSubmodule( 182 parentDreamNodePath: string, 183 sourceDreamNodePath: string, 184 submoduleName?: string 185 ): Promise<SubmoduleImportResult> { 186 const parentFullPath = this.getFullPath(parentDreamNodePath); 187 const sourceFullPath = this.getFullPath(sourceDreamNodePath); 188 189 // Use directory name if submodule name not provided 190 const actualSubmoduleName = submoduleName || path.basename(sourceDreamNodePath); 191 192 try { 193 console.log(`SubmoduleManagerService: Importing ${sourceDreamNodePath} as submodule ${actualSubmoduleName} into ${parentDreamNodePath}`); 194 195 // Check if parent is a git repository 196 await this.verifyGitRepository(parentFullPath); 197 198 // Check if source is a git repository 199 await this.verifyGitRepository(sourceFullPath); 200 201 // Check for naming conflicts 202 await this.checkSubmoduleNameConflict(parentFullPath, actualSubmoduleName); 203 204 // Import the submodule (use --force to handle previously-removed submodules) 205 const submoduleCommand = `git submodule add --force "${sourceFullPath}" "${actualSubmoduleName}"`; 206 await execAsync(submoduleCommand, { cwd: parentFullPath }); 207 208 console.log(`SubmoduleManagerService: Successfully imported submodule ${actualSubmoduleName}`); 209 210 return { 211 success: true, 212 submoduleName: actualSubmoduleName, 213 originalPath: sourceDreamNodePath, 214 newPath: actualSubmoduleName 215 }; 216 217 } catch (error) { 218 const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 219 console.error('SubmoduleManagerService: Failed to import submodule:', errorMessage); 220 221 return { 222 success: false, 223 submoduleName: actualSubmoduleName, 224 originalPath: sourceDreamNodePath, 225 newPath: '', 226 error: errorMessage 227 }; 228 } 229 } 230 231 /** 232 * Check if a submodule name would conflict with existing files/directories 233 */ 234 private async checkSubmoduleNameConflict(parentPath: string, submoduleName: string): Promise<void> { 235 const targetPath = path.join(parentPath, submoduleName); 236 237 try { 238 // Check if path exists using Node.js fs directly 239 const fs = require('fs'); 240 const exists = fs.existsSync(targetPath); 241 242 if (exists) { 243 throw new Error(`Submodule name conflict: ${submoduleName} already exists in ${parentPath}`); 244 } 245 } catch (error) { 246 if (error instanceof Error && error.message.includes('already exists')) { 247 throw error; 248 } 249 // Other errors (like permission issues) we'll let slide for now 250 console.warn('SubmoduleManagerService: Could not check for name conflicts:', error); 251 } 252 } 253 254 /** 255 * Verify that a path is a git repository 256 */ 257 private async verifyGitRepository(repoPath: string): Promise<void> { 258 try { 259 await execAsync('git rev-parse --git-dir', { cwd: repoPath }); 260 } catch { 261 throw new Error(`Not a git repository: ${repoPath}`); 262 } 263 } 264 265 /** 266 * List existing submodules in a repository 267 */ 268 async listSubmodules(dreamNodePath: string): Promise<SubmoduleInfo[]> { 269 const fullPath = this.getFullPath(dreamNodePath); 270 const submodules: SubmoduleInfo[] = []; 271 272 try { 273 console.log(`SubmoduleManagerService: Listing submodules in ${dreamNodePath} (${fullPath})`); 274 const { stdout } = await execAsync('git submodule status', { cwd: fullPath }); 275 276 console.log(`SubmoduleManagerService: git submodule status output:`, stdout); 277 278 if (!stdout.trim()) { 279 console.log(`SubmoduleManagerService: No submodules found (empty output)`); 280 return submodules; 281 } 282 283 // Don't trim before splitting - each line needs its leading space for the regex 284 const lines = stdout.split('\n').filter((line: string) => line.trim()); 285 console.log(`SubmoduleManagerService: Processing ${lines.length} lines`); 286 for (const line of lines) { 287 console.log(`SubmoduleManagerService: Processing line: "${line}"`); 288 // Git submodule status format: " hash path (branch)" or "+hash path (branch)" 289 const match = line.match(/^[\s+-]\w+\s+(.+?)(?:\s+\(.+\))?$/); 290 console.log(`SubmoduleManagerService: Regex match result:`, match); 291 if (match) { 292 const submodulePath = match[1]; 293 const submoduleName = path.basename(submodulePath); 294 console.log(`SubmoduleManagerService: Parsed submodule: path="${submodulePath}", name="${submoduleName}"`); 295 296 // Get submodule URL 297 try { 298 const { stdout: urlOutput } = await execAsync( 299 `git config --file .gitmodules submodule.${submodulePath}.url`, 300 { cwd: fullPath } 301 ); 302 303 submodules.push({ 304 name: submoduleName, 305 path: submodulePath, 306 url: urlOutput.trim() 307 }); 308 } catch { 309 // If we can't get URL, still include the submodule 310 submodules.push({ 311 name: submoduleName, 312 path: submodulePath, 313 url: 'unknown' 314 }); 315 } 316 } 317 } 318 319 return submodules; 320 } catch (error) { 321 console.error('SubmoduleManagerService: Failed to list submodules:', error); 322 return submodules; 323 } 324 } 325 326 /** 327 * Sync canvas submodules - complete end-to-end workflow 328 */ 329 async syncCanvasSubmodules(canvasPath: string): Promise<SyncResult> { 330 console.log(`SubmoduleManagerService: Starting sync for canvas ${canvasPath}`); 331 332 try { 333 // Analyze canvas dependencies 334 const analysis = await this.canvasParser.analyzeCanvasDependencies(canvasPath); 335 336 // Check git state safety 337 await this.ensureCleanGitState(analysis.dreamNodeBoundary); 338 339 // Import external dependencies as submodules (only if there are any) 340 const importResults = analysis.hasExternalDependencies 341 ? await this.importExternalDependencies(analysis) 342 : []; 343 344 // Check for unused submodules (bidirectional sync) 345 // This runs EVEN if there are no external dependencies, to clean up orphaned submodules 346 const removedSubmodules = await this.removeUnusedSubmodules(analysis, importResults); 347 348 // Update bidirectional .udd relationships (submodules <-> supermodules) 349 // This ALWAYS runs to ensure existing submodules have correct relationships 350 await this.updateBidirectionalRelationships( 351 analysis.dreamNodeBoundary, 352 importResults, 353 removedSubmodules 354 ); 355 356 // Early exit: If all submodules already existed AND none removed, no git commit needed 357 const newImports = importResults.filter(r => r.success && !r.alreadyExisted); 358 if (newImports.length === 0 && removedSubmodules.length === 0) { 359 console.log(`SubmoduleManagerService: All submodules already synced - no git changes needed`); 360 return { 361 canvasPath, 362 dreamNodePath: analysis.dreamNodeBoundary, 363 submodulesImported: importResults, 364 submodulesRemoved: [], 365 pathsUpdated: new Map(), 366 success: true 367 }; 368 } 369 370 // Log sync summary for git changes 371 console.log(`SubmoduleManagerService: Git sync summary - Added: ${newImports.length}, Removed: ${removedSubmodules.length}`); 372 if (newImports.length > 0) { 373 console.log(` Added submodules: ${newImports.map(r => r.submoduleName).join(', ')}`); 374 } 375 if (removedSubmodules.length > 0) { 376 console.log(` Removed submodules: ${removedSubmodules.join(', ')}`); 377 } 378 379 // Update canvas file paths (only if there are new imports) 380 const pathUpdates = this.buildCanvasPathUpdates(analysis, importResults); 381 if (pathUpdates.size > 0) { 382 await this.canvasParser.updateCanvasFilePaths(canvasPath, pathUpdates); 383 } 384 385 // Commit changes (including removals) 386 let commitHash: string | undefined; 387 commitHash = await this.commitSubmoduleChanges( 388 analysis.dreamNodeBoundary, 389 canvasPath, 390 importResults, 391 removedSubmodules 392 ); 393 394 console.log(`SubmoduleManagerService: Successfully synced canvas ${canvasPath}`); 395 396 return { 397 canvasPath, 398 dreamNodePath: analysis.dreamNodeBoundary, 399 submodulesImported: importResults, 400 submodulesRemoved: removedSubmodules, 401 pathsUpdated: pathUpdates, 402 commitHash, 403 success: true 404 }; 405 406 } catch (error) { 407 const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 408 console.error('SubmoduleManagerService: Sync failed:', errorMessage); 409 410 return { 411 canvasPath, 412 dreamNodePath: '', 413 submodulesImported: [], 414 submodulesRemoved: [], 415 pathsUpdated: new Map(), 416 error: errorMessage, 417 success: false 418 }; 419 } 420 } 421 422 /** 423 * Ensure git repository is in clean state before submodule operations 424 * Auto-commits any uncommitted changes to prevent data loss 425 */ 426 private async ensureCleanGitState(dreamNodePath: string): Promise<void> { 427 const hasUncommitted = await this.gitService.hasUncommittedChanges(dreamNodePath); 428 if (hasUncommitted) { 429 // Auto-commit changes before submodule operations 430 // This is safer than stashing because commits are permanent and traceable 431 const committed = await this.gitService.commitAllChanges( 432 dreamNodePath, 433 'Auto-save before submodule sync' 434 ); 435 if (committed) { 436 console.log('SubmoduleManagerService: Auto-committed uncommitted changes for clean state'); 437 } 438 } 439 } 440 441 /** 442 * Import all external dependencies as submodules 443 */ 444 private async importExternalDependencies(analysis: CanvasAnalysis): Promise<SubmoduleImportResult[]> { 445 const results: SubmoduleImportResult[] = []; 446 447 // Group dependencies by DreamNode to avoid duplicate submodules 448 const dreamNodeGroups = new Map<string, DependencyInfo[]>(); 449 450 for (const dep of analysis.externalDependencies) { 451 if (dep.dreamNodePath) { 452 const existing = dreamNodeGroups.get(dep.dreamNodePath) || []; 453 existing.push(dep); 454 dreamNodeGroups.set(dep.dreamNodePath, existing); 455 } 456 } 457 458 // Get existing submodules to avoid conflicts 459 const existingSubmodules = await this.listSubmodules(analysis.dreamNodeBoundary); 460 const existingSubmoduleNames = new Set(existingSubmodules.map(s => s.name)); 461 462 console.log(`SubmoduleManagerService: Found ${existingSubmodules.length} existing submodules:`, Array.from(existingSubmoduleNames)); 463 464 // Import each unique external DreamNode as a submodule (only if not already present) 465 for (const [dreamNodePath, dependencies] of dreamNodeGroups) { 466 const submoduleName = path.basename(dreamNodePath); 467 468 if (existingSubmoduleNames.has(submoduleName)) { 469 console.log(`SubmoduleManagerService: Submodule ${submoduleName} already exists, skipping import`); 470 // Create a success result for already-existing submodule 471 results.push({ 472 success: true, 473 submoduleName, 474 originalPath: dreamNodePath, 475 newPath: submoduleName, 476 alreadyExisted: true 477 }); 478 } else { 479 console.log(`SubmoduleManagerService: Importing ${dreamNodePath} for ${dependencies.length} dependencies`); 480 481 const result = await this.importSubmodule( 482 analysis.dreamNodeBoundary, 483 dreamNodePath 484 ); 485 486 results.push(result); 487 } 488 } 489 490 return results; 491 } 492 493 /** 494 * Remove submodules that are no longer referenced in the canvas (bidirectional sync) 495 */ 496 private async removeUnusedSubmodules( 497 analysis: CanvasAnalysis, 498 _importResults: SubmoduleImportResult[] 499 ): Promise<string[]> { 500 const removedSubmodules: string[] = []; 501 502 try { 503 // Get all existing submodules 504 const existingSubmodules = await this.listSubmodules(analysis.dreamNodeBoundary); 505 506 // Build set of required submodule names from analysis 507 const requiredSubmoduleNames = new Set<string>(); 508 for (const dep of analysis.externalDependencies) { 509 if (dep.dreamNodePath) { 510 const submoduleName = path.basename(dep.dreamNodePath); 511 requiredSubmoduleNames.add(submoduleName); 512 } 513 } 514 515 console.log(`SubmoduleManagerService: Required submodules:`, Array.from(requiredSubmoduleNames)); 516 console.log(`SubmoduleManagerService: Existing submodules:`, existingSubmodules.map(s => s.name)); 517 518 // Find submodules that are no longer needed 519 for (const existingSubmodule of existingSubmodules) { 520 if (!requiredSubmoduleNames.has(existingSubmodule.name)) { 521 console.log(`SubmoduleManagerService: Removing unused submodule: ${existingSubmodule.name}`); 522 523 try { 524 const fullPath = this.getFullPath(analysis.dreamNodeBoundary); 525 526 // Step 1: Deinitialize submodule 527 await execAsync(`git submodule deinit -f "${existingSubmodule.path}"`, { cwd: fullPath }); 528 529 // Step 2: Remove from git index and .gitmodules 530 await execAsync(`git rm -f "${existingSubmodule.path}"`, { cwd: fullPath }); 531 532 // Step 3: Remove directory if it still exists 533 try { 534 const fs = require('fs'); 535 const submoduleFullPath = path.join(fullPath, existingSubmodule.path); 536 if (fs.existsSync(submoduleFullPath)) { 537 fs.rmSync(submoduleFullPath, { recursive: true, force: true }); 538 console.log(`SubmoduleManagerService: Removed directory: ${existingSubmodule.path}`); 539 } 540 } catch (dirError) { 541 console.warn(`SubmoduleManagerService: Could not remove directory ${existingSubmodule.path}:`, dirError); 542 } 543 544 removedSubmodules.push(existingSubmodule.name); 545 console.log(`SubmoduleManagerService: Successfully removed submodule: ${existingSubmodule.name}`); 546 547 } catch (error) { 548 console.error(`SubmoduleManagerService: Failed to remove submodule ${existingSubmodule.name}:`, error); 549 // Continue with other submodules even if one fails 550 } 551 } 552 } 553 554 if (removedSubmodules.length > 0) { 555 console.log(`SubmoduleManagerService: Removed ${removedSubmodules.length} unused submodule(s)`); 556 } else { 557 console.log(`SubmoduleManagerService: No unused submodules to remove`); 558 } 559 560 return removedSubmodules; 561 562 } catch (error) { 563 console.error('SubmoduleManagerService: Failed to check for unused submodules:', error); 564 return []; 565 } 566 } 567 568 /** 569 * Build path update map for canvas file rewriting (old method, kept for compatibility) 570 */ 571 private buildPathUpdates(importResults: SubmoduleImportResult[]): Map<string, string> { 572 const pathUpdates = new Map<string, string>(); 573 574 for (const result of importResults) { 575 if (result.success) { 576 // Map original DreamNode path to submodule path 577 pathUpdates.set(result.originalPath, result.newPath); 578 } 579 } 580 581 return pathUpdates; 582 } 583 584 /** 585 * Build canvas path updates using the correct logic from the working command 586 */ 587 private buildCanvasPathUpdates(analysis: CanvasAnalysis, importResults: SubmoduleImportResult[]): Map<string, string> { 588 const pathUpdates = new Map<string, string>(); 589 590 // Build path updates from successful imports (same logic as working command) 591 for (const dep of analysis.externalDependencies) { 592 if (dep.dreamNodePath) { 593 // Find the import result for this dependency's DreamNode 594 const matchingImport = importResults.find(result => 595 result.success && result.originalPath === dep.dreamNodePath 596 ); 597 598 if (matchingImport) { 599 // Build new path: dreamNodeBoundary/submoduleName + file path within that DreamNode 600 const relativePath = dep.filePath.replace(dep.dreamNodePath + '/', ''); 601 const newPath = `${analysis.dreamNodeBoundary}/${matchingImport.submoduleName}/${relativePath}`; 602 603 // Only add to pathUpdates if the path actually changes 604 if (dep.filePath !== newPath) { 605 pathUpdates.set(dep.filePath, newPath); 606 console.log(`SubmoduleManager path mapping: ${dep.filePath} → ${newPath}`); 607 } else { 608 console.log(`SubmoduleManager path already correct: ${dep.filePath}`); 609 } 610 } 611 } 612 } 613 614 return pathUpdates; 615 } 616 617 /** 618 * Commit submodule additions/removals and canvas changes 619 */ 620 private async commitSubmoduleChanges( 621 dreamNodePath: string, 622 canvasPath: string, 623 importResults: SubmoduleImportResult[], 624 removedSubmodules: string[] = [] 625 ): Promise<string> { 626 const fullPath = this.getFullPath(dreamNodePath); 627 628 try { 629 // Add all changes (submodules and updated canvas) 630 await execAsync('git add -A', { cwd: fullPath }); 631 632 // Check if there are actually changes to commit 633 const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: fullPath }); 634 635 if (!statusOutput.trim()) { 636 console.log('SubmoduleManagerService: No changes to commit (already committed by submodule operations)'); 637 return 'no-changes'; 638 } 639 640 // Create commit message 641 const addedCount = importResults.filter(r => r.success && !r.alreadyExisted).length; 642 const removedCount = removedSubmodules.length; 643 const canvasName = path.basename(canvasPath, '.canvas'); 644 645 let commitMessage = `Sync submodules for canvas ${canvasName}`; 646 if (addedCount > 0 && removedCount > 0) { 647 commitMessage = `Sync submodules for ${canvasName}: +${addedCount}, -${removedCount}`; 648 } else if (addedCount > 0) { 649 commitMessage = `Add ${addedCount} submodule(s) for ${canvasName}`; 650 } else if (removedCount > 0) { 651 commitMessage = `Remove ${removedCount} unused submodule(s) from ${canvasName}`; 652 } 653 654 // Commit changes 655 const { stdout } = await execAsync(`git commit -m "${commitMessage}"`, { cwd: fullPath }); 656 657 // Extract commit hash from output 658 const hashMatch = stdout.match(/\[.+\s+(\w+)\]/); 659 const commitHash = hashMatch ? hashMatch[1] : 'unknown'; 660 661 console.log(`SubmoduleManagerService: Committed changes with hash ${commitHash}`); 662 return commitHash; 663 664 } catch (error) { 665 console.error('SubmoduleManagerService: Failed to commit changes:', error); 666 throw new Error(`Failed to commit submodule changes: ${error instanceof Error ? error.message : 'Unknown error'}`); 667 } 668 } 669 670 /** 671 * Update bidirectional .udd relationships after submodule sync 672 * This implements the Coherence Beacon foundation: parent tracks children, children track parents 673 */ 674 private async updateBidirectionalRelationships( 675 parentPath: string, 676 importResults: SubmoduleImportResult[], 677 removedSubmodules: string[] 678 ): Promise<void> { 679 console.log('SubmoduleManagerService: Updating bidirectional .udd relationships...'); 680 681 const fullParentPath = this.getFullPath(parentPath); 682 683 try { 684 // Get parent's Radicle ID (initialize if needed) and title 685 const parentUDD = await UDDService.readUDD(fullParentPath); 686 const parentRadicleId = await this.getOrInitializeRadicleId(fullParentPath); 687 const parentTitle = parentUDD.title; 688 689 if (!parentRadicleId) { 690 console.error('SubmoduleManagerService: Could not get/initialize parent Radicle ID - skipping relationship tracking'); 691 return; 692 } 693 694 console.log(`SubmoduleManagerService: Parent Radicle ID: ${parentRadicleId}`); 695 696 let parentModified = false; 697 698 // Process ALL successful submodules (both new and existing) 699 // This ensures bidirectional relationships are always in sync, even for pre-existing submodules 700 const allSuccessfulImports = importResults.filter(r => r.success); 701 for (const result of allSuccessfulImports) { 702 const isNew = !result.alreadyExisted; 703 704 console.log(`SubmoduleManagerService: Checking ${isNew ? 'new' : 'existing'} submodule: ${result.submoduleName}`); 705 706 try { 707 // Detect sovereign repo path FIRST (e.g., Cseti/Hawkinsscale -> ../Hawkinsscale at vault root) 708 const sovereignPath = path.join(this.vaultPath, result.submoduleName); 709 const sovereignExists = require('fs').existsSync(path.join(sovereignPath, '.git')); 710 711 if (!sovereignExists) { 712 console.log(`SubmoduleManagerService: No sovereign repo found for ${result.submoduleName} - skipping relationship tracking`); 713 console.log(`SubmoduleManagerService: (This is normal for DreamNodes cloned from GitHub/Radicle)`); 714 continue; 715 } 716 717 console.log(`SubmoduleManagerService: Found sovereign repo at vault root: ${result.submoduleName}`); 718 719 // STEP 1: Work in sovereign repo ONLY - update all metadata before importing submodule 720 721 // Get child's Radicle ID (initialize if needed) 722 const childRadicleId = await this.getOrInitializeRadicleId(sovereignPath); 723 let childUDD = await UDDService.readUDD(sovereignPath); 724 const childTitle = childUDD.title; 725 726 if (!childRadicleId) { 727 console.warn(`SubmoduleManagerService: Could not get/initialize Radicle ID for ${result.submoduleName} - skipping`); 728 continue; 729 } 730 731 console.log(`SubmoduleManagerService: Child Radicle ID: ${childRadicleId}`); 732 733 let sovereignModified = false; 734 735 // Ensure sovereign's .udd has its own Radicle ID (may have been just initialized) 736 if (!childUDD.radicleId || childUDD.radicleId !== childRadicleId) { 737 console.log(`SubmoduleManagerService: Adding Radicle ID to sovereign ${childTitle}'s .udd...`); 738 childUDD.radicleId = childRadicleId; 739 await UDDService.writeUDD(sovereignPath, childUDD); 740 sovereignModified = true; 741 } 742 743 // Add parent's Radicle ID to sovereign's supermodules array (source of truth) 744 if (await UDDService.addSupermodule(sovereignPath, parentRadicleId)) { 745 console.log(`SubmoduleManagerService: Added ${parentTitle} (${parentRadicleId}) to sovereign ${childTitle}'s supermodules`); 746 sovereignModified = true; 747 } 748 749 // Commit all sovereign changes at once (only if there are actual changes) 750 if (sovereignModified) { 751 try { 752 await execAsync('git add .udd', { cwd: sovereignPath }); 753 754 // Check if there are actually staged changes before committing 755 const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: sovereignPath }); 756 757 if (statusOutput.trim()) { 758 // Commit with COHERENCE_BEACON metadata for network discovery 759 const beaconData = JSON.stringify({ 760 type: 'supermodule', 761 radicleId: parentRadicleId, 762 title: parentTitle 763 }); 764 765 const commitMessage = `Add supermodule relationship: ${parentTitle}\n\nCOHERENCE_BEACON: ${beaconData}`; 766 767 console.log(`SubmoduleManagerService: 🎯 Creating COHERENCE_BEACON commit in sovereign ${childTitle}`); 768 console.log(`SubmoduleManagerService: Beacon metadata:`, beaconData); 769 console.log(`SubmoduleManagerService: Full commit message:\n${commitMessage}`); 770 771 const { stdout: commitOutput } = await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: sovereignPath }); 772 console.log(`SubmoduleManagerService: Commit output:`, commitOutput); 773 774 // Get the commit hash 775 const { stdout: commitHash } = await execAsync('git rev-parse HEAD', { cwd: sovereignPath }); 776 console.log(`SubmoduleManagerService: ✓ COHERENCE_BEACON commit created: ${commitHash.trim()}`); 777 console.log(`SubmoduleManagerService: This commit will be detected when other vaults run "Check for Updates"`) 778 } else { 779 console.log(`SubmoduleManagerService: No changes to commit in sovereign ${childTitle} (metadata already up to date)`); 780 } 781 } catch (error) { 782 console.warn(`SubmoduleManagerService: Failed to commit sovereign .udd changes:`, error); 783 } 784 } 785 786 // STEP 2: NOW update submodule to point to latest sovereign commit with all metadata 787 console.log(`SubmoduleManagerService: Updating submodule to latest sovereign state...`); 788 789 // Initialize submodule first (if not already) 790 await execAsync(`git submodule update --init "${result.submoduleName}"`, { cwd: fullParentPath }); 791 792 // Update submodule to point to latest commit from sovereign (remote origin/main) 793 const submodulePath = path.join(fullParentPath, result.submoduleName); 794 await execAsync(`git fetch origin`, { cwd: submodulePath }); 795 await execAsync(`git checkout origin/main`, { cwd: submodulePath }); 796 797 console.log(`SubmoduleManagerService: Submodule ${childTitle} updated to latest with complete metadata`); 798 799 // Update parent's .udd (add child's Radicle ID to submodules array if missing) 800 if (await UDDService.addSubmodule(fullParentPath, childRadicleId)) { 801 console.log(`SubmoduleManagerService: Added ${childTitle} (${childRadicleId}) to parent's submodules`); 802 parentModified = true; 803 } 804 805 } catch (error) { 806 console.error(`SubmoduleManagerService: Error processing ${result.submoduleName}: ${error instanceof Error ? error.message : 'Unknown error'}`); 807 } 808 } 809 810 // Process removed submodules 811 for (const submoduleName of removedSubmodules) { 812 console.log(`SubmoduleManagerService: Processing removed submodule: ${submoduleName}`); 813 814 try { 815 // Try to get child's Radicle ID from sovereign repo (preferred source) 816 const sovereignPath = path.join(this.vaultPath, submoduleName); 817 const sovereignExists = require('fs').existsSync(path.join(sovereignPath, '.git')); 818 819 if (!sovereignExists) { 820 console.log(`SubmoduleManagerService: No sovereign repo found for ${submoduleName} - skipping relationship cleanup`); 821 console.log(`SubmoduleManagerService: (This is expected for DreamNodes cloned from GitHub/Radicle)`); 822 continue; 823 } 824 825 // Get child's Radicle ID from sovereign repo 826 const childRadicleId = await this.getOrInitializeRadicleId(sovereignPath); 827 828 if (!childRadicleId) { 829 console.warn(`SubmoduleManagerService: Could not get Radicle ID for removed submodule ${submoduleName} - skipping cleanup`); 830 continue; 831 } 832 833 console.log(`SubmoduleManagerService: Removed submodule Radicle ID: ${childRadicleId}`); 834 835 // Update parent's .udd (remove child's Radicle ID from submodules array) 836 if (await UDDService.removeSubmodule(fullParentPath, childRadicleId)) { 837 console.log(`SubmoduleManagerService: Removed ${submoduleName} (${childRadicleId}) from parent's submodules`); 838 parentModified = true; 839 } 840 841 // Update sovereign's supermodules on removal (bidirectional cleanup) 842 console.log(`SubmoduleManagerService: Removing supermodule relationship from sovereign ${submoduleName}`); 843 844 if (await UDDService.removeSupermodule(sovereignPath, parentRadicleId)) { 845 console.log(`SubmoduleManagerService: Removed ${parentTitle} (${parentRadicleId}) from sovereign ${submoduleName}'s supermodules`); 846 847 // Commit the change in the sovereign repository 848 try { 849 await execAsync('git add .udd', { cwd: sovereignPath }); 850 await execAsync(`git commit -m "Remove supermodule relationship: ${parentTitle}"`, { cwd: sovereignPath }); 851 console.log(`SubmoduleManagerService: Committed supermodule removal in sovereign ${submoduleName}`); 852 } catch (error) { 853 console.error(`SubmoduleManagerService: Failed to commit sovereign .udd changes: ${error instanceof Error ? error.message : 'Unknown error'}`); 854 } 855 } else { 856 console.log(`SubmoduleManagerService: Supermodule relationship already removed from sovereign ${submoduleName}`); 857 } 858 859 } catch (error) { 860 console.error(`SubmoduleManagerService: Error processing removed ${submoduleName}: ${error instanceof Error ? error.message : 'Unknown error'}`); 861 } 862 } 863 864 // Commit parent's .udd changes if needed 865 if (parentModified) { 866 try { 867 await execAsync('git add .udd', { cwd: fullParentPath }); 868 await execAsync('git commit -m "Update submodule relationships in .udd"', { cwd: fullParentPath }); 869 console.log('SubmoduleManagerService: Committed parent .udd relationship changes'); 870 } catch (error) { 871 console.error(`SubmoduleManagerService: Failed to commit parent .udd: ${error instanceof Error ? error.message : 'Unknown error'}`); 872 } 873 } 874 875 console.log('SubmoduleManagerService: Bidirectional relationship tracking complete'); 876 877 } catch (error) { 878 console.error(`SubmoduleManagerService: Fatal error in bidirectional tracking: ${error instanceof Error ? error.message : 'Unknown error'}`); 879 // Don't throw - this is a non-critical enhancement 880 } 881 } 882 883 /** 884 * Generate a summary report of sync operation 885 */ 886 generateSyncReport(result: SyncResult): string { 887 let report = `Submodule Sync Report: ${result.canvasPath}\n`; 888 report += `DreamNode: ${result.dreamNodePath}\n`; 889 report += `Status: ${result.success ? 'SUCCESS' : 'FAILED'}\n`; 890 891 if (result.error) { 892 report += `Error: ${result.error}\n`; 893 } 894 895 if (result.commitHash) { 896 report += `Commit: ${result.commitHash}\n`; 897 } 898 899 // Show added submodules (filter out already-existed ones) 900 const newImports = result.submodulesImported.filter(r => r.success && !r.alreadyExisted); 901 report += `\nSubmodules Added: ${newImports.length}\n`; 902 for (const imported of newImports) { 903 report += ` + ${imported.submoduleName}\n`; 904 } 905 906 // Show removed submodules 907 report += `\nSubmodules Removed: ${result.submodulesRemoved.length}\n`; 908 for (const removed of result.submodulesRemoved) { 909 report += ` - ${removed}\n`; 910 } 911 912 // Show unchanged submodules (already existed) 913 const unchanged = result.submodulesImported.filter(r => r.alreadyExisted); 914 if (unchanged.length > 0) { 915 report += `\nSubmodules Unchanged: ${unchanged.length}\n`; 916 for (const existing of unchanged) { 917 report += ` = ${existing.submoduleName}\n`; 918 } 919 } 920 921 report += `\nPaths Updated: ${result.pathsUpdated.size}\n`; 922 for (const [original, updated] of result.pathsUpdated) { 923 report += ` - ${original} → ${updated}\n`; 924 } 925 926 return report; 927 } 928 }