a73x

1c9fed82

Sync every remote with collab refspecs, not just one

a73x   2026-08-09 16:48

Commit message
Sync every remote with collab refspecs, not just one

`git-collab init` configures collab refspecs on every remote, but
`sync` only ever fetched/pushed a single remote (positional arg,
default "origin") and printed an unqualified "Sync complete." even
when other remotes were never touched. Auto-sync after write commands
had the same problem via collab.autoSyncRemote.

Fixes #390481e1.

README.md
Old New
@@ -100,6 +100,29 @@ than type.
100 100
101 Every command takes `--help`, and `man git-collab` covers the same ground. 101 Every command takes `--help`, and `man git-collab` covers the same ground.
102 102
103 ## Sync
104
105 `git-collab init` adds collab refspecs to every remote of the repo. `git-collab
106 sync` (no arguments) syncs all of them — fetch, reconcile, push — and only
107 reports success once every one of them has actually succeeded. A remote that
108 fails does not stop the others: failures are reported together, alongside
109 which remotes did succeed, and the command exits non-zero. Pass `--remote
110 <name>` to sync just one remote instead.
111
112 Write commands (`issue`/`patch`) auto-sync afterwards. Two git config keys
113 control that:
114
115 - `collab.autoSync` (bool, default `true`) — set to `false` to disable
116 auto-sync entirely.
117 - `collab.autoSyncRemote` (string, unset by default) — pins auto-sync to a
118 single named remote. Left unset, auto-sync covers every configured remote,
119 same as a plain `sync`.
120
121 ```console
122 $ git config collab.autoSync false # disable auto-sync
123 $ git config collab.autoSyncRemote origin # auto-sync only 'origin'
124 ```
125
103 ## Trust 126 ## Trust
104 127
105 Sync verifies every signature it fetches. Until you add a trusted key, valid 128 Sync verifies every signature it fetches. Until you add a trusted key, valid
src/cli.rs
Old New
@@ -90,11 +90,22 @@ pub enum Commands {
90 shell: Shell, 90 shell: Shell,
91 }, 91 },
92 92
93 /// Sync with a remote (fetch, reconcile, push) 93 /// Sync with configured remotes (fetch, reconcile, push)
94 ///
95 /// With no --remote, syncs every remote that has collab refspecs
96 /// configured (every remote `git-collab init` touched), and only
97 /// reports success once all of them have succeeded. A failure on one
98 /// remote does not stop the others — failures are reported together
99 /// and the command exits non-zero if any remote failed.
100 ///
101 /// Write commands (`issue`/`patch`) auto-sync afterwards unless
102 /// `collab.autoSync` is set to false. By default auto-sync covers
103 /// every configured remote, same as a plain `sync`; set the git config
104 /// `collab.autoSyncRemote` to a remote name to pin it to just that one.
94 Sync { 105 Sync {
95 /// Remote name (default: origin) 106 /// Sync only this remote instead of every configured remote
96 #[arg(default_value = "origin")] 107 #[arg(long)]
97 remote: String, 108 remote: Option<String>,
98 }, 109 },
99 110
100 /// Generate an Ed25519 signing keypair 111 /// Generate an Ed25519 signing keypair
src/error.rs
Old New
@@ -32,6 +32,9 @@ pub enum Error {
32 #[error("sync partially failed: {succeeded} of {total} refs pushed")] 32 #[error("sync partially failed: {succeeded} of {total} refs pushed")]
33 PartialSync { succeeded: usize, total: usize }, 33 PartialSync { succeeded: usize, total: usize },
34 34
35 #[error("sync failed for {failed} of {total} configured remote(s)")]
36 MultiRemoteSync { failed: usize, total: usize },
37
35 #[error("event.json blob exceeds {limit} byte limit (actual: {actual})")] 38 #[error("event.json blob exceeds {limit} byte limit (actual: {actual})")]
36 PayloadTooLarge { actual: usize, limit: usize }, 39 PayloadTooLarge { actual: usize, limit: usize },
37 40
src/lib.rs
Old New
@@ -55,17 +55,52 @@ fn maybe_auto_sync(repo: &Repository) {
55 return; 55 return;
56 } 56 }
57 57
58 let remote = repo 58 // `collab.autoSyncRemote` pins auto-sync to a single remote. Unset (the
59 // default), auto-sync covers every remote with collab refspecs
60 // configured — same set as a plain `git-collab sync` — so it can never
61 // silently skip a remote that `init` set up.
62 let pinned_remote = repo
59 .config() 63 .config()
60 .ok() 64 .ok()
61 .and_then(|c| c.get_string("collab.autoSyncRemote").ok()) 65 .and_then(|c| c.get_string("collab.autoSyncRemote").ok());
62 .unwrap_or_else(|| "origin".to_string());
63 66
64 eprintln!("Auto-syncing with '{}'...", remote); 67 if let Some(remote) = pinned_remote {
65 match sync::sync(repo, &remote) { 68 eprintln!("Auto-syncing with '{}'...", remote);
69 match sync::sync(repo, &remote) {
70 Ok(()) => {}
71 Err(error::Error::PartialSync { succeeded, total }) => {
72 eprintln!(
73 "warning: auto-sync partially failed ({}/{} refs pushed)",
74 succeeded, total
75 );
76 }
77 Err(e) => {
78 eprintln!("warning: auto-sync failed: {}", e);
79 }
80 }
81 return;
82 }
83
84 let remotes = match sync::collab_remotes(repo) {
85 Ok(remotes) => remotes,
86 Err(e) => {
87 eprintln!("warning: auto-sync failed to list remotes: {}", e);
88 return;
89 }
90 };
91
92 if remotes.is_empty() {
93 return;
94 }
95
96 eprintln!("Auto-syncing with {}...", sync::format_remote_list(&remotes));
97 match sync::sync_all(repo) {
66 Ok(()) => {} 98 Ok(()) => {}
67 Err(error::Error::PartialSync { succeeded, total }) => { 99 Err(error::Error::MultiRemoteSync { failed, total }) => {
68 eprintln!("warning: auto-sync partially failed ({}/{} refs pushed)", succeeded, total); 100 eprintln!(
101 "warning: auto-sync failed for {} of {} remote(s)",
102 failed, total
103 );
69 } 104 }
70 Err(e) => { 105 Err(e) => {
71 eprintln!("warning: auto-sync failed: {}", e); 106 eprintln!("warning: auto-sync failed: {}", e);
@@ -540,7 +575,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
540 Commands::Log { limit } => log::print_log(repo, limit), 575 Commands::Log { limit } => log::print_log(repo, limit),
541 Commands::Dashboard => tui::run(repo), 576 Commands::Dashboard => tui::run(repo),
542 Commands::Completions { .. } => unreachable!("handled before repo open"), 577 Commands::Completions { .. } => unreachable!("handled before repo open"),
543 Commands::Sync { remote } => sync::sync(repo, &remote), 578 Commands::Sync { remote } => match remote {
579 Some(remote) => sync::sync(repo, &remote),
580 None => sync::sync_all(repo),
581 },
544 Commands::InitKey { force } => { 582 Commands::InitKey { force } => {
545 let config_dir = signing::signing_key_dir()?; 583 let config_dir = signing::signing_key_dir()?;
546 let sk_path = config_dir.join("signing-key"); 584 let sk_path = config_dir.join("signing-key");
src/main.rs
Old New
@@ -27,7 +27,8 @@ fn main() {
27 27
28 if let Err(e) = git_collab::run(cli, &repo) { 28 if let Err(e) = git_collab::run(cli, &repo) {
29 match &e { 29 match &e {
30 git_collab::error::Error::PartialSync { .. } => { 30 git_collab::error::Error::PartialSync { .. }
31 | git_collab::error::Error::MultiRemoteSync { .. } => {
31 // Summary already printed by sync; just exit with code 1 32 // Summary already printed by sync; just exit with code 1
32 std::process::exit(1); 33 std::process::exit(1);
33 } 34 }
src/sync.rs
Old New
@@ -272,7 +272,7 @@ fn print_sync_summary(result: &SyncResult) {
272 } 272 }
273 273
274 eprintln!( 274 eprintln!(
275 "\nRun `collab sync {}` again to retry {} failed ref(s).", 275 "\nRun `collab sync --remote {}` again to retry {} failed ref(s).",
276 result.remote, 276 result.remote,
277 failed.len() 277 failed.len()
278 ); 278 );
@@ -318,6 +318,104 @@ pub fn init(repo: &Repository) -> Result<(), Error> {
318 Ok(()) 318 Ok(())
319 } 319 }
320 320
321 // ---------------------------------------------------------------------------
322 // Multi-remote orchestration
323 // ---------------------------------------------------------------------------
324
325 /// Format a list of remote names for display, e.g. `'origin', 'second'`.
326 pub(crate) fn format_remote_list(names: &[String]) -> String {
327 names
328 .iter()
329 .map(|n| format!("'{}'", n))
330 .collect::<Vec<_>>()
331 .join(", ")
332 }
333
334 /// Return the names of remotes that have collab refspecs configured — i.e.
335 /// every remote `git-collab init` has touched — sorted for stable output.
336 /// A remote added after `init` (with no collab fetch refspec) is excluded,
337 /// since syncing it would just fail on refs that were never meant to travel
338 /// there.
339 pub fn collab_remotes(repo: &Repository) -> Result<Vec<String>, Error> {
340 let mut names = Vec::new();
341 for remote_name in repo.remotes()?.iter().flatten() {
342 let remote = repo.find_remote(remote_name)?;
343 let configured = remote.refspecs().any(|rs| {
344 rs.direction() == git2::Direction::Fetch && rs.src() == Some("refs/collab/*")
345 });
346 if configured {
347 names.push(remote_name.to_string());
348 }
349 }
350 names.sort();
351 Ok(names)
352 }
353
354 /// Sync every remote that has collab refspecs configured.
355 ///
356 /// `init` configures collab refspecs on every remote, but a bare `sync
357 /// <remote>` only ever touched the one remote it was given — leaving the
358 /// others silently stale. This syncs all of them, one at a time so a
359 /// failure on one remote can't take down the others, and only ever reports
360 /// unqualified success once every configured remote has actually succeeded.
361 pub fn sync_all(repo: &Repository) -> Result<(), Error> {
362 let remotes = collab_remotes(repo)?;
363
364 if remotes.is_empty() {
365 println!("No remotes with collab refspecs configured. Run `git-collab init` first.");
366 return Ok(());
367 }
368
369 let multiple = remotes.len() > 1;
370 if multiple {
371 println!(
372 "Syncing {} remotes: {}",
373 remotes.len(),
374 format_remote_list(&remotes)
375 );
376 }
377
378 let mut succeeded = Vec::new();
379 let mut failed = Vec::new();
380 for remote_name in &remotes {
381 if multiple {
382 println!();
383 }
384 match sync(repo, remote_name) {
385 Ok(()) => succeeded.push(remote_name.clone()),
386 Err(e) => {
387 eprintln!("error: sync with '{}' failed: {}", remote_name, e);
388 failed.push(remote_name.clone());
389 }
390 }
391 }
392
393 if failed.is_empty() {
394 if multiple {
395 println!(
396 "\nAll {} remotes synced: {}",
397 remotes.len(),
398 format_remote_list(&remotes)
399 );
400 }
401 Ok(())
402 } else {
403 eprintln!(
404 "\nSync incomplete: {} of {} remote(s) failed.",
405 failed.len(),
406 remotes.len()
407 );
408 eprintln!(" Failed: {}", format_remote_list(&failed));
409 if !succeeded.is_empty() {
410 eprintln!(" Succeeded: {}", format_remote_list(&succeeded));
411 }
412 Err(Error::MultiRemoteSync {
413 failed: failed.len(),
414 total: remotes.len(),
415 })
416 }
417 }
418
321 /// Sync with a specific remote: fetch, reconcile, push. 419 /// Sync with a specific remote: fetch, reconcile, push.
322 pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { 420 pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
323 // Acquire advisory lock — held until _lock is dropped (RAII) 421 // Acquire advisory lock — held until _lock is dropped (RAII)
@@ -420,7 +518,7 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
420 // Step 4: Clean up sync refs 518 // Step 4: Clean up sync refs
421 cleanup_sync_refs(&repo)?; 519 cleanup_sync_refs(&repo)?;
422 520
423 println!("Sync complete."); 521 println!("Sync complete for '{}'.", remote_name);
424 Ok(()) 522 Ok(())
425 } 523 }
426 524
@@ -455,9 +553,12 @@ fn sync_resume(
455 if sync_result.results.is_empty() { 553 if sync_result.results.is_empty() {
456 println!("All pending refs are already up to date. Clearing stale sync state."); 554 println!("All pending refs are already up to date. Clearing stale sync state.");
457 } else { 555 } else {
458 println!("\nSync complete. All previously-failed refs pushed."); 556 println!(
557 "\nSync complete for '{}'. All previously-failed refs pushed.",
558 remote_name
559 );
459 } 560 }
460 println!("Sync complete."); 561 println!("Sync complete for '{}'.", remote_name);
461 Ok(()) 562 Ok(())
462 } else { 563 } else {
463 // Some refs still failing - update state 564 // Some refs still failing - update state
tests/sync_test.rs
Old New
@@ -23,6 +23,22 @@ use common::{
23 open_issue, test_signing_key, ScopedTestConfig, 23 open_issue, test_signing_key, ScopedTestConfig,
24 }; 24 };
25 25
26 /// Create a bare repo with a single empty commit on `main`, suitable for use
27 /// as a collab remote fixture. Never a real network remote — always local.
28 fn init_bare_remote() -> TempDir {
29 let dir = TempDir::new().unwrap();
30 let bare_repo = Repository::init_bare(dir.path()).unwrap();
31
32 let sig = git2::Signature::now("init", "init@test").unwrap();
33 let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
34 let tree = bare_repo.find_tree(tree_oid).unwrap();
35 bare_repo
36 .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
37 .unwrap();
38
39 dir
40 }
41
26 // --------------------------------------------------------------------------- 42 // ---------------------------------------------------------------------------
27 // Test cluster 43 // Test cluster
28 // --------------------------------------------------------------------------- 44 // ---------------------------------------------------------------------------
@@ -46,18 +62,7 @@ impl TestCluster {
46 let config = ScopedTestConfig::new(); 62 let config = ScopedTestConfig::new();
47 config.ensure_signing_key(); 63 config.ensure_signing_key();
48 64
49 let bare_dir = TempDir::new().unwrap(); 65 let bare_dir = init_bare_remote();
50 let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();
51
52 {
53 let sig = git2::Signature::now("init", "init@test").unwrap();
54 let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
55 let tree = bare_repo.find_tree(tree_oid).unwrap();
56 bare_repo
57 .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
58 .unwrap();
59 }
60 drop(bare_repo);
61 66
62 let alice_dir = TempDir::new().unwrap(); 67 let alice_dir = TempDir::new().unwrap();
63 let bob_dir = TempDir::new().unwrap(); 68 let bob_dir = TempDir::new().unwrap();
@@ -139,6 +144,20 @@ impl TestCluster {
139 fn config_dir(&self) -> std::path::PathBuf { 144 fn config_dir(&self) -> std::path::PathBuf {
140 self._config.config_dir() 145 self._config.config_dir()
141 } 146 }
147
148 /// Add a second local bare remote fixture and register it as
149 /// `remote_name` on the repo checked out at `repo_dir`. Returns the
150 /// `TempDir` owning the bare repo — keep it alive for the test's
151 /// duration.
152 fn add_bare_remote(&self, repo_dir: &std::path::Path, remote_name: &str) -> TempDir {
153 let dir = init_bare_remote();
154 Command::new("git")
155 .args(["remote", "add", remote_name, dir.path().to_str().unwrap()])
156 .current_dir(repo_dir)
157 .status()
158 .unwrap();
159 dir
160 }
142 } 161 }
143 162
144 // --------------------------------------------------------------------------- 163 // ---------------------------------------------------------------------------
@@ -188,14 +207,15 @@ fn test_cli_init_and_sync_transfer_issue_between_repos() {
188 .expect("issue created through CLI should exist locally") 207 .expect("issue created through CLI should exist locally")
189 .id; 208 .id;
190 209
191 let alice_sync = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); 210 let alice_sync =
211 cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "--remote", "origin"]);
192 assert!(alice_sync.contains("Fetching from 'origin'...")); 212 assert!(alice_sync.contains("Fetching from 'origin'..."));
193 assert!(alice_sync.contains("Pushing to 'origin'...")); 213 assert!(alice_sync.contains("Pushing to 'origin'..."));
194 assert!(alice_sync.contains("Sync complete.")); 214 assert!(alice_sync.contains("Sync complete for 'origin'."));
195 215
196 let bob_sync = cluster.run_collab_ok(cluster.bob_dir.path(), &["sync", "origin"]); 216 let bob_sync = cluster.run_collab_ok(cluster.bob_dir.path(), &["sync", "--remote", "origin"]);
197 assert!(bob_sync.contains("Fetching from 'origin'...")); 217 assert!(bob_sync.contains("Fetching from 'origin'..."));
198 assert!(bob_sync.contains("Sync complete.")); 218 assert!(bob_sync.contains("Sync complete for 'origin'."));
199 219
200 let bob_repo = cluster.bob_repo(); 220 let bob_repo = cluster.bob_repo();
201 let bob_ref = format!("refs/collab/issues/{}", issue_id); 221 let bob_ref = format!("refs/collab/issues/{}", issue_id);
@@ -210,7 +230,8 @@ fn test_cli_sync_reports_missing_remote_failure() {
210 230
211 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); 231 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
212 232
213 let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "upstream"]); 233 let (_stdout, stderr) =
234 cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "--remote", "upstream"]);
214 assert!(stderr.contains("error:")); 235 assert!(stderr.contains("error:"));
215 assert!(stderr.contains("git fetch exited with status")); 236 assert!(stderr.contains("git fetch exited with status"));
216 } 237 }
@@ -227,7 +248,8 @@ fn test_cli_sync_partial_failure_can_resume_successfully() {
227 248
228 install_reject_hook(cluster.bare_dir(), &id1[..8]); 249 install_reject_hook(cluster.bare_dir(), &id1[..8]);
229 250
230 let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "origin"]); 251 let (_stdout, stderr) =
252 cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "--remote", "origin"]);
231 assert!(stderr.contains("Sync partially failed: 1 of 2 refs pushed.")); 253 assert!(stderr.contains("Sync partially failed: 1 of 2 refs pushed."));
232 assert!(stderr.contains(&format!("refs/collab/issues/{}", id1))); 254 assert!(stderr.contains(&format!("refs/collab/issues/{}", id1)));
233 255
@@ -246,12 +268,224 @@ fn test_cli_sync_partial_failure_can_resume_successfully() {
246 268
247 remove_reject_hook(cluster.bare_dir()); 269 remove_reject_hook(cluster.bare_dir());
248 270
249 let resume_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); 271 let resume_output =
272 cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "--remote", "origin"]);
250 assert!(resume_output.contains("Resuming sync to 'origin'")); 273 assert!(resume_output.contains("Resuming sync to 'origin'"));
251 assert!(resume_output.contains("Sync complete.")); 274 assert!(resume_output.contains("Sync complete for 'origin'."));
252 assert!(sync::SyncState::load(&cluster.alice_repo()).is_none()); 275 assert!(sync::SyncState::load(&cluster.alice_repo()).is_none());
253 } 276 }
254 277
278 // ---------------------------------------------------------------------------
279 // Multi-remote sync (390481e1: `init` configures every remote, `sync` must
280 // not silently touch only one and report unqualified success)
281 // ---------------------------------------------------------------------------
282
283 fn disable_auto_sync(repo_dir: &std::path::Path) {
284 Command::new("git")
285 .args(["config", "collab.autoSync", "false"])
286 .current_dir(repo_dir)
287 .status()
288 .unwrap();
289 }
290
291 #[test]
292 fn test_sync_default_syncs_every_configured_remote() {
293 let cluster = TestCluster::new_without_collab_init();
294 let second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
295 disable_auto_sync(cluster.alice_dir.path());
296
297 let init_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
298 assert!(init_output.contains("Configured remote 'origin'"));
299 assert!(init_output.contains("Configured remote 'second'"));
300
301 let alice_repo = cluster.alice_repo();
302 let (_ref, id) = open_issue(&alice_repo, &alice(), "Multi-remote issue");
303
304 let sync_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync"]);
305 assert!(
306 sync_output.contains("Syncing 2 remotes: 'origin', 'second'"),
307 "stdout: {}",
308 sync_output
309 );
310 assert!(sync_output.contains("Sync complete for 'origin'."));
311 assert!(sync_output.contains("Sync complete for 'second'."));
312
313 let issue_ref = format!("refs/collab/issues/{}", id);
314 let origin_bare = Repository::open_bare(cluster.bare_dir()).unwrap();
315 let second_bare_repo = Repository::open_bare(second_bare.path()).unwrap();
316 assert!(origin_bare.refname_to_id(&issue_ref).is_ok());
317 assert!(
318 second_bare_repo.refname_to_id(&issue_ref).is_ok(),
319 "default `sync` must not skip a configured remote"
320 );
321 }
322
323 #[test]
324 fn test_sync_remote_flag_restricts_to_named_remote() {
325 let cluster = TestCluster::new_without_collab_init();
326 let second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
327 disable_auto_sync(cluster.alice_dir.path());
328
329 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
330
331 let alice_repo = cluster.alice_repo();
332 let (_ref, id) = open_issue(&alice_repo, &alice(), "Single-remote issue");
333
334 let sync_output =
335 cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "--remote", "origin"]);
336 assert!(!sync_output.contains("second"), "stdout: {}", sync_output);
337
338 let issue_ref = format!("refs/collab/issues/{}", id);
339 let origin_bare = Repository::open_bare(cluster.bare_dir()).unwrap();
340 let second_bare_repo = Repository::open_bare(second_bare.path()).unwrap();
341 assert!(origin_bare.refname_to_id(&issue_ref).is_ok());
342 assert!(
343 second_bare_repo.refname_to_id(&issue_ref).is_err(),
344 "--remote origin must not touch the 'second' remote"
345 );
346 }
347
348 #[test]
349 fn test_sync_partial_multi_remote_failure_reports_and_exits_nonzero() {
350 let cluster = TestCluster::new_without_collab_init();
351 let second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
352 disable_auto_sync(cluster.alice_dir.path());
353
354 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
355
356 let alice_repo = cluster.alice_repo();
357 let (_ref, id) = open_issue(&alice_repo, &alice(), "Partial multi-remote issue");
358
359 // Make 'second' unreachable without touching the healthy 'origin' remote.
360 drop(second_bare);
361
362 let (stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync"]);
363 assert!(
364 stderr.contains("Sync incomplete: 1 of 2 remote(s) failed."),
365 "stderr: {}",
366 stderr
367 );
368 assert!(stderr.contains("Failed: 'second'"), "stderr: {}", stderr);
369 assert!(stderr.contains("Succeeded: 'origin'"), "stderr: {}", stderr);
370 assert!(
371 stdout.contains("Sync complete for 'origin'."),
372 "the healthy remote's own success must still be reported: {}",
373 stdout
374 );
375
376 let issue_ref = format!("refs/collab/issues/{}", id);
377 let origin_bare = Repository::open_bare(cluster.bare_dir()).unwrap();
378 assert!(
379 origin_bare.refname_to_id(&issue_ref).is_ok(),
380 "a failure on one remote must not prevent syncing the others"
381 );
382 }
383
384 #[test]
385 fn test_sync_with_no_configured_remotes_reports_clearly_and_succeeds() {
386 let cluster = TestCluster::new_without_collab_init();
387 disable_auto_sync(cluster.alice_dir.path());
388 // Never run `init`, so no remote has a collab refspec configured.
389
390 let stdout = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync"]);
391 assert!(
392 stdout.contains("No remotes with collab refspecs configured"),
393 "stdout: {}",
394 stdout
395 );
396 }
397
398 #[test]
399 fn test_collab_remotes_excludes_remote_added_after_init() {
400 let cluster = TestCluster::new_without_collab_init();
401 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); // configures 'origin' only
402
403 // 'second' is added after `init` runs, so it never got a collab refspec.
404 let _second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
405
406 let alice_repo = cluster.alice_repo();
407 let remotes = sync::collab_remotes(&alice_repo).unwrap();
408 assert_eq!(remotes, vec!["origin".to_string()]);
409 }
410
411 #[test]
412 fn test_auto_sync_after_write_covers_every_configured_remote() {
413 let cluster = TestCluster::new_without_collab_init();
414 let second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
415 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
416 // collab.autoSync is left at its default (unset == enabled).
417
418 let output = cluster.run_collab(
419 cluster.alice_dir.path(),
420 &["issue", "open", "-t", "Auto-synced everywhere"],
421 );
422 assert!(output.status.success());
423 let stderr = String::from_utf8(output.stderr).unwrap();
424 assert!(
425 stderr.contains("Auto-syncing with 'origin', 'second'..."),
426 "stderr: {}",
427 stderr
428 );
429
430 let issue_id = state::list_issues(&cluster.alice_repo())
431 .unwrap()
432 .into_iter()
433 .find(|issue| issue.title == "Auto-synced everywhere")
434 .expect("issue should exist locally")
435 .id;
436 let issue_ref = format!("refs/collab/issues/{}", issue_id);
437
438 let origin_bare = Repository::open_bare(cluster.bare_dir()).unwrap();
439 let second_bare_repo = Repository::open_bare(second_bare.path()).unwrap();
440 assert!(origin_bare.refname_to_id(&issue_ref).is_ok());
441 assert!(
442 second_bare_repo.refname_to_id(&issue_ref).is_ok(),
443 "auto-sync must not silently skip a configured remote"
444 );
445 }
446
447 #[test]
448 fn test_auto_sync_remote_config_pins_to_single_remote() {
449 let cluster = TestCluster::new_without_collab_init();
450 let second_bare = cluster.add_bare_remote(cluster.alice_dir.path(), "second");
451 cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
452
453 Command::new("git")
454 .args(["config", "collab.autoSyncRemote", "second"])
455 .current_dir(cluster.alice_dir.path())
456 .status()
457 .unwrap();
458
459 let output = cluster.run_collab(
460 cluster.alice_dir.path(),
461 &["issue", "open", "-t", "Pinned auto-sync"],
462 );
463 assert!(output.status.success());
464 let stderr = String::from_utf8(output.stderr).unwrap();
465 assert!(
466 stderr.contains("Auto-syncing with 'second'..."),
467 "stderr: {}",
468 stderr
469 );
470 assert!(!stderr.contains("'origin'"), "stderr: {}", stderr);
471
472 let issue_id = state::list_issues(&cluster.alice_repo())
473 .unwrap()
474 .into_iter()
475 .find(|issue| issue.title == "Pinned auto-sync")
476 .expect("issue should exist locally")
477 .id;
478 let issue_ref = format!("refs/collab/issues/{}", issue_id);
479
480 let origin_bare = Repository::open_bare(cluster.bare_dir()).unwrap();
481 let second_bare_repo = Repository::open_bare(second_bare.path()).unwrap();
482 assert!(
483 origin_bare.refname_to_id(&issue_ref).is_err(),
484 "pinning auto-sync to 'second' must not also touch 'origin'"
485 );
486 assert!(second_bare_repo.refname_to_id(&issue_ref).is_ok());
487 }
488
255 #[test] 489 #[test]
256 fn test_bob_comments_on_alice_issue_then_sync() { 490 fn test_bob_comments_on_alice_issue_then_sync() {
257 let cluster = TestCluster::new(); 491 let cluster = TestCluster::new();