f9785c84
Diagnose refname conflicts instead of advising a retry
a73x 2026-08-10 10:08
Commit message
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -248,6 +248,101 @@ fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> { | |||
| 248 | // Summary printing (T012) | 248 | // Summary printing (T012) |
| 249 | // --------------------------------------------------------------------------- | 249 | // --------------------------------------------------------------------------- |
| 250 | 250 | ||
| 251 | const PATCH_REF_PREFIX: &str = "refs/collab/patches/"; | ||
| 252 | |||
| 253 | /// The patch id whose stale remote ref is blocking this push, if that is what | ||
| 254 | /// went wrong. `None` for every other kind of failure, which then keeps the | ||
| 255 | /// ordinary retry advice. | ||
| 256 | /// | ||
| 257 | /// The push path shells out to `git` (see `push_ref_single`), so what lands in | ||
| 258 | /// `RefPushResult::error` is git's own stderr, not a libgit2 message. Both | ||
| 259 | /// markers below were read off a real rejection rather than guessed: | ||
| 260 | /// | ||
| 261 | /// remote: error: cannot lock ref 'refs/collab/patches/<id>/events': | ||
| 262 | /// 'refs/collab/patches/<id>' exists; cannot create '.../events' | ||
| 263 | /// ! [remote rejected] .../events -> .../events (refname conflict) | ||
| 264 | /// | ||
| 265 | /// `(refname conflict)` is receive-pack's own name for this rejection reason, | ||
| 266 | /// and `exists; cannot create` is the underlying directory/file conflict | ||
| 267 | /// message from the ref backend. Either alone is conclusive; both are matched | ||
| 268 | /// because the first is a comparatively recent addition to git's per-ref | ||
| 269 | /// reason codes and the second has been stable for far longer. | ||
| 270 | /// | ||
| 271 | /// Matching has to be this narrow. A stale lock file on the remote also | ||
| 272 | /// reports `cannot lock ref`, but reads `Unable to create '...events.lock': | ||
| 273 | /// File exists.` and is rejected as `(reference already exists)` — a genuinely | ||
| 274 | /// transient failure that *should* keep the retry advice. Keying off | ||
| 275 | /// `cannot lock ref`, or off a bare `exists`, would swallow it. | ||
| 276 | fn refname_conflict_patch_id(result: &RefPushResult) -> Option<String> { | ||
| 277 | let error = result.error.as_deref()?; | ||
| 278 | if !error.contains("(refname conflict)") && !error.contains("exists; cannot create") { | ||
| 279 | return None; | ||
| 280 | } | ||
| 281 | // Only patch refs changed layout, so only they can collide this way. | ||
| 282 | // Anything else matching the text is a situation we have not diagnosed and | ||
| 283 | // must not hand a destructive remedy for. | ||
| 284 | let id = result | ||
| 285 | .ref_name | ||
| 286 | .strip_prefix(PATCH_REF_PREFIX)? | ||
| 287 | .split('/') | ||
| 288 | .next()?; | ||
| 289 | validate_collab_ref_id(id).ok()?; | ||
| 290 | Some(id.to_string()) | ||
| 291 | } | ||
| 292 | |||
| 293 | /// Explain a refname conflict and print the command that clears it. | ||
| 294 | /// | ||
| 295 | /// Worth being emphatic about, because everything else about this failure | ||
| 296 | /// looks benign: `git push --dry-run` reports `[new reference]` for the very | ||
| 297 | /// push that is about to be rejected, and the fetch path already understands | ||
| 298 | /// the legacy layout, so every read looks healthy while only writes are wedged. | ||
| 299 | fn print_refname_conflict_advice(remote: &str, ids: &[String]) { | ||
| 300 | let stale: Vec<String> = ids | ||
| 301 | .iter() | ||
| 302 | .map(|id| format!("{}{}", PATCH_REF_PREFIX, id)) | ||
| 303 | .collect(); | ||
| 304 | |||
| 305 | eprintln!( | ||
| 306 | "\nRefname conflict: '{}' still has the pre-migration patch ref layout.", | ||
| 307 | remote | ||
| 308 | ); | ||
| 309 | eprintln!( | ||
| 310 | "\nPatch refs moved from a single `{}<id>` to `<id>/events` plus\n\ | ||
| 311 | `<id>/rev/<oid>`. Git cannot hold both a ref and a directory at the same\n\ | ||
| 312 | path, so while the old ref is still on the remote every push of the new\n\ | ||
| 313 | layout is rejected. Retrying cannot clear this — it will fail identically\n\ | ||
| 314 | on every future sync until the stale ref is deleted on the remote.", | ||
| 315 | PATCH_REF_PREFIX | ||
| 316 | ); | ||
| 317 | |||
| 318 | // One conflicting patch is by far the common case; reading "1 stale | ||
| 319 | // remote ref(s) ... delete them" in the middle of a destructive | ||
| 320 | // instruction just makes the reader wonder what else they were not told. | ||
| 321 | let (count, them, refs_above) = if stale.len() == 1 { | ||
| 322 | ("1 stale remote ref".to_string(), "it", "the ref") | ||
| 323 | } else { | ||
| 324 | ( | ||
| 325 | format!("{} stale remote refs", stale.len()), | ||
| 326 | "them", | ||
| 327 | "the refs", | ||
| 328 | ) | ||
| 329 | }; | ||
| 330 | |||
| 331 | eprintln!("\nBlocked by {}:", count); | ||
| 332 | for name in &stale { | ||
| 333 | eprintln!(" {}", name); | ||
| 334 | } | ||
| 335 | |||
| 336 | eprintln!("\nTo fix, delete {} on the remote and sync again:\n", them); | ||
| 337 | eprintln!(" git push {} --delete {}", remote, stale.join(" ")); | ||
| 338 | eprintln!( | ||
| 339 | "\nThat deletes only {} listed above, and only on '{}'. Nothing local is\n\ | ||
| 340 | touched: the next sync republishes the same events under `<id>/events`,\n\ | ||
| 341 | which is where every current version reads them from.", | ||
| 342 | refs_above, remote | ||
| 343 | ); | ||
| 344 | } | ||
| 345 | |||
| 251 | /// Print a failure summary to stderr. | 346 | /// Print a failure summary to stderr. |
| 252 | fn print_sync_summary(result: &SyncResult) { | 347 | fn print_sync_summary(result: &SyncResult) { |
| 253 | let succeeded = result.succeeded().len(); | 348 | let succeeded = result.succeeded().len(); |
| @@ -272,11 +367,38 @@ fn print_sync_summary(result: &SyncResult) { | |||
| 272 | ); | 367 | ); |
| 273 | } | 368 | } |
| 274 | 369 | ||
| 275 | eprintln!( | 370 | // A single stale bare ref blocks every ref in that patch's subtree — |
| 276 | "\nRun `collab sync --remote {}` again to retry {} failed ref(s).", | 371 | // `<id>/events` and each `<id>/rev/<oid>` alike — so the same id arrives |
| 277 | result.remote, | 372 | // here several times over. Deduplicate, or the remedy would list the same |
| 278 | failed.len() | 373 | // deletion repeatedly and overstate how much is being removed. Order |
| 279 | ); | 374 | // follows the push order so the output is stable. |
| 375 | let mut conflicted: Vec<String> = Vec::new(); | ||
| 376 | let mut retryable = 0usize; | ||
| 377 | for f in &failed { | ||
| 378 | match refname_conflict_patch_id(f) { | ||
| 379 | Some(id) => { | ||
| 380 | if !conflicted.contains(&id) { | ||
| 381 | conflicted.push(id); | ||
| 382 | } | ||
| 383 | } | ||
| 384 | None => retryable += 1, | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | if !conflicted.is_empty() { | ||
| 389 | print_refname_conflict_advice(&result.remote, &conflicted); | ||
| 390 | } | ||
| 391 | |||
| 392 | // Printed alongside the conflict advice, never instead of it: a sync can | ||
| 393 | // fail for both reasons at once, and each needs its own answer. Counting | ||
| 394 | // only the retryable refs keeps this line honest — retrying the conflicted | ||
| 395 | // ones is exactly what the advice above says not to do. | ||
| 396 | if retryable > 0 { | ||
| 397 | eprintln!( | ||
| 398 | "\nRun `git-collab sync --remote {}` again to retry {} failed ref(s).", | ||
| 399 | result.remote, retryable | ||
| 400 | ); | ||
| 401 | } | ||
| 280 | } | 402 | } |
| 281 | 403 | ||
| 282 | /// Clean up refs/collab/sync/* refs. | 404 | /// Clean up refs/collab/sync/* refs. |
| @@ -303,7 +425,25 @@ fn cleanup_sync_refs(repo: &Repository) -> Result<(), Error> { | |||
| 303 | Ok(()) | 425 | Ok(()) |
| 304 | } | 426 | } |
| 305 | 427 | ||
| 428 | /// Whether `remote_name` already carries a collab fetch refspec. | ||
| 429 | /// | ||
| 430 | /// The same predicate `collab_remotes` selects on, factored out so the two | ||
| 431 | /// cannot drift: if `init` decided a remote was unconfigured on a basis | ||
| 432 | /// `collab_remotes` disagreed with, `init` would append a duplicate refspec on | ||
| 433 | /// every run while `sync_all` kept reporting the remote as already configured. | ||
| 434 | fn has_collab_refspec(repo: &Repository, remote_name: &str) -> Result<bool, Error> { | ||
| 435 | let remote = repo.find_remote(remote_name)?; | ||
| 436 | Ok(remote | ||
| 437 | .refspecs() | ||
| 438 | .any(|rs| rs.direction() == git2::Direction::Fetch && rs.src() == Some("refs/collab/*"))) | ||
| 439 | } | ||
| 440 | |||
| 306 | /// Add collab refspecs to all remotes. | 441 | /// Add collab refspecs to all remotes. |
| 442 | /// | ||
| 443 | /// Idempotent: `git remote_add_fetch` appends unconditionally, so without this | ||
| 444 | /// check a second `init` silently gives the remote a duplicate refspec — and | ||
| 445 | /// says "Configured remote" as though it had done something new, which is how | ||
| 446 | /// a repo ends up fetching every collab ref twice with nothing to indicate it. | ||
| 307 | pub fn init(repo: &Repository) -> Result<(), Error> { | 447 | pub fn init(repo: &Repository) -> Result<(), Error> { |
| 308 | let remotes = repo.remotes()?; | 448 | let remotes = repo.remotes()?; |
| 309 | if remotes.is_empty() { | 449 | if remotes.is_empty() { |
| @@ -311,6 +451,10 @@ pub fn init(repo: &Repository) -> Result<(), Error> { | |||
| 311 | return Ok(()); | 451 | return Ok(()); |
| 312 | } | 452 | } |
| 313 | for remote_name in remotes.iter().flatten() { | 453 | for remote_name in remotes.iter().flatten() { |
| 454 | if has_collab_refspec(repo, remote_name)? { | ||
| 455 | println!("Remote '{}' already configured", remote_name); | ||
| 456 | continue; | ||
| 457 | } | ||
| 314 | let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name); | 458 | let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name); |
| 315 | repo.remote_add_fetch(remote_name, &fetch_spec)?; | 459 | repo.remote_add_fetch(remote_name, &fetch_spec)?; |
| 316 | println!("Configured remote '{}'", remote_name); | 460 | println!("Configured remote '{}'", remote_name); |
| @@ -340,11 +484,7 @@ pub(crate) fn format_remote_list(names: &[String]) -> String { | |||
| 340 | pub fn collab_remotes(repo: &Repository) -> Result<Vec<String>, Error> { | 484 | pub fn collab_remotes(repo: &Repository) -> Result<Vec<String>, Error> { |
| 341 | let mut names = Vec::new(); | 485 | let mut names = Vec::new(); |
| 342 | for remote_name in repo.remotes()?.iter().flatten() { | 486 | for remote_name in repo.remotes()?.iter().flatten() { |
| 343 | let remote = repo.find_remote(remote_name)?; | 487 | if has_collab_refspec(repo, remote_name)? { |
| 344 | let configured = remote.refspecs().any(|rs| { | ||
| 345 | rs.direction() == git2::Direction::Fetch && rs.src() == Some("refs/collab/*") | ||
| 346 | }); | ||
| 347 | if configured { | ||
| 348 | names.push(remote_name.to_string()); | 488 | names.push(remote_name.to_string()); |
| 349 | } | 489 | } |
| 350 | } | 490 | } |
tests/sync_diagnostics_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,280 @@ | |||
| 1 | //! Sync diagnostics: telling the user something true about a failed push, | ||
| 2 | //! and not lying to them about `init` having done something. | ||
| 3 | //! | ||
| 4 | //! Both tests here drive the real CLI against a real local bare remote. The | ||
| 5 | //! refname-conflict case in particular is built end to end — an actual stale | ||
| 6 | //! bare ref planted on the remote, an actual `git push` rejected by an actual | ||
| 7 | //! `receive-pack` — because the whole point of the diagnostic is that it keys | ||
| 8 | //! off git's own wording. A test that fed a hand-written error string into the | ||
| 9 | //! classifier would only prove the classifier matches its own constant, and | ||
| 10 | //! would keep passing on the day git rephrases the rejection. | ||
| 11 | |||
| 12 | mod common; | ||
| 13 | |||
| 14 | use std::process::Command; | ||
| 15 | |||
| 16 | use tempfile::TempDir; | ||
| 17 | |||
| 18 | use common::TestRepo; | ||
| 19 | |||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | // Harness | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | /// A `TestRepo` with a local bare `origin`, with collab refspecs configured. | ||
| 25 | /// Never a real network remote. The returned `TempDir` owns the bare repo — | ||
| 26 | /// keep it alive for the duration of the test. | ||
| 27 | fn repo_with_origin() -> (TestRepo, TempDir) { | ||
| 28 | let bare = TempDir::new().unwrap(); | ||
| 29 | let status = Command::new("git") | ||
| 30 | .args(["init", "--bare", "-b", "main"]) | ||
| 31 | .arg(bare.path()) | ||
| 32 | .status() | ||
| 33 | .unwrap(); | ||
| 34 | assert!(status.success()); | ||
| 35 | |||
| 36 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 37 | repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]); | ||
| 38 | repo.git(&["push", "-u", "origin", "main"]); | ||
| 39 | repo.run_ok(&["init"]); | ||
| 40 | (repo, bare) | ||
| 41 | } | ||
| 42 | |||
| 43 | /// The full 40-char ids of every patch in the repo, deduplicated. `patch | ||
| 44 | /// create` only prints the 8-char short form, and the ref names a conflict has | ||
| 45 | /// to talk about are full ids. | ||
| 46 | fn patch_ids(repo: &TestRepo) -> Vec<String> { | ||
| 47 | let refs = repo.git(&[ | ||
| 48 | "for-each-ref", | ||
| 49 | "--format=%(refname)", | ||
| 50 | "refs/collab/patches/", | ||
| 51 | ]); | ||
| 52 | let mut ids: Vec<String> = Vec::new(); | ||
| 53 | for name in refs.lines() { | ||
| 54 | let Some(rest) = name.strip_prefix("refs/collab/patches/") else { | ||
| 55 | continue; | ||
| 56 | }; | ||
| 57 | let Some(id) = rest.split('/').next() else { | ||
| 58 | continue; | ||
| 59 | }; | ||
| 60 | if !ids.iter().any(|existing| existing == id) { | ||
| 61 | ids.push(id.to_string()); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | ids | ||
| 65 | } | ||
| 66 | |||
| 67 | /// The full id of the single patch in the repo, asserting there is exactly one. | ||
| 68 | fn only_patch_id(repo: &TestRepo) -> String { | ||
| 69 | let ids = patch_ids(repo); | ||
| 70 | assert_eq!(ids.len(), 1, "expected exactly one patch, found: {:?}", ids); | ||
| 71 | ids.into_iter().next().unwrap() | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Plant the pre-migration layout on the remote: a bare `refs/collab/patches/<id>` | ||
| 75 | /// holding the event DAG, exactly as a remote that has never been migrated | ||
| 76 | /// still carries it. This is what makes the new `<id>/events` unpushable. | ||
| 77 | fn plant_legacy_bare_ref(repo: &TestRepo, id: &str) { | ||
| 78 | repo.git(&[ | ||
| 79 | "push", | ||
| 80 | "origin", | ||
| 81 | &format!( | ||
| 82 | "refs/collab/patches/{}/events:refs/collab/patches/{}", | ||
| 83 | id, id | ||
| 84 | ), | ||
| 85 | ]); | ||
| 86 | } | ||
| 87 | |||
| 88 | // --------------------------------------------------------------------------- | ||
| 89 | // Fix 1 — a refname conflict is not retryable (issue 866b28c5) | ||
| 90 | // --------------------------------------------------------------------------- | ||
| 91 | |||
| 92 | #[test] | ||
| 93 | fn refname_conflict_is_diagnosed_not_reported_as_retryable() { | ||
| 94 | let (repo, _bare) = repo_with_origin(); | ||
| 95 | repo.patch_create("Needs a migrated remote"); | ||
| 96 | let id = only_patch_id(&repo); | ||
| 97 | plant_legacy_bare_ref(&repo, &id); | ||
| 98 | |||
| 99 | let stderr = repo.run_err(&["sync", "--remote", "origin"]); | ||
| 100 | |||
| 101 | // The retry advice must be gone: retrying can never clear this. | ||
| 102 | assert!( | ||
| 103 | !stderr.contains("again to retry"), | ||
| 104 | "refname conflict was reported as retryable:\n{}", | ||
| 105 | stderr | ||
| 106 | ); | ||
| 107 | // And the old wrong binary name must not appear anywhere. | ||
| 108 | assert!( | ||
| 109 | !stderr.contains("`collab sync"), | ||
| 110 | "stderr uses the wrong binary name `collab`:\n{}", | ||
| 111 | stderr | ||
| 112 | ); | ||
| 113 | |||
| 114 | // It must name the condition, the specific patch, and the exact remedy. | ||
| 115 | assert!( | ||
| 116 | stderr.contains("refname conflict"), | ||
| 117 | "diagnostic does not name the condition:\n{}", | ||
| 118 | stderr | ||
| 119 | ); | ||
| 120 | assert!( | ||
| 121 | stderr.contains(&format!( | ||
| 122 | "git push origin --delete refs/collab/patches/{}", | ||
| 123 | id | ||
| 124 | )), | ||
| 125 | "diagnostic does not contain the pasteable fix for {}:\n{}", | ||
| 126 | id, | ||
| 127 | stderr | ||
| 128 | ); | ||
| 129 | // The remedy must be scoped to the conflicting id, never a blanket glob | ||
| 130 | // over every patch ref on the remote. | ||
| 131 | assert!( | ||
| 132 | !stderr.contains("refs/collab/patches/*"), | ||
| 133 | "diagnostic suggests a blanket glob deletion:\n{}", | ||
| 134 | stderr | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | /// The remedy the diagnostic prints has to actually work. Running it verbatim | ||
| 139 | /// must unblock the very sync that produced it. | ||
| 140 | #[test] | ||
| 141 | fn the_suggested_deletion_actually_unblocks_the_sync() { | ||
| 142 | let (repo, _bare) = repo_with_origin(); | ||
| 143 | repo.patch_create("Needs a migrated remote"); | ||
| 144 | let id = only_patch_id(&repo); | ||
| 145 | plant_legacy_bare_ref(&repo, &id); | ||
| 146 | |||
| 147 | repo.run_err(&["sync", "--remote", "origin"]); | ||
| 148 | |||
| 149 | // Exactly the command the diagnostic tells the user to paste. | ||
| 150 | repo.git(&[ | ||
| 151 | "push", | ||
| 152 | "origin", | ||
| 153 | "--delete", | ||
| 154 | &format!("refs/collab/patches/{}", id), | ||
| 155 | ]); | ||
| 156 | |||
| 157 | repo.run_ok(&["sync", "--remote", "origin"]); | ||
| 158 | } | ||
| 159 | |||
| 160 | /// A push failure that is *not* a refname conflict keeps the ordinary retry | ||
| 161 | /// advice — spelled with the real binary name. A stale lock on the remote | ||
| 162 | /// produces "cannot lock ref" too, so this pins the boundary: the classifier | ||
| 163 | /// must not fire on every locking failure. | ||
| 164 | #[test] | ||
| 165 | fn ordinary_push_failure_still_gets_retry_advice() { | ||
| 166 | let (repo, bare) = repo_with_origin(); | ||
| 167 | repo.patch_create("Ordinary failure"); | ||
| 168 | let id = only_patch_id(&repo); | ||
| 169 | |||
| 170 | // A stale lock file on the remote: rejected, but nothing to do with the | ||
| 171 | // ref layout, and clearing it does make a retry succeed. | ||
| 172 | let lock_dir = bare.path().join("refs/collab/patches").join(&id); | ||
| 173 | std::fs::create_dir_all(&lock_dir).unwrap(); | ||
| 174 | std::fs::write(lock_dir.join("events.lock"), "0000000000000000000000000000000000000000\n") | ||
| 175 | .unwrap(); | ||
| 176 | |||
| 177 | let stderr = repo.run_err(&["sync", "--remote", "origin"]); | ||
| 178 | |||
| 179 | assert!( | ||
| 180 | stderr.contains("git-collab sync --remote origin"), | ||
| 181 | "ordinary failure lost its retry advice:\n{}", | ||
| 182 | stderr | ||
| 183 | ); | ||
| 184 | assert!( | ||
| 185 | !stderr.contains("--delete"), | ||
| 186 | "ordinary failure was misclassified as a refname conflict:\n{}", | ||
| 187 | stderr | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | |||
| 191 | /// A conflict and an ordinary failure in the same sync each need their own | ||
| 192 | /// answer, and neither may suppress the other. The retry count must cover only | ||
| 193 | /// the ordinary failure — telling the user to retry the conflicted ref is | ||
| 194 | /// exactly what the conflict advice says not to do. | ||
| 195 | #[test] | ||
| 196 | fn a_conflict_and_an_ordinary_failure_are_both_reported() { | ||
| 197 | let (repo, bare) = repo_with_origin(); | ||
| 198 | |||
| 199 | repo.patch_create("Conflicting"); | ||
| 200 | let conflicted = only_patch_id(&repo); | ||
| 201 | plant_legacy_bare_ref(&repo, &conflicted); | ||
| 202 | |||
| 203 | repo.patch_create("Merely locked"); | ||
| 204 | let locked = patch_ids(&repo) | ||
| 205 | .into_iter() | ||
| 206 | .find(|id| *id != conflicted) | ||
| 207 | .expect("second patch id"); | ||
| 208 | let lock_dir = bare.path().join("refs/collab/patches").join(&locked); | ||
| 209 | std::fs::create_dir_all(&lock_dir).unwrap(); | ||
| 210 | std::fs::write( | ||
| 211 | lock_dir.join("events.lock"), | ||
| 212 | "0000000000000000000000000000000000000000\n", | ||
| 213 | ) | ||
| 214 | .unwrap(); | ||
| 215 | |||
| 216 | let stderr = repo.run_err(&["sync", "--remote", "origin"]); | ||
| 217 | |||
| 218 | assert!( | ||
| 219 | stderr.contains(&format!( | ||
| 220 | "git push origin --delete refs/collab/patches/{}", | ||
| 221 | conflicted | ||
| 222 | )), | ||
| 223 | "conflict advice missing when an ordinary failure was also present:\n{}", | ||
| 224 | stderr | ||
| 225 | ); | ||
| 226 | assert!( | ||
| 227 | stderr.contains("git-collab sync --remote origin"), | ||
| 228 | "retry advice missing when a conflict was also present:\n{}", | ||
| 229 | stderr | ||
| 230 | ); | ||
| 231 | // The locked patch must not be offered up for deletion. | ||
| 232 | assert!( | ||
| 233 | !stderr.contains(&format!("--delete refs/collab/patches/{}", locked)) | ||
| 234 | && !stderr.contains(&format!(" refs/collab/patches/{} ", locked)), | ||
| 235 | "the merely-locked patch {} was offered for deletion:\n{}", | ||
| 236 | locked, | ||
| 237 | stderr | ||
| 238 | ); | ||
| 239 | // Only the locked ref is retryable, not the two conflicted ones. | ||
| 240 | assert!( | ||
| 241 | stderr.contains("retry 1 failed ref(s)"), | ||
| 242 | "retry count includes conflicted refs:\n{}", | ||
| 243 | stderr | ||
| 244 | ); | ||
| 245 | } | ||
| 246 | |||
| 247 | // --------------------------------------------------------------------------- | ||
| 248 | // Fix 2 — `init` is idempotent (issue 7824bd7b) | ||
| 249 | // --------------------------------------------------------------------------- | ||
| 250 | |||
| 251 | #[test] | ||
| 252 | fn init_twice_leaves_exactly_one_collab_refspec() { | ||
| 253 | let (repo, _bare) = repo_with_origin(); | ||
| 254 | // repo_with_origin already ran `init` once. | ||
| 255 | repo.run_ok(&["init"]); | ||
| 256 | repo.run_ok(&["init"]); | ||
| 257 | |||
| 258 | let fetch_specs = repo.git(&["config", "--get-all", "remote.origin.fetch"]); | ||
| 259 | let collab_specs: Vec<&str> = fetch_specs | ||
| 260 | .lines() | ||
| 261 | .filter(|l| l.contains("refs/collab/*")) | ||
| 262 | .collect(); | ||
| 263 | assert_eq!( | ||
| 264 | collab_specs.len(), | ||
| 265 | 1, | ||
| 266 | "expected exactly one collab refspec after repeated init, got {:?}", | ||
| 267 | collab_specs | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | #[test] | ||
| 272 | fn init_says_already_configured_on_a_second_run() { | ||
| 273 | let (repo, _bare) = repo_with_origin(); | ||
| 274 | let out = repo.run_ok(&["init"]); | ||
| 275 | assert!( | ||
| 276 | out.contains("already configured"), | ||
| 277 | "second init claimed to configure something new:\n{}", | ||
| 278 | out | ||
| 279 | ); | ||
| 280 | } | ||