mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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>
394 lines
18 KiB
TypeScript
394 lines
18 KiB
TypeScript
/* eslint-disable @typescript-eslint/unbound-method */
|
|
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
|
|
import { SyncManager } from '../../src/logic/sync-manager';
|
|
import { App, DataAdapter, TFile } from 'obsidian';
|
|
import { GitLabFilesPushSettings } from '../../src/settings';
|
|
import { GitServiceInterface } from '../../src/services/git-service-interface';
|
|
|
|
vi.mock('obsidian');
|
|
|
|
describe('SyncManager Batch Operations', () => {
|
|
let manager: SyncManager;
|
|
let mockApp: Mocked<App>;
|
|
let mockGitService: Mocked<GitServiceInterface>;
|
|
let mockSettings: GitLabFilesPushSettings;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
const mockAdapter = {
|
|
exists: vi.fn(),
|
|
read: vi.fn(),
|
|
write: vi.fn(),
|
|
readBinary: vi.fn(),
|
|
writeBinary: vi.fn(),
|
|
} as unknown as Mocked<DataAdapter>;
|
|
|
|
mockApp = {
|
|
vault: {
|
|
read: vi.fn(),
|
|
modify: vi.fn(),
|
|
getFileByPath: vi.fn(),
|
|
adapter: mockAdapter,
|
|
},
|
|
workspace: {
|
|
getActiveFile: vi.fn(),
|
|
detachLeavesOfType: vi.fn(),
|
|
}
|
|
} as unknown as Mocked<App>;
|
|
|
|
mockGitService = {
|
|
pushFile: vi.fn(),
|
|
getFile: vi.fn(),
|
|
testConnection: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
listFilesDetailed: vi.fn().mockResolvedValue([]),
|
|
deleteFile: vi.fn(),
|
|
getRepoGitignores: vi.fn(),
|
|
updateConfig: vi.fn(),
|
|
} as unknown as Mocked<GitServiceInterface>;
|
|
|
|
mockSettings = {
|
|
serviceType: 'github',
|
|
githubToken: 'token',
|
|
githubOwner: 'owner',
|
|
githubRepo: 'repo',
|
|
branch: 'main',
|
|
syncMetadata: {},
|
|
} as unknown as GitLabFilesPushSettings;
|
|
|
|
manager = new SyncManager(mockApp, mockGitService, mockSettings);
|
|
// @ts-ignore - accessing private for test
|
|
manager.saveSettings = vi.fn().mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('pushAllFiles', () => {
|
|
it('should push multiple files correctly (strings and TFiles)', async () => {
|
|
const mockFile = Object.assign(new TFile(), { path: 'file2.md', name: 'file2.md' });
|
|
const files = ['file1.md', mockFile];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('content1');
|
|
vi.mocked(mockApp.vault.read).mockResolvedValue('content2');
|
|
vi.mocked(mockApp.vault.getFileByPath).mockReturnValue(mockFile);
|
|
|
|
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'old-sha' });
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'path', sha: 'new-sha' });
|
|
|
|
const results = await manager.pushAllFiles(files);
|
|
|
|
expect(results.success).toBe(2);
|
|
expect(vi.mocked(mockGitService.pushFile)).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle failures during batch push', async () => {
|
|
const files = ['good.md', 'bad.md'];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('content');
|
|
|
|
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'old-sha' });
|
|
|
|
vi.mocked(mockGitService.pushFile)
|
|
.mockResolvedValueOnce({ path: 'path', sha: 'new-sha' })
|
|
.mockRejectedValueOnce(new Error('Push failed'));
|
|
|
|
const results = await manager.pushAllFiles(files);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(results.failed).toBe(1);
|
|
expect(results.errors[0]!.file).toBe('bad.md');
|
|
});
|
|
});
|
|
|
|
describe('batch commit via gitService.pushBatch', () => {
|
|
it('groups all queued files into one pushBatch call when the provider supports it', async () => {
|
|
const files = ['a.md', 'b.md'];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockImplementation(async (p) => (p === 'a.md' ? 'content-a' : 'content-b'));
|
|
// No tree entries: both files are new, so both are queued.
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
|
mockGitService.pushBatch = vi.fn().mockResolvedValue([
|
|
{ path: 'a.md', sha: 'sha-a' },
|
|
{ path: 'b.md', sha: 'sha-b' },
|
|
]);
|
|
|
|
const results = await manager.pushAllFiles(files);
|
|
|
|
expect(results.success).toBe(2);
|
|
expect(results.failed).toBe(0);
|
|
expect(mockGitService.pushBatch).toHaveBeenCalledTimes(1);
|
|
expect(mockGitService.pushFile).not.toHaveBeenCalled();
|
|
const [items] = vi.mocked(mockGitService.pushBatch).mock.calls[0]!;
|
|
expect(items).toEqual([
|
|
{ path: 'a.md', content: 'content-a', existedRemotely: false },
|
|
{ path: 'b.md', content: 'content-b', existedRemotely: false },
|
|
]);
|
|
});
|
|
|
|
it('handles a mixed binary + text batch, computing blob shas for both', async () => {
|
|
const files = ['note.md', 'image.png'];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('text content');
|
|
vi.mocked(adapter.readBinary).mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
|
mockGitService.pushBatch = vi.fn().mockResolvedValue([
|
|
{ path: 'note.md', sha: 'sha-note' },
|
|
{ path: 'image.png', sha: 'sha-image' },
|
|
]);
|
|
|
|
const results = await manager.pushAllFiles(files);
|
|
|
|
expect(results.success).toBe(2);
|
|
expect(mockGitService.pushBatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('marks every file in a failed chunk as failed, not dropped', async () => {
|
|
const files = ['a.md', 'b.md'];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('content');
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
|
mockGitService.pushBatch = vi.fn().mockRejectedValue(new Error('commit failed'));
|
|
|
|
const results = await manager.pushAllFiles(files);
|
|
|
|
expect(results.success).toBe(0);
|
|
expect(results.failed).toBe(2);
|
|
expect(results.errors).toEqual([
|
|
{ file: 'a.md', error: 'commit failed' },
|
|
{ file: 'b.md', error: 'commit failed' },
|
|
]);
|
|
});
|
|
|
|
it('skips both getFile and pushBatch when the local blob sha already matches the tree', async () => {
|
|
const path = 'unchanged.md';
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('same content');
|
|
|
|
// Compute the real git blob sha for the content so it matches the tree entry.
|
|
const { gitBlobSha } = await import('../../src/utils/git-blob-sha');
|
|
const sha = await gitBlobSha('same content');
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([{ path, symlink: false, sha }]);
|
|
mockGitService.pushBatch = vi.fn();
|
|
|
|
const results = await manager.pushAllFiles([path]);
|
|
|
|
expect(results.success).toBe(0);
|
|
expect(results.conflicts).toBe(0);
|
|
expect(results.failed).toBe(0);
|
|
expect(mockGitService.getFile).not.toHaveBeenCalled();
|
|
expect(mockGitService.pushBatch).not.toHaveBeenCalled();
|
|
expect(mockSettings.syncMetadata[path]?.lastSyncedSha).toBe(sha);
|
|
});
|
|
});
|
|
|
|
describe('pullAllAllFiles', () => {
|
|
it('should pull multiple files correctly (strings and TFiles)', async () => {
|
|
const mockFile = Object.assign(new TFile(), { path: 'file2.md', name: 'file2.md' });
|
|
const files = ['file1.md', mockFile];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote content', sha: 'new-sha' });
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
|
|
const results = await manager.pullAllFiles(files);
|
|
|
|
expect(results.success).toBe(2);
|
|
expect(vi.mocked(adapter.write)).toHaveBeenCalledWith('file1.md', 'remote content');
|
|
expect(vi.mocked(mockApp.vault.modify)).toHaveBeenCalledWith(mockFile, 'remote content');
|
|
});
|
|
|
|
it('should handle missing remote files during batch pull', async () => {
|
|
const files = ['exists.md', 'missing.md'];
|
|
|
|
vi.mocked(mockGitService.getFile)
|
|
.mockResolvedValueOnce({ content: 'content', sha: 'sha' })
|
|
.mockResolvedValueOnce({ content: '', sha: '' });
|
|
|
|
const results = await manager.pullAllFiles(files);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(results.failed).toBe(1);
|
|
expect(results.errors[0]!.error).toContain('File not found in remote');
|
|
});
|
|
});
|
|
|
|
describe('onProgress callback', () => {
|
|
it('should call onProgress for each file processed', async () => {
|
|
const files = ['file1.md', 'file2.md', 'file3.md'];
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('content');
|
|
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'sha' });
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'path', sha: 'new' });
|
|
|
|
const onProgress = vi.fn();
|
|
await manager.pushAllFiles(files, onProgress);
|
|
|
|
expect(onProgress).toHaveBeenCalledTimes(3);
|
|
expect(onProgress).toHaveBeenCalledWith(1, 3, 'file1.md');
|
|
expect(onProgress).toHaveBeenCalledWith(2, 3, 'file2.md');
|
|
expect(onProgress).toHaveBeenCalledWith(3, 3, 'file3.md');
|
|
});
|
|
});
|
|
|
|
describe('batch conflict detection', () => {
|
|
it('should skip (not overwrite) a push when the remote has moved on since last sync', async () => {
|
|
const path = 'conflicted.md';
|
|
mockSettings.syncMetadata = {
|
|
[path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path }
|
|
};
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('local edit');
|
|
// Remote tree entry's sha differs from what we last synced.
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
|
{ path, symlink: false, sha: 'sha-changed-on-remote' }
|
|
]);
|
|
|
|
const results = await manager.pushAllFiles([path]);
|
|
|
|
expect(results.success).toBe(0);
|
|
expect(results.conflicts).toBe(1);
|
|
expect(results.failed).toBe(0);
|
|
expect(mockGitService.pushFile).not.toHaveBeenCalled();
|
|
expect(mockGitService.getFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip (not overwrite) a pull when the local file has diverged since last sync', async () => {
|
|
const path = 'conflicted.md';
|
|
mockSettings.syncMetadata = {
|
|
[path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path }
|
|
};
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('local edit');
|
|
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' });
|
|
|
|
const results = await manager.pullAllFiles([path]);
|
|
|
|
expect(results.success).toBe(0);
|
|
expect(results.conflicts).toBe(1);
|
|
expect(results.failed).toBe(0);
|
|
expect(mockApp.vault.adapter.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still push normally when there is no prior sync metadata (not a conflict)', async () => {
|
|
const path = 'new-file.md';
|
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
|
|
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
|
vi.mocked(adapter.read).mockResolvedValue('local content');
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
|
{ path, symlink: false, sha: 'some-sha' }
|
|
]);
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path, sha: 'new-sha' });
|
|
|
|
const results = await manager.pushAllFiles([path]);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(results.conflicts).toBe(0);
|
|
expect(mockGitService.pushFile).toHaveBeenCalledWith(path, 'local content', 'main', expect.any(String), 'some-sha');
|
|
});
|
|
});
|
|
|
|
describe('batch push with rename detection', () => {
|
|
it('should detect and handle rename during batch push', async () => {
|
|
const oldPath = 'old.md';
|
|
const newPath = 'new.md';
|
|
const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' });
|
|
mockSettings.syncMetadata = {
|
|
[oldPath]: { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: oldPath }
|
|
};
|
|
|
|
vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === oldPath ? null : mockFile);
|
|
vi.mocked(mockApp.vault.read).mockResolvedValue('content');
|
|
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' });
|
|
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
|
|
// Remote still has the old path with matching content: confirms a real rename.
|
|
if (path === oldPath) return { content: 'content', sha: 'sha' };
|
|
// New path does not exist on the remote yet.
|
|
return { content: '', sha: '' };
|
|
});
|
|
|
|
const results = await manager.pushAllFiles([mockFile]);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(mockGitService.pushFile).toHaveBeenCalledWith(
|
|
newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, ''
|
|
);
|
|
});
|
|
|
|
it('should send existing sha when rename target already exists remotely (avoids 422)', async () => {
|
|
const oldPath = 'old.md';
|
|
const newPath = 'new.md';
|
|
const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' });
|
|
mockSettings.syncMetadata = {
|
|
[oldPath]: { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: oldPath }
|
|
};
|
|
|
|
vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === oldPath ? null : mockFile);
|
|
vi.mocked(mockApp.vault.read).mockResolvedValue('content');
|
|
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' });
|
|
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
|
|
// Remote still has the old path with matching content: confirms a real rename.
|
|
if (path === oldPath) return { content: 'content', sha: 'sha' };
|
|
// A file already exists on the remote at the new path.
|
|
return { content: 'old remote content', sha: 'remote-existing-sha' };
|
|
});
|
|
|
|
const results = await manager.pushAllFiles([mockFile]);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(mockGitService.pushFile).toHaveBeenCalledWith(
|
|
newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, 'remote-existing-sha'
|
|
);
|
|
});
|
|
|
|
it('does not misclassify an unrelated push as a rename just because an orphaned metadata entry exists', async () => {
|
|
const orphanedPath = 'deleted-unrelated-note.md';
|
|
const pushedPath = 'unrelated.md';
|
|
const mockFile = Object.assign(new TFile(), { path: pushedPath, name: 'unrelated.md' });
|
|
mockSettings.syncMetadata = {
|
|
[orphanedPath]: { lastSyncedSha: 'orphaned-sha', lastSyncedAt: 0, lastKnownPath: orphanedPath }
|
|
};
|
|
|
|
vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === orphanedPath ? null : mockFile);
|
|
vi.mocked(mockApp.vault.read).mockResolvedValue('unrelated content');
|
|
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: pushedPath, sha: 'new-sha' });
|
|
// detectRename still checks the orphaned metadata entry's remote content directly.
|
|
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
|
|
if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' };
|
|
return { content: '', sha: '' };
|
|
});
|
|
// The pushed path's own remote state comes from the pre-fetched tree, not getFile.
|
|
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
|
{ path: pushedPath, symlink: false, sha: 'remote-sha' }
|
|
]);
|
|
|
|
const results = await manager.pushAllFiles([mockFile]);
|
|
|
|
expect(results.success).toBe(1);
|
|
expect(mockGitService.pushFile).toHaveBeenCalledWith(
|
|
pushedPath, 'unrelated content', 'main', `Update ${mockFile.name} from Obsidian`, 'remote-sha'
|
|
);
|
|
expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined();
|
|
});
|
|
});
|
|
});
|