Skip to content

Commit 5a40d00

Browse files
ClaudiaFangclaude
andcommitted
fix(gitlab): fix sha/revision semantics for optimistic locking
- GitFile.sha now represents blob identity (blob_id), not commit revision - Add GitFile.revision for provider-specific write control (last_commit_id) - Separate existingSha (blob detection) from existingRevision (lock token) - GitLab pushFile now uses revision for optimistic locking, not blob SHA - Update SyncManager conflict detection to support lazy migration - Metadata from older single-pull (commit revision) or batch (blob SHA) can coexist without false conflicts - isSameBaseline() checks if lastSyncedSha matches remote.sha or remote.revision - Update all tests to match new pushFile signature and getFile semantics Fixes issue #101 where GitLab single-file and batch flows used inconsistent SHA semantics, causing conflicts and incorrect behavior on concurrent edits. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 5ab9bf8 commit 5a40d00

12 files changed

Lines changed: 54 additions & 36 deletions

src/logic/sync-manager.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type BatchOutcome = 'done' | 'unchanged' | 'conflict';
1818
type PlanClassification = { kind: 'addition' | 'modification' | 'move' | 'unchanged' | 'conflict' | 'skip'; movedFrom?: string };
1919

2020
/** A file classified as needing a push, queued for the grouped batch-commit call. */
21-
type ToPushEntry = { path: string; name: string; repoPath: string; content: string | ArrayBuffer; existingSha?: string };
21+
type ToPushEntry = { path: string; name: string; repoPath: string; content: string | ArrayBuffer; existingSha?: string; existingRevision?: string };
2222

2323
/** A renamed file classified as a safe move, queued for the grouped batch-commit call. */
2424
type ToMoveEntry = { path: string; name: string; repoPath: string; oldPath: string; oldRepoPath: string; content: string | ArrayBuffer };
@@ -195,15 +195,15 @@ export class SyncManager {
195195

196196
const lastSynced = this.settings.syncMetadata[path];
197197

198-
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
198+
if (remote.sha && lastSynced && !this.isSameBaseline(lastSynced.lastSyncedSha, remote)) {
199199
this.openPushConflictModal(fileOrPath, { path, name }, content, remote);
200200
return undefined;
201201
}
202202

203203
const confirmed = await this.confirmPlan(this.singleEntryPlan(remote.sha ? 'modification' : 'addition', path, name), 'push');
204204
if (!confirmed) return undefined;
205205

206-
const sha = await this.performPush({ path, name }, content, remote.sha);
206+
const sha = await this.performPush({ path, name }, content, remote.sha, remote.revision);
207207
return { sha };
208208
} catch (e) {
209209
this.handleError(`Failed to push ${name} to ${this.serviceName}`, e);
@@ -259,7 +259,7 @@ export class SyncManager {
259259
try {
260260
const fileRep = typeof fileOrPath === 'string' ? file : fileOrPath;
261261
if (choice === 'local') {
262-
await this.performPush(file, content, remote.sha);
262+
await this.performPush(file, content, remote.sha, remote.revision);
263263
} else {
264264
await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote));
265265
}
@@ -367,7 +367,7 @@ export class SyncManager {
367367
}
368368

369369
const remoteAtOldPath = await this.gitService.getFile(oldRepoPath, this.settings.branch);
370-
const safeToDeleteOld = !remoteAtOldPath.sha || !metadata?.lastSyncedSha || remoteAtOldPath.sha === metadata.lastSyncedSha;
370+
const safeToDeleteOld = !remoteAtOldPath.sha || !metadata?.lastSyncedSha || this.isSameBaseline(metadata.lastSyncedSha, remoteAtOldPath);
371371

372372
const newSha = await this.commitMove(repoPath, oldRepoPath, content, safeToDeleteOld && !!remoteAtOldPath.sha);
373373

@@ -405,14 +405,15 @@ export class SyncManager {
405405
return newSha;
406406
}
407407

408-
private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, silent = false): Promise<string | undefined> {
408+
private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, existingRevision?: string, silent = false): Promise<string | undefined> {
409409
const repoPath = this.getNormalizedPath(file.path);
410410
const result = await this.gitService.pushFile(
411411
repoPath,
412412
content,
413413
this.settings.branch,
414414
`Update ${file.name} from Obsidian`,
415-
existingSha
415+
existingSha,
416+
existingRevision
416417
);
417418

418419
// Update metadata
@@ -474,13 +475,13 @@ export class SyncManager {
474475
}
475476

476477
// Conflict detection for pull (only if local exists)
477-
if (exists && remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
478+
if (exists && remote.sha && lastSynced && !this.isSameBaseline(lastSynced.lastSyncedSha, remote)) {
478479
new SyncConflictModal(this.app, name, (localContent as string) || '', remote.content as string, (choice) => {
479480
void (async () => {
480481
try {
481482
const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath;
482483
if (choice === 'local') {
483-
await this.performPush(fileRep, localContent || '', remote.sha);
484+
await this.performPush(fileRep, localContent || '', remote.sha, remote.revision);
484485
} else {
485486
await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote));
486487
}
@@ -506,6 +507,11 @@ export class SyncManager {
506507
return contentsEqual(a, b);
507508
}
508509

510+
/** Check if remote baseline matches metadata, supporting lazy migration of old metadata. */
511+
private isSameBaseline(lastSyncedSha: string, remoteFile: GitFile): boolean {
512+
return lastSyncedSha === remoteFile.sha || lastSyncedSha === remoteFile.revision;
513+
}
514+
509515
private isBinary(path: string): boolean {
510516
return isBinaryPath(path);
511517
}
@@ -1034,7 +1040,7 @@ export class SyncManager {
10341040
private async pushSequentialFallback(toPush: ToPushEntry[], results: PushResults): Promise<void> {
10351041
for (const f of toPush) {
10361042
try {
1037-
const sha = await this.performPush({ path: f.path, name: f.name }, f.content, f.existingSha, true);
1043+
const sha = await this.performPush({ path: f.path, name: f.name }, f.content, f.existingSha, f.existingRevision, true);
10381044
results.success++;
10391045
results.syncedPaths.push({ path: f.path, sha });
10401046
} catch (e) {
@@ -1220,7 +1226,7 @@ export class SyncManager {
12201226

12211227
// Same conflict check as the single-file flow (see processSingleBatchPush).
12221228
const lastSynced = this.settings.syncMetadata[path];
1223-
if (lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
1229+
if (lastSynced && !this.isSameBaseline(lastSynced.lastSyncedSha, remote)) {
12241230
return 'conflict';
12251231
}
12261232
}

src/services/git-service-base.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GitTreeEntry } from './git-service-interface';
55
export interface GitFile {
66
content: string | ArrayBuffer;
77
sha: string;
8+
revision?: string;
89
isSymlink?: boolean;
910
symlinkTarget?: string;
1011
}
@@ -376,7 +377,7 @@ export abstract class BaseGitService {
376377
}
377378

378379
abstract getFile(path: string, branch: string): Promise<GitFile>;
379-
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>;
380+
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string, revision?: string): Promise<{ path: string, sha?: string }>;
380381
abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
381382
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
382383
abstract testConnection(branch: string): Promise<ConnectionTestResult>;

src/services/git-service-interface.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { ConnectionTestResult } from './git-service-base';
22

33
export interface GitFile {
44
content: string | ArrayBuffer;
5+
/** The blob's git SHA — permanent blob identity, used to compare content across syncs. */
56
sha: string;
7+
/** Provider-specific revision for write/concurrency control (e.g., GitLab's last_commit_id). */
8+
revision?: string;
69
/** True when the remote blob is a symbolic link (mode 120000). */
710
isSymlink?: boolean;
811
/** The link target path, when isSymlink is true and it could be determined. */
@@ -54,7 +57,7 @@ export interface BatchMoveItem {
5457
export interface GitServiceInterface {
5558
updateConfig(...args: unknown[]): void;
5659
getFile(path: string, branch: string): Promise<GitFile>;
57-
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>;
60+
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string, existingRevision?: string): Promise<{ path: string, sha?: string }>;
5861
/** Returns the branch's current commit SHA when the provider can expose it cheaply. */
5962
getBranchHead?(branch: string): Promise<string>;
6063
/** Checks the repository is reachable and the given branch exists. */

src/services/gitea-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
5959
}
6060
}
6161

62-
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
62+
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string, _revision?: string): Promise<{ path: string, sha?: string }> {
6363
const url = this.getApiUrl(path);
6464
const body: { message: string; content: string; branch: string; sha?: string } = {
6565
message,

src/services/github-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
103103
}
104104
}
105105

106-
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, _existingSha?: string): Promise<{ path: string, sha?: string }> {
106+
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, _existingSha?: string, _revision?: string): Promise<{ path: string, sha?: string }> {
107107
const [result] = await this.pushBatch([{ path, content }], branch, message);
108108
return result ?? { path };
109109
}

src/services/gitlab-service.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,27 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
2828
const url = `${this.getApiUrl(path)}?ref=${branch}`;
2929
const response = await this.safeRequest(url, 'GET');
3030
const data = this.parseJson<GitLabFileResponse>(response);
31-
31+
3232
return {
3333
content: this.decodeContent(data.content, path),
34-
sha: data.last_commit_id
34+
sha: data.blob_id,
35+
revision: data.last_commit_id
3536
};
3637
} catch (e) {
3738
return this.handleFileNotFound(e);
3839
}
3940
}
4041

41-
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
42+
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string, revision?: string): Promise<{ path: string, sha?: string }> {
4243
const url = this.getApiUrl(path);
4344
const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = {
4445
branch,
4546
content: this.encodeContent(content),
4647
encoding: 'base64',
4748
commit_message: message,
4849
};
49-
// A blank sha means the file is new: create it (POST) without last_commit_id.
50-
if (sha) body.last_commit_id = sha;
50+
// A blank sha means the file is new: create it (POST). Use revision for GitLab's optimistic locking.
51+
if (revision) body.last_commit_id = revision;
5152

5253
const method = sha ? 'PUT' : 'POST';
5354
const response = await this.safeRequest(url, method, body);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ describe('SyncManager Batch Operations', () => {
452452

453453
expect(results.success).toBe(1);
454454
expect(results.conflicts).toBe(0);
455-
expect(mockGitService.pushFile).toHaveBeenCalledWith(path, 'local content', 'main', expect.any(String), 'some-sha');
455+
expect(mockGitService.pushFile).toHaveBeenCalledWith(path, 'local content', 'main', expect.any(String), 'some-sha', undefined);
456456
});
457457
});
458458

@@ -473,7 +473,7 @@ describe('SyncManager Batch Operations', () => {
473473
await manager.pushAllFiles([mockFile]);
474474

475475
expect(mockGitService.getFile).not.toHaveBeenCalled();
476-
expect(mockGitService.pushFile).toHaveBeenCalledWith(newPath, 'new content', 'main', expect.any(String), undefined);
476+
expect(mockGitService.pushFile).toHaveBeenCalledWith(newPath, 'new content', 'main', expect.any(String), undefined, undefined);
477477
});
478478
});
479479

@@ -599,7 +599,7 @@ describe('SyncManager Batch Operations', () => {
599599

600600
expect(results.success).toBe(1);
601601
expect(mockGitService.pushFile).toHaveBeenCalledWith(
602-
pushedPath, 'unrelated content', 'main', `Update ${mockFile.name} from Obsidian`, 'remote-sha'
602+
pushedPath, 'unrelated content', 'main', `Update ${mockFile.name} from Obsidian`, 'remote-sha', undefined
603603
);
604604
expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined();
605605
// The tree doesn't list the orphaned path, so it can't be a rename

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('SyncManager – binary file handling', () => {
3636
expect(mockAdapter.readBinary).toHaveBeenCalledWith('photo.png');
3737
expect(mockAdapter.read).not.toHaveBeenCalled();
3838
expect(mockGitService.pushFile).toHaveBeenCalledWith(
39-
'photo.png', buf, 'main', expect.any(String), ''
39+
'photo.png', buf, 'main', expect.any(String), '', undefined
4040
);
4141
});
4242

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('SyncManager – hidden file support', () => {
8080
await manager.pushFile('.claude/CLAUDE.md');
8181

8282
expect(mockGitService.pushFile).toHaveBeenCalledWith(
83-
'.claude/CLAUDE.md', '# Memory\n\nsome content', 'main', expect.any(String), ''
83+
'.claude/CLAUDE.md', '# Memory\n\nsome content', 'main', expect.any(String), '', undefined
8484
);
8585
});
8686

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ describe('SyncManager Mapping', () => {
9999
'content',
100100
'main',
101101
'Update test.md from Obsidian',
102-
''
102+
'',
103+
undefined
103104
);
104105
});
105106

0 commit comments

Comments
 (0)