diff --git a/package.json b/package.json index cb87657..b015ab8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@uiw/react-codemirror": "^4.23.8", "@zip.js/zip.js": "^2.7.60", "codemirror": "^6.0.1", + "ignore": "5.3.2", "react": "^19.0.0", "react-dom": "^19.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57e4dbc..171a456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: codemirror: specifier: ^6.0.1 version: 6.0.1 + ignore: + specifier: 5.3.2 + version: 5.3.2 react: specifier: ^19.0.0 version: 19.0.0 diff --git a/src/events-listener.ts b/src/events-listener.ts index 15093c4..7264158 100644 --- a/src/events-listener.ts +++ b/src/events-listener.ts @@ -3,6 +3,7 @@ import MetadataStore, { MANIFEST_FILE_NAME } from "./metadata-store"; import { GitHubSyncSettings } from "./settings/settings"; import Logger, { LOG_FILE_NAME } from "./logger"; import GitHubSyncPlugin from "./main"; +import { isGitignored, loadGitignoreMatcher } from "./gitignore"; /** * Tracks changes to local sync directory and updates files metadata. @@ -27,7 +28,7 @@ export default class EventsListener { private async onCreate(file: TAbstractFile) { await this.logger.info("Received create event", file.path); - if (!this.isSyncable(file.path)) { + if (!(await this.isSyncable(file.path))) { // The file has not been created in directory that we're syncing with GitHub await this.logger.info("Skipped created file", file.path); return; @@ -66,7 +67,7 @@ export default class EventsListener { // Skip folders return; } - if (!this.isSyncable(filePath)) { + if (!(await this.isSyncable(filePath))) { // The file was not in directory that we're syncing with GitHub return; } @@ -79,7 +80,7 @@ export default class EventsListener { private async onModify(file: TAbstractFile) { await this.logger.info("Received modify event", file.path); - if (!this.isSyncable(file.path)) { + if (!(await this.isSyncable(file.path))) { // The file has not been create in directory that we're syncing with GitHub await this.logger.info("Skipped modified file", file.path); return; @@ -112,30 +113,33 @@ export default class EventsListener { // Skip folders return; } - if (!this.isSyncable(file.path) && !this.isSyncable(oldPath)) { + const newFileIsSyncable = await this.isSyncable(file.path); + const oldFileIsSyncable = await this.isSyncable(oldPath); + + if (!newFileIsSyncable && !oldFileIsSyncable) { // Both are not in directory that we're syncing with GitHub return; } - if (this.isSyncable(file.path) && this.isSyncable(oldPath)) { + if (newFileIsSyncable && oldFileIsSyncable) { // Both files are in the synced directory // First create the new one await this.onCreate(file); // Then delete the old one await this.onDelete(oldPath); return; - } else if (this.isSyncable(file.path)) { + } else if (newFileIsSyncable) { // Only the new file is in the local directory await this.onCreate(file); return; - } else if (this.isSyncable(oldPath)) { + } else if (oldFileIsSyncable) { // Only the old file was in the local directory await this.onDelete(oldPath); return; } } - private isSyncable(filePath: string) { + private async isSyncable(filePath: string) { if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) { // Manifest file must always be synced return true; @@ -153,10 +157,20 @@ export default class EventsListener { filePath.startsWith(this.vault.configDir) ) { // Sync configs only if the user explicitly wants to - return true; + return !(await this.isIgnored(filePath)); } else { // All other files can be synced - return true; + return !(await this.isIgnored(filePath)); + } + } + + private async isIgnored(filePath: string): Promise { + if (!this.settings.useGitignore) { + return false; } + + // The file can change during the session, so events always read + // the current version before deciding whether to update local metadata. + return isGitignored(await loadGitignoreMatcher(this.vault), filePath); } } diff --git a/src/gitignore.ts b/src/gitignore.ts new file mode 100644 index 0000000..46df7f7 --- /dev/null +++ b/src/gitignore.ts @@ -0,0 +1,43 @@ +import { Vault, normalizePath } from "obsidian"; +import ignore, { Ignore } from "ignore"; + +export const GITIGNORE_FILE_NAME = ".gitignore" as const; + +export function createGitignoreMatcher(patterns = ""): Ignore { + return ignore().add(patterns); +} + +/** + * Loads root vault rules and returns a matcher equivalent to gitignore. + */ +export async function loadGitignoreMatcher(vault: Vault): Promise { + const matcher = createGitignoreMatcher(); + const gitignorePath = normalizePath(GITIGNORE_FILE_NAME); + + if (await vault.adapter.exists(gitignorePath)) { + matcher.add(await vault.adapter.read(gitignorePath)); + } + + return matcher; +} + +/** + * Normalizes Obsidian paths to the relative format expected by the parser. + */ +export function isGitignored( + matcher: Ignore | null, + filePath: string, + isDirectory = false, +): boolean { + if (!matcher || filePath === "") { + return false; + } + + const normalizedPath = normalizePath(filePath); + const pathToTest = + isDirectory && !normalizedPath.endsWith("/") + ? `${normalizedPath}/` + : normalizedPath; + + return matcher.ignores(pathToTest); +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 97baf09..1005437 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -4,6 +4,7 @@ export interface GitHubSyncSettings { githubOwner: string; githubRepo: string; githubBranch: string; + useGitignore: boolean; syncStrategy: "manual" | "interval"; syncInterval: number; syncOnStartup: boolean; @@ -22,6 +23,7 @@ export const DEFAULT_SETTINGS: GitHubSyncSettings = { githubOwner: "", githubRepo: "", githubBranch: "main", + useGitignore: false, syncStrategy: "manual", syncInterval: 1, syncOnStartup: false, diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 8921460..52d9f39 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -91,6 +91,18 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab { }), ); + new Setting(containerEl) + .setName("Use .gitignore") + .setDesc("Ignore files as defined in .gitignore") + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.useGitignore) + .onChange(async (value) => { + this.plugin.settings.useGitignore = value; + await this.plugin.saveSettings(); + }); + }); + new Setting(containerEl).setName("Sync").setHeading(); const syncStrategies = { diff --git a/src/sync-manager.ts b/src/sync-manager.ts index da11a5a..609df13 100644 --- a/src/sync-manager.ts +++ b/src/sync-manager.ts @@ -21,6 +21,13 @@ import Logger, { LOG_FILE_NAME } from "./logger"; import { decodeBase64String, hasTextExtension } from "./utils"; import GitHubSyncPlugin from "./main"; import { BlobReader, Entry, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js"; +import { Ignore } from "ignore"; +import { + createGitignoreMatcher, + GITIGNORE_FILE_NAME, + isGitignored, + loadGitignoreMatcher, +} from "./gitignore"; interface SyncAction { type: "upload" | "download" | "delete_local" | "delete_remote"; @@ -69,6 +76,76 @@ export default class SyncManager { ); } + private async getGitignoreMatcher(): Promise { + if (!this.settings.useGitignore) { + return null; + } + + return await loadGitignoreMatcher(this.vault); + } + + private getZipEntryTargetPath(entry: Entry): string { + // GitHub ZIPs include an artificial root folder; we remove it so patterns + // are tested as vault-relative paths. + const pathParts = entry.filename.split("/"); + return pathParts.length > 1 ? pathParts.slice(1).join("/") : entry.filename; + } + + private async getGitignoreMatcherFromZipEntries( + entries: Entry[], + ): Promise { + if (!this.settings.useGitignore) { + return null; + } + + const gitignoreEntry = entries.find( + (entry) => this.getZipEntryTargetPath(entry) === GITIGNORE_FILE_NAME, + ); + if (!gitignoreEntry || gitignoreEntry.directory) { + return createGitignoreMatcher(); + } + + const writer = new Uint8ArrayWriter(); + await gitignoreEntry.getData!(writer); + return createGitignoreMatcher(new TextDecoder().decode(await writer.getData())); + } + + private filterIgnoredFiles(files: { [key: string]: T }, matcher: Ignore | null) { + return Object.keys(files).reduce( + (filteredFiles: { [key: string]: T }, filePath: string) => { + if ( + filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}` || + !isGitignored(matcher, filePath) + ) { + filteredFiles[filePath] = files[filePath]; + } + return filteredFiles; + }, + {}, + ); + } + + private async removeIgnoredFilesFromMetadata(matcher: Ignore | null) { + if (!matcher) { + return; + } + + let hasChanges = false; + Object.keys(this.metadataStore.data.files).forEach((filePath: string) => { + if ( + filePath !== `${this.vault.configDir}/${MANIFEST_FILE_NAME}` && + isGitignored(matcher, filePath) + ) { + delete this.metadataStore.data.files[filePath]; + hasChanges = true; + } + }); + + if (hasChanges) { + await this.metadataStore.save(); + } + } + /** * Returns true if the local vault root is empty. */ @@ -194,6 +271,8 @@ export default class SyncManager { const zipBlob = new Blob([zipBuffer]); const reader = new ZipReader(new BlobReader(zipBlob)); const entries = await reader.getEntries(); + const gitignoreMatcher = + await this.getGitignoreMatcherFromZipEntries(entries); await this.logger.info("Extracting files from ZIP", { length: entries.length, @@ -201,12 +280,7 @@ export default class SyncManager { await Promise.all( entries.map(async (entry: Entry) => { - // All repo ZIPs contain a root directory that contains all the content - // of that repo, we need to ignore that directory so we strip the first - // folder segment from the path - const pathParts = entry.filename.split("/"); - const targetPath = - pathParts.length > 1 ? pathParts.slice(1).join("/") : entry.filename; + const targetPath = this.getZipEntryTargetPath(entry); if (targetPath === "") { // Must be the root folder, skip it. @@ -215,6 +289,11 @@ export default class SyncManager { return; } + if (isGitignored(gitignoreMatcher, targetPath, entry.directory)) { + await this.logger.info("Skipping .gitignore match", targetPath); + return; + } + if ( this.settings.syncConfigDir && targetPath.startsWith(this.vault.configDir) && @@ -241,7 +320,12 @@ export default class SyncManager { return; } - if (targetPath.split("/").last()?.startsWith(".")) { + // .gitignore is also a hidden file, but it must be downloaded to keep + // the same rules across vaults. + if ( + targetPath !== GITIGNORE_FILE_NAME && + targetPath.split("/").last()?.startsWith(".") + ) { // We must skip hidden files as that creates issues with syncing. // This is fine as users can't edit hidden files in Obsidian anyway. await this.logger.info("Skipping hidden file", targetPath); @@ -296,7 +380,10 @@ export default class SyncManager { await Promise.all( Object.keys(this.metadataStore.data.files) .filter((filePath: string) => { - return !Object.keys(files).contains(filePath); + return ( + !Object.keys(files).contains(filePath) && + !isGitignored(gitignoreMatcher, filePath) + ); }) .map(async (filePath: string) => { const normalizedPath = normalizePath(filePath); @@ -338,6 +425,9 @@ export default class SyncManager { treeSha: string, ) { await this.logger.info("Starting first sync from local files"); + const gitignoreMatcher = await this.getGitignoreMatcher(); + await this.removeIgnoredFilesFromMetadata(gitignoreMatcher); + const newTreeFiles = Object.keys(files) .map((filePath: string) => ({ path: files[filePath].path, @@ -358,7 +448,10 @@ export default class SyncManager { // We should not try to sync deleted files, this can happen when // the user renames or deletes files after enabling the plugin but // before syncing for the first time - return !this.metadataStore.data.files[filePath].deleted; + return ( + !this.metadataStore.data.files[filePath].deleted && + !isGitignored(gitignoreMatcher, filePath) + ); }) .map(async (filePath: string) => { const normalizedPath = normalizePath(filePath); @@ -439,6 +532,12 @@ export default class SyncManager { const remoteMetadata: Metadata = JSON.parse( decodeBase64String(blob.content), ); + const gitignoreMatcher = await this.getGitignoreMatcher(); + await this.removeIgnoredFilesFromMetadata(gitignoreMatcher); + remoteMetadata.files = this.filterIgnoredFiles( + remoteMetadata.files, + gitignoreMatcher, + ); const conflicts = await this.findConflicts(remoteMetadata.files); @@ -965,6 +1064,7 @@ export default class SyncManager { async loadMetadata() { await this.logger.info("Loading metadata"); await this.metadataStore.load(); + const gitignoreMatcher = await this.getGitignoreMatcher(); if (Object.keys(this.metadataStore.data.files).length === 0) { await this.logger.info("Metadata was empty, loading all files"); let files = []; @@ -979,6 +1079,10 @@ export default class SyncManager { // Skip the config dir if the user doesn't want to sync it continue; } + if (isGitignored(gitignoreMatcher, folder, true)) { + await this.logger.info("Skipping .gitignore folder match", folder); + continue; + } const res = await this.vault.adapter.list(folder); files.push(...res.files); folders.push(...res.folders); @@ -988,6 +1092,9 @@ export default class SyncManager { // Obsidian recommends not syncing the workspace file return; } + if (isGitignored(gitignoreMatcher, filePath)) { + return; + } this.metadataStore.data.files[filePath] = { path: filePath, @@ -1010,6 +1117,8 @@ export default class SyncManager { lastModified: Date.now(), }; this.metadataStore.save(); + } else { + await this.removeIgnoredFilesFromMetadata(gitignoreMatcher); } await this.logger.info("Loaded metadata"); } @@ -1021,6 +1130,7 @@ export default class SyncManager { */ async addConfigDirToMetadata() { await this.logger.info("Adding config dir to metadata"); + const gitignoreMatcher = await this.getGitignoreMatcher(); // Get all the files in the config dir let files = []; let folders = [this.vault.configDir]; @@ -1029,12 +1139,20 @@ export default class SyncManager { if (folder === undefined) { continue; } + if (isGitignored(gitignoreMatcher, folder, true)) { + await this.logger.info("Skipping .gitignore folder match", folder); + continue; + } const res = await this.vault.adapter.list(folder); files.push(...res.files); folders.push(...res.folders); } // Add them to the metadata store files.forEach((filePath: string) => { + if (isGitignored(gitignoreMatcher, filePath)) { + return; + } + this.metadataStore.data.files[filePath] = { path: filePath, sha: null,