From 572add11f926f3211c3fded63594f31dbf435c5a Mon Sep 17 00:00:00 2001 From: Mark Rhoades-Brown Date: Fri, 29 May 2026 21:26:14 +0100 Subject: [PATCH] Add support for multiple git repos --- main.ts | 159 +++++++++++++++++++++- src/services/syncService.ts | 146 ++++++++++++++------- src/types/settings.ts | 28 ++++ src/ui/settingsTab.ts | 200 +++++++++++++++++++++++++++- src/views/SyncView.ts | 46 ++++++- styles.css | 29 +++++ tests/services/syncService.test.ts | 203 +++++++++++++++++++++++++++++ 7 files changed, 753 insertions(+), 58 deletions(-) diff --git a/main.ts b/main.ts index 5c23305..bb7cce4 100644 --- a/main.ts +++ b/main.ts @@ -5,12 +5,20 @@ import { LoggerService } from './src/services/loggerService'; import { DiffView, DIFF_VIEW_TYPE } from './src/views/DiffView'; import { SyncView, SYNC_VIEW_TYPE } from './src/views/SyncView'; import { GitHubOctokitSettingTab, SyncModal } from './src/ui'; -import { GitHubOctokitSettings, DEFAULT_SETTINGS } from './src/types/settings'; +import { GitHubOctokitSettings, DEFAULT_SETTINGS, AdditionalRepoConfig } from './src/types/settings'; + +/** Per-repo runtime state for additional repositories */ +export interface AdditionalRepoRuntime { + config: AdditionalRepoConfig; + githubService: GitHubService; + syncService: SyncService; + syncState: PersistedSyncState | null; +} /** Shape of the data persisted via loadData/saveData */ interface PersistedPluginData extends Partial { syncState?: PersistedSyncState | null; - additionalRepoStates?: Record; + additionalRepoStates?: Record; } export default class GitHubOctokitPlugin extends Plugin { @@ -20,6 +28,8 @@ export default class GitHubOctokitPlugin extends Plugin { logger!: LoggerService; private statusBarItem: HTMLElement | null = null; private syncState: PersistedSyncState | null = null; + private additionalRepoStates: Record = {}; + private additionalRepos: Map = new Map(); private syncIntervalId: number | null = null; private isSyncing = false; @@ -51,6 +61,9 @@ export default class GitHubOctokitPlugin extends Plugin { await this.validateAndConnect(); } + // Initialize additional repos + await this.initializeAdditionalRepos(); + // This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon('github', 'Sync with remote repository', async (evt: MouseEvent) => { if (evt.button === 0) { @@ -208,6 +221,10 @@ export default class GitHubOctokitPlugin extends Plugin { onunload() { this.githubService.disconnect(); + for (const runtime of this.additionalRepos.values()) { + runtime.githubService.disconnect(); + } + this.additionalRepos.clear(); if (this.syncIntervalId) { window.clearInterval(this.syncIntervalId); } @@ -224,9 +241,9 @@ export default class GitHubOctokitPlugin extends Plugin { this.settings.auth.username = this.githubService.user.login; await this.saveSettings(); this.updateStatusBar(); - // Update sync service config + // Update sync service config with additional repo exclusions this.syncService.configure( - this.settings.ignorePatterns, + this.getMainRepoIgnorePatterns(), this.settings.subfolderPath, this.settings.syncConfiguration ); @@ -240,6 +257,87 @@ export default class GitHubOctokitPlugin extends Plugin { } } + /** + * Get the additional repo runtime instances (for SyncView) + */ + getAdditionalRepos(): Map { + return this.additionalRepos; + } + + /** + * Get ignore patterns for the main repo, including additional repo directories + */ + getMainRepoIgnorePatterns(): string[] { + const patterns = [...this.settings.ignorePatterns]; + // Exclude additional repo directories from main repo sync + for (const repoConfig of this.settings.additionalRepos) { + if (repoConfig.enabled && repoConfig.localPath) { + const dirPattern = `${repoConfig.localPath}/**`; + if (!patterns.includes(dirPattern)) { + patterns.push(dirPattern); + } + } + } + return patterns; + } + + /** + * Initialize additional repo services and authenticate them + */ + async initializeAdditionalRepos(): Promise { + // Clean up existing + for (const runtime of this.additionalRepos.values()) { + runtime.githubService.disconnect(); + } + this.additionalRepos.clear(); + + for (const repoConfig of this.settings.additionalRepos) { + if (!repoConfig.enabled) continue; + + const token = repoConfig.useMainToken + ? this.settings.auth.token + : repoConfig.token; + + if (!token) { + this.logger.warn('AdditionalRepo', `No token for ${repoConfig.owner}/${repoConfig.repo}, skipping`); + continue; + } + + const ghService = new GitHubService(); + const authenticated = await ghService.authenticate(token); + + if (!authenticated) { + this.logger.warn('AdditionalRepo', `Failed to authenticate ${repoConfig.owner}/${repoConfig.repo}`); + continue; + } + + const syncService = new SyncService( + this.app, + ghService, + repoConfig.ignorePatterns, + repoConfig.subfolderPath, + false, // No config sync for additional repos + repoConfig.localPath + ); + + this.additionalRepos.set(repoConfig.id, { + config: repoConfig, + githubService: ghService, + syncService, + syncState: this.additionalRepoStates[repoConfig.id] || null, + }); + + this.logger.info('AdditionalRepo', `Initialized ${repoConfig.owner}/${repoConfig.repo} → ${repoConfig.localPath}`); + } + + // Update main repo ignore patterns to exclude additional repo directories + this.syncService.configure( + this.getMainRepoIgnorePatterns(), + this.settings.subfolderPath, + this.settings.syncConfiguration + ); + } + /** * Update the status bar with current sync status */ @@ -386,6 +484,9 @@ export default class GitHubOctokitPlugin extends Plugin { errors: result.errors, }); + // Sync additional repos + await this.syncAdditionalRepos(direction, commitMessage); + // Show result notification if (this.settings.showNotifications) { if (result.success) { @@ -448,29 +549,73 @@ export default class GitHubOctokitPlugin extends Plugin { } } + /** + * Sync all enabled additional repositories + */ + private async syncAdditionalRepos(direction: 'pull' | 'push' | 'sync', commitMessage: string): Promise { + for (const [id, runtime] of this.additionalRepos) { + try { + const { config, syncService, syncState } = runtime; + this.logger.info('AdditionalRepo', `Syncing ${config.owner}/${config.repo}`, { direction }); + + const { result: repoResult, newState: repoNewState } = await syncService.sync( + config.owner, + config.repo, + config.branch, + commitMessage, + syncState || undefined, + { direction } + ); + + // Update runtime state + runtime.syncState = repoNewState; + this.additionalRepoStates[id] = repoNewState; + await this.saveSyncState(); + + if (repoResult.filesProcessed > 0) { + this.logger.info('AdditionalRepo', `${config.owner}/${config.repo}: ${repoResult.filesPulled} pulled, ${repoResult.filesPushed} pushed`); + } + + if (repoResult.errors.length > 0) { + this.logger.error('AdditionalRepo', `Errors in ${config.owner}/${config.repo}`, { errors: repoResult.errors }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.logger.error('AdditionalRepo', `Failed to sync ${runtime.config.owner}/${runtime.config.repo}: ${msg}`); + } + } + } + async loadSettings() { const data = (await this.loadData() || {}) as PersistedPluginData; - // Extract syncState before merging with defaults - syncState is separate from settings - const { syncState: _ignored, ...settingsData } = data; + // Extract syncState and additionalRepoStates before merging with defaults + const { syncState: _ignored, additionalRepoStates: _ignored2, ...settingsData } = data; void _ignored; // intentionally unused - just extracting syncState from data + void _ignored2; this.settings = Object.assign({}, DEFAULT_SETTINGS, settingsData); } async saveSettings() { // Preserve syncState when saving settings const data = (await this.loadData() || {}) as PersistedPluginData; - await this.saveData({ ...this.settings, syncState: data.syncState }); + await this.saveData({ + ...this.settings, + syncState: data.syncState, + additionalRepoStates: data.additionalRepoStates, + }); } async loadSyncState() { const data = await this.loadData() as PersistedPluginData | null; this.syncState = data?.syncState || null; + this.additionalRepoStates = data?.additionalRepoStates || {}; } async saveSyncState() { // Preserve settings when saving syncState const data = (await this.loadData() || {}) as PersistedPluginData; data.syncState = this.syncState; + data.additionalRepoStates = this.additionalRepoStates; await this.saveData(data); } diff --git a/src/services/syncService.ts b/src/services/syncService.ts index c308877..846ec43 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -93,13 +93,15 @@ export class SyncService { private ignorePatterns: string[]; private subfolderPath: string; private syncConfiguration: boolean; + private localBasePath: string; constructor( app: App, githubService: GitHubService, ignorePatterns: string[] = [], subfolderPath: string = '', - syncConfiguration: boolean = false + syncConfiguration: boolean = false, + localBasePath: string = '' ) { this.app = app; this.vault = app.vault; @@ -107,15 +109,22 @@ export class SyncService { this.ignorePatterns = ignorePatterns; this.subfolderPath = subfolderPath; this.syncConfiguration = syncConfiguration; + this.localBasePath = localBasePath; } /** * Update configuration */ - configure(ignorePatterns: string[], subfolderPath: string, syncConfiguration: boolean = false): void { + configure( + ignorePatterns: string[], + subfolderPath: string, + syncConfiguration: boolean = false, + localBasePath: string = '' + ): void { this.ignorePatterns = ignorePatterns; this.subfolderPath = subfolderPath; this.syncConfiguration = syncConfiguration; + this.localBasePath = localBasePath; } /** @@ -162,47 +171,74 @@ export class SyncService { // Local File Index // ======================================================================== + /** + * Convert an absolute vault path to a path relative to localBasePath. + * Returns null if the path is not under localBasePath. + */ + private toRelativeLocalPath(absolutePath: string): string | null { + if (!this.localBasePath) return absolutePath; + if (!absolutePath.startsWith(this.localBasePath + '/')) return null; + return absolutePath.slice(this.localBasePath.length + 1); + } + + /** + * Convert a relative path back to an absolute vault path (prepend localBasePath). + */ + toAbsoluteLocalPath(relativePath: string): string { + if (!this.localBasePath) return relativePath; + return normalizePath(`${this.localBasePath}/${relativePath}`); + } + /** * Build index of all local files in the vault + * When localBasePath is set, only files under that directory are indexed + * and paths are stored relative to localBasePath. */ async buildLocalIndex(): Promise> { const index = new Map(); - const files = this.vault.getFiles(); - for (const file of files) { - // Skip ignored files - if (this.shouldIgnore(file.path)) { - continue; - } + if (this.localBasePath) { + // Additional repo mode: scan only files under localBasePath using adapter + await this.indexFolderRecursive(this.localBasePath, index, true); + } else { + // Main repo mode: scan all vault files + const files = this.vault.getFiles(); - const isBin = isBinaryFile(file.path); - let hash: string; - let gitSha: string; + for (const file of files) { + // Skip ignored files + if (this.shouldIgnore(file.path)) { + continue; + } - if (isBin) { - const content = await this.vault.readBinary(file); - hash = hashContent(Array.from(new Uint8Array(content)).join(',')); - gitSha = await computeGitBlobShaBinary(content); - } else { - const content = await this.vault.read(file); - hash = hashContent(content); - gitSha = await computeGitBlobSha(content); - } + const isBin = isBinaryFile(file.path); + let hash: string; + let gitSha: string; - index.set(file.path, { - path: file.path, - hash, - gitSha, - modified: file.stat.mtime, - size: file.stat.size, - isBinary: isBin, - }); - } + if (isBin) { + const content = await this.vault.readBinary(file); + hash = hashContent(Array.from(new Uint8Array(content)).join(',')); + gitSha = await computeGitBlobShaBinary(content); + } else { + const content = await this.vault.read(file); + hash = hashContent(content); + gitSha = await computeGitBlobSha(content); + } - // If syncing configuration, also include files from the config folder - // (vault.getFiles() doesn't include config folder files) - if (this.syncConfiguration) { - await this.indexConfigFolder(index); + index.set(file.path, { + path: file.path, + hash, + gitSha, + modified: file.stat.mtime, + size: file.stat.size, + isBinary: isBin, + }); + } + + // If syncing configuration, also include files from the config folder + // (vault.getFiles() doesn't include config folder files) + if (this.syncConfiguration) { + await this.indexConfigFolder(index); + } } return index; @@ -218,14 +254,27 @@ export class SyncService { /** * Recursively index files in a folder using the adapter API + * When stripBasePath is true, paths are stored relative to localBasePath */ - private async indexFolderRecursive(folderPath: string, index: Map): Promise { + private async indexFolderRecursive( + folderPath: string, + index: Map, + stripBasePath: boolean = false + ): Promise { try { const listing = await this.vault.adapter.list(folderPath); // Process files for (const filePath of listing.files) { - if (this.shouldIgnore(filePath)) { + // Determine the key path (relative if stripping base, absolute otherwise) + const keyPath = stripBasePath + ? this.toRelativeLocalPath(filePath) + : filePath; + + // Skip files outside localBasePath when stripping + if (keyPath === null) continue; + + if (this.shouldIgnore(keyPath)) { continue; } @@ -247,8 +296,8 @@ export class SyncService { gitSha = await computeGitBlobSha(content); } - index.set(filePath, { - path: filePath, + index.set(keyPath, { + path: keyPath, hash, gitSha, modified: stat.mtime, @@ -256,13 +305,13 @@ export class SyncService { isBinary: isBin, }); } catch (error) { - console.warn(`[Sync] Failed to index config file ${filePath}:`, error); + console.warn(`[Sync] Failed to index file ${filePath}:`, error); } } // Recurse into subfolders for (const subfolder of listing.folders) { - await this.indexFolderRecursive(subfolder, index); + await this.indexFolderRecursive(subfolder, index, stripBasePath); } } catch (error) { console.warn(`[Sync] Failed to list folder ${folderPath}:`, error); @@ -548,19 +597,24 @@ export class SyncService { const remote = remoteIndex.get(change.path); + // Convert relative path to absolute vault path for file operations + const absolutePath = this.toAbsoluteLocalPath(change.path); + if (remote) { // File exists on remote - pull it const result = await this.pullFile( owner, repo, remote.path, - change.path + absolutePath ); + // Store the relative path in the result for consistency + result.path = change.path; results.push(result); } else if (change.status === 'deleted') { // File was deleted on remote - delete locally (using trash to respect user preferences) try { - const file = this.vault.getAbstractFileByPath(change.path); + const file = this.vault.getAbstractFileByPath(absolutePath); if (file) { await this.app.fileManager.trashFile(file); } @@ -605,6 +659,8 @@ export class SyncService { const local = localIndex.get(change.path); const remotePath = this.toRemotePath(change.path); + // Convert relative path to absolute vault path for file operations + const absolutePath = this.toAbsoluteLocalPath(change.path); if (local) { // File exists locally - push it @@ -612,7 +668,7 @@ export class SyncService { let content: string; // Try vault API first (for regular vault files) - const file = this.vault.getAbstractFileByPath(change.path); + const file = this.vault.getAbstractFileByPath(absolutePath); if (file instanceof TFile) { if (local.isBinary) { const binaryData = await this.vault.readBinary(file); @@ -622,12 +678,12 @@ export class SyncService { content = encodeBase64(textContent); } } else { - // Fall back to adapter API (for config folder files) + // Fall back to adapter API (for config folder files or additional repo files) if (local.isBinary) { - const binaryData = await this.vault.adapter.readBinary(change.path); + const binaryData = await this.vault.adapter.readBinary(absolutePath); content = encodeBase64Binary(binaryData); } else { - const textContent = await this.vault.adapter.read(change.path); + const textContent = await this.vault.adapter.read(absolutePath); content = encodeBase64(textContent); } } diff --git a/src/types/settings.ts b/src/types/settings.ts index 5e09d72..717bee5 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -52,6 +52,30 @@ export interface CommitConfig { includeFileCount: boolean; } +/** Configuration for an additional repository synced to a vault directory */ +export interface AdditionalRepoConfig { + /** Stable unique identifier for this repo config */ + id: string; + /** Repository owner (user or organization) */ + owner: string; + /** Repository name */ + repo: string; + /** Branch to sync with */ + branch: string; + /** Local vault directory to sync this repo into */ + localPath: string; + /** Whether to use the main auth token or a separate one */ + useMainToken: boolean; + /** Separate personal access token (used when useMainToken is false) */ + token: string; + /** Subfolder within the remote repo to sync (optional) */ + subfolderPath: string; + /** Ignore patterns specific to this repo */ + ignorePatterns: string[]; + /** Whether this repo is enabled for syncing */ + enabled: boolean; +} + /** Main plugin settings */ export interface GitHubOctokitSettings { // Authentication @@ -72,6 +96,9 @@ export interface GitHubOctokitSettings { // Ignore patterns ignorePatterns: string[]; + // Additional repositories + additionalRepos: AdditionalRepoConfig[]; + // UI preferences showStatusBar: boolean; showNotifications: boolean; @@ -165,6 +192,7 @@ export const DEFAULT_SETTINGS: GitHubOctokitSettings = { }, syncConfiguration: false, // Don't sync config folder by default defaultConflictResolution: 'manual', + additionalRepos: [], // Config-specific patterns are added dynamically using vault.configDir // These are only non-config patterns ignorePatterns: [ diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index e1450e2..6871f49 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -1,7 +1,7 @@ import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; import type { GitHubRepo } from '../services/githubService'; import { LogLevel } from '../services/loggerService'; -import { ConflictResolution } from '../types/settings'; +import { AdditionalRepoConfig, ConflictResolution } from '../types/settings'; import { LogViewerModal } from './modals/LogViewerModal'; import type GitHubOctokitPlugin from '../../main'; @@ -23,6 +23,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { this.renderAuthSection(containerEl); this.renderRepoSection(containerEl); + this.renderAdditionalReposSection(containerEl); this.renderIgnorePatternsSection(containerEl); this.renderSyncTriggersSection(containerEl); this.renderCommitSection(containerEl); @@ -153,6 +154,197 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { })); } + private renderAdditionalReposSection(containerEl: HTMLElement): void { + new Setting(containerEl).setName('Additional repositories').setHeading(); + new Setting(containerEl) + .setDesc('Sync additional GitHub repositories into specific vault directories. Each repo is synced independently.'); + + // Render existing additional repos + for (const repoConfig of this.plugin.settings.additionalRepos) { + this.renderAdditionalRepoEntry(containerEl, repoConfig); + } + + // Add new repo button + new Setting(containerEl) + .setName('Add repository') + .addButton(button => button + .setButtonText('Add') + .setCta() + .onClick(async () => { + const newRepo: AdditionalRepoConfig = { + id: this.generateId(), + owner: '', + repo: '', + branch: 'main', + localPath: '', + useMainToken: true, + token: '', + subfolderPath: '', + ignorePatterns: [], + enabled: true, + }; + this.plugin.settings.additionalRepos.push(newRepo); + await this.plugin.saveSettings(); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions + this.display(); + })); + } + + private renderAdditionalRepoEntry(containerEl: HTMLElement, repoConfig: AdditionalRepoConfig): void { + const repoContainer = containerEl.createDiv({ cls: 'github-octokit-additional-repo' }); + const index = this.plugin.settings.additionalRepos.indexOf(repoConfig); + + // Header with repo name and controls + const headerLabel = repoConfig.owner && repoConfig.repo + ? `${repoConfig.owner}/${repoConfig.repo}` + : 'New repository'; + + new Setting(repoContainer) + .setName(headerLabel) + .addToggle(toggle => toggle + .setValue(repoConfig.enabled) + .setTooltip('Enable or disable this repository') + .onChange(async (value) => { + repoConfig.enabled = value; + await this.plugin.saveSettings(); + await this.plugin.initializeAdditionalRepos(); + })) + .addButton(button => button + .setIcon('trash') + .setTooltip('Remove repository') + .onClick(async () => { + this.plugin.settings.additionalRepos.splice(index, 1); + await this.plugin.saveSettings(); + await this.plugin.initializeAdditionalRepos(); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions + this.display(); + })); + + // Owner + new Setting(repoContainer) + .setName('Owner') + .setDesc('GitHub user or organization') + .addText(text => text + .setPlaceholder('Owner') + .setValue(repoConfig.owner) + .onChange(async (value) => { + repoConfig.owner = value.trim(); + await this.plugin.saveSettings(); + })); + + // Repo name + new Setting(repoContainer) + .setName('Repository name') + .addText(text => text + .setPlaceholder('Repo name') + .setValue(repoConfig.repo) + .onChange(async (value) => { + repoConfig.repo = value.trim(); + await this.plugin.saveSettings(); + })); + + // Branch + new Setting(repoContainer) + .setName('Branch') + .addText(text => text + .setPlaceholder('Main') + .setValue(repoConfig.branch) + .onChange(async (value) => { + repoConfig.branch = value.trim() || 'main'; + await this.plugin.saveSettings(); + })); + + // Local path + new Setting(repoContainer) + .setName('Vault directory') + .setDesc('Directory in the vault to sync this repo into') + .addText(text => text + .setPlaceholder('My other repo') + .setValue(repoConfig.localPath) + .onChange(async (value) => { + const trimmed = value.trim(); + // Validate no overlap with other repos + const overlap = this.validateLocalPath(trimmed, repoConfig.id); + if (overlap) { + new Notice(overlap); + return; + } + repoConfig.localPath = trimmed; + await this.plugin.saveSettings(); + await this.plugin.initializeAdditionalRepos(); + })); + + // Token settings + new Setting(repoContainer) + .setName('Use main token') + .setDesc('Use the same token as the main repository') + .addToggle(toggle => toggle + .setValue(repoConfig.useMainToken) + .onChange(async (value) => { + repoConfig.useMainToken = value; + await this.plugin.saveSettings(); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions + this.display(); + })); + + if (!repoConfig.useMainToken) { + new Setting(repoContainer) + .setName('Personal access token') + .addText(text => { + text.inputEl.type = 'password'; + text + .setPlaceholder('Paste token here') + .setValue(repoConfig.token) + .onChange(async (value) => { + repoConfig.token = value; + await this.plugin.saveSettings(); + }); + }); + } + + // Subfolder path + new Setting(repoContainer) + .setName('Subfolder path') + .setDesc('Optional: sync a subfolder of the remote repo') + .addText(text => text + .setPlaceholder('E.g., docs/notes') + .setValue(repoConfig.subfolderPath) + .onChange(async (value) => { + repoConfig.subfolderPath = value.trim(); + await this.plugin.saveSettings(); + })); + } + + /** + * Validate that a local path does not overlap with other repos + */ + private validateLocalPath(localPath: string, excludeId: string): string | null { + if (!localPath) return null; + + for (const repo of this.plugin.settings.additionalRepos) { + if (repo.id === excludeId) continue; + if (!repo.localPath) continue; + + // Check for exact match + if (repo.localPath === localPath) { + return `Path "${localPath}" is already used by ${repo.owner}/${repo.repo}`; + } + + // Check for nesting + if (localPath.startsWith(repo.localPath + '/') || repo.localPath.startsWith(localPath + '/')) { + return `Path "${localPath}" overlaps with ${repo.owner}/${repo.repo} (${repo.localPath})`; + } + } + return null; + } + + /** + * Generate a simple unique ID + */ + private generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 9); + } + private renderIgnorePatternsSection(containerEl: HTMLElement): void { new Setting(containerEl).setName('Ignore patterns').setHeading(); new Setting(containerEl) @@ -170,7 +362,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { this.plugin.settings.ignorePatterns.splice(index, 1); await this.plugin.saveSettings(); this.plugin.syncService.configure( - this.plugin.settings.ignorePatterns, + this.plugin.getMainRepoIgnorePatterns(), this.plugin.settings.subfolderPath, this.plugin.settings.syncConfiguration ); @@ -194,7 +386,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { this.plugin.settings.ignorePatterns.push(value); await this.plugin.saveSettings(); this.plugin.syncService.configure( - this.plugin.settings.ignorePatterns, + this.plugin.getMainRepoIgnorePatterns(), this.plugin.settings.subfolderPath, this.plugin.settings.syncConfiguration ); @@ -217,7 +409,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { this.plugin.settings.syncConfiguration = value; await this.plugin.saveSettings(); this.plugin.syncService.configure( - this.plugin.settings.ignorePatterns, + this.plugin.getMainRepoIgnorePatterns(), this.plugin.settings.subfolderPath, this.plugin.settings.syncConfiguration ); diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index ae63411..ef27770 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -17,6 +17,8 @@ interface FileGroup { export class SyncView extends ItemView { private plugin: GitHubOctokitPlugin; private changes: FileSyncState[] = []; + /** Maps file path to a repo label (empty string for main repo) */ + private fileRepoLabels: Map = new Map(); private stagedPaths: Set = new Set(); private isRefreshing = false; private logUnsubscribe: (() => void) | null = null; @@ -68,7 +70,9 @@ export class SyncView extends ItemView { return; } - // Build indexes and compare + this.fileRepoLabels.clear(); + + // Build indexes and compare for main repo const localIndex = await this.plugin.syncService.buildLocalIndex(); const remoteIndex = await this.plugin.syncService.buildRemoteIndex( this.plugin.settings.repo.owner, @@ -85,6 +89,38 @@ export class SyncView extends ItemView { // Filter to only changed files this.changes = this.changes.filter(c => c.status !== 'unchanged'); + // Label main repo files + for (const change of this.changes) { + this.fileRepoLabels.set(change.path, ''); + } + + // Gather changes from additional repos + for (const [, runtime] of this.plugin.getAdditionalRepos()) { + try { + const addlLocal = await runtime.syncService.buildLocalIndex(); + const addlRemote = await runtime.syncService.buildRemoteIndex( + runtime.config.owner, + runtime.config.repo, + runtime.config.branch + ); + const addlChanges = runtime.syncService.compareIndexes( + addlLocal, + addlRemote, + runtime.syncState || undefined + ).filter(c => c.status !== 'unchanged'); + + const repoLabel = `${runtime.config.owner}/${runtime.config.repo}`; + for (const change of addlChanges) { + // Convert relative path to absolute vault path for display + const vaultPath = runtime.syncService.toAbsoluteLocalPath(change.path); + this.fileRepoLabels.set(vaultPath, repoLabel); + this.changes.push({ ...change, path: vaultPath }); + } + } catch (error) { + console.error(`Failed to refresh additional repo ${runtime.config.owner}/${runtime.config.repo}:`, error); + } + } + this.render(); } catch (error) { console.error('Failed to refresh sync view:', error); @@ -267,7 +303,13 @@ export class SyncView extends ItemView { // File name with hover tooltip const pathParts = file.path.split('/'); const displayName = pathParts[pathParts.length - 1]; - const fileNameEl = fileEl.createSpan({ cls: 'file-name', text: displayName }); + const fileNameEl = fileEl.createSpan({ cls: 'file-name' }); + const repoLabel = this.fileRepoLabels.get(file.path); + if (repoLabel) { + const badge = fileNameEl.createSpan({ cls: 'file-repo-badge', text: repoLabel }); + badge.setAttribute('title', `From additional repo: ${repoLabel}`); + } + fileNameEl.appendText(displayName); fileNameEl.setAttribute('title', file.path); // Actions diff --git a/styles.css b/styles.css index 8b01be0..36d0acb 100644 --- a/styles.css +++ b/styles.css @@ -606,3 +606,32 @@ padding: 2px 6px; font-size: 12px; } + +/* ============================================ + Additional Repos Settings + ============================================ */ + +.github-octokit-additional-repo { + margin: 12px 0; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-secondary); +} + +.github-octokit-additional-repo .setting-item { + border-top: none; + padding: 6px 0; +} + +/* Repo badge in sync file list */ +.file-repo-badge { + display: inline-block; + font-size: 0.7em; + padding: 1px 4px; + margin-right: 4px; + border-radius: 3px; + background: var(--interactive-accent); + color: var(--text-on-accent); + vertical-align: middle; +} diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts index 9faafd6..73d7551 100644 --- a/tests/services/syncService.test.ts +++ b/tests/services/syncService.test.ts @@ -533,3 +533,206 @@ describe('SyncService - Modified File Detection', () => { }); }); }); + +// ============================================================================ +// Multi-Repo Path Validation Tests +// ============================================================================ + +describe('Multi-repo path validation', () => { + interface AdditionalRepoLike { + id: string; + localPath: string; + owner: string; + repo: string; + enabled: boolean; + } + + function validateLocalPath( + localPath: string, + excludeId: string, + repos: AdditionalRepoLike[] + ): string | null { + if (!localPath) return null; + for (const repo of repos) { + if (repo.id === excludeId) continue; + if (!repo.localPath) continue; + if (repo.localPath === localPath) { + return `Path "${localPath}" is already used by ${repo.owner}/${repo.repo}`; + } + if (localPath.startsWith(repo.localPath + '/') || repo.localPath.startsWith(localPath + '/')) { + return `Path "${localPath}" overlaps with ${repo.owner}/${repo.repo} (${repo.localPath})`; + } + } + return null; + } + + test('should allow non-overlapping paths', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'shared-templates', owner: 'org', repo: 'templates', enabled: true }, + ]; + expect(validateLocalPath('reference-notes', 'b', repos)).toBeNull(); + }); + + test('should reject exact duplicate paths', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'shared-templates', owner: 'org', repo: 'templates', enabled: true }, + ]; + const result = validateLocalPath('shared-templates', 'b', repos); + expect(result).toContain('already used'); + }); + + test('should reject nested paths (child inside parent)', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'docs', owner: 'org', repo: 'docs', enabled: true }, + ]; + const result = validateLocalPath('docs/sub', 'b', repos); + expect(result).toContain('overlaps'); + }); + + test('should reject nested paths (parent containing child)', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'docs/sub', owner: 'org', repo: 'sub', enabled: true }, + ]; + const result = validateLocalPath('docs', 'b', repos); + expect(result).toContain('overlaps'); + }); + + test('should skip self when validating', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'shared-templates', owner: 'org', repo: 'templates', enabled: true }, + ]; + expect(validateLocalPath('shared-templates', 'a', repos)).toBeNull(); + }); + + test('should allow empty local path', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: 'shared-templates', owner: 'org', repo: 'templates', enabled: true }, + ]; + expect(validateLocalPath('', 'b', repos)).toBeNull(); + }); + + test('should skip repos with empty local paths', () => { + const repos: AdditionalRepoLike[] = [ + { id: 'a', localPath: '', owner: 'org', repo: 'templates', enabled: true }, + ]; + expect(validateLocalPath('anything', 'b', repos)).toBeNull(); + }); +}); + +// ============================================================================ +// Main Repo Ignore Patterns Tests +// ============================================================================ + +describe('Main repo ignore patterns with additional repos', () => { + function getMainRepoIgnorePatterns( + basePatterns: string[], + additionalRepos: Array<{ enabled: boolean; localPath: string }> + ): string[] { + const patterns = [...basePatterns]; + for (const repoConfig of additionalRepos) { + if (repoConfig.enabled && repoConfig.localPath) { + const dirPattern = `${repoConfig.localPath}/**`; + if (!patterns.includes(dirPattern)) { + patterns.push(dirPattern); + } + } + } + return patterns; + } + + test('should include base patterns when no additional repos', () => { + const result = getMainRepoIgnorePatterns(['.git/**', '*.tmp'], []); + expect(result).toEqual(['.git/**', '*.tmp']); + }); + + test('should add glob patterns for enabled repos', () => { + const result = getMainRepoIgnorePatterns(['.git/**'], [ + { enabled: true, localPath: 'shared' }, + { enabled: true, localPath: 'reference' }, + ]); + expect(result).toContain('shared/**'); + expect(result).toContain('reference/**'); + }); + + test('should not add patterns for disabled repos', () => { + const result = getMainRepoIgnorePatterns(['.git/**'], [ + { enabled: false, localPath: 'shared' }, + ]); + expect(result).not.toContain('shared/**'); + }); + + test('should not add patterns for repos without a local path', () => { + const result = getMainRepoIgnorePatterns(['.git/**'], [ + { enabled: true, localPath: '' }, + ]); + expect(result).toEqual(['.git/**']); + }); + + test('should not duplicate existing patterns', () => { + const result = getMainRepoIgnorePatterns(['shared/**'], [ + { enabled: true, localPath: 'shared' }, + ]); + expect(result.filter(p => p === 'shared/**')).toHaveLength(1); + }); + + test('pattern matching with additional repo directories', () => { + const patterns = getMainRepoIgnorePatterns([], [ + { enabled: true, localPath: 'shared-templates' }, + ]); + // The generated pattern should match files inside the directory + expect(matchesIgnorePattern('shared-templates/file.md', patterns)).toBe(true); + expect(matchesIgnorePattern('shared-templates/sub/file.md', patterns)).toBe(true); + // But not files outside + expect(matchesIgnorePattern('other/file.md', patterns)).toBe(false); + }); +}); + +// ============================================================================ +// toRelativeLocalPath / toAbsoluteLocalPath Tests +// ============================================================================ + +describe('Path conversion for additional repos', () => { + // Standalone functions matching the SyncService logic + function toRelativeLocalPath(absolutePath: string, localBasePath: string): string | null { + if (!localBasePath) return absolutePath; + if (!absolutePath.startsWith(localBasePath + '/')) return null; + return absolutePath.slice(localBasePath.length + 1); + } + + function toAbsoluteLocalPath(relativePath: string, localBasePath: string): string { + if (!localBasePath) return relativePath; + return `${localBasePath}/${relativePath}`; + } + + test('toRelativeLocalPath returns path unchanged when no base', () => { + expect(toRelativeLocalPath('notes/file.md', '')).toBe('notes/file.md'); + }); + + test('toRelativeLocalPath strips base path', () => { + expect(toRelativeLocalPath('shared/file.md', 'shared')).toBe('file.md'); + }); + + test('toRelativeLocalPath strips nested base path', () => { + expect(toRelativeLocalPath('shared/sub/file.md', 'shared')).toBe('sub/file.md'); + }); + + test('toRelativeLocalPath returns null for path outside base', () => { + expect(toRelativeLocalPath('other/file.md', 'shared')).toBeNull(); + }); + + test('toRelativeLocalPath returns null for partial prefix match', () => { + expect(toRelativeLocalPath('shared-extra/file.md', 'shared')).toBeNull(); + }); + + test('toAbsoluteLocalPath returns path unchanged when no base', () => { + expect(toAbsoluteLocalPath('file.md', '')).toBe('file.md'); + }); + + test('toAbsoluteLocalPath prepends base path', () => { + expect(toAbsoluteLocalPath('file.md', 'shared')).toBe('shared/file.md'); + }); + + test('toAbsoluteLocalPath prepends nested base path', () => { + expect(toAbsoluteLocalPath('sub/file.md', 'shared')).toBe('shared/sub/file.md'); + }); +});