@@ -22,7 +22,7 @@ type PlanClassification = { kind: 'addition' | 'modification' | 'move' | 'unchan
2222type 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.
0 commit comments