firstsun-dev_git-files-sync/tests/logic/gitignore-manager.test.ts
ClaudiaFang c28e0ec09a perf(push): batch-commit push-all files + SHA-based diffing
Push-all was up to 3N sequential HTTP calls and N separate commits for
N files (getFile check -> pushFile PUT -> optional extra getFile),
plus two redundant full remote-tree fetches per run (one discarded in
main.ts, one inside GitignoreManager).

- Add optional GitServiceInterface.pushBatch. GitHub/Gitea implement it
  via the git blob->tree->commit->ref Data API, generalizing
  pushSymlink's existing pattern into shared
  resolveGitHubStyleBaseTree/commitGitHubStyleTree helpers in
  git-service-base.ts. GitLab implements it via its native multi-file
  Commits API (actions array), with a follow-up listFilesDetailed call
  to recover each file's new blob sha since that endpoint doesn't
  return them. Symlinks stay on the existing per-file pushSymlink path.
- sync-manager.ts's push-all flow now classifies each file by comparing
  a locally-computed git blob sha (utils/git-blob-sha.ts, already used
  by the feat-006 status refresh) against a pre-fetched remote tree's
  per-entry sha, eliminating the per-file getFile call for the common
  case. Queued files are committed in one grouped pushBatch call,
  chunked at MAX_BATCH_PUSH_SIZE=200; a failed chunk marks every file
  in it as failed rather than dropping results silently. Providers
  without pushBatch fall back to the original sequential path.
- main.ts fetches the remote tree once per push/pull-all run and
  threads it into both GitignoreManager.loadGitignores(tree) and
  SyncManager.pushAllFiles(files, onProgress, tree), replacing the
  previously-discarded listFiles() call and gitignore-manager's
  separate fetch with one shared call.

Rename detection and the pull-all path are unchanged (out of scope).

Evidence: npx eslint . -> 0 errors; npm run build -> clean;
npx vitest run -> 330/330 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 09:58:06 +00:00

370 lines
18 KiB
TypeScript

/* eslint-disable @typescript-eslint/unbound-method */
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
import { GitignoreManager } from '../../src/logic/gitignore-manager';
import { App, DataAdapter } from 'obsidian';
import { GitServiceInterface } from '../../src/services/git-service-interface';
import { readLocalSymlinkTarget } from '../../src/utils/symlink';
vi.mock('obsidian');
vi.mock('../../src/utils/symlink', () => ({ readLocalSymlinkTarget: vi.fn() }));
describe('GitignoreManager', () => {
let manager: GitignoreManager;
let mockApp: Mocked<App>;
let mockGitService: Mocked<GitServiceInterface>;
const branch = 'main';
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(readLocalSymlinkTarget).mockReturnValue(null);
const mockAdapter = {
exists: vi.fn(),
read: vi.fn(),
list: vi.fn().mockResolvedValue({ files: [], folders: [] }),
} as unknown as Mocked<DataAdapter>;
mockApp = {
vault: {
adapter: mockAdapter,
}
} as unknown as Mocked<App>;
mockGitService = {
getRepoGitignores: vi.fn(),
getFile: vi.fn(),
} as unknown as Mocked<GitServiceInterface>;
// Start with empty rootPath for simple unit tests
manager = new GitignoreManager(mockApp, mockGitService, branch, '');
});
describe('loadGitignores', () => {
it('should load root gitignore from local if it exists', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('node_modules/\n*.log');
await manager.loadGitignores();
expect(adapter.read).toHaveBeenCalledWith('.gitignore');
expect(manager.isIgnored('node_modules/test.js')).toBe(true);
expect(manager.isIgnored('test.log')).toBe(true);
expect(manager.isIgnored('src/main.ts')).toBe(false);
});
it('should load gitignores from remote as fallback', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(false);
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'secret.txt', sha: 'sha' });
await manager.loadGitignores();
expect(mockGitService.getFile).toHaveBeenCalledWith('/.gitignore', branch);
expect(manager.isIgnored('secret.txt')).toBe(true);
});
it('should fall back to [".gitignore"] when getRepoGitignores throws', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockRejectedValue(new Error('Network error'));
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('node_modules/');
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await manager.loadGitignores();
expect(consoleSpy).toHaveBeenCalled();
expect(manager.isIgnored('node_modules/test.js')).toBe(true);
});
it('should fall back to remote when local .gitignore read throws', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockRejectedValue(new Error('Permission denied'));
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'secret.txt', sha: 'sha' });
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await manager.loadGitignores();
expect(consoleSpy).toHaveBeenCalled();
expect(mockGitService.getFile).toHaveBeenCalledWith('/.gitignore', branch);
expect(manager.isIgnored('secret.txt')).toBe(true);
});
it('should handle subdirectory gitignores correctly', async () => {
// Repo structure:
// .gitignore
// sub/.gitignore
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore', 'sub/.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockImplementation((path) => {
if (path === '.gitignore') return Promise.resolve('root-ignored.txt');
if (path === 'sub/.gitignore') return Promise.resolve('*.tmp');
return Promise.resolve('');
});
await manager.loadGitignores();
// Should ignore sub/test.tmp (from sub/.gitignore)
expect(manager.isIgnored('sub/test.tmp')).toBe(true);
// Should NOT ignore top-level test.tmp (sub/.gitignore only applies to sub/)
expect(manager.isIgnored('test.tmp')).toBe(false);
// Should ignore root-ignored.txt (from root .gitignore)
expect(manager.isIgnored('root-ignored.txt')).toBe(true);
// Should ignore sub/root-ignored.txt (root .gitignore applies to subfolders too)
expect(manager.isIgnored('sub/root-ignored.txt')).toBe(true);
});
it('scans a pre-fetched remote tree for .gitignore paths instead of calling getRepoGitignores', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(false);
vi.mocked(mockGitService.getFile).mockImplementation((path) => {
if (path === '/.gitignore') return Promise.resolve({ content: 'node_modules/', sha: '1' });
if (path === '/sub/.gitignore') return Promise.resolve({ content: '*.tmp', sha: '2' });
return Promise.resolve({ content: '', sha: '' });
});
await manager.loadGitignores([
{ path: '.gitignore', symlink: false, sha: 'a' },
{ path: 'sub/.gitignore', symlink: false, sha: 'b' },
{ path: 'src/main.ts', symlink: false, sha: 'c' },
]);
expect(mockGitService.getRepoGitignores).not.toHaveBeenCalled();
expect(manager.isIgnored('node_modules/test.js')).toBe(true);
expect(manager.isIgnored('sub/test.tmp')).toBe(true);
});
it('should pick up local-only subdirectory .gitignore not yet on remote', async () => {
// Remote only knows about root .gitignore; sub/.gitignore exists locally but not pushed yet
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.list).mockImplementation((dir: string) => {
if (dir === '' || dir === undefined) {
return Promise.resolve({ files: ['.gitignore'], folders: ['sub'] });
}
if (dir === 'sub') {
return Promise.resolve({ files: ['sub/.gitignore'], folders: [] });
}
return Promise.resolve({ files: [], folders: [] });
});
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockImplementation((path: string) => {
if (path === '.gitignore') return Promise.resolve('root-only.log');
if (path === 'sub/.gitignore') return Promise.resolve('local-only.tmp');
return Promise.resolve('');
});
await manager.loadGitignores();
expect(manager.isIgnored('sub/local-only.tmp')).toBe(true);
expect(manager.isIgnored('local-only.tmp')).toBe(false);
expect(manager.isIgnored('root-only.log')).toBe(true);
});
});
describe('isIgnored with rootPath (vault is subdirectory)', () => {
beforeEach(async () => {
// Repo structure:
// .gitignore (contains 'dist/')
// vault/ (Obsidian vault root)
// .gitignore (contains '*.tmp')
// src/
manager = new GitignoreManager(mockApp, mockGitService, branch, 'vault');
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore', 'vault/.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockImplementation((path) => Promise.resolve(path === '.gitignore' || path === '.gitignore')); // Wait, local paths are relative to vault/
// For '.gitignore' (repo root), it's NOT in vault, so adapter.exists('.gitignore') is false
vi.mocked(adapter.exists).mockImplementation((path) => Promise.resolve(path === '.gitignore')); // This is vault/.gitignore
vi.mocked(mockGitService.getFile).mockImplementation((path) => {
if (path === '/.gitignore') return Promise.resolve({ content: 'dist/', sha: '1' });
return Promise.resolve({ content: '', sha: '' });
});
vi.mocked(adapter.read).mockImplementation((path) => {
if (path === '.gitignore') return Promise.resolve('*.tmp');
return Promise.resolve('');
});
await manager.loadGitignores();
});
it('should correctly resolve paths relative to git root', () => {
// vault/dist/file.js should be ignored by repo root .gitignore
expect(manager.isIgnored('dist/file.js')).toBe(true);
// vault/test.tmp should be ignored by vault/.gitignore
expect(manager.isIgnored('test.tmp')).toBe(true);
// vault/src/main.ts should NOT be ignored
expect(manager.isIgnored('src/main.ts')).toBe(false);
});
});
describe('local ignorePatterns setting', () => {
it('ignores files matching a local pattern even with no remote/local .gitignore', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(false);
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', 'secrets/\n*.private');
await localManager.loadGitignores();
expect(localManager.isIgnored('secrets/key.txt')).toBe(true);
expect(localManager.isIgnored('note.private')).toBe(true);
expect(localManager.isIgnored('note.md')).toBe(false);
});
it('applies local patterns in addition to remote .gitignore, not instead of it', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('*.log');
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '*.tmp');
await localManager.loadGitignores();
expect(localManager.isIgnored('test.log')).toBe(true);
expect(localManager.isIgnored('test.tmp')).toBe(true);
expect(localManager.isIgnored('test.md')).toBe(false);
});
it('ignores blank ignorePatterns without throwing', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(false);
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', ' \n ');
await localManager.loadGitignores();
expect(localManager.isIgnored('note.md')).toBe(false);
});
it('respects comments and negation in local patterns', async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(false);
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '# comment\ndraft/*\n!draft/keep.md');
await localManager.loadGitignores();
expect(localManager.isIgnored('draft/scratch.md')).toBe(true);
expect(localManager.isIgnored('draft/keep.md')).toBe(false);
});
});
describe('complex patterns', () => {
beforeEach(async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
// Content will be set in individual tests
});
it('should handle negative patterns (!)', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('*.log\n!important.log');
await manager.loadGitignores();
expect(manager.isIgnored('test.log')).toBe(true);
expect(manager.isIgnored('important.log')).toBe(false);
});
it('should handle directory-only patterns', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('build/');
await manager.loadGitignores();
expect(manager.isIgnored('build/file.js')).toBe(true);
expect(manager.isIgnored('other/build/file.js')).toBe(true);
});
it('should handle deep wildcards (**)', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('**/temp/*');
await manager.loadGitignores();
expect(manager.isIgnored('temp/file.txt')).toBe(true);
expect(manager.isIgnored('a/b/temp/file.txt')).toBe(true);
expect(manager.isIgnored('a/temp/foo')).toBe(true);
});
});
describe('hidden file and directory filtering', () => {
let adapter: Mocked<DataAdapter>;
beforeEach(async () => {
adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
vi.mocked(adapter.exists).mockResolvedValue(true);
});
async function loadWith(rules: string) {
vi.mocked(adapter.read).mockResolvedValue(rules);
await manager.loadGitignores();
}
it('should ignore hidden directory when listed in .gitignore', async () => {
await loadWith('.private/\n.trash/');
expect(manager.isIgnored('.private/secrets.md')).toBe(true);
expect(manager.isIgnored('.trash/note.md')).toBe(true);
});
it('should not ignore hidden directory absent from .gitignore', async () => {
await loadWith('node_modules/\n*.log');
expect(manager.isIgnored('.claude/settings.json')).toBe(false);
expect(manager.isIgnored('.claude/CLAUDE.md')).toBe(false);
});
it('should ignore .claude/ when explicitly added to .gitignore', async () => {
await loadWith('.claude/');
expect(manager.isIgnored('.claude/settings.json')).toBe(true);
expect(manager.isIgnored('.claude/memory/user.md')).toBe(true);
});
it('should still pass normal files when only hidden dirs are ignored', async () => {
await loadWith('.private/');
expect(manager.isIgnored('notes/my-note.md')).toBe(false);
expect(manager.isIgnored('projects/work.md')).toBe(false);
});
it('should find .gitignore inside a hidden directory via scanDir', async () => {
vi.mocked(adapter.list)
.mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] })
.mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] });
vi.mocked(adapter.read).mockResolvedValue('*.secret');
await manager.loadGitignores();
expect(adapter.list).toHaveBeenCalledWith('');
expect(adapter.list).toHaveBeenCalledWith('.claude');
});
it('should not recurse into a folder that is actually a symlink', async () => {
// "linked" is a directory symlink (e.g. a shared skills folder) pointing
// outside the repo; walking into it would scan an unrelated tree and
// produce bogus .gitignore lookups for paths that don't exist remotely.
vi.mocked(adapter.list).mockImplementation((dir: string) => {
if (dir === '' || dir === undefined) {
return Promise.resolve({ files: ['.gitignore'], folders: ['linked'] });
}
return Promise.resolve({ files: ['linked/nested/.gitignore'], folders: ['linked/nested'] });
});
vi.mocked(readLocalSymlinkTarget).mockImplementation((_app, path) => path === 'linked' ? '/some/other/dir' : null);
vi.mocked(adapter.read).mockResolvedValue('*.secret');
await manager.loadGitignores();
expect(adapter.list).toHaveBeenCalledWith('');
expect(adapter.list).not.toHaveBeenCalledWith('linked');
});
});
});