diff --git a/README.md b/README.md index ab2ff6b..dba7c70 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ git-sync sync --jobs 8 While jobs run, the bottom of the terminal shows one live status line per worker. When a repository finishes, its detailed log is printed as one complete block above those status lines. The default is 4 workers; use `--jobs 1` for serial sync. -`git-sync` stores a small ref cache in the work directory. On later runs it first checks each repository with `git ls-remote --heads --tags`; when all endpoints report the same refs as the last successful sync, it skips the full fetch/push pass for that repository. +`git-sync` stores a small ref cache in the work directory. On later runs it first checks each repository with `git ls-remote --heads --tags`; when all endpoints report the same refs as the last successful sync, or the existing local bare mirror cache already has those refs, it skips the full fetch/push pass for that repository. Use cron or another scheduler for automatic execution: diff --git a/src/git.rs b/src/git.rs index ce0f0cc..3ad4861 100644 --- a/src/git.rs +++ b/src/git.rs @@ -109,6 +109,27 @@ impl GitMirror { self.run(["fetch", "--prune", &remote.name, &tag_refspec]) } + pub fn cached_remote_refs_match( + &self, + remote: &RemoteSpec, + expected: &RemoteRefSnapshot, + ) -> Result { + if !self.path.exists() || self.dry_run { + return Ok(false); + } + let branches = self.remote_branches(&remote.name)?; + let tags = self.remote_tags(&remote.name)?; + let mut refs = Vec::with_capacity(branches.len() + tags.len()); + for (branch, sha) in branches { + refs.push(format!("{sha}\trefs/heads/{branch}")); + } + for (tag, sha) in tags { + refs.push(format!("{sha}\trefs/tags/{tag}")); + } + let snapshot = snapshot_from_refs(refs); + Ok(&snapshot == expected) + } + pub fn branch_decisions( &self, remotes: &[RemoteSpec], @@ -432,18 +453,23 @@ pub fn ls_remote_refs(remote: &RemoteSpec, redactor: &Redactor) -> Result>(); + + Ok(snapshot_from_refs(refs)) +} + +fn snapshot_from_refs(mut refs: Vec) -> RemoteRefSnapshot { refs.sort(); - Ok(RemoteRefSnapshot { + RemoteRefSnapshot { hash: stable_ref_hash(&refs), refs: refs.len(), - }) + } } fn stable_ref_hash(refs: &[String]) -> String { @@ -698,6 +724,35 @@ mod tests { assert!(main.target_remotes.is_empty()); } + #[test] + fn cached_remote_refs_match_ls_remote_snapshot_after_fetch() { + let fixture = GitFixture::new(); + fixture.commit("base", "base", 1_700_000_000); + fixture.tag("v1"); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_tag(&fixture.remote_a, "v1"); + + let mirror = fixture.mirror(); + let remote = fixture.remotes().remove(0); + assert!( + !mirror + .cached_remote_refs_match( + &remote, + &ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap(), + ) + .unwrap() + ); + + mirror.fetch_remote(&remote).unwrap(); + let snapshot = ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap(); + assert!(mirror.cached_remote_refs_match(&remote, &snapshot).unwrap()); + + fixture.commit("newer", "newer", 1_700_000_100); + fixture.push_head(&fixture.remote_a, "main"); + let changed = ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap(); + assert!(!mirror.cached_remote_refs_match(&remote, &changed).unwrap()); + } + #[test] fn branch_decisions_report_divergent_tips_without_force() { let fixture = GitFixture::new(); diff --git a/src/sync.rs b/src/sync.rs index 37c76c1..143e4d7 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -230,6 +230,15 @@ impl From for RemoteRefState { } } +impl From<&RemoteRefState> for RemoteRefSnapshot { + fn from(value: &RemoteRefState) -> Self { + Self { + hash: value.hash.clone(), + refs: value.refs, + } + } +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] struct RefState { #[serde(default)] @@ -659,8 +668,9 @@ fn sync_repo( let Some(initial_ref_state) = check_remote_refs(context, repo_name, &initial_remotes)? else { return Ok(RepoSyncOutcome::default()); }; + let all_endpoints_present = all_configured_endpoints_present(context.mirror, repos); if !context.dry_run - && all_configured_endpoints_present(context.mirror, repos) + && all_endpoints_present && ref_state.repo_matches(&context.mirror.name, repo_name, &initial_ref_state) { crate::logln!( @@ -677,6 +687,19 @@ fn sync_repo( let mirror_repo = GitMirror::open(path, context.redactor.clone(), context.dry_run)?; mirror_repo.configure_remotes(&initial_remotes)?; + if !context.dry_run + && all_endpoints_present + && cached_refs_match(&mirror_repo, &initial_remotes, &initial_ref_state)? + { + crate::logln!( + " {} refs unchanged from local mirror cache", + style("up-to-date").green().bold() + ); + return Ok(RepoSyncOutcome { + ref_update: Some(initial_ref_state), + }); + } + for remote in &initial_remotes { if let Err(error) = mirror_repo.fetch_remote(remote) { if is_disabled_repository_error(&error) { @@ -763,6 +786,22 @@ fn all_configured_endpoints_present(mirror: &MirrorConfig, repos: &[EndpointRepo .all(|endpoint| present.contains(endpoint)) } +fn cached_refs_match( + mirror_repo: &GitMirror, + remotes: &[RemoteSpec], + expected_refs: &BTreeMap, +) -> Result { + for remote in remotes { + let Some(expected) = expected_refs.get(&remote.name) else { + return Ok(false); + }; + if !mirror_repo.cached_remote_refs_match(remote, &RemoteRefSnapshot::from(expected))? { + return Ok(false); + } + } + Ok(true) +} + fn check_remote_refs( context: &RepoSyncContext<'_>, repo_name: &str,