This commit is contained in:
tiagoarroz 2026-05-05 08:42:28 +00:00 committed by GitHub
commit 1211205eeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 212 additions and 19 deletions

View file

@ -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"
}

View file

@ -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

View file

@ -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<boolean> {
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);
}
}

43
src/gitignore.ts Normal file
View file

@ -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<Ignore> {
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);
}

View file

@ -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,

View file

@ -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 = {

View file

@ -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<Ignore | null> {
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<Ignore | null> {
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<T>(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,