Skip to content

Commit ce61588

Browse files
committed
fix(provider): preserve sync correctness
1 parent 45dc39d commit ce61588

11 files changed

Lines changed: 154 additions & 58 deletions

src/logic/sync-manager.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type PlanClassification = { kind: 'addition' | 'modification' | 'move' | 'unchan
2222
type ToPushEntry = { path: string; name: string; repoPath: string; content: string | ArrayBuffer; existingSha?: string; existingRevision?: string };
2323

2424
/** A renamed file classified as a safe move, queued for the grouped batch-commit call. */
25-
type ToMoveEntry = { path: string; name: string; repoPath: string; oldPath: string; oldRepoPath: string; content: string | ArrayBuffer };
25+
type ToMoveEntry = { path: string; name: string; repoPath: string; oldPath: string; oldRepoPath: string; content: string | ArrayBuffer; oldRevision?: string };
2626

2727
/**
2828
* Result of a batch push. `syncedPaths` lists every path that's now confirmed
@@ -682,6 +682,7 @@ export class SyncManager {
682682
}
683683

684684
const treeEntry = treeByFullPath.get(this.getFullPathForTree(repoPath));
685+
await this.migrateGitLabLegacyBaseline(path, repoPath, treeEntry);
685686
const outcome = await this.classifyAgainstTreeEntry(path, content, treeEntry, true);
686687
if (outcome === 'queued') return { kind: treeEntry ? 'modification' : 'addition' };
687688
// classifyAgainstTreeEntry's dry-run path only ever resolves to
@@ -714,7 +715,7 @@ export class SyncManager {
714715
if (!renamedFrom) return undefined;
715716

716717
const scratch: ToMoveEntry[] = [];
717-
const outcome = this.queueMove(path, name, renamedFrom, content, treeByFullPath, scratch);
718+
const outcome = await this.queueMove(path, name, renamedFrom, content, treeByFullPath, scratch);
718719
return outcome === 'queued' ? { kind: 'move', movedFrom: renamedFrom } : { kind: 'conflict' };
719720
}
720721

@@ -742,6 +743,7 @@ export class SyncManager {
742743

743744
const localSha = await gitBlobSha(await this.getFileContent(fileOrPath));
744745
if (localSha === entry.sha) return 'unchanged';
746+
await this.migrateGitLabLegacyBaseline(path, repoPath, entry);
745747
const lastSynced = this.settings.syncMetadata[path];
746748
if (lastSynced && entry.sha !== lastSynced.lastSyncedSha) return 'conflict';
747749
return 'modification';
@@ -891,15 +893,18 @@ export class SyncManager {
891893
const trackedOldPath = this.settings.syncMetadata[path]?.renamedFrom;
892894
const renamedFrom = trackedOldPath ?? (hasOrphans ? await this.detectRename(fileOrPath, content, treeByFullPath) : null);
893895
if (renamedFrom) {
894-
return this.queueMove(path, name, renamedFrom, content, treeByFullPath, toMove);
896+
return await this.queueMove(path, name, renamedFrom, content, treeByFullPath, toMove);
895897
}
896898
}
897899

898-
const treeEntry = treeByFullPath.get(this.getFullPathForTree(repoPath));
900+
let treeEntry = treeByFullPath.get(this.getFullPathForTree(repoPath));
901+
await this.migrateGitLabLegacyBaseline(path, repoPath, treeEntry);
902+
const revision = await this.refreshGitLabBatchRevision(repoPath, treeEntry);
903+
if (revision) treeEntry = { ...treeEntry!, sha: revision.sha };
899904
const outcome = await this.classifyAgainstTreeEntry(path, content, treeEntry);
900905
if (outcome !== 'queued') return outcome;
901906

902-
toPush.push({ path, name, repoPath, content, existingSha: treeEntry?.sha });
907+
toPush.push({ path, name, repoPath, content, existingSha: treeEntry?.sha, existingRevision: revision?.revision });
903908
return 'queued';
904909
}
905910

@@ -912,28 +917,50 @@ export class SyncManager {
912917
* silently deleted. Both surface as 'conflict' so the batch can't quietly
913918
* clobber either side the way a plain content push already refuses to.
914919
*/
915-
private queueMove(
920+
private async queueMove(
916921
path: string,
917922
name: string,
918923
oldPath: string,
919924
content: string | ArrayBuffer,
920925
treeByFullPath: Map<string, GitTreeEntry>,
921926
toMove: ToMoveEntry[]
922-
): BatchOutcome | 'queued' {
927+
): Promise<BatchOutcome | 'queued'> {
923928
const repoPath = this.getNormalizedPath(path);
924929
const oldRepoPath = this.getNormalizedPath(oldPath);
925930

926931
if (treeByFullPath.get(this.getFullPathForTree(repoPath))) return 'conflict';
927932

928-
const oldEntry = treeByFullPath.get(this.getFullPathForTree(oldRepoPath));
933+
let oldEntry = treeByFullPath.get(this.getFullPathForTree(oldRepoPath));
934+
await this.migrateGitLabLegacyBaseline(oldPath, oldRepoPath, oldEntry);
935+
const oldRevision = await this.refreshGitLabBatchRevision(oldRepoPath, oldEntry);
936+
if (oldRevision) oldEntry = { ...oldEntry!, sha: oldRevision.sha };
929937
const metadata = this.settings.syncMetadata[path] ?? this.settings.syncMetadata[oldPath];
930938
const safeToDeleteOld = !oldEntry?.sha || !metadata?.lastSyncedSha || oldEntry.sha === metadata.lastSyncedSha;
931939
if (oldEntry?.sha && !safeToDeleteOld) return 'conflict';
932940

933-
toMove.push({ path, name, repoPath, oldPath, oldRepoPath, content });
941+
toMove.push({ path, name, repoPath, oldPath, oldRepoPath, content, oldRevision: oldRevision?.revision });
934942
return 'queued';
935943
}
936944

945+
/** GitLab tree rows expose blob identity but not the commit revision needed
946+
* for optimistic locking. Read it during planning and compare the fresh blob
947+
* again before accepting the action; the stored revision then protects the
948+
* interval between planning and the atomic commit. */
949+
private async refreshGitLabBatchRevision(repoPath: string, entry: GitTreeEntry | undefined): Promise<{ sha: string; revision?: string } | undefined> {
950+
if (this.settings.serviceType !== 'gitlab' || !entry?.sha) return undefined;
951+
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
952+
return remote.sha ? { sha: remote.sha, revision: remote.revision } : undefined;
953+
}
954+
955+
/** Migrates a legacy GitLab last_commit_id baseline only when the current
956+
* file endpoint proves it still describes this tree blob. */
957+
private async migrateGitLabLegacyBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise<void> {
958+
const metadata = this.settings.syncMetadata[path];
959+
if (this.settings.serviceType !== 'gitlab' || !metadata?.lastSyncedSha || !entry?.sha || entry.sha === metadata.lastSyncedSha) return;
960+
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
961+
if (remote.sha === entry.sha && remote.revision === metadata.lastSyncedSha) await this.updateMetadata(path, remote.sha);
962+
}
963+
937964
/**
938965
* Decides a non-symlink, non-renamed file's outcome purely from a
939966
* pre-fetched tree entry and a locally-computed git blob sha — no network
@@ -1060,7 +1087,7 @@ export class SyncManager {
10601087
try {
10611088
const commitMessage = `Push ${chunk.length} file(s) from Obsidian`;
10621089
const batchResults = await this.gitService.pushBatch!(
1063-
chunk.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha })),
1090+
chunk.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha, revision: f.existingRevision })),
10641091
this.settings.branch,
10651092
commitMessage
10661093
);
@@ -1106,8 +1133,8 @@ export class SyncManager {
11061133
const commitMessage = this.combinedChunkCommitMessage(pushEntries.length, moveEntries.length);
11071134

11081135
const batchResults = await this.gitService.commitBatch!(
1109-
pushEntries.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha })),
1110-
moveEntries.map(f => ({ oldPath: f.oldRepoPath, newPath: f.repoPath, content: f.content })),
1136+
pushEntries.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha, revision: f.existingRevision })),
1137+
moveEntries.map(f => ({ oldPath: f.oldRepoPath, newPath: f.repoPath, content: f.content, oldRevision: f.oldRevision })),
11111138
this.settings.branch,
11121139
commitMessage
11131140
);
@@ -1249,6 +1276,8 @@ export class SyncManager {
12491276
return 'unchanged';
12501277
}
12511278

1279+
await this.migrateGitLabLegacyBaseline(path, this.getNormalizedPath(path), entry);
1280+
12521281
// Same conflict check as the content path below: local differs and the
12531282
// remote has moved since we last synced, so pulling would discard one of
12541283
// the two changes.

src/services/git-service-base.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { requestUrl, RequestUrlResponse } from 'obsidian';
22
import { logger } from '../utils/logger';
33
import { GitTreeEntry } from './git-service-interface';
4+
import { isBinaryPath } from '../utils/path';
45

56
export interface GitFile {
67
content: string | ArrayBuffer;
@@ -195,18 +196,6 @@ export abstract class BaseGitService {
195196
return cleanRoot + path;
196197
}
197198

198-
protected isBinary(path: string): boolean {
199-
const ext = path.split('.').pop()?.toLowerCase();
200-
if (!ext) return false;
201-
const BINARY_EXTENSIONS = new Set([
202-
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar',
203-
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp',
204-
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so',
205-
'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso'
206-
]);
207-
return BINARY_EXTENSIONS.has(ext);
208-
}
209-
210199
protected encodeContent(content: string | ArrayBuffer): string {
211200
if (typeof content === 'string') {
212201
const bytes = new TextEncoder().encode(content);
@@ -235,7 +224,7 @@ export abstract class BaseGitService {
235224
bytes[i] = cp !== undefined ? cp : 0;
236225
}
237226

238-
return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes);
227+
return isBinaryPath(path) ? bytes.buffer : new TextDecoder().decode(bytes);
239228
}
240229

241230
/**

src/services/git-service-interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface BatchPushItem {
3636
* action 'create' vs 'update'); GitHub/Gitea's tree-based commit ignores it.
3737
*/
3838
existedRemotely?: boolean;
39+
/** Revision read during batch planning, used by GitLab's optimistic lock. */
40+
revision?: string;
3941
}
4042

4143
/** Result for one file after a batch push completes. */
@@ -52,6 +54,8 @@ export interface BatchMoveItem {
5254
/** Path relative to rootPath, where the file now lives. */
5355
newPath: string;
5456
content: string | ArrayBuffer;
57+
/** Revision of oldPath read during batch planning, used by GitLab's optimistic lock. */
58+
oldRevision?: string;
5559
}
5660

5761
export interface GitServiceInterface {

src/services/gitea-service.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchMoveItem } from './git-service-interface';
22
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base';
3-
import { logger } from '../utils/logger';
43

54
export class GiteaService extends BaseGitService implements GitServiceInterface {
65
private baseUrl: string = '';
@@ -156,9 +155,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
156155
const treeResponse = await this.safeRequest(treeUrl, 'GET');
157156
const treeData = this.parseJson<GitHubTreeResponse>(treeResponse);
158157

159-
if (treeData.truncated) {
160-
logger.warn('Gitea tree result is truncated. Some files might not be shown.');
161-
}
158+
if (treeData.truncated) throw new Error(`Gitea tree for branch "${branch}" is truncated; sync stopped to avoid treating an incomplete remote tree as a snapshot.`);
162159

163160
const entries = treeData.tree
164161
.filter(item => item.type === 'blob')

src/services/github-service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchMoveItem } from './git-service-interface';
22
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base';
3-
import { logger } from '../utils/logger';
43
import { PushTimingCollector, PushTimingHandler, PushTimingRecord } from './push-timing';
54

65
/**
@@ -104,6 +103,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
104103
}
105104

106105
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, _existingSha?: string, _revision?: string): Promise<{ path: string, sha?: string }> {
106+
const entry = (await this.listFilesDetailed(branch, false)).find(item => item.path === this.getFullPath(path));
107+
if (entry?.symlink) {
108+
throw new Error(`Cannot overwrite symlink "${path}" with a regular file.`);
109+
}
107110
const [result] = await this.pushBatch([{ path, content }], branch, message);
108111
return result ?? { path };
109112
}
@@ -309,9 +312,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
309312
throw this.branchNotFoundError(e, branch);
310313
}
311314

312-
if (data.truncated) {
313-
logger.warn('GitHub tree result is truncated. Some files might not be shown.');
314-
}
315+
if (data.truncated) throw new Error(`GitHub tree for branch "${branch}" is truncated; sync stopped to avoid treating an incomplete remote tree as a snapshot.`);
315316

316317
const entries = data.tree
317318
.filter(item => item.type === 'blob')

src/services/gitlab-service.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchMoveItem } from './git-service-interface';
22
import { BaseGitService, ConnectionTestResult, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base';
3+
import { isBinaryPath } from '../utils/path';
34

45
export class GitLabService extends BaseGitService implements GitServiceInterface {
56
private baseUrl: string = 'https://gitlab.com';
@@ -61,12 +62,13 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
6162
const encodedProjectId = encodeURIComponent(this.projectId);
6263
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/commits`;
6364

64-
const actions = items.map(item => ({
65+
const actions = await Promise.all(items.map(async item => ({
6566
action: item.existedRemotely ? 'update' : 'create',
6667
file_path: this.getFullPath(item.path),
6768
content: this.encodeContent(item.content),
6869
encoding: 'base64',
69-
}));
70+
...(item.existedRemotely && item.revision ? { last_commit_id: item.revision } : {}),
71+
})));
7072

7173
await this.safeRequest(url, 'POST', { branch, commit_message: message, actions });
7274

@@ -89,19 +91,21 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
8991
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/commits`;
9092

9193
const actions = [
92-
...additions.map(item => ({
94+
...await Promise.all(additions.map(async item => ({
9395
action: item.existedRemotely ? 'update' : 'create',
9496
file_path: this.getFullPath(item.path),
9597
content: this.encodeContent(item.content),
9698
encoding: 'base64',
97-
})),
98-
...moves.map(item => ({
99+
...(item.existedRemotely && item.revision ? { last_commit_id: item.revision } : {}),
100+
}))),
101+
...await Promise.all(moves.map(async item => ({
99102
action: 'move',
100103
file_path: this.getFullPath(item.newPath),
101104
previous_path: this.getFullPath(item.oldPath),
102105
content: this.encodeContent(item.content),
103106
encoding: 'base64',
104-
})),
107+
...(item.oldRevision ? { last_commit_id: item.oldRevision } : {}),
108+
}))),
105109
];
106110

107111
await this.safeRequest(url, 'POST', { branch, commit_message: message, actions });
@@ -160,7 +164,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
160164
const encodedProjectId = encodeURIComponent(this.projectId);
161165
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/blobs/${sha}/raw`;
162166
const response = await this.safeRequest(url, 'GET');
163-
const content = this.isBinary(path) ? response.arrayBuffer : response.text;
167+
const content = isBinaryPath(path) ? response.arrayBuffer : response.text;
164168
return { content, sha };
165169
}
166170

@@ -179,7 +183,11 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
179183
const encodedProjectId = encodeURIComponent(this.projectId);
180184
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/commits`;
181185

182-
const actions = paths.map(path => ({ action: 'delete', file_path: this.getFullPath(path) }));
186+
const actions = await Promise.all(paths.map(async path => ({
187+
action: 'delete',
188+
file_path: this.getFullPath(path),
189+
last_commit_id: (await this.getFile(path, branch)).revision,
190+
})));
183191

184192
await this.safeRequest(url, 'POST', { branch, commit_message: message, actions });
185193
}

tests/logic/sync-manager-batch.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,27 @@ describe('SyncManager Batch Operations', () => {
148148
]);
149149
});
150150

151+
it('reads and forwards GitLab revision for an existing batch update', async () => {
152+
const path = 'locked.md';
153+
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
154+
mockSettings.serviceType = 'gitlab';
155+
mockSettings.syncMetadata = {
156+
[path]: { lastSyncedSha: 'remote-blob', lastSyncedAt: 0, lastKnownPath: path }
157+
};
158+
vi.mocked(adapter.exists).mockResolvedValue(true);
159+
vi.mocked(adapter.read).mockResolvedValue('local edit');
160+
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([{ path, symlink: false, sha: 'remote-blob' }]);
161+
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote original', sha: 'remote-blob', revision: 'remote-commit' });
162+
mockGitService.pushBatch = vi.fn().mockResolvedValue([{ path, sha: 'new-blob' }]);
163+
164+
const results = await manager.pushAllFiles([path]);
165+
166+
expect(results.success).toBe(1);
167+
expect(mockGitService.pushBatch).toHaveBeenCalledWith([
168+
{ path, content: 'local edit', existedRemotely: true, revision: 'remote-commit' },
169+
], 'main', expect.any(String));
170+
});
171+
151172
it('reports syncedPaths via the sequential fallback when the provider has no pushBatch', async () => {
152173
const files = ['a.md', 'b.md'];
153174
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
@@ -319,6 +340,25 @@ describe('SyncManager Batch Operations', () => {
319340
expect(adapter.write).not.toHaveBeenCalled();
320341
});
321342

343+
it('migrates a legacy GitLab last_commit_id baseline instead of creating a false pull conflict', async () => {
344+
const path = 'legacy.md';
345+
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
346+
mockSettings.serviceType = 'gitlab';
347+
mockSettings.syncMetadata = {
348+
[path]: { lastSyncedSha: 'legacy-last-commit', lastSyncedAt: 0, lastKnownPath: path }
349+
};
350+
vi.mocked(adapter.exists).mockResolvedValue(true);
351+
vi.mocked(adapter.read).mockResolvedValue('local old copy');
352+
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([{ path, symlink: false, sha: 'remote-blob' }]);
353+
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote current copy', sha: 'remote-blob', revision: 'legacy-last-commit' });
354+
355+
const results = await manager.pullAllFiles([path]);
356+
357+
expect(results.conflicts).toBe(0);
358+
expect(results.success).toBe(1);
359+
expect(mockSettings.syncMetadata[path]?.lastSyncedSha).toBe('remote-blob');
360+
});
361+
322362
it('still downloads when the local file differs and the remote has not moved', async () => {
323363
const path = 'stale.md';
324364
mockSettings.syncMetadata = {

0 commit comments

Comments
 (0)