/ src / services / submodule-manager-service.ts
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  }