mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
Merge branch 'codex-use-gitignore'
This commit is contained in:
commit
c3cfbe3b5e
7 changed files with 212 additions and 19 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
43
src/gitignore.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue