a73x

e921ed54

Address review: label parity in list_to_writer, cache version bump

a73x   2026-08-10 05:57

Commit message
Address review: label parity in list_to_writer, cache version bump

- patch::list_to_writer threaded the --label filter but never rendered
  the ` [a, b]` suffix, silently dropping labels on the one path that
  bypasses lib.rs's own rendering. Fixed by extracting the suffix logic
  into cli::label_suffix() and using it at all four call sites
  (issue.rs, lib.rs x2, patch.rs), removing the duplication rather than
  adding a fourth divergent copy.
- Bumped CACHE_FORMAT_VERSION to 4. serde(default) on PatchState.labels
  is what lets old JSON deserialize, not what keeps the fold cache
  correct -- a stale v3 entry missing the key deserializes identically
  whether or not a patch.label event was later folded into that ref, so
  without the bump a stale entry would be served as a silent-loss cache
  hit. Reworded the field comment to stop implying serde(default) alone
  settles this, and added a regression test that reproduces the exact
  silent-loss scenario (confirmed red before the bump, green after).
- Added a test isolating the --all list-filter axis from --archived,
  using an auto-detected merge (active, non-open, never archived) since
  patch close conflates the two by always archiving.

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

src/cache.rs
Old New
@@ -19,8 +19,11 @@ fn sanitize_ref_name(ref_name: &str) -> String {
19 /// entries computed with older logic are discarded even if the ref tip 19 /// entries computed with older logic are discarded even if the ref tip
20 /// hasn't moved. v2: review vote supersession per (author, revision). 20 /// hasn't moved. v2: review vote supersession per (author, revision).
21 /// v3: `IssueState::relates_to` became a multi-member set instead of a 21 /// v3: `IssueState::relates_to` became a multi-member set instead of a
22 /// single optional value. 22 /// single optional value. v4: `PatchState` gained a `labels` field; a
23 const CACHE_FORMAT_VERSION: u32 = 3; 23 /// stale v3 entry would deserialize fine (the field is `#[serde(default)]`)
24 /// but silently omit any labels folded in since, so it must be rejected by
25 /// version rather than relying on that default to catch it.
26 const CACHE_FORMAT_VERSION: u32 = 4;
24 27
25 /// Cache entry stored on disk: the tip OID at cache time + serialized state. 28 /// Cache entry stored on disk: the tip OID at cache time + serialized state.
26 #[derive(serde::Serialize, serde::Deserialize)] 29 #[derive(serde::Serialize, serde::Deserialize)]
src/cli.rs
Old New
@@ -53,6 +53,41 @@ pub fn filter_sort_paginate<T: Listable>(
53 .collect() 53 .collect()
54 } 54 }
55 55
56 /// The `" [a, b]"` suffix list output appends after an item's title when it
57 /// carries labels, or `""` when it carries none. Shared so `issue list` and
58 /// `patch list` (both their CLI rendering and their `list_to_writer` twins)
59 /// render labels identically rather than each re-deriving the same suffix.
60 pub fn label_suffix(labels: &[String]) -> String {
61 if labels.is_empty() {
62 String::new()
63 } else {
64 format!(" [{}]", labels.join(", "))
65 }
66 }
67
68 #[cfg(test)]
69 mod tests {
70 use super::*;
71
72 #[test]
73 fn label_suffix_empty_is_blank() {
74 assert_eq!(label_suffix(&[]), "");
75 }
76
77 #[test]
78 fn label_suffix_one_label() {
79 assert_eq!(label_suffix(&["bug".to_string()]), " [bug]");
80 }
81
82 #[test]
83 fn label_suffix_multiple_labels_joined_with_comma_space() {
84 assert_eq!(
85 label_suffix(&["bug".to_string(), "priority".to_string()]),
86 " [bug, priority]"
87 );
88 }
89 }
90
56 #[derive(Parser)] 91 #[derive(Parser)]
57 #[command( 92 #[command(
58 name = "git-collab", 93 name = "git-collab",
src/issue.rs
Old New
@@ -81,11 +81,7 @@ pub fn list_to_writer(
81 for e in &entries { 81 for e in &entries {
82 let i = &e.issue; 82 let i = &e.issue;
83 let status = i.status.as_str(); 83 let status = i.status.as_str();
84 let labels = if i.labels.is_empty() { 84 let labels = cli::label_suffix(&i.labels);
85 String::new()
86 } else {
87 format!(" [{}]", i.labels.join(", "))
88 };
89 let unread = match e.unread { 85 let unread = match e.unread {
90 Some(n) if n > 0 => format!(" ({} new)", n), 86 Some(n) if n > 0 => format!(" ({} new)", n),
91 _ => String::new(), 87 _ => String::new(),
src/lib.rs
Old New
@@ -143,11 +143,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
143 for e in &entries { 143 for e in &entries {
144 let i = &e.issue; 144 let i = &e.issue;
145 let status = i.status.as_str(); 145 let status = i.status.as_str();
146 let labels = if i.labels.is_empty() { 146 let labels = cli::label_suffix(&i.labels);
147 String::new()
148 } else {
149 format!(" [{}]", i.labels.join(", "))
150 };
151 let unread = match e.unread { 147 let unread = match e.unread {
152 Some(n) if n > 0 => format!(" ({} new)", n), 148 Some(n) if n > 0 => format!(" ({} new)", n),
153 _ => String::new(), 149 _ => String::new(),
@@ -361,11 +357,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
361 } else { 357 } else {
362 for e in &entries { 358 for e in &entries {
363 let p = &e.patch; 359 let p = &e.patch;
364 let labels = if p.labels.is_empty() { 360 let labels = cli::label_suffix(&p.labels);
365 String::new()
366 } else {
367 format!(" [{}]", p.labels.join(", "))
368 };
369 let stale = match p.staleness(repo) { 361 let stale = match p.staleness(repo) {
370 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind), 362 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
371 Ok(_) => String::new(), 363 Ok(_) => String::new(),
src/patch.rs
Old New
@@ -150,6 +150,7 @@ pub fn list_to_writer(
150 } 150 }
151 for e in &entries { 151 for e in &entries {
152 let p = &e.patch; 152 let p = &e.patch;
153 let labels = cli::label_suffix(&p.labels);
153 let stale = match p.staleness(repo) { 154 let stale = match p.staleness(repo) {
154 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind), 155 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
155 Ok(_) => String::new(), 156 Ok(_) => String::new(),
@@ -161,8 +162,8 @@ pub fn list_to_writer(
161 }; 162 };
162 writeln!( 163 writeln!(
163 writer, 164 writer,
164 "{:.8} {:6} {} (by {}){}{}", 165 "{:.8} {:6} {}{} (by {}){}{}",
165 p.id, p.status, p.title, p.author.name, stale, unread 166 p.id, p.status, p.title, labels, p.author.name, stale, unread
166 ) 167 )
167 .ok(); 168 .ok();
168 } 169 }
src/state.rs
Old New
@@ -238,7 +238,14 @@ pub struct PatchState {
238 /// revision refs, so an ephemeral or rewritten branch costs it nothing. 238 /// revision refs, so an ephemeral or rewritten branch costs it nothing.
239 pub branch: String, 239 pub branch: String,
240 /// Postdates patch labelling: absent on any `PatchState` serialized 240 /// Postdates patch labelling: absent on any `PatchState` serialized
241 /// before this field existed, which must load as an empty vec. 241 /// before this field existed, which must load as an empty vec. This
242 /// `#[serde(default)]` is what makes such JSON deserialize at all; it is
243 /// not what keeps the on-disk fold cache correct. A stale cache entry
244 /// missing this key would deserialize just as cleanly whether or not it
245 /// predates a `patch.label` event actually folded into that ref -- so
246 /// the cache still needs `cache::CACHE_FORMAT_VERSION` bumped alongside
247 /// this field to force a refold instead of quietly serving a
248 /// labels-empty hit.
242 #[serde(default)] 249 #[serde(default)]
243 pub labels: Vec<String>, 250 pub labels: Vec<String>,
244 pub comments: Vec<Comment>, 251 pub comments: Vec<Comment>,
tests/cache_test.rs
Old New
@@ -217,3 +217,46 @@ fn cache_entry_from_older_format_is_a_miss() {
217 // Stale-format entries must not be served 217 // Stale-format entries must not be served
218 assert!(cache::get_cached_state::<IssueState>(&repo, &ref_name).is_none()); 218 assert!(cache::get_cached_state::<IssueState>(&repo, &ref_name).is_none());
219 } 219 }
220
221 #[test]
222 fn stale_v3_patch_cache_entry_missing_labels_is_a_miss() {
223 // A v3 `PatchState` cache entry predates patch labelling and has no
224 // `labels` key. Because `PatchState::labels` is `#[serde(default)]`,
225 // such an entry deserializes cleanly on its own -- so if the cache
226 // format were not bumped, a stale v3 entry left behind at a tip that
227 // *now* contains a `patch.label` event would be served as a hit with
228 // the label silently dropped, rather than forcing a refold. Bumping
229 // CACHE_FORMAT_VERSION to 4 closes that: any v3-tagged entry is
230 // rejected outright, regardless of which fields it happens to have.
231 let dir = TempDir::new().unwrap();
232 let repo = init_repo(dir.path(), &alice());
233 let (ref_name, id) = create_patch(&repo, &alice(), "Cache label test");
234
235 git_collab::patch::label(&repo, &id, "bug").unwrap();
236
237 // Populate the cache at the current (post-label) tip.
238 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
239 assert_eq!(state.labels, vec!["bug".to_string()]);
240
241 // Simulate a v3 entry (predating labels) surviving at this same tip:
242 // same tip_oid, version rolled back to 3, and the `labels` key removed
243 // from the serialized state -- exactly what a v3 fold would have written.
244 let path = repo
245 .path()
246 .join("collab")
247 .join("cache")
248 .join(ref_name.replace('/', "_"));
249 let data = std::fs::read_to_string(&path).unwrap();
250 let mut entry: serde_json::Value = serde_json::from_str(&data).unwrap();
251 entry["version"] = serde_json::json!(3);
252 entry["state"].as_object_mut().unwrap().remove("labels");
253 std::fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
254
255 // A v3-tagged entry must be a miss -- not served with the label
256 // silently missing.
257 assert!(cache::get_cached_state::<PatchState>(&repo, &ref_name).is_none());
258
259 // A fresh from_ref() refolds from the DAG and recovers the label.
260 let refolded = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
261 assert_eq!(refolded.labels, vec!["bug".to_string()]);
262 }
tests/cli_test.rs
Old New
@@ -726,6 +726,44 @@ fn test_patch_list_label_composes_with_all_archived() {
726 } 726 }
727 727
728 #[test] 728 #[test]
729 fn test_patch_list_label_all_flag_isolated_from_archived() {
730 // The test above only exercises "neither flag" vs "both flags" -- the
731 // one pair that cannot tell --all and --archived apart, since `patch
732 // close` always archives the ref. There is no way to reach an "open but
733 // archived" patch (archiving happens only inside `close`), so that half
734 // of the pair genuinely cannot be isolated under current semantics.
735 //
736 // But the other half can: a patch auto-detected as Merged (its branch
737 // fast-forwarded into base *outside* git-collab, which
738 // `PatchState::check_auto_merge` picks up on read) becomes non-open
739 // without ever being archived -- archiving is something only `close`
740 // does. That gives a labeled, non-open, still-*active* patch, which
741 // isolates --all: visible with --all alone, hidden without it, and
742 // --archived is irrelevant either way since it was never archived.
743 let repo = TestRepo::new("Alice", "alice@example.com");
744
745 repo.git(&["checkout", "-b", "merge-me"]);
746 repo.commit_file("merge-me.txt", "content", "commit for merge-me");
747 let out = repo.run_ok(&["patch", "create", "-t", "Merged labeled", "-B", "merge-me"]);
748 let id = out
749 .trim()
750 .strip_prefix("Created patch ")
751 .unwrap()
752 .to_string();
753 repo.run_ok(&["patch", "label", &id, "bug"]);
754
755 // Simulate `git merge merge-me` on main, outside of git-collab.
756 repo.git(&["checkout", "main"]);
757 repo.git(&["merge", "--ff-only", "merge-me"]);
758
759 let out = repo.run_ok(&["patch", "list", "--label", "bug"]);
760 assert!(!out.contains("Merged labeled"));
761
762 let out = repo.run_ok(&["patch", "list", "--all", "--label", "bug"]);
763 assert!(out.contains("Merged labeled"));
764 }
765
766 #[test]
729 fn test_patch_list_label_limit_offset_applies_after_filter() { 767 fn test_patch_list_label_limit_offset_applies_after_filter() {
730 let repo = TestRepo::new("Alice", "alice@example.com"); 768 let repo = TestRepo::new("Alice", "alice@example.com");
731 let a = repo.patch_create("Alpha patch"); 769 let a = repo.patch_create("Alpha patch");
tests/collab_test.rs
Old New
@@ -1456,6 +1456,26 @@ fn test_patch_list_offset_beyond_end() {
1456 ); 1456 );
1457 } 1457 }
1458 1458
1459 #[test]
1460 fn test_patch_list_to_writer_renders_labels() {
1461 // patch::list_to_writer threaded the `labels` filter parameter through
1462 // but, unlike issue::list_to_writer, did not render the ` [a, b]` suffix
1463 // -- the one place a patch's labels were silently dropped. Regression
1464 // test for that parity gap.
1465 let tmp = TempDir::new().unwrap();
1466 let repo = init_repo(tmp.path(), &alice());
1467
1468 let (_ref_name, id) = create_patch(&repo, &alice(), "Labeled patch");
1469 patch::label(&repo, &id, "bug").unwrap();
1470
1471 let output = capture_patch_list(&repo, false, None, None);
1472 assert!(
1473 output.contains("[bug]"),
1474 "expected labels rendered in list_to_writer output, got: {}",
1475 output
1476 );
1477 }
1478
1459 // --------------------------------------------------------------------------- 1479 // ---------------------------------------------------------------------------
1460 // JSON serialization tests 1480 // JSON serialization tests
1461 // --------------------------------------------------------------------------- 1481 // ---------------------------------------------------------------------------