import type { FilesystemApi } from '../filesystem/types'; import { join, basename, dirname } from '../filesystem/vaultFs'; import { contentHash } from './utils/content'; import type { DetectedChange, DirectoryDocument, HabitatRepo, HabitatUri, SyncSnapshot, } from './types'; import { isFolderEntry } from './types'; export class VaultChangeDetector { constructor( private fs: FilesystemApi, private repo: HabitatRepo, ) {} /** * Steady-state detection: local filesystem vs snapshot only. No network. * Returns local file and directory changes (adds/edits/deletes) to push. */ async detectLocal( rootPath: string, snapshot: SyncSnapshot, ): Promise { const [files, dirs] = await Promise.all([ this.scanLocalFiles(rootPath, snapshot), this.scanLocalDirs(rootPath, snapshot), ]); return [...files, ...dirs]; } /** * First-open clone only: walk the entire remote tree and return every entry * not yet in the snapshot as an "add". Expensive (one `repo.find` per * directory) — only run once, when a vault has no local snapshot yet. */ async scanRemoteTree( rootUri: HabitatUri, snapshot: SyncSnapshot, ): Promise { return this.walkRemoteTree(rootUri, '', snapshot); } private async walkFilesystem( rootPath: string, relDir: string, ): Promise { const base = relDir ? join(rootPath, relDir) : rootPath; const entries = await this.fs.readdir(base); const result: string[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; const relPath = relDir ? join(relDir, entry.name) : entry.name; if (entry.isDirectory) { result.push(...(await this.walkFilesystem(rootPath, relPath))); } else if (entry.isFile) { result.push(relPath); } } return result; } private async walkFilesystemDirs( rootPath: string, relDir: string, ): Promise { const base = relDir ? join(rootPath, relDir) : rootPath; const entries = await this.fs.readdir(base); const result: string[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; if (entry.isDirectory) { const relPath = relDir ? join(relDir, entry.name) : entry.name; result.push(relPath); result.push(...(await this.walkFilesystemDirs(rootPath, relPath))); } } return result; } private async scanLocalFiles( rootPath: string, snapshot: SyncSnapshot, ): Promise { const fsFiles = await this.walkFilesystem(rootPath, ''); const changes: DetectedChange[] = []; for (const relPath of fsFiles) { const content = await this.fs.readFile(join(rootPath, relPath)); const hash = contentHash(content); if (snapshot.files.has(relPath)) { const entry = snapshot.files.get(relPath)!; if (entry.contentHash !== hash) { changes.push({ type: 'edit', path: relPath, isDirectory: false, uri: entry.uri, contentHash: hash, }); } } else { changes.push({ type: 'add', path: relPath, isDirectory: false, contentHash: hash, }); } } for (const [relPath, entry] of snapshot.files) { if (!fsFiles.includes(relPath)) { changes.push({ type: 'delete', path: relPath, isDirectory: false, uri: entry.uri, }); } } return changes; } private async scanLocalDirs( rootPath: string, snapshot: SyncSnapshot, ): Promise { const fsDirs = await this.walkFilesystemDirs(rootPath, ''); const changes: DetectedChange[] = []; for (const relPath of fsDirs) { if (!snapshot.directories.has(relPath)) { changes.push({ type: 'add', path: relPath, isDirectory: true }); } } for (const [relPath] of snapshot.directories) { if (!fsDirs.includes(relPath)) { const entry = snapshot.directories.get(relPath)!; changes.push({ type: 'delete', path: relPath, isDirectory: true, uri: entry.uri, }); } } return changes; } private async walkRemoteTree( dirUri: HabitatUri, relPath: string, snapshot: SyncSnapshot, ): Promise { const result: DetectedChange[] = []; try { const handle = await this.repo.find(dirUri); const dirDoc = handle.doc(); for (const entry of dirDoc.docs ?? []) { const childRel = relPath ? join(relPath, entry.name) : entry.name; if (isFolderEntry(entry)) { if (!snapshot.directories.has(childRel)) { const childHandle = await this.repo.find( entry.url, ); result.push({ type: 'add', path: childRel, isDirectory: true, uri: entry.url, head: childHandle.heads(), }); } result.push( ...(await this.walkRemoteTree(entry.url, childRel, snapshot)), ); } else { if (!snapshot.files.has(childRel)) { result.push({ type: 'add', path: childRel, isDirectory: false, uri: entry.url, }); } } } } catch (e) { console.warn('[ChangeDetector] walkRemoteTree failed for', dirUri, e); } return result; } } // Re-export for use in engine export { basename, dirname };