a73x

f9785c84

Diagnose refname conflicts instead of advising a retry

a73x   2026-08-10 10:08

Commit message
Diagnose refname conflicts instead of advising a retry

A remote still holding the pre-migration bare `refs/collab/patches/<id>`
cannot accept `<id>/events`, and never will: git will not let one name be
both a ref and a directory. Sync reported this as a retryable failure and
pointed at a binary that does not exist, so the one thing that actually
fixes it -- a deletion on the remote -- appeared nowhere.

Classify the failure and print the remedy instead, scoped to the ids that
actually conflicted rather than a glob over every patch ref. Non-conflict
failures keep the retry line, now spelled `git-collab`. Both messages can
appear together; the retry count covers only the retryable refs.

Also makes `sync::init` idempotent (fixes 7824bd7b). It called
remote_add_fetch unconditionally, appending a duplicate collab refspec on
every run while claiming to have configured something new. It now reuses
the predicate `collab_remotes` already had, and says "already configured".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 }