1c9fed82
Sync every remote with collab refspecs, not just one
a73x 2026-08-09 16:48
Commit message
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(); |