e921ed54
Address review: label parity in list_to_writer, cache version bump
a73x 2026-08-10 05:57
Commit message
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 | // --------------------------------------------------------------------------- |