@@ -179,6 +179,51 @@ fn tmp_path() -> PathBuf {
179179 dir. join ( format ! ( "{}.{}" , std:: process:: id( ) , nanos) )
180180}
181181
182+ /// True iff `path` (relative to sync root) is `.git` or lies under `.git/`.
183+ /// Cheap path-component check — no filesystem access.
184+ pub fn is_under_git ( rel : & Path ) -> bool {
185+ rel. components ( )
186+ . next ( )
187+ . map ( |c| c. as_os_str ( ) == ".git" )
188+ . unwrap_or ( false )
189+ }
190+
191+ /// True iff the sync root has a git operation in progress that would race
192+ /// with file-level sync of `.git/`. While any of these markers exist, we
193+ /// pause syncing of paths under `.git/` on both walk, push, and apply.
194+ ///
195+ /// Why: git treats `.git/` as transactional state. Atomically renaming a
196+ /// ref while we mid-stream a different version of that ref from the peer
197+ /// causes "cannot lock ref" failures and breaks rebase/merge/cherry-pick.
198+ /// Pausing only `.git/` (not the working tree) is correct — your source
199+ /// edits keep syncing, only the VCS metadata is held back until the
200+ /// in-progress operation finishes.
201+ pub fn git_busy ( root : & Path ) -> bool {
202+ let git_dir = root. join ( ".git" ) ;
203+ // `.git` may not exist, may be a worktree pointer file, or a real dir.
204+ // We only handle the regular-dir case; worktrees are uncommon enough
205+ // that paying for them isn't worth the complexity now.
206+ if !git_dir. is_dir ( ) {
207+ return false ;
208+ }
209+ const MARKERS : & [ & str ] = & [
210+ "rebase-merge" ,
211+ "rebase-apply" ,
212+ "MERGE_HEAD" ,
213+ "CHERRY_PICK_HEAD" ,
214+ "REVERT_HEAD" ,
215+ "BISECT_LOG" ,
216+ "index.lock" ,
217+ "HEAD.lock" ,
218+ ] ;
219+ for m in MARKERS {
220+ if git_dir. join ( m) . exists ( ) {
221+ return true ;
222+ }
223+ }
224+ false
225+ }
226+
182227/// Remove tmp files left over from a previous crashed run.
183228/// Age-based (> 1 hour) so we don't step on a concurrently-running synx.
184229/// Cheap; safe to call at startup of both client and agent.
@@ -634,19 +679,15 @@ pub async fn live_loop<R, W>(
634679 compress : bool ,
635680 is_client : bool ,
636681 ignores : Arc < IgnoreStack > ,
637- // Suppression + Pending are passed in (not constructed here) so any
638- // entries recorded during the initial sync — i.e. files we just wrote
639- // to disk — remain in scope when the watcher starts firing events.
640- // Without this, FSEvents/inotify events for init-sync writes have
641- // nothing to match in the suppression map and bounce back to the peer
642- // as spurious "local changes".
682+ // Carried over from the init-sync apply phase: marks for every file we
683+ // wrote during initial sync stay valid here (TTL 60s) so the watcher's
684+ // FSEvents/inotify echoes for those writes are filtered, not bounced
685+ // back to the peer as spurious local changes.
643686 suppress : Suppression ,
644687 pending : Pending ,
645688 // The watcher is spawned BEFORE the initial sync so events for files
646- // the user modifies during the walk/exchange window are captured and
647- // replayed after init sync completes (otherwise they're lost: notify
648- // uses "events since now" at registration time). Caller owns spawning
649- // it; we receive the live channel + keepalive here.
689+ // the user modifies during the walk/exchange/apply window aren't lost.
690+ // Caller owns spawning; we receive the live channel + keepalive here.
650691 watcher_handle : watcher:: WatcherHandle ,
651692) -> Result < ( ) >
652693where
@@ -698,8 +739,8 @@ where
698739 Some ( Ok ( Message :: Bye ) ) => break ,
699740 Some ( Ok ( m) ) => {
700741 // Per-op apply errors are non-fatal — log and
701- // continue. Connection-level failures show up as
702- // Err(...) from the reader task (next arm).
742+ // continue. Connection-level failures appear as
743+ // Err from the reader task (the next arm).
703744 if let Err ( e) = handle_incoming( & root, m, & suppress, & pending, compress, & writer, apply_remote, Some ( & ignores) , is_client) . await {
704745 tracing:: warn!( "apply failed: {}" , e) ;
705746 let mut w = writer. lock( ) . await ;
@@ -753,6 +794,33 @@ where
753794 // forwarded over SSH to the same terminal, so any logs there would just
754795 // duplicate the client's transcript.
755796 let log_event = is_client;
797+ // If git is mid-operation locally, refuse to apply any change under
798+ // `.git/`. Otherwise the peer (who may NOT be busy) would clobber our
799+ // in-progress rebase/merge state and break ref locking.
800+ let busy = git_busy ( root) ;
801+ let path_of = |m : & Message | -> Option < PathBuf > {
802+ match m {
803+ Message :: FileData { entry, .. } => Some ( entry. path . clone ( ) ) ,
804+ Message :: FileStart { entry, .. } => Some ( entry. path . clone ( ) ) ,
805+ Message :: FileChunk { path, .. } => Some ( path. clone ( ) ) ,
806+ Message :: FileEnd { path } => Some ( path. clone ( ) ) ,
807+ Message :: MkDir { entry } => Some ( entry. path . clone ( ) ) ,
808+ Message :: MkSymlink { entry } => Some ( entry. path . clone ( ) ) ,
809+ Message :: Delete { path } => Some ( path. clone ( ) ) ,
810+ Message :: Rename { from : _, to } => Some ( to. clone ( ) ) ,
811+ Message :: Delta { entry, .. } => Some ( entry. path . clone ( ) ) ,
812+ Message :: Touch { path, .. } => Some ( path. clone ( ) ) ,
813+ _ => None ,
814+ }
815+ } ;
816+ if busy {
817+ if let Some ( p) = path_of ( & msg) {
818+ if is_under_git ( & p) {
819+ tracing:: debug!( "git busy: skip incoming for {}" , p. display( ) ) ;
820+ return Ok ( ( ) ) ;
821+ }
822+ }
823+ }
756824 match msg {
757825 Message :: FileData { entry, content } => {
758826 if !apply_remote {
@@ -1015,7 +1083,7 @@ where
10151083 Message :: Pong => { }
10161084 // Per-op error reported by the peer (type conflict, perm denied,
10171085 // etc.). Log and keep the session alive — bailing would just
1018- // trigger a reconnect that would repeat the same failure.
1086+ // trigger a reconnect that repeats the same failure.
10191087 Message :: Error ( e) => tracing:: warn!( "peer error: {e}" ) ,
10201088 other => {
10211089 tracing:: debug!(
@@ -1092,11 +1160,25 @@ where
10921160 // forwarded over SSH stderr and duplicate every transfer line.
10931161 let log_event = is_client;
10941162 let events = coalesce ( events) ;
1163+ // Once per batch: if git is mid-operation, suppress every event that
1164+ // touches .git/. Prevents partial rebase/merge state from leaking to
1165+ // the peer where it would race with the peer's own ref updates.
1166+ let pause_git = git_busy ( root) ;
10951167 for ev in events {
10961168 if suppress. is_echo ( root, & ev) {
10971169 tracing:: trace!( "echo suppressed: {:?}" , ev) ;
10981170 continue ;
10991171 }
1172+ if pause_git {
1173+ let key = match & ev {
1174+ FsEvent :: Created ( p) | FsEvent :: Modified ( p) | FsEvent :: Removed ( p) => p,
1175+ FsEvent :: Renamed { to, .. } => to,
1176+ } ;
1177+ if is_under_git ( key) {
1178+ tracing:: debug!( "git busy: skip event {:?}" , ev) ;
1179+ continue ;
1180+ }
1181+ }
11001182
11011183 match ev {
11021184 FsEvent :: Created ( p) | FsEvent :: Modified ( p) => {
0 commit comments