mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
feat: Support .gitignore filtering and GitHub-style diff viewer
This commit is contained in:
parent
5d9cd3345d
commit
cbc55d502d
8 changed files with 221 additions and 36 deletions
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "0-BSD",
|
||||
"dependencies": {
|
||||
"ignore": "^7.0.5",
|
||||
"obsidian": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -960,6 +961,16 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
|
||||
|
|
@ -2716,16 +2727,6 @@
|
|||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.35.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
|
||||
|
|
@ -5113,6 +5114,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
|
|
@ -5318,6 +5329,16 @@
|
|||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
|
|
@ -6190,10 +6211,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"vitest": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ignore": "^7.0.5",
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
src/logic/gitignore-manager.ts
Normal file
100
src/logic/gitignore-manager.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import ignore, { Ignore } from 'ignore';
|
||||
import { App } from 'obsidian';
|
||||
import { GitServiceInterface } from '../services/git-service-interface';
|
||||
|
||||
export class GitignoreManager {
|
||||
private app: App;
|
||||
private gitService: GitServiceInterface;
|
||||
private branch: string;
|
||||
|
||||
// Maps directory path (empty string for root) to Ignore instance
|
||||
private ignoreMap: Map<string, Ignore> = new Map();
|
||||
|
||||
constructor(app: App, gitService: GitServiceInterface, branch: string) {
|
||||
this.app = app;
|
||||
this.gitService = gitService;
|
||||
this.branch = branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and parses .gitignore files from the local filesystem and remote repository.
|
||||
* Uses remoteFiles as a hint for where .gitignore files exist in subdirectories.
|
||||
*/
|
||||
async loadGitignores(remoteFiles: string[]): Promise<void> {
|
||||
this.ignoreMap.clear();
|
||||
|
||||
// 1. Identify all unique directories that contain a .gitignore
|
||||
const gitignorePaths = new Set<string>();
|
||||
|
||||
// Root gitignore
|
||||
gitignorePaths.add('.gitignore');
|
||||
|
||||
// Check remote files for any .gitignore in subdirectories
|
||||
for (const remotePath of remoteFiles) {
|
||||
if (remotePath.endsWith('.gitignore')) {
|
||||
gitignorePaths.add(remotePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch and parse each .gitignore
|
||||
for (const gitignorePath of gitignorePaths) {
|
||||
const dirPath = gitignorePath === '.gitignore' ? '' : gitignorePath.slice(0, -('.gitignore'.length + 1));
|
||||
|
||||
let content: string | undefined;
|
||||
|
||||
// Try local first
|
||||
if (await this.app.vault.adapter.exists(gitignorePath)) {
|
||||
try {
|
||||
content = await this.app.vault.adapter.read(gitignorePath);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read local ${gitignorePath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to remote
|
||||
if (content === undefined && remoteFiles.includes(gitignorePath)) {
|
||||
try {
|
||||
const remoteFile = await this.gitService.getFile(gitignorePath, this.branch);
|
||||
if (remoteFile && remoteFile.content) {
|
||||
content = remoteFile.content;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch remote ${gitignorePath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const ig = ignore().add(content);
|
||||
this.ignoreMap.set(dirPath, ig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given file path should be ignored based on loaded .gitignore rules.
|
||||
*/
|
||||
isIgnored(filePath: string): boolean {
|
||||
// Iterate through all loaded .gitignore files.
|
||||
// A .gitignore file only applies to paths within its directory.
|
||||
for (const [dirPath, ig] of this.ignoreMap.entries()) {
|
||||
if (dirPath === '') {
|
||||
// Root .gitignore applies to everything
|
||||
if (ig.ignores(filePath)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Subdirectory .gitignore
|
||||
// Check if the file is inside this directory
|
||||
const prefix = dirPath + '/';
|
||||
if (filePath.startsWith(prefix)) {
|
||||
// Extract the path relative to the directory containing .gitignore
|
||||
const relativePath = filePath.substring(prefix.length);
|
||||
if (ig.ignores(relativePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -213,8 +213,8 @@ export class SyncManager {
|
|||
private async performPull(file: TFile | {path: string, name: string}, remoteContent: string, remoteSha: string) {
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
if (file instanceof TFile || ('vault' in file)) {
|
||||
await this.app.vault.modify(file as TFile, remoteContent);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.vault.modify(file, remoteContent);
|
||||
} else {
|
||||
await this.app.vault.adapter.write(file.path, remoteContent);
|
||||
}
|
||||
|
|
|
|||
15
src/main.ts
15
src/main.ts
|
|
@ -5,11 +5,13 @@ import { GitHubService } from './services/github-service';
|
|||
import { GitServiceInterface } from './services/git-service-interface';
|
||||
import { SyncManager } from './logic/sync-manager';
|
||||
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
|
||||
import { GitignoreManager } from './logic/gitignore-manager';
|
||||
|
||||
export default class GitLabFilesPush extends Plugin {
|
||||
settings: GitLabFilesPushSettings;
|
||||
gitService: GitServiceInterface;
|
||||
sync: SyncManager;
|
||||
gitignoreManager: GitignoreManager;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -33,6 +35,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
});
|
||||
|
||||
this.initializeGitService();
|
||||
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch);
|
||||
this.sync = new SyncManager(this.app, this.gitService, this.settings);
|
||||
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
|
@ -134,9 +137,13 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
async pushAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
let files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
const remoteFiles = await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores(remoteFiles);
|
||||
files = files.filter(f => !this.gitignoreManager.isIgnored(f.path));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to push in the configured vault folder');
|
||||
return;
|
||||
|
|
@ -166,9 +173,13 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
async pullAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
let files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
const remoteFiles = await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores(remoteFiles);
|
||||
files = files.filter(f => !this.gitignoreManager.isIgnored(f.path));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to pull in the configured vault folder');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export class SyncConflictModal extends Modal {
|
|||
const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
diffPre.createEl('code', { text: this.generateDiff() });
|
||||
this.renderDiff(diffPre);
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||
|
||||
|
|
@ -67,14 +67,18 @@ export class SyncConflictModal extends Modal {
|
|||
}));
|
||||
}
|
||||
|
||||
private generateDiff(): string {
|
||||
private renderDiff(container: HTMLElement) {
|
||||
const localLines = this.localContent.split('\n');
|
||||
const remoteLines = this.remoteContent.split('\n');
|
||||
|
||||
const diff: string[] = [];
|
||||
diff.push('--- Remote');
|
||||
diff.push('+++ Local');
|
||||
diff.push('');
|
||||
const createLine = (text: string, type: 'header' | 'added' | 'removed' | 'unchanged') => {
|
||||
const lineEl = container.createSpan({ cls: `diff-line ${type}` });
|
||||
lineEl.textContent = text + '\n';
|
||||
};
|
||||
|
||||
createLine('--- Remote', 'header');
|
||||
createLine('+++ Local', 'header');
|
||||
createLine('', 'unchanged');
|
||||
|
||||
const maxLines = Math.max(localLines.length, remoteLines.length);
|
||||
|
||||
|
|
@ -84,17 +88,15 @@ export class SyncConflictModal extends Modal {
|
|||
|
||||
if (remoteLine !== localLine) {
|
||||
if (remoteLine !== undefined) {
|
||||
diff.push(`- ${remoteLine}`);
|
||||
createLine(`- ${remoteLine}`, 'removed');
|
||||
}
|
||||
if (localLine !== undefined) {
|
||||
diff.push(`+ ${localLine}`);
|
||||
createLine(`+ ${localLine}`, 'added');
|
||||
}
|
||||
} else if (remoteLine !== undefined) {
|
||||
diff.push(` ${remoteLine}`);
|
||||
createLine(` ${remoteLine}`, 'unchanged');
|
||||
}
|
||||
}
|
||||
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
onClose() {
|
||||
|
|
|
|||
|
|
@ -385,7 +385,17 @@ export class SyncStatusView extends ItemView {
|
|||
diffContainer.addClass('hidden');
|
||||
|
||||
const diffPre = diffContainer.createEl('pre');
|
||||
diffPre.createEl('code', { text: fileStatus.diff });
|
||||
|
||||
const diffLines = fileStatus.diff.split('\n');
|
||||
for (const line of diffLines) {
|
||||
let type: 'header' | 'added' | 'removed' | 'unchanged' = 'unchanged';
|
||||
if (line.startsWith('---') || line.startsWith('+++')) type = 'header';
|
||||
else if (line.startsWith('+ ')) type = 'added';
|
||||
else if (line.startsWith('- ')) type = 'removed';
|
||||
|
||||
const lineEl = diffPre.createSpan({ cls: `diff-line ${type}` });
|
||||
lineEl.textContent = line + '\n';
|
||||
}
|
||||
|
||||
diffToggle.addEventListener('click', () => {
|
||||
if (diffContainer.hasClass('hidden')) {
|
||||
|
|
@ -473,10 +483,16 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
try {
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
const files = this.plugin.filterFilesByVaultFolder(allFiles);
|
||||
let files = this.plugin.filterFilesByVaultFolder(allFiles);
|
||||
|
||||
// Get remote files
|
||||
const remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
|
||||
let remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
|
||||
|
||||
// Load .gitignore rules based on remote files, then filter out ignored paths
|
||||
await this.plugin.gitignoreManager.loadGitignores(remoteFiles);
|
||||
remoteFiles = remoteFiles.filter(path => !this.plugin.gitignoreManager.isIgnored(path));
|
||||
files = files.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path));
|
||||
|
||||
const localFilePaths = new Set(files.map(f => f.path));
|
||||
// Use ALL local files (not just vaultFolder-filtered) for remote-only detection,
|
||||
// so files like .claude/skills/*.md that live outside vaultFolder are not
|
||||
|
|
@ -530,7 +546,13 @@ export class SyncStatusView extends ItemView {
|
|||
this.renderView();
|
||||
|
||||
// Check status for local files with progress
|
||||
const filesToCheck: Array<TFile | string> = [...files, ...extraFilesToCheck];
|
||||
let filesToCheck: Array<TFile | string> = [...files, ...extraFilesToCheck];
|
||||
|
||||
// Final filter for extra files to check (some might be ignored)
|
||||
filesToCheck = filesToCheck.filter(f => {
|
||||
const path = typeof f === 'string' ? f : f.path;
|
||||
return !this.plugin.gitignoreManager.isIgnored(path);
|
||||
});
|
||||
const totalFiles = filesToCheck.length;
|
||||
let checkedFiles = 0;
|
||||
|
||||
|
|
@ -567,9 +589,9 @@ export class SyncStatusView extends ItemView {
|
|||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const file = isString ? undefined : fileOrPath;
|
||||
|
||||
const localContent = isString
|
||||
? await this.app.vault.adapter.read(path)
|
||||
: await this.app.vault.read(file as TFile);
|
||||
const localContent = typeof fileOrPath === 'string'
|
||||
? await this.app.vault.adapter.read(fileOrPath)
|
||||
: await this.app.vault.read(fileOrPath);
|
||||
|
||||
const remote = await this.plugin.gitService.getFile(path, this.plugin.settings.branch);
|
||||
|
||||
|
|
|
|||
29
styles.css
29
styles.css
|
|
@ -479,3 +479,32 @@ If your plugin does not need CSS, delete this file.
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
/* GitHub style diff viewer */
|
||||
.diff-line {
|
||||
display: block;
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-monospace);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-line.added {
|
||||
background-color: rgba(46, 160, 67, 0.2);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.diff-line.removed {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.diff-line.unchanged {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-line.header {
|
||||
color: var(--text-accent);
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue