2b8da3a0
Report build provenance, accept guessed verbs, size ids to the repo
a73x 2026-08-10 16:41
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -148,6 +148,54 @@ than type. | |||
| 148 | 148 | ||
| 149 | Every command takes `--help`, and `man git-collab` covers the same ground. | 149 | Every command takes `--help`, and `man git-collab` covers the same ground. |
| 150 | 150 | ||
| 151 | Both binaries take `--version`, which reports the crate version and the git | ||
| 152 | commit the binary was built from, with a `-dirty` marker for a build made from | ||
| 153 | a modified tree: | ||
| 154 | |||
| 155 | ```console | ||
| 156 | $ git-collab --version | ||
| 157 | git-collab 0.1.0 (29768e2c6fd3e1eed89f095b057365b353ce9652) | ||
| 158 | ``` | ||
| 159 | |||
| 160 | The commit is the full object name so that checking an installed binary against | ||
| 161 | a checkout is one string comparison against `git rev-parse HEAD`. `git-collab | ||
| 162 | status` leads with the same line. A build from a source tree with no `.git`, or | ||
| 163 | on a machine with no git, prints the crate version alone. | ||
| 164 | |||
| 165 | Common synonyms for the verbs above are accepted but not advertised — `issue | ||
| 166 | create` and `patch open` both work, as do `ls`, `rm` and `keys`. They exist so a | ||
| 167 | wrong guess in a script succeeds rather than writing usage text into a pipeline. | ||
| 168 | |||
| 169 | ## Ids | ||
| 170 | |||
| 171 | Issues and patches are named by 40-character ids, and every command that takes | ||
| 172 | one accepts any unambiguous prefix — `issue show 9ae` is fine. A prefix that | ||
| 173 | matches more than one object is refused, never resolved: | ||
| 174 | |||
| 175 | ```console | ||
| 176 | $ git-collab issue show 9a | ||
| 177 | error: ambiguous issue prefix '9a': 2 matches | ||
| 178 | ``` | ||
| 179 | |||
| 180 | Lists, headers and confirmations print a uniform abbreviation: 8 characters by | ||
| 181 | default, widening as the repository grows, on the same birthday-bound rule git | ||
| 182 | uses for `core.abbrev`. An id that would still be ambiguous at that width is | ||
| 183 | printed wider, so anything the tool prints is unique at the moment it is | ||
| 184 | printed and can be pasted straight back into a command or a commit trailer. | ||
| 185 | |||
| 186 | Set `collab.abbrev` to override the width — a number (clamped to 4–40), or `no` | ||
| 187 | for whole ids: | ||
| 188 | |||
| 189 | ```console | ||
| 190 | $ git config collab.abbrev 12 # print 12 characters | ||
| 191 | $ git config collab.abbrev no # print ids in full | ||
| 192 | ``` | ||
| 193 | |||
| 194 | Note that displayed width and accepted width are deliberately different. What | ||
| 195 | you can type is as short as stays unambiguous; what gets printed carries a | ||
| 196 | collision margin, because printed ids end up in commit messages and scripts | ||
| 197 | where they have to keep working as the repository grows. | ||
| 198 | |||
| 151 | ## Sync | 199 | ## Sync |
| 152 | 200 | ||
| 153 | `git-collab init` adds collab refspecs to every remote of the repo (and installs | 201 | `git-collab init` adds collab refspecs to every remote of the repo (and installs |
build.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,21 @@ | |||
| 1 | use std::env; | 1 | use std::env; |
| 2 | use std::fs; | 2 | use std::fs; |
| 3 | use std::path::PathBuf; | 3 | use std::path::PathBuf; |
| 4 | use std::process::Command; | ||
| 4 | 5 | ||
| 5 | include!("src/cli.rs"); | 6 | include!("src/cli.rs"); |
| 6 | 7 | ||
| 7 | fn main() { | 8 | fn main() { |
| 9 | // Cargo's default is to rerun this script whenever any file in the package | ||
| 10 | // changes. Emitting any `rerun-if-changed` replaces that default, so the | ||
| 11 | // package contents have to be re-declared alongside the git state, or man | ||
| 12 | // pages would go stale when `src/cli.rs` changes. | ||
| 13 | println!("cargo:rerun-if-changed=build.rs"); | ||
| 14 | println!("cargo:rerun-if-changed=src"); | ||
| 15 | println!("cargo:rerun-if-changed=Cargo.toml"); | ||
| 16 | |||
| 17 | emit_build_provenance(); | ||
| 18 | |||
| 8 | let out = PathBuf::from( | 19 | let out = PathBuf::from( |
| 9 | env::var("MAN_OUT_DIR").unwrap_or_else(|_| env::var("OUT_DIR").expect("OUT_DIR not set")), | 20 | env::var("MAN_OUT_DIR").unwrap_or_else(|_| env::var("OUT_DIR").expect("OUT_DIR not set")), |
| 10 | ); | 21 | ); |
| @@ -13,6 +24,76 @@ fn main() { | |||
| 13 | generate_manpages(&cmd, &out); | 24 | generate_manpages(&cmd, &out); |
| 14 | } | 25 | } |
| 15 | 26 | ||
| 27 | /// Capture the commit this binary is being built from, for `--version`. | ||
| 28 | /// | ||
| 29 | /// Every step here is allowed to fail and none of them may fail the build: the | ||
| 30 | /// source may be a release tarball with no `.git`, git may not be installed, | ||
| 31 | /// or the checkout may be one this build has no permission to inspect. In all | ||
| 32 | /// of those cases nothing is emitted, `option_env!` in `src/cli.rs` yields | ||
| 33 | /// `None`, and `--version` degrades to the crate version alone. | ||
| 34 | fn emit_build_provenance() { | ||
| 35 | if !in_our_own_checkout() { | ||
| 36 | return; | ||
| 37 | } | ||
| 38 | let Some(commit) = git(&["rev-parse", "HEAD"]) else { | ||
| 39 | return; | ||
| 40 | }; | ||
| 41 | if commit.is_empty() || !commit.chars().all(|c| c.is_ascii_hexdigit()) { | ||
| 42 | return; | ||
| 43 | } | ||
| 44 | println!("cargo:rustc-env=GIT_COLLAB_BUILD_COMMIT={commit}"); | ||
| 45 | |||
| 46 | // `--untracked-files=no` matches `git describe --dirty`: a stray build | ||
| 47 | // artifact or editor swapfile is not a modification of the source. | ||
| 48 | if let Some(status) = git(&["status", "--porcelain", "--untracked-files=no"]) { | ||
| 49 | if !status.is_empty() { | ||
| 50 | println!("cargo:rustc-env=GIT_COLLAB_BUILD_DIRTY=1"); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | // Rebuild when the checkout moves to another commit. `--git-path` resolves | ||
| 55 | // through worktrees and `$GIT_DIR`, where `.git` is a file rather than a | ||
| 56 | // directory, so a hard-coded `.git/HEAD` would silently track nothing. | ||
| 57 | for path in ["HEAD", "refs", "packed-refs"] { | ||
| 58 | if let Some(resolved) = git(&["rev-parse", "--git-path", path]) { | ||
| 59 | if PathBuf::from(&resolved).exists() { | ||
| 60 | println!("cargo:rerun-if-changed={resolved}"); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | /// Whether the enclosing git repository is this crate's own checkout. | ||
| 67 | /// | ||
| 68 | /// Vendoring `git-collab` into another project's tree would otherwise make | ||
| 69 | /// `git rev-parse HEAD` report *that* project's commit, and a `--version` that | ||
| 70 | /// names the wrong commit is worse than one that names none: the whole point | ||
| 71 | /// is to settle arguments about which source a binary came from. | ||
| 72 | fn in_our_own_checkout() -> bool { | ||
| 73 | let Some(toplevel) = git(&["rev-parse", "--show-toplevel"]) else { | ||
| 74 | return false; | ||
| 75 | }; | ||
| 76 | let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") else { | ||
| 77 | return false; | ||
| 78 | }; | ||
| 79 | match ( | ||
| 80 | fs::canonicalize(&toplevel), | ||
| 81 | fs::canonicalize(&manifest_dir), | ||
| 82 | ) { | ||
| 83 | (Ok(a), Ok(b)) => a == b, | ||
| 84 | _ => false, | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | /// Run git and return trimmed stdout, or `None` for any failure at all. | ||
| 89 | fn git(args: &[&str]) -> Option<String> { | ||
| 90 | let output = Command::new("git").args(args).output().ok()?; | ||
| 91 | if !output.status.success() { | ||
| 92 | return None; | ||
| 93 | } | ||
| 94 | Some(String::from_utf8(output.stdout).ok()?.trim().to_string()) | ||
| 95 | } | ||
| 96 | |||
| 16 | fn generate_manpages(cmd: &clap::Command, out: &PathBuf) { | 97 | fn generate_manpages(cmd: &clap::Command, out: &PathBuf) { |
| 17 | let man = clap_mangen::Man::new(cmd.clone()); | 98 | let man = clap_mangen::Man::new(cmd.clone()); |
| 18 | let name = cmd.get_name().to_string(); | 99 | let name = cmd.get_name().to_string(); |
src/abbrev.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,366 @@ | |||
| 1 | //! How wide a collab id is printed. | ||
| 2 | //! | ||
| 3 | //! # Policy | ||
| 4 | //! | ||
| 5 | //! Every list, header and confirmation used to print a hard-coded 8 characters | ||
| 6 | //! while `resolve_ref` accepted any unambiguous prefix. Two things are wrong | ||
| 7 | //! with a hard-coded width, and they pull in opposite directions: | ||
| 8 | //! | ||
| 9 | //! * It is arbitrary. Nothing checks that 8 characters actually identify | ||
| 10 | //! anything, so a repository large enough for two ids to share their first 8 | ||
| 11 | //! characters would print the same string for both. | ||
| 12 | //! * It is fixed. It cannot grow with the repository. | ||
| 13 | //! | ||
| 14 | //! The tempting fix — print the shortest prefix that is unambiguous across the | ||
| 15 | //! current set, the way `git log --abbrev-commit` is often assumed to work — is | ||
| 16 | //! wrong, and it is worth being precise about why, because git does not do it | ||
| 17 | //! either. Git computes a *uniform* width from the approximate object count | ||
| 18 | //! (`core.abbrev`, floor 7) and only extends past it for an object that is | ||
| 19 | //! genuinely ambiguous at that width. The shortest-unambiguous width is by | ||
| 20 | //! definition the minimum that works *this instant*: the next object created | ||
| 21 | //! can collide with it. That matters here more than it does in git, because | ||
| 22 | //! displayed collab ids leave the tool. They are pasted into `Patch:` and | ||
| 23 | //! `Issue:` commit trailers, into branch names, into review comments and into | ||
| 24 | //! scripts, where they are permanent. A width with no collision margin turns | ||
| 25 | //! every one of those into a delayed failure. | ||
| 26 | //! | ||
| 27 | //! So: | ||
| 28 | //! | ||
| 29 | //! * **Display width is uniform per repository and carries a margin.** It is | ||
| 30 | //! `max(8, birthday_width(count))`, where `birthday_width` is git's rule — | ||
| 31 | //! roughly `log2(count)/2 + 1` hex digits, so that `16^width` stays well | ||
| 32 | //! clear of `count^2`. 8 remains the floor, both because it is what this | ||
| 33 | //! project already prints and because it is already wider than git's 7. | ||
| 34 | //! * **A width is never merely probable.** The birthday bound makes collisions | ||
| 35 | //! unlikely, not impossible, so an id that is still ambiguous at the uniform | ||
| 36 | //! width is printed wider until it is not. A printed id is always unique | ||
| 37 | //! within its kind at the moment it is printed. | ||
| 38 | //! * **Display width and accepted-prefix width are different things.** | ||
| 39 | //! Accepting a prefix is an interactive convenience whose mistakes surface | ||
| 40 | //! immediately: `resolve_ref` refuses an ambiguous prefix with an error. | ||
| 41 | //! Printing an id mints a reference that will outlive the command. The first | ||
| 42 | //! should be as permissive as possible, the second should have margin. They | ||
| 43 | //! are deliberately not the same number. | ||
| 44 | //! | ||
| 45 | //! The abbreviation must be computed over *all* ids of a kind — open, closed | ||
| 46 | //! and archived — not just the ones being listed, or `issue list` and | ||
| 47 | //! `issue list --all` could print the same string for different issues. | ||
| 48 | |||
| 49 | /// The narrowest id this tool will print. | ||
| 50 | /// | ||
| 51 | /// Also the width every list printed before this module existed, so nothing | ||
| 52 | /// that reads current output changes until a repository is big enough to need | ||
| 53 | /// more. | ||
| 54 | pub const MIN_WIDTH: usize = 8; | ||
| 55 | |||
| 56 | /// The floor `collab.abbrev` is clamped to, matching git's `core.abbrev`. | ||
| 57 | /// | ||
| 58 | /// Below four characters a displayed id stops being a usable reference: it | ||
| 59 | /// would be ambiguous in almost any repository, and the widening below would | ||
| 60 | /// have to undo the setting on nearly every row anyway. | ||
| 61 | pub const CONFIG_MIN_WIDTH: usize = 4; | ||
| 62 | |||
| 63 | /// Length of an unabbreviated id. | ||
| 64 | pub const FULL_WIDTH: usize = 40; | ||
| 65 | |||
| 66 | /// The git config key that overrides the automatic width, named after — and | ||
| 67 | /// behaving like — git's own `core.abbrev`. | ||
| 68 | pub const CONFIG_KEY: &str = "collab.abbrev"; | ||
| 69 | |||
| 70 | /// Hex digits needed to keep collisions unlikely among `count` random ids. | ||
| 71 | /// | ||
| 72 | /// Git's rule, from `object-name.c`: take the position of the most significant | ||
| 73 | /// bit of the count, halve it, add one. That yields `16^width` on the order of | ||
| 74 | /// `count^2` times a constant, which is the birthday bound with margin. | ||
| 75 | fn birthday_width(count: usize) -> usize { | ||
| 76 | if count < 2 { | ||
| 77 | return 1; | ||
| 78 | } | ||
| 79 | let msb = (usize::BITS - 1 - count.leading_zeros()) as usize; | ||
| 80 | msb / 2 + 1 | ||
| 81 | } | ||
| 82 | |||
| 83 | /// The uniform display width for a kind holding `count` objects. | ||
| 84 | pub fn uniform_width(count: usize) -> usize { | ||
| 85 | birthday_width(count).max(MIN_WIDTH) | ||
| 86 | } | ||
| 87 | |||
| 88 | /// Abbreviates ids of one kind against the full set of ids of that kind. | ||
| 89 | /// | ||
| 90 | /// Build one per kind per command from *every* id of that kind, including | ||
| 91 | /// closed and archived ones, then ask it for each id you print. | ||
| 92 | #[derive(Debug, Clone)] | ||
| 93 | pub struct Abbrev { | ||
| 94 | width: usize, | ||
| 95 | /// Sorted, so the minimum unique length of any id is decided by comparing | ||
| 96 | /// it with its two immediate neighbours. | ||
| 97 | sorted: Vec<String>, | ||
| 98 | } | ||
| 99 | |||
| 100 | impl Abbrev { | ||
| 101 | /// Build an abbreviator over every id of one kind. | ||
| 102 | pub fn new<I, S>(ids: I) -> Self | ||
| 103 | where | ||
| 104 | I: IntoIterator<Item = S>, | ||
| 105 | S: Into<String>, | ||
| 106 | { | ||
| 107 | Self::with_width(ids, None) | ||
| 108 | } | ||
| 109 | |||
| 110 | /// As [`Abbrev::new`], but with an explicit uniform width from | ||
| 111 | /// `collab.abbrev`. Per-id widening still applies on top of it. | ||
| 112 | pub fn with_width<I, S>(ids: I, configured: Option<usize>) -> Self | ||
| 113 | where | ||
| 114 | I: IntoIterator<Item = S>, | ||
| 115 | S: Into<String>, | ||
| 116 | { | ||
| 117 | let mut sorted: Vec<String> = ids.into_iter().map(Into::into).collect(); | ||
| 118 | sorted.sort_unstable(); | ||
| 119 | sorted.dedup(); | ||
| 120 | let width = configured.unwrap_or_else(|| uniform_width(sorted.len())); | ||
| 121 | Abbrev { width, sorted } | ||
| 122 | } | ||
| 123 | |||
| 124 | /// An abbreviator over no ids at all, for the handful of places that print | ||
| 125 | /// an id without having the set to hand. Prints the floor width. | ||
| 126 | pub fn minimal() -> Self { | ||
| 127 | Abbrev { | ||
| 128 | width: MIN_WIDTH, | ||
| 129 | sorted: Vec::new(), | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | /// The uniform width for this set, before per-id disambiguation. | ||
| 134 | pub fn width(&self) -> usize { | ||
| 135 | self.width | ||
| 136 | } | ||
| 137 | |||
| 138 | /// The prefix of `id` to display: the uniform width, widened if this | ||
| 139 | /// particular id would otherwise be ambiguous, capped at the whole id. | ||
| 140 | pub fn of<'a>(&self, id: &'a str) -> &'a str { | ||
| 141 | let len = self.display_len(id).min(id.len()); | ||
| 142 | &id[..len] | ||
| 143 | } | ||
| 144 | |||
| 145 | /// The number of characters `of` would return, before the length cap. | ||
| 146 | fn display_len(&self, id: &str) -> usize { | ||
| 147 | let needed = match self.sorted.binary_search_by(|probe| probe.as_str().cmp(id)) { | ||
| 148 | // `id` is in the set: only its sorted neighbours can share a | ||
| 149 | // prefix with it, and the longer of the two shared prefixes is one | ||
| 150 | // character short of what makes it unique. | ||
| 151 | Ok(pos) => { | ||
| 152 | let before = pos.checked_sub(1).map(|i| self.sorted[i].as_str()); | ||
| 153 | let after = self.sorted.get(pos + 1).map(|s| s.as_str()); | ||
| 154 | before | ||
| 155 | .into_iter() | ||
| 156 | .chain(after) | ||
| 157 | .map(|other| common_prefix_len(id, other) + 1) | ||
| 158 | .max() | ||
| 159 | .unwrap_or(0) | ||
| 160 | } | ||
| 161 | // `id` is not in the set — a stale reference, or a caller printing | ||
| 162 | // an id of a different kind. Its insertion point's neighbours are | ||
| 163 | // still the only candidates for a shared prefix. | ||
| 164 | Err(pos) => { | ||
| 165 | let before = pos.checked_sub(1).map(|i| self.sorted[i].as_str()); | ||
| 166 | let after = self.sorted.get(pos).map(|s| s.as_str()); | ||
| 167 | before | ||
| 168 | .into_iter() | ||
| 169 | .chain(after) | ||
| 170 | .map(|other| common_prefix_len(id, other) + 1) | ||
| 171 | .max() | ||
| 172 | .unwrap_or(0) | ||
| 173 | } | ||
| 174 | }; | ||
| 175 | needed.max(self.width) | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | /// Length in bytes of the longest common prefix of two ids. | ||
| 180 | /// | ||
| 181 | /// Ids are ASCII hex, so bytes and characters coincide and slicing at a byte | ||
| 182 | /// index is always on a character boundary. | ||
| 183 | fn common_prefix_len(a: &str, b: &str) -> usize { | ||
| 184 | a.bytes() | ||
| 185 | .zip(b.bytes()) | ||
| 186 | .take_while(|(x, y)| x == y) | ||
| 187 | .count() | ||
| 188 | } | ||
| 189 | |||
| 190 | /// Read `collab.abbrev`, or `None` to use the automatic width. | ||
| 191 | /// | ||
| 192 | /// Unset, unreadable and unparseable all mean the same thing: fall back to the | ||
| 193 | /// automatic width. A bad value is a display preference nobody can act on, not | ||
| 194 | /// a reason to fail a command that was otherwise going to work. | ||
| 195 | pub fn configured_width(repo: &git2::Repository) -> Option<usize> { | ||
| 196 | let raw = repo.config().ok()?.get_string(CONFIG_KEY).ok()?; | ||
| 197 | match raw.trim().to_ascii_lowercase().as_str() { | ||
| 198 | // git's spellings for "print the whole thing". | ||
| 199 | "no" | "false" | "off" | "full" => Some(FULL_WIDTH), | ||
| 200 | other => other | ||
| 201 | .parse::<usize>() | ||
| 202 | .ok() | ||
| 203 | .map(|n| n.clamp(CONFIG_MIN_WIDTH, FULL_WIDTH)), | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | /// The abbreviator for issue ids in this repository. | ||
| 208 | pub fn for_issues(repo: &git2::Repository) -> Abbrev { | ||
| 209 | match crate::state::all_issue_ids(repo) { | ||
| 210 | Ok(ids) => Abbrev::with_width(ids, configured_width(repo)), | ||
| 211 | // Losing the id set costs a well-chosen width, not correctness: the | ||
| 212 | // floor is still printed and `resolve_ref` still refuses anything | ||
| 213 | // ambiguous. Failing the command instead would be worse. | ||
| 214 | Err(_) => Abbrev::minimal(), | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | /// The abbreviator for patch ids in this repository. | ||
| 219 | pub fn for_patches(repo: &git2::Repository) -> Abbrev { | ||
| 220 | match crate::state::all_patch_ids(repo) { | ||
| 221 | Ok(ids) => Abbrev::with_width(ids, configured_width(repo)), | ||
| 222 | Err(_) => Abbrev::minimal(), | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | #[cfg(test)] | ||
| 227 | mod tests { | ||
| 228 | use super::*; | ||
| 229 | |||
| 230 | fn ids(prefixes: &[&str]) -> Vec<String> { | ||
| 231 | // Pad to a realistic 40-hex id so widths behave as they do in a repo. | ||
| 232 | prefixes | ||
| 233 | .iter() | ||
| 234 | .map(|p| format!("{:0<40}", p)) | ||
| 235 | .collect::<Vec<_>>() | ||
| 236 | } | ||
| 237 | |||
| 238 | #[test] | ||
| 239 | fn a_small_repository_still_prints_eight_characters() { | ||
| 240 | for count in [0usize, 1, 2, 10, 100, 1000] { | ||
| 241 | assert_eq!( | ||
| 242 | uniform_width(count), | ||
| 243 | 8, | ||
| 244 | "count {} should still print 8 characters", | ||
| 245 | count | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | #[test] | ||
| 251 | fn the_width_grows_once_the_repository_outgrows_eight_characters() { | ||
| 252 | assert_eq!(uniform_width(1 << 16), 9); | ||
| 253 | assert_eq!(uniform_width(1 << 18), 10); | ||
| 254 | assert!(uniform_width(1 << 30) > uniform_width(1 << 20)); | ||
| 255 | } | ||
| 256 | |||
| 257 | #[test] | ||
| 258 | fn the_width_never_shrinks_as_objects_are_added() { | ||
| 259 | let mut previous = 0; | ||
| 260 | for count in 0..2000usize { | ||
| 261 | let width = uniform_width(count); | ||
| 262 | assert!(width >= previous, "width shrank at count {}", count); | ||
| 263 | previous = width; | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | /// The whole point of a uniform width is that it keeps a margin over the | ||
| 268 | /// birthday bound: `16^width` must stay comfortably above `count^2`. | ||
| 269 | #[test] | ||
| 270 | fn the_width_keeps_a_margin_over_the_birthday_bound() { | ||
| 271 | for shift in 4..24u32 { | ||
| 272 | let count = 1usize << shift; | ||
| 273 | let width = uniform_width(count); | ||
| 274 | let space = 16f64.powi(width as i32); | ||
| 275 | let pairs = (count as f64).powi(2); | ||
| 276 | assert!( | ||
| 277 | space >= pairs, | ||
| 278 | "width {} for {} objects leaves no birthday margin", | ||
| 279 | width, | ||
| 280 | count | ||
| 281 | ); | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | #[test] | ||
| 286 | fn an_id_is_abbreviated_to_the_uniform_width() { | ||
| 287 | let a = Abbrev::new(ids(&["aaaa1111", "bbbb2222"])); | ||
| 288 | assert_eq!(a.of(&ids(&["aaaa1111"])[0]), "aaaa1111"); | ||
| 289 | } | ||
| 290 | |||
| 291 | /// The case a fixed 8 gets wrong: two ids sharing their first 8 characters | ||
| 292 | /// must not print as the same string. | ||
| 293 | #[test] | ||
| 294 | fn a_colliding_id_is_printed_wider_than_the_uniform_width() { | ||
| 295 | let all = ids(&["aaaaaaaa1", "aaaaaaaa2"]); | ||
| 296 | let a = Abbrev::new(all.clone()); | ||
| 297 | assert_eq!(a.of(&all[0]), "aaaaaaaa1"); | ||
| 298 | assert_eq!(a.of(&all[1]), "aaaaaaaa2"); | ||
| 299 | assert_ne!(a.of(&all[0]), a.of(&all[1])); | ||
| 300 | } | ||
| 301 | |||
| 302 | #[test] | ||
| 303 | fn only_the_colliding_ids_are_widened() { | ||
| 304 | let all = ids(&["aaaaaaaa1", "aaaaaaaa2", "bbbbbbbb"]); | ||
| 305 | let a = Abbrev::new(all.clone()); | ||
| 306 | assert_eq!(a.of(&all[2]), "bbbbbbbb"); | ||
| 307 | } | ||
| 308 | |||
| 309 | #[test] | ||
| 310 | fn a_run_of_near_identical_ids_widens_until_unique() { | ||
| 311 | let all = ids(&["aaaaaaaaaaaa1", "aaaaaaaaaaaa2", "aaaaaaaaaaaa3"]); | ||
| 312 | let a = Abbrev::new(all.clone()); | ||
| 313 | for id in &all { | ||
| 314 | assert_eq!(a.of(id).len(), 13, "{} was not widened far enough", id); | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | /// The guarantee that makes displayed ids safe to paste: whatever this | ||
| 319 | /// prints is unique across the whole set it was built from. | ||
| 320 | #[test] | ||
| 321 | fn every_printed_prefix_matches_exactly_one_id() { | ||
| 322 | let all = ids(&[ | ||
| 323 | "aaaaaaaa1", "aaaaaaaa2", "aaaaaaab", "bbbbbbbb", "bbbbbbbc", "c", "d", "e", | ||
| 324 | ]); | ||
| 325 | let a = Abbrev::new(all.clone()); | ||
| 326 | for id in &all { | ||
| 327 | let shown = a.of(id); | ||
| 328 | let matches = all.iter().filter(|other| other.starts_with(shown)).count(); | ||
| 329 | assert_eq!(matches, 1, "'{}' matched {} ids", shown, matches); | ||
| 330 | } | ||
| 331 | } | ||
| 332 | |||
| 333 | #[test] | ||
| 334 | fn an_id_shorter_than_the_width_is_printed_whole() { | ||
| 335 | let a = Abbrev::new(vec!["abc".to_string()]); | ||
| 336 | assert_eq!(a.of("abc"), "abc"); | ||
| 337 | } | ||
| 338 | |||
| 339 | #[test] | ||
| 340 | fn an_unknown_id_is_still_printed_unambiguously() { | ||
| 341 | let all = ids(&["aaaaaaaa1", "aaaaaaaa2"]); | ||
| 342 | let a = Abbrev::new(all.clone()); | ||
| 343 | // Not in the set, but shares 8 characters with two that are. | ||
| 344 | let stranger = format!("{:0<40}", "aaaaaaaa3"); | ||
| 345 | let shown = a.of(&stranger); | ||
| 346 | assert!( | ||
| 347 | !all.iter().any(|id| id.starts_with(shown)), | ||
| 348 | "'{}' should not match a real id", | ||
| 349 | shown | ||
| 350 | ); | ||
| 351 | } | ||
| 352 | |||
| 353 | #[test] | ||
| 354 | fn an_empty_set_prints_the_floor_width() { | ||
| 355 | let a = Abbrev::new(Vec::<String>::new()); | ||
| 356 | assert_eq!(a.width(), MIN_WIDTH); | ||
| 357 | assert_eq!(a.of(&ids(&["deadbeefcafe"])[0]), "deadbeef"); | ||
| 358 | } | ||
| 359 | |||
| 360 | #[test] | ||
| 361 | fn duplicates_in_the_input_do_not_widen_anything() { | ||
| 362 | let one = ids(&["aaaaaaaa"])[0].clone(); | ||
| 363 | let a = Abbrev::new(vec![one.clone(), one.clone(), one.clone()]); | ||
| 364 | assert_eq!(a.of(&one), "aaaaaaaa"); | ||
| 365 | } | ||
| 366 | } | ||
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,57 @@ | |||
| 1 | use clap::{Parser, Subcommand, ValueEnum}; | 1 | use clap::{Parser, Subcommand, ValueEnum}; |
| 2 | use clap_complete::Shell; | 2 | use clap_complete::Shell; |
| 3 | 3 | ||
| 4 | // --------------------------------------------------------------------------- | ||
| 5 | // Build provenance | ||
| 6 | // | ||
| 7 | // `build.rs` captures these with `cargo:rustc-env` and is allowed to capture | ||
| 8 | // neither. `option_env!` is what makes that degradation total: a source tree | ||
| 9 | // with no `.git`, or a machine with no git, simply yields `None` here and the | ||
| 10 | // build succeeds. It also has to be `option_env!` rather than `env!` because | ||
| 11 | // `build.rs` itself `include!`s this file, and the variables do not exist | ||
| 12 | // while the build script is being compiled. | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | /// The full git commit this binary was built from, or `None` if the build had | ||
| 16 | /// no git checkout to read. | ||
| 17 | pub const BUILD_COMMIT: Option<&str> = option_env!("GIT_COLLAB_BUILD_COMMIT"); | ||
| 18 | |||
| 19 | /// Whether tracked files were modified in the checkout at build time. | ||
| 20 | pub const BUILD_DIRTY: bool = option_env!("GIT_COLLAB_BUILD_DIRTY").is_some(); | ||
| 21 | |||
| 22 | /// Render a version string from its parts. | ||
| 23 | /// | ||
| 24 | /// Split out from [`version_string`] so the no-git case is testable: a | ||
| 25 | /// checkout always has a `.git`, so nothing built from this repository can | ||
| 26 | /// exercise the degraded path. | ||
| 27 | pub fn format_version(crate_version: &str, commit: Option<&str>, dirty: bool) -> String { | ||
| 28 | match commit { | ||
| 29 | // A dirty marker with no commit is not reported: "dirty" is only | ||
| 30 | // meaningful relative to a commit, and printing it alone would claim | ||
| 31 | // knowledge the build did not have. | ||
| 32 | None => crate_version.to_string(), | ||
| 33 | Some(commit) if dirty => format!("{crate_version} ({commit}-dirty)"), | ||
| 34 | Some(commit) => format!("{crate_version} ({commit})"), | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | /// The version both binaries report: crate version plus build provenance. | ||
| 39 | /// | ||
| 40 | /// The commit is the full object name rather than an abbreviation so that | ||
| 41 | /// checking an installed binary against a checkout is an exact string | ||
| 42 | /// comparison against `git rev-parse HEAD` — which is the question that | ||
| 43 | /// motivated this ("is the binary I am running actually built from this | ||
| 44 | /// source?"), and it should not need a second command to answer. | ||
| 45 | pub fn version_string() -> String { | ||
| 46 | format_version(env!("CARGO_PKG_VERSION"), BUILD_COMMIT, BUILD_DIRTY) | ||
| 47 | } | ||
| 48 | |||
| 49 | /// [`version_string`] as a `&'static str`, which is what clap's builder wants. | ||
| 50 | pub fn version_static() -> &'static str { | ||
| 51 | static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new(); | ||
| 52 | VERSION.get_or_init(version_string).as_str() | ||
| 53 | } | ||
| 54 | |||
| 4 | #[derive(Debug, Clone, Copy, Default, ValueEnum)] | 55 | #[derive(Debug, Clone, Copy, Default, ValueEnum)] |
| 5 | pub enum SortMode { | 56 | pub enum SortMode { |
| 6 | /// Sort by last updated (most recent first) | 57 | /// Sort by last updated (most recent first) |
| @@ -88,9 +139,25 @@ mod tests { | |||
| 88 | } | 139 | } |
| 89 | } | 140 | } |
| 90 | 141 | ||
| 91 | #[derive(Parser)] | 142 | // --------------------------------------------------------------------------- |
| 143 | // A note on the `alias` attributes below. | ||
| 144 | // | ||
| 145 | // Every one of them exists because guessing wrong here is expensive out of | ||
| 146 | // proportion to the mistake. A wrong verb makes clap write usage text to | ||
| 147 | // stderr and exit 2; a script that captured that output carries on with usage | ||
| 148 | // text where an id should be, and fails several steps later somewhere that | ||
| 149 | // says nothing about the cause. That has already happened in this project. | ||
| 150 | // | ||
| 151 | // They are hidden (`alias`, not `visible_alias`) on purpose: they are a safety | ||
| 152 | // net for a wrong guess, not a second vocabulary to document. `--help` keeps | ||
| 153 | // advertising exactly one spelling per command, so nothing about the surface | ||
| 154 | // someone learns gets wider. | ||
| 155 | // --------------------------------------------------------------------------- | ||
| 156 | |||
| 157 | #[derive(Parser, Debug)] | ||
| 92 | #[command( | 158 | #[command( |
| 93 | name = "git-collab", | 159 | name = "git-collab", |
| 160 | version = version_static(), | ||
| 94 | about = "Distributed issues and code review over Git" | 161 | about = "Distributed issues and code review over Git" |
| 95 | )] | 162 | )] |
| 96 | pub struct Cli { | 163 | pub struct Cli { |
| @@ -98,25 +165,25 @@ pub struct Cli { | |||
| 98 | pub command: Commands, | 165 | pub command: Commands, |
| 99 | } | 166 | } |
| 100 | 167 | ||
| 101 | #[derive(Subcommand)] | 168 | #[derive(Subcommand, Debug)] |
| 102 | pub enum Commands { | 169 | pub enum Commands { |
| 103 | /// Initialize collab refspecs on all remotes | 170 | /// Initialize collab refspecs on all remotes |
| 104 | Init, | 171 | Init, |
| 105 | 172 | ||
| 106 | /// Manage the commit-msg hook that stamps `Patch:` trailers | 173 | /// Manage the commit-msg hook that stamps `Patch:` trailers |
| 107 | #[command(subcommand)] | 174 | #[command(subcommand, alias = "hook")] |
| 108 | Hooks(HookCmd), | 175 | Hooks(HookCmd), |
| 109 | 176 | ||
| 110 | /// Manage issues | 177 | /// Manage issues |
| 111 | #[command(subcommand)] | 178 | #[command(subcommand, alias = "issues")] |
| 112 | Issue(IssueCmd), | 179 | Issue(IssueCmd), |
| 113 | 180 | ||
| 114 | /// Manage patches (code review) | 181 | /// Manage patches (code review) |
| 115 | #[command(subcommand)] | 182 | #[command(subcommand, alias = "patches")] |
| 116 | Patch(PatchCmd), | 183 | Patch(PatchCmd), |
| 117 | 184 | ||
| 118 | /// Manage release artifacts on the server | 185 | /// Manage release artifacts on the server |
| 119 | #[command(subcommand)] | 186 | #[command(subcommand, alias = "releases")] |
| 120 | Release(ReleaseCmd), | 187 | Release(ReleaseCmd), |
| 121 | 188 | ||
| 122 | /// Show project status overview | 189 | /// Show project status overview |
| @@ -157,7 +224,10 @@ pub enum Commands { | |||
| 157 | }, | 224 | }, |
| 158 | 225 | ||
| 159 | /// Generate an Ed25519 signing keypair | 226 | /// Generate an Ed25519 signing keypair |
| 160 | #[clap(name = "init-key")] | 227 | /// |
| 228 | /// Also reachable as `key generate`, since key *management* lives under | ||
| 229 | /// `key` and looking for generation there is the obvious guess. | ||
| 230 | #[clap(name = "init-key", alias = "keygen", alias = "generate-key")] | ||
| 161 | InitKey { | 231 | InitKey { |
| 162 | /// Overwrite existing key files | 232 | /// Overwrite existing key files |
| 163 | #[arg(long)] | 233 | #[arg(long)] |
| @@ -165,24 +235,25 @@ pub enum Commands { | |||
| 165 | }, | 235 | }, |
| 166 | 236 | ||
| 167 | /// Manage trusted keys | 237 | /// Manage trusted keys |
| 168 | #[command(subcommand)] | 238 | #[command(subcommand, alias = "keys")] |
| 169 | Key(KeyCmd), | 239 | Key(KeyCmd), |
| 170 | 240 | ||
| 171 | /// Show current user identity | 241 | /// Show current user identity |
| 172 | Whoami, | 242 | Whoami, |
| 173 | 243 | ||
| 174 | /// Manage identity aliases | 244 | /// Manage identity aliases |
| 175 | #[command(subcommand)] | 245 | #[command(subcommand, alias = "identities")] |
| 176 | Identity(IdentityCmd), | 246 | Identity(IdentityCmd), |
| 177 | 247 | ||
| 178 | /// Full-text search across all issues and patches | 248 | /// Full-text search across all issues and patches |
| 249 | #[command(alias = "find", alias = "grep")] | ||
| 179 | Search { | 250 | Search { |
| 180 | /// Search query (case-insensitive substring match) | 251 | /// Search query (case-insensitive substring match) |
| 181 | query: String, | 252 | query: String, |
| 182 | }, | 253 | }, |
| 183 | } | 254 | } |
| 184 | 255 | ||
| 185 | #[derive(Subcommand)] | 256 | #[derive(Subcommand, Debug)] |
| 186 | pub enum HookCmd { | 257 | pub enum HookCmd { |
| 187 | /// Install the commit-msg hook (also done by `git-collab init`) | 258 | /// Install the commit-msg hook (also done by `git-collab init`) |
| 188 | Install, | 259 | Install, |
| @@ -204,9 +275,12 @@ pub enum HookCmd { | |||
| 204 | }, | 275 | }, |
| 205 | } | 276 | } |
| 206 | 277 | ||
| 207 | #[derive(Subcommand)] | 278 | #[derive(Subcommand, Debug)] |
| 208 | pub enum IssueCmd { | 279 | pub enum IssueCmd { |
| 209 | /// Open a new issue | 280 | /// Open a new issue |
| 281 | /// | ||
| 282 | /// `patch` spells this `create`, so both spellings work on both nouns. | ||
| 283 | #[command(alias = "create", alias = "new")] | ||
| 210 | Open { | 284 | Open { |
| 211 | /// Issue title | 285 | /// Issue title |
| 212 | #[arg(short, long)] | 286 | #[arg(short, long)] |
| @@ -219,6 +293,7 @@ pub enum IssueCmd { | |||
| 219 | relates_to: Option<String>, | 293 | relates_to: Option<String>, |
| 220 | }, | 294 | }, |
| 221 | /// List issues | 295 | /// List issues |
| 296 | #[command(alias = "ls")] | ||
| 222 | List { | 297 | List { |
| 223 | /// Show closed issues too | 298 | /// Show closed issues too |
| 224 | #[arg(short = 'a', long)] | 299 | #[arg(short = 'a', long)] |
| @@ -243,6 +318,7 @@ pub enum IssueCmd { | |||
| 243 | label: Vec<String>, | 318 | label: Vec<String>, |
| 244 | }, | 319 | }, |
| 245 | /// Show issue details | 320 | /// Show issue details |
| 321 | #[command(alias = "view", alias = "info")] | ||
| 246 | Show { | 322 | Show { |
| 247 | /// Issue ID (prefix match) | 323 | /// Issue ID (prefix match) |
| 248 | id: String, | 324 | id: String, |
| @@ -320,6 +396,10 @@ pub enum IssueCmd { | |||
| 320 | reason: Option<String>, | 396 | reason: Option<String>, |
| 321 | }, | 397 | }, |
| 322 | /// Delete an issue (removes the local collab ref) | 398 | /// Delete an issue (removes the local collab ref) |
| 399 | /// | ||
| 400 | /// `key` spells removal `remove`, so all three spellings work everywhere | ||
| 401 | /// something can be removed. | ||
| 402 | #[command(alias = "remove", alias = "rm")] | ||
| 323 | Delete { | 403 | Delete { |
| 324 | /// Issue ID (prefix match) | 404 | /// Issue ID (prefix match) |
| 325 | id: String, | 405 | id: String, |
| @@ -331,8 +411,20 @@ pub enum IssueCmd { | |||
| 331 | }, | 411 | }, |
| 332 | } | 412 | } |
| 333 | 413 | ||
| 334 | #[derive(Subcommand)] | 414 | #[derive(Subcommand, Debug)] |
| 335 | pub enum KeyCmd { | 415 | pub enum KeyCmd { |
| 416 | /// Generate an Ed25519 signing keypair (same as `git-collab init-key`) | ||
| 417 | /// | ||
| 418 | /// A separate variant rather than an alias because clap aliases cannot | ||
| 419 | /// cross from one subcommand enum to another; `run` dispatches both to the | ||
| 420 | /// same code. Hidden, so `init-key` stays the one advertised spelling. | ||
| 421 | #[command(name = "generate", alias = "init", alias = "init-key", hide = true)] | ||
| 422 | Generate { | ||
| 423 | /// Overwrite existing key files | ||
| 424 | #[arg(long)] | ||
| 425 | force: bool, | ||
| 426 | }, | ||
| 427 | |||
| 336 | /// Add a trusted public key | 428 | /// Add a trusted public key |
| 337 | Add { | 429 | Add { |
| 338 | /// Base64-encoded Ed25519 public key | 430 | /// Base64-encoded Ed25519 public key |
| @@ -348,12 +440,14 @@ pub enum KeyCmd { | |||
| 348 | global: bool, | 440 | global: bool, |
| 349 | }, | 441 | }, |
| 350 | /// List trusted public keys | 442 | /// List trusted public keys |
| 443 | #[command(alias = "ls")] | ||
| 351 | List { | 444 | List { |
| 352 | /// Show only global trusted keys | 445 | /// Show only global trusted keys |
| 353 | #[arg(long)] | 446 | #[arg(long)] |
| 354 | global: bool, | 447 | global: bool, |
| 355 | }, | 448 | }, |
| 356 | /// Remove a trusted public key | 449 | /// Remove a trusted public key |
| 450 | #[command(alias = "delete", alias = "rm")] | ||
| 357 | Remove { | 451 | Remove { |
| 358 | /// Base64-encoded public key to remove | 452 | /// Base64-encoded public key to remove |
| 359 | pubkey: String, | 453 | pubkey: String, |
| @@ -363,9 +457,12 @@ pub enum KeyCmd { | |||
| 363 | }, | 457 | }, |
| 364 | } | 458 | } |
| 365 | 459 | ||
| 366 | #[derive(Subcommand)] | 460 | #[derive(Subcommand, Debug)] |
| 367 | pub enum PatchCmd { | 461 | pub enum PatchCmd { |
| 368 | /// Create a new patch for review | 462 | /// Create a new patch for review |
| 463 | /// | ||
| 464 | /// `issue` spells this `open`, so both spellings work on both nouns. | ||
| 465 | #[command(alias = "open", alias = "new")] | ||
| 369 | Create { | 466 | Create { |
| 370 | /// Patch title | 467 | /// Patch title |
| 371 | #[arg(short, long)] | 468 | #[arg(short, long)] |
| @@ -384,6 +481,7 @@ pub enum PatchCmd { | |||
| 384 | fixes: Option<String>, | 481 | fixes: Option<String>, |
| 385 | }, | 482 | }, |
| 386 | /// List patches | 483 | /// List patches |
| 484 | #[command(alias = "ls")] | ||
| 387 | List { | 485 | List { |
| 388 | /// Show closed/merged patches too | 486 | /// Show closed/merged patches too |
| 389 | #[arg(short = 'a', long)] | 487 | #[arg(short = 'a', long)] |
| @@ -408,6 +506,7 @@ pub enum PatchCmd { | |||
| 408 | label: Vec<String>, | 506 | label: Vec<String>, |
| 409 | }, | 507 | }, |
| 410 | /// Show patch details | 508 | /// Show patch details |
| 509 | #[command(alias = "view", alias = "info")] | ||
| 411 | Show { | 510 | Show { |
| 412 | /// Patch ID (prefix match) | 511 | /// Patch ID (prefix match) |
| 413 | id: String, | 512 | id: String, |
| @@ -461,6 +560,7 @@ pub enum PatchCmd { | |||
| 461 | revision: Option<u32>, | 560 | revision: Option<u32>, |
| 462 | }, | 561 | }, |
| 463 | /// Revise a patch (record a new revision snapshot) | 562 | /// Revise a patch (record a new revision snapshot) |
| 563 | #[command(alias = "update")] | ||
| 464 | Revise { | 564 | Revise { |
| 465 | /// Patch ID (prefix match) | 565 | /// Patch ID (prefix match) |
| 466 | id: String, | 566 | id: String, |
| @@ -518,11 +618,13 @@ pub enum PatchCmd { | |||
| 518 | reason: Option<String>, | 618 | reason: Option<String>, |
| 519 | }, | 619 | }, |
| 520 | /// Delete a patch (removes the local collab ref) | 620 | /// Delete a patch (removes the local collab ref) |
| 621 | #[command(alias = "remove", alias = "rm")] | ||
| 521 | Delete { | 622 | Delete { |
| 522 | /// Patch ID (prefix match) | 623 | /// Patch ID (prefix match) |
| 523 | id: String, | 624 | id: String, |
| 524 | }, | 625 | }, |
| 525 | /// Check out a patch's latest revision as a local branch | 626 | /// Check out a patch's latest revision as a local branch |
| 627 | #[command(alias = "co")] | ||
| 526 | Checkout { | 628 | Checkout { |
| 527 | /// Patch ID (prefix match) | 629 | /// Patch ID (prefix match) |
| 528 | id: String, | 630 | id: String, |
| @@ -562,9 +664,10 @@ impl Commands { | |||
| 562 | } | 664 | } |
| 563 | } | 665 | } |
| 564 | 666 | ||
| 565 | #[derive(Subcommand)] | 667 | #[derive(Subcommand, Debug)] |
| 566 | pub enum ReleaseCmd { | 668 | pub enum ReleaseCmd { |
| 567 | /// Upload files to a release version on the server | 669 | /// Upload files to a release version on the server |
| 670 | #[command(alias = "upload", alias = "create", alias = "new")] | ||
| 568 | Publish { | 671 | Publish { |
| 569 | /// Release version (e.g. v1.2.0) | 672 | /// Release version (e.g. v1.2.0) |
| 570 | version: String, | 673 | version: String, |
| @@ -579,6 +682,7 @@ pub enum ReleaseCmd { | |||
| 579 | remote: String, | 682 | remote: String, |
| 580 | }, | 683 | }, |
| 581 | /// List releases on the server | 684 | /// List releases on the server |
| 685 | #[command(alias = "ls")] | ||
| 582 | List { | 686 | List { |
| 583 | /// Output as JSON | 687 | /// Output as JSON |
| 584 | #[arg(long)] | 688 | #[arg(long)] |
| @@ -588,6 +692,7 @@ pub enum ReleaseCmd { | |||
| 588 | remote: String, | 692 | remote: String, |
| 589 | }, | 693 | }, |
| 590 | /// Delete a release version, or a single file from it | 694 | /// Delete a release version, or a single file from it |
| 695 | #[command(alias = "remove", alias = "rm")] | ||
| 591 | Delete { | 696 | Delete { |
| 592 | /// Release version | 697 | /// Release version |
| 593 | version: String, | 698 | version: String, |
| @@ -599,18 +704,24 @@ pub enum ReleaseCmd { | |||
| 599 | }, | 704 | }, |
| 600 | } | 705 | } |
| 601 | 706 | ||
| 602 | #[derive(Subcommand)] | 707 | #[derive(Subcommand, Debug)] |
| 603 | pub enum IdentityCmd { | 708 | pub enum IdentityCmd { |
| 604 | /// Link another email to your current identity | 709 | /// Link another email to your current identity |
| 710 | /// | ||
| 711 | /// Every other collection on this surface is managed with add/remove; | ||
| 712 | /// aliases are the odd pair out, so both vocabularies work. | ||
| 713 | #[command(alias = "add")] | ||
| 605 | Alias { | 714 | Alias { |
| 606 | /// Email address to add as alias | 715 | /// Email address to add as alias |
| 607 | email: String, | 716 | email: String, |
| 608 | }, | 717 | }, |
| 609 | /// Remove an email alias | 718 | /// Remove an email alias |
| 719 | #[command(alias = "remove", alias = "rm", alias = "delete")] | ||
| 610 | Unalias { | 720 | Unalias { |
| 611 | /// Email address to remove | 721 | /// Email address to remove |
| 612 | email: String, | 722 | email: String, |
| 613 | }, | 723 | }, |
| 614 | /// Show current identity and aliases | 724 | /// Show current identity and aliases |
| 725 | #[command(alias = "ls", alias = "show")] | ||
| 615 | List, | 726 | List, |
| 616 | } | 727 | } |
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -78,6 +78,10 @@ pub fn list_to_writer( | |||
| 78 | writeln!(writer, "No issues found.").ok(); | 78 | writeln!(writer, "No issues found.").ok(); |
| 79 | return Ok(()); | 79 | return Ok(()); |
| 80 | } | 80 | } |
| 81 | // Over every issue in the repository, not just the rows being printed: | ||
| 82 | // otherwise `issue list` and `issue list --all` could print the same | ||
| 83 | // string for two different issues. | ||
| 84 | let abbrev = crate::abbrev::for_issues(repo); | ||
| 81 | for e in &entries { | 85 | for e in &entries { |
| 82 | let i = &e.issue; | 86 | let i = &e.issue; |
| 83 | let status = i.status.as_str(); | 87 | let status = i.status.as_str(); |
| @@ -88,8 +92,13 @@ pub fn list_to_writer( | |||
| 88 | }; | 92 | }; |
| 89 | writeln!( | 93 | writeln!( |
| 90 | writer, | 94 | writer, |
| 91 | "{:.8} {:6} {}{} (by {}){}", | 95 | "{} {:6} {}{} (by {}){}", |
| 92 | i.id, status, i.title, labels, i.author.name, unread | 96 | abbrev.of(&i.id), |
| 97 | status, | ||
| 98 | i.title, | ||
| 99 | labels, | ||
| 100 | i.author.name, | ||
| 101 | unread | ||
| 93 | ) | 102 | ) |
| 94 | .ok(); | 103 | .ok(); |
| 95 | } | 104 | } |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,4 @@ | |||
| 1 | pub mod abbrev; | ||
| 1 | pub mod cache; | 2 | pub mod cache; |
| 2 | pub mod cli; | 3 | pub mod cli; |
| 3 | pub mod commit_link; | 4 | pub mod commit_link; |
| @@ -26,6 +27,27 @@ use cli::{Commands, HookCmd, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd | |||
| 26 | use event::ReviewVerdict; | 27 | use event::ReviewVerdict; |
| 27 | use git2::Repository; | 28 | use git2::Repository; |
| 28 | 29 | ||
| 30 | /// Generate the local Ed25519 signing keypair. | ||
| 31 | /// | ||
| 32 | /// Shared by the top-level `init-key` and by `key generate`, which exist as | ||
| 33 | /// two spellings of one command because key generation sits at the top level | ||
| 34 | /// while key management sits under `key`. | ||
| 35 | fn generate_signing_key(force: bool) -> Result<(), error::Error> { | ||
| 36 | let config_dir = signing::signing_key_dir()?; | ||
| 37 | let sk_path = config_dir.join("signing-key"); | ||
| 38 | if sk_path.exists() && !force { | ||
| 39 | return Err(error::Error::Signing( | ||
| 40 | "signing key already exists; use --force to overwrite".to_string(), | ||
| 41 | )); | ||
| 42 | } | ||
| 43 | |||
| 44 | let vk = signing::generate_keypair(&config_dir)?; | ||
| 45 | let pubkey_b64 = base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()); | ||
| 46 | println!("Signing key generated."); | ||
| 47 | println!("Public key: {}", pubkey_b64); | ||
| 48 | Ok(()) | ||
| 49 | } | ||
| 50 | |||
| 29 | /// Check if the reviewer's base ref has moved ahead of the patch's latest revision. | 51 | /// Check if the reviewer's base ref has moved ahead of the patch's latest revision. |
| 30 | pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> { | 52 | pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> { |
| 31 | let latest_commit = &patch.revisions.last()?.commit; | 53 | let latest_commit = &patch.revisions.last()?.commit; |
| @@ -136,7 +158,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 136 | relates_to, | 158 | relates_to, |
| 137 | } => { | 159 | } => { |
| 138 | let id = issue::open(repo, &title, &body, relates_to.as_deref())?; | 160 | let id = issue::open(repo, &title, &body, relates_to.as_deref())?; |
| 139 | println!("Opened issue {:.8}", id); | 161 | println!("Opened issue {}", abbrev::for_issues(repo).of(&id)); |
| 140 | Ok(()) | 162 | Ok(()) |
| 141 | } | 163 | } |
| 142 | IssueCmd::List { | 164 | IssueCmd::List { |
| @@ -153,24 +175,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 153 | println!("{}", output); | 175 | println!("{}", output); |
| 154 | return Ok(()); | 176 | return Ok(()); |
| 155 | } | 177 | } |
| 156 | let entries = issue::list(repo, all, archived, limit, offset, sort, &label)?; | 178 | // One renderer, not two: this was a copy of |
| 157 | if entries.is_empty() { | 179 | // `list_to_writer`'s body, exactly as `patch list` used to be. |
| 158 | println!("No issues found."); | 180 | issue::list_to_writer( |
| 159 | } else { | 181 | repo, |
| 160 | for e in &entries { | 182 | all, |
| 161 | let i = &e.issue; | 183 | archived, |
| 162 | let status = i.status.as_str(); | 184 | limit, |
| 163 | let labels = cli::label_suffix(&i.labels); | 185 | offset, |
| 164 | let unread = match e.unread { | 186 | sort, |
| 165 | Some(n) if n > 0 => format!(" ({} new)", n), | 187 | &label, |
| 166 | _ => String::new(), | 188 | &mut std::io::stdout(), |
| 167 | }; | 189 | )?; |
| 168 | println!( | ||
| 169 | "{:.8} {:6} {}{} (by {}){}", | ||
| 170 | i.id, status, i.title, labels, i.author.name, unread | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | } | ||
| 174 | Ok(()) | 190 | Ok(()) |
| 175 | } | 191 | } |
| 176 | IssueCmd::Show { id, json } => { | 192 | IssueCmd::Show { id, json } => { |
| @@ -180,7 +196,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 180 | return Ok(()); | 196 | return Ok(()); |
| 181 | } | 197 | } |
| 182 | let i = issue::show(repo, &id)?; | 198 | let i = issue::show(repo, &id)?; |
| 183 | println!("Issue {} [{}]", &i.id[..8], i.status); | 199 | let abbrev = abbrev::for_issues(repo); |
| 200 | println!("Issue {} [{}]", abbrev.of(&i.id), i.status); | ||
| 184 | println!("Title: {}", i.title); | 201 | println!("Title: {}", i.title); |
| 185 | println!("Author: {} <{}>", i.author.name, i.author.email); | 202 | println!("Author: {} <{}>", i.author.name, i.author.email); |
| 186 | println!("Created: {}", i.created_at); | 203 | println!("Created: {}", i.created_at); |
| @@ -191,8 +208,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 191 | println!("Assignees: {}", i.assignees.join(", ")); | 208 | println!("Assignees: {}", i.assignees.join(", ")); |
| 192 | } | 209 | } |
| 193 | if !i.relates_to.is_empty() { | 210 | if !i.relates_to.is_empty() { |
| 194 | let short: Vec<String> = | 211 | let short: Vec<&str> = i.relates_to.iter().map(|r| abbrev.of(r)).collect(); |
| 195 | i.relates_to.iter().map(|r| format!("{:.8}", r)).collect(); | ||
| 196 | println!("Relates-to: {}", short.join(", ")); | 212 | println!("Relates-to: {}", short.join(", ")); |
| 197 | } | 213 | } |
| 198 | if let Some(ref reason) = i.close_reason { | 214 | if let Some(ref reason) = i.close_reason { |
| @@ -301,7 +317,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 301 | } | 317 | } |
| 302 | IssueCmd::Delete { id } => { | 318 | IssueCmd::Delete { id } => { |
| 303 | let full_id = issue::delete(repo, &id)?; | 319 | let full_id = issue::delete(repo, &id)?; |
| 304 | println!("Deleted issue {:.8}", full_id); | 320 | // The issue is gone from the set by now, so this is the |
| 321 | // `of()` case for an id that is not a member: still widened | ||
| 322 | // far enough not to collide with what remains. | ||
| 323 | println!("Deleted issue {}", abbrev::for_issues(repo).of(&full_id)); | ||
| 305 | Ok(()) | 324 | Ok(()) |
| 306 | } | 325 | } |
| 307 | IssueCmd::Reopen { id } => { | 326 | IssueCmd::Reopen { id } => { |
| @@ -351,7 +370,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 351 | } | 370 | } |
| 352 | }; | 371 | }; |
| 353 | let id = patch::create(repo, &title, &body, &base, &branch_name, fixes.as_deref())?; | 372 | let id = patch::create(repo, &title, &body, &base, &branch_name, fixes.as_deref())?; |
| 354 | println!("Created patch {:.8}", id); | 373 | println!("Created patch {}", abbrev::for_patches(repo).of(&id)); |
| 355 | Ok(()) | 374 | Ok(()) |
| 356 | } | 375 | } |
| 357 | PatchCmd::List { | 376 | PatchCmd::List { |
| @@ -399,7 +418,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 399 | }; | 418 | }; |
| 400 | println!( | 419 | println!( |
| 401 | "Patch {} [{}{}] (r{})", | 420 | "Patch {} [{}{}] (r{})", |
| 402 | &p.id[..8], | 421 | abbrev::for_patches(repo).of(&p.id), |
| 403 | p.status_display(repo), | 422 | p.status_display(repo), |
| 404 | status_detail, | 423 | status_detail, |
| 405 | rev_count | 424 | rev_count |
| @@ -441,7 +460,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 441 | println!("Labels: {}", p.labels.join(", ")); | 460 | println!("Labels: {}", p.labels.join(", ")); |
| 442 | } | 461 | } |
| 443 | if let Some(ref fixes) = p.fixes { | 462 | if let Some(ref fixes) = p.fixes { |
| 444 | println!("Fixes: {:.8}", fixes); | 463 | // An *issue* id, so it is abbreviated against the issues. |
| 464 | println!("Fixes: {}", abbrev::for_issues(repo).of(fixes)); | ||
| 445 | } | 465 | } |
| 446 | // Staleness warning | 466 | // Staleness warning |
| 447 | if let Some(warning) = staleness_warning(repo, &p) { | 467 | if let Some(warning) = staleness_warning(repo, &p) { |
| @@ -607,13 +627,20 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 607 | no_close, | 627 | no_close, |
| 608 | } => { | 628 | } => { |
| 609 | let report = patch::merge(repo, &id, commit.as_deref(), !no_close)?; | 629 | let report = patch::merge(repo, &id, commit.as_deref(), !no_close)?; |
| 630 | let abbrev = abbrev::for_patches(repo); | ||
| 610 | match report.outcome { | 631 | match report.outcome { |
| 632 | // `report.commit` is a git object name, not a collab id, | ||
| 633 | // so it keeps git's own abbreviation rather than this one. | ||
| 611 | merge_scan::MergeOutcome::Recorded => println!( | 634 | merge_scan::MergeOutcome::Recorded => println!( |
| 612 | "Recorded patch {:.8} as merged in {:.8}", | 635 | "Recorded patch {} as merged in {:.8}", |
| 613 | report.id, report.commit | 636 | abbrev.of(&report.id), |
| 637 | report.commit | ||
| 614 | ), | 638 | ), |
| 615 | merge_scan::MergeOutcome::AlreadyMerged => { | 639 | merge_scan::MergeOutcome::AlreadyMerged => { |
| 616 | println!("Patch {:.8} is already recorded as merged.", report.id) | 640 | println!( |
| 641 | "Patch {} is already recorded as merged.", | ||
| 642 | abbrev.of(&report.id) | ||
| 643 | ) | ||
| 617 | } | 644 | } |
| 618 | } | 645 | } |
| 619 | if report.close == merge_scan::CloseOutcome::Closed { | 646 | if report.close == merge_scan::CloseOutcome::Closed { |
| @@ -628,7 +655,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 628 | } | 655 | } |
| 629 | PatchCmd::Delete { id } => { | 656 | PatchCmd::Delete { id } => { |
| 630 | let full_id = patch::delete(repo, &id)?; | 657 | let full_id = patch::delete(repo, &id)?; |
| 631 | println!("Deleted patch {:.8}", full_id); | 658 | println!("Deleted patch {}", abbrev::for_patches(repo).of(&full_id)); |
| 632 | Ok(()) | 659 | Ok(()) |
| 633 | } | 660 | } |
| 634 | PatchCmd::Checkout { id } => { | 661 | PatchCmd::Checkout { id } => { |
| @@ -651,6 +678,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 651 | } => release::delete(repo, &remote, &version, filename.as_deref()), | 678 | } => release::delete(repo, &remote, &version, filename.as_deref()), |
| 652 | }, | 679 | }, |
| 653 | Commands::Status => { | 680 | Commands::Status => { |
| 681 | // Status is where someone looks when the tool is behaving | ||
| 682 | // inexplicably, and "the binary is older than the checkout" is one | ||
| 683 | // of the explanations. Leading the output with the build identity | ||
| 684 | // means nobody has to know to ask for it. | ||
| 685 | println!("git-collab {}", cli::version_string()); | ||
| 686 | println!(); | ||
| 654 | let project_status = status::compute(repo)?; | 687 | let project_status = status::compute(repo)?; |
| 655 | print!("{}", project_status); | 688 | print!("{}", project_status); |
| 656 | Ok(()) | 689 | Ok(()) |
| @@ -662,21 +695,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 662 | Some(remote) => sync::sync(repo, &remote), | 695 | Some(remote) => sync::sync(repo, &remote), |
| 663 | None => sync::sync_all(repo), | 696 | None => sync::sync_all(repo), |
| 664 | }, | 697 | }, |
| 665 | Commands::InitKey { force } => { | 698 | Commands::InitKey { force } => generate_signing_key(force), |
| 666 | let config_dir = signing::signing_key_dir()?; | ||
| 667 | let sk_path = config_dir.join("signing-key"); | ||
| 668 | if sk_path.exists() && !force { | ||
| 669 | return Err(error::Error::Signing( | ||
| 670 | "signing key already exists; use --force to overwrite".to_string(), | ||
| 671 | )); | ||
| 672 | } | ||
| 673 | |||
| 674 | let vk = signing::generate_keypair(&config_dir)?; | ||
| 675 | let pubkey_b64 = base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()); | ||
| 676 | println!("Signing key generated."); | ||
| 677 | println!("Public key: {}", pubkey_b64); | ||
| 678 | Ok(()) | ||
| 679 | } | ||
| 680 | Commands::Whoami => { | 699 | Commands::Whoami => { |
| 681 | let info = identity::whoami(repo)?; | 700 | let info = identity::whoami(repo)?; |
| 682 | println!("{}", info); | 701 | println!("{}", info); |
| @@ -714,6 +733,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 714 | } | 733 | } |
| 715 | }, | 734 | }, |
| 716 | Commands::Key(cmd) => match cmd { | 735 | Commands::Key(cmd) => match cmd { |
| 736 | // `key generate` and the top-level `init-key` are the same | ||
| 737 | // command reached two ways; see `KeyCmd::Generate`. | ||
| 738 | KeyCmd::Generate { force } => generate_signing_key(force), | ||
| 717 | KeyCmd::Add { | 739 | KeyCmd::Add { |
| 718 | pubkey, | 740 | pubkey, |
| 719 | self_key, | 741 | self_key, |
| @@ -879,12 +901,23 @@ fn search(repo: &Repository, query: &str) -> Result<(), error::Error> { | |||
| 879 | } | 901 | } |
| 880 | } | 902 | } |
| 881 | 903 | ||
| 904 | // Issues and patches are separate namespaces with separate widths, so | ||
| 905 | // each column is abbreviated against its own kind. | ||
| 906 | let issue_abbrev = abbrev::for_issues(repo); | ||
| 907 | let patch_abbrev = abbrev::for_patches(repo); | ||
| 908 | |||
| 882 | println!("Issues:"); | 909 | println!("Issues:"); |
| 883 | if issue_results.is_empty() { | 910 | if issue_results.is_empty() { |
| 884 | println!(" (none)"); | 911 | println!(" (none)"); |
| 885 | } else { | 912 | } else { |
| 886 | for (id, status, title, match_field) in &issue_results { | 913 | for (id, status, title, match_field) in &issue_results { |
| 887 | println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field); | 914 | println!( |
| 915 | " {} {:6} {} ({} match)", | ||
| 916 | issue_abbrev.of(id), | ||
| 917 | status, | ||
| 918 | title, | ||
| 919 | match_field | ||
| 920 | ); | ||
| 888 | } | 921 | } |
| 889 | } | 922 | } |
| 890 | println!(); | 923 | println!(); |
| @@ -893,7 +926,13 @@ fn search(repo: &Repository, query: &str) -> Result<(), error::Error> { | |||
| 893 | println!(" (none)"); | 926 | println!(" (none)"); |
| 894 | } else { | 927 | } else { |
| 895 | for (id, status, title, match_field) in &patch_results { | 928 | for (id, status, title, match_field) in &patch_results { |
| 896 | println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field); | 929 | println!( |
| 930 | " {} {:6} {} ({} match)", | ||
| 931 | patch_abbrev.of(id), | ||
| 932 | status, | ||
| 933 | title, | ||
| 934 | match_field | ||
| 935 | ); | ||
| 897 | } | 936 | } |
| 898 | } | 937 | } |
| 899 | 938 | ||
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -148,6 +148,8 @@ pub fn list_to_writer( | |||
| 148 | writeln!(writer, "No patches found.").ok(); | 148 | writeln!(writer, "No patches found.").ok(); |
| 149 | return Ok(()); | 149 | return Ok(()); |
| 150 | } | 150 | } |
| 151 | // Over every patch in the repository; see `issue::list_to_writer`. | ||
| 152 | let abbrev = crate::abbrev::for_patches(repo); | ||
| 151 | for e in &entries { | 153 | for e in &entries { |
| 152 | let p = &e.patch; | 154 | let p = &e.patch; |
| 153 | let labels = cli::label_suffix(&p.labels); | 155 | let labels = cli::label_suffix(&p.labels); |
| @@ -164,8 +166,8 @@ pub fn list_to_writer( | |||
| 164 | // Reachability is a hint here, never the recorded status. | 166 | // Reachability is a hint here, never the recorded status. |
| 165 | writeln!( | 167 | writeln!( |
| 166 | writer, | 168 | writer, |
| 167 | "{:.8} {:7} {}{} (by {}){}{}", | 169 | "{} {:7} {}{} (by {}){}{}", |
| 168 | p.id, | 170 | abbrev.of(&p.id), |
| 169 | p.status_display(repo), | 171 | p.status_display(repo), |
| 170 | p.title, | 172 | p.title, |
| 171 | labels, | 173 | labels, |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,11 @@ mod repos; | |||
| 10 | mod ssh; | 10 | mod ssh; |
| 11 | 11 | ||
| 12 | #[derive(Parser)] | 12 | #[derive(Parser)] |
| 13 | #[command(name = "git-collab-server", about = "Minimal git hosting server")] | 13 | #[command( |
| 14 | name = "git-collab-server", | ||
| 15 | version = git_collab::cli::version_static(), | ||
| 16 | about = "Minimal git hosting server" | ||
| 17 | )] | ||
| 14 | struct Args { | 18 | struct Args { |
| 15 | #[arg(short, long)] | 19 | #[arg(short, long)] |
| 16 | config: PathBuf, | 20 | config: PathBuf, |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -1063,6 +1063,31 @@ fn resolve_ref( | |||
| 1063 | } | 1063 | } |
| 1064 | } | 1064 | } |
| 1065 | 1065 | ||
| 1066 | /// Every issue id in the repository: open, closed and archived. | ||
| 1067 | /// | ||
| 1068 | /// Reads ref names only — no DAG materialization — because the one caller, | ||
| 1069 | /// [`crate::abbrev`], needs the id set and nothing else, and pays for it on | ||
| 1070 | /// every command that prints an id. | ||
| 1071 | pub fn all_issue_ids(repo: &Repository) -> Result<Vec<String>, crate::error::Error> { | ||
| 1072 | all_ids(repo, "issues") | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | /// Every patch id in the repository: open, merged, closed and archived. | ||
| 1076 | pub fn all_patch_ids(repo: &Repository) -> Result<Vec<String>, crate::error::Error> { | ||
| 1077 | all_ids(repo, "patches") | ||
| 1078 | } | ||
| 1079 | |||
| 1080 | fn all_ids(repo: &Repository, kind: &str) -> Result<Vec<String>, crate::error::Error> { | ||
| 1081 | let mut ids: Vec<String> = collab_refs(repo, kind)? | ||
| 1082 | .into_iter() | ||
| 1083 | .map(|(_, id)| id) | ||
| 1084 | .collect(); | ||
| 1085 | ids.extend(collab_archive_refs(repo, kind)?.into_iter().map(|(_, id)| id)); | ||
| 1086 | ids.sort_unstable(); | ||
| 1087 | ids.dedup(); | ||
| 1088 | Ok(ids) | ||
| 1089 | } | ||
| 1090 | |||
| 1066 | /// List active issue refs, excluding any that also have an archived ref. | 1091 | /// List active issue refs, excluding any that also have an archived ref. |
| 1067 | pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { | 1092 | pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { |
| 1068 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "issues")? | 1093 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "issues")? |
src/status.rs
| Old | New | ||
|---|---|---|---|
| @@ -74,11 +74,16 @@ pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> { | |||
| 74 | } | 74 | } |
| 75 | 75 | ||
| 76 | // Collect recent items from both issues and patches, sort by created_at descending, cap at 10. | 76 | // Collect recent items from both issues and patches, sort by created_at descending, cap at 10. |
| 77 | // Ids are abbreviated per kind, against every object of that kind — the | ||
| 78 | // same width `issue list` and `patch list` print, so an id copied out of | ||
| 79 | // status is the same string those commands show. | ||
| 80 | let issue_abbrev = crate::abbrev::for_issues(repo); | ||
| 81 | let patch_abbrev = crate::abbrev::for_patches(repo); | ||
| 77 | let mut recent_items: Vec<RecentItem> = Vec::new(); | 82 | let mut recent_items: Vec<RecentItem> = Vec::new(); |
| 78 | for issue in &issues { | 83 | for issue in &issues { |
| 79 | recent_items.push(RecentItem { | 84 | recent_items.push(RecentItem { |
| 80 | kind: "issue", | 85 | kind: "issue", |
| 81 | id: issue.id[..8.min(issue.id.len())].to_string(), | 86 | id: issue_abbrev.of(&issue.id).to_string(), |
| 82 | title: issue.title.clone(), | 87 | title: issue.title.clone(), |
| 83 | status: issue.status.to_string(), | 88 | status: issue.status.to_string(), |
| 84 | created_at: issue.created_at.clone(), | 89 | created_at: issue.created_at.clone(), |
| @@ -87,7 +92,7 @@ pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> { | |||
| 87 | for patch in &patches { | 92 | for patch in &patches { |
| 88 | recent_items.push(RecentItem { | 93 | recent_items.push(RecentItem { |
| 89 | kind: "patch", | 94 | kind: "patch", |
| 90 | id: patch.id[..8.min(patch.id.len())].to_string(), | 95 | id: patch_abbrev.of(&patch.id).to_string(), |
| 91 | title: patch.title.clone(), | 96 | title: patch.title.clone(), |
| 92 | status: patch.status.to_string(), | 97 | status: patch.status.to_string(), |
| 93 | created_at: patch.created_at.clone(), | 98 | created_at: patch.created_at.clone(), |
tests/abbrev_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,359 @@ | |||
| 1 | //! The id-display policy, end to end. | ||
| 2 | //! | ||
| 3 | //! The unit tests in `src/abbrev.rs` cover the width arithmetic. These cover | ||
| 4 | //! the two promises the CLI makes about it: an id it prints can always be | ||
| 5 | //! typed straight back in, and a prefix that names more than one object is | ||
| 6 | //! refused rather than resolved. | ||
| 7 | |||
| 8 | mod common; | ||
| 9 | |||
| 10 | use common::TestRepo; | ||
| 11 | |||
| 12 | /// Pull the leading id column out of a `list` line. | ||
| 13 | fn first_column(line: &str) -> &str { | ||
| 14 | line.split_whitespace().next().unwrap_or("") | ||
| 15 | } | ||
| 16 | |||
| 17 | fn open_issues(repo: &TestRepo, n: usize) -> Vec<String> { | ||
| 18 | (0..n) | ||
| 19 | .map(|i| { | ||
| 20 | let title = format!("issue {}", i); | ||
| 21 | let out = repo.run_ok(&["issue", "open", "-t", &title]); | ||
| 22 | out.trim() | ||
| 23 | .rsplit(' ') | ||
| 24 | .next() | ||
| 25 | .expect("open should print an id") | ||
| 26 | .to_string() | ||
| 27 | }) | ||
| 28 | .collect() | ||
| 29 | } | ||
| 30 | |||
| 31 | /// The floor stays at 8, so nothing that reads today's output changes. | ||
| 32 | #[test] | ||
| 33 | fn a_small_repo_still_prints_eight_character_ids() { | ||
| 34 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 35 | open_issues(&repo, 3); | ||
| 36 | let out = repo.run_ok(&["issue", "list"]); | ||
| 37 | for line in out.lines().filter(|l| !l.trim().is_empty()) { | ||
| 38 | assert_eq!( | ||
| 39 | first_column(line).len(), | ||
| 40 | 8, | ||
| 41 | "expected an 8-character id in: {}", | ||
| 42 | line | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | /// Anything printed must be typeable. This is the whole contract: a displayed | ||
| 48 | /// id is a reference someone will paste back, into a command or a commit | ||
| 49 | /// message, and it has to work when they do. | ||
| 50 | #[test] | ||
| 51 | fn every_id_printed_by_list_resolves_on_its_own() { | ||
| 52 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 53 | open_issues(&repo, 12); | ||
| 54 | |||
| 55 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 56 | let shown: Vec<String> = listed | ||
| 57 | .lines() | ||
| 58 | .filter(|l| !l.trim().is_empty()) | ||
| 59 | .map(|l| first_column(l).to_string()) | ||
| 60 | .collect(); | ||
| 61 | assert_eq!(shown.len(), 12, "expected 12 listed issues: {}", listed); | ||
| 62 | |||
| 63 | for id in &shown { | ||
| 64 | // Resolving is the assertion; run_ok panics on a non-zero exit, which | ||
| 65 | // is what an ambiguous or unknown prefix produces. | ||
| 66 | let detail = repo.run_ok(&["issue", "show", id]); | ||
| 67 | assert!( | ||
| 68 | detail.contains(id), | ||
| 69 | "showing '{}' should echo it back: {}", | ||
| 70 | id, | ||
| 71 | detail | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | /// The ids in `issue list` and `issue show` must agree, or the id someone | ||
| 77 | /// copies depends on which command they happened to run. | ||
| 78 | #[test] | ||
| 79 | fn list_and_show_print_the_same_width() { | ||
| 80 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 81 | let ids = open_issues(&repo, 5); | ||
| 82 | |||
| 83 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 84 | let listed_width = first_column(listed.lines().next().unwrap()).len(); | ||
| 85 | |||
| 86 | let shown = repo.run_ok(&["issue", "show", &ids[0]]); | ||
| 87 | let header = shown.lines().next().unwrap(); | ||
| 88 | let shown_id = header | ||
| 89 | .strip_prefix("Issue ") | ||
| 90 | .and_then(|rest| rest.split_whitespace().next()) | ||
| 91 | .expect("show should start with an Issue header"); | ||
| 92 | assert_eq!(shown_id.len(), listed_width, "header was: {}", header); | ||
| 93 | } | ||
| 94 | |||
| 95 | /// The abbreviation is computed over every issue of that kind, so `--all` | ||
| 96 | /// cannot print a different string for the same issue than the default view. | ||
| 97 | #[test] | ||
| 98 | fn closed_issues_do_not_change_the_width_of_open_ones() { | ||
| 99 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 100 | let ids = open_issues(&repo, 6); | ||
| 101 | let before = repo.run_ok(&["issue", "list"]); | ||
| 102 | let before_id = first_column(before.lines().next().unwrap()).to_string(); | ||
| 103 | |||
| 104 | for id in ids.iter().take(3) { | ||
| 105 | repo.run_ok(&["issue", "close", id]); | ||
| 106 | } | ||
| 107 | |||
| 108 | let after = repo.run_ok(&["issue", "list"]); | ||
| 109 | let after_ids: Vec<&str> = after | ||
| 110 | .lines() | ||
| 111 | .filter(|l| !l.trim().is_empty()) | ||
| 112 | .map(first_column) | ||
| 113 | .collect(); | ||
| 114 | assert!( | ||
| 115 | after_ids.iter().all(|id| id.len() == before_id.len()), | ||
| 116 | "closing issues changed the printed width: {}", | ||
| 117 | after | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | |||
| 121 | /// The other half of the policy: a prefix short enough to name two objects | ||
| 122 | /// must fail at the point of use. Twenty issues over sixteen possible first | ||
| 123 | /// characters guarantees a shared one. | ||
| 124 | #[test] | ||
| 125 | fn an_ambiguous_prefix_fails_loudly_instead_of_picking_one() { | ||
| 126 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 127 | let ids = open_issues(&repo, 20); | ||
| 128 | |||
| 129 | let mut seen: std::collections::HashMap<char, usize> = std::collections::HashMap::new(); | ||
| 130 | for id in &ids { | ||
| 131 | *seen.entry(id.chars().next().unwrap()).or_default() += 1; | ||
| 132 | } | ||
| 133 | let (shared, count) = seen | ||
| 134 | .iter() | ||
| 135 | .find(|(_, n)| **n > 1) | ||
| 136 | .expect("20 ids over 16 first characters must share one"); | ||
| 137 | |||
| 138 | let err = repo.run_err(&["issue", "show", &shared.to_string()]); | ||
| 139 | assert!( | ||
| 140 | err.contains("ambiguous"), | ||
| 141 | "a prefix matching {} issues should be refused as ambiguous, got: {}", | ||
| 142 | count, | ||
| 143 | err | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | /// A prefix shorter than the displayed width still works when it happens to be | ||
| 148 | /// unique — accepting prefixes and displaying them are separate concerns, and | ||
| 149 | /// the accepting side stays as permissive as it was. | ||
| 150 | #[test] | ||
| 151 | fn a_unique_short_prefix_is_still_accepted() { | ||
| 152 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 153 | let ids = open_issues(&repo, 1); | ||
| 154 | let short = &ids[0][..3]; | ||
| 155 | let out = repo.run_ok(&["issue", "show", short]); | ||
| 156 | assert!(out.starts_with("Issue "), "got: {}", out); | ||
| 157 | } | ||
| 158 | |||
| 159 | // --------------------------------------------------------------------------- | ||
| 160 | // `collab.abbrev`, this tool's `core.abbrev`. | ||
| 161 | // | ||
| 162 | // These also serve as the proof that every display site actually goes through | ||
| 163 | // the abbreviator rather than a hard-coded 8: a repository small enough to | ||
| 164 | // test by hand can never produce a natural collision, so the width has to be | ||
| 165 | // forced to see the wiring at all. | ||
| 166 | // --------------------------------------------------------------------------- | ||
| 167 | |||
| 168 | fn set_abbrev(repo: &TestRepo, value: &str) { | ||
| 169 | let status = std::process::Command::new("git") | ||
| 170 | .args(["config", "collab.abbrev", value]) | ||
| 171 | .current_dir(repo.dir.path()) | ||
| 172 | .status() | ||
| 173 | .expect("failed to set config"); | ||
| 174 | assert!(status.success()); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn collab_abbrev_sets_the_width_of_every_list() { | ||
| 179 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 180 | open_issues(&repo, 3); | ||
| 181 | set_abbrev(&repo, "14"); | ||
| 182 | |||
| 183 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 184 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 185 | assert_eq!( | ||
| 186 | first_column(line).len(), | ||
| 187 | 14, | ||
| 188 | "issue list ignored collab.abbrev: {}", | ||
| 189 | line | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | #[test] | ||
| 195 | fn collab_abbrev_sets_the_width_of_show_and_status() { | ||
| 196 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 197 | let ids = open_issues(&repo, 2); | ||
| 198 | set_abbrev(&repo, "14"); | ||
| 199 | |||
| 200 | let shown = repo.run_ok(&["issue", "show", &ids[0]]); | ||
| 201 | let header = shown.lines().next().unwrap(); | ||
| 202 | let shown_id = header | ||
| 203 | .strip_prefix("Issue ") | ||
| 204 | .and_then(|r| r.split_whitespace().next()) | ||
| 205 | .expect("show should start with an Issue header"); | ||
| 206 | assert_eq!(shown_id.len(), 14, "show ignored collab.abbrev: {}", header); | ||
| 207 | |||
| 208 | let status = repo.run_ok(&["status"]); | ||
| 209 | let recent = status | ||
| 210 | .lines() | ||
| 211 | .find(|l| l.trim_start().starts_with("[issue]")) | ||
| 212 | .expect("status should list a recent issue"); | ||
| 213 | let status_id = recent.split_whitespace().nth(1).unwrap(); | ||
| 214 | assert_eq!( | ||
| 215 | status_id.len(), | ||
| 216 | 14, | ||
| 217 | "status ignored collab.abbrev: {}", | ||
| 218 | recent | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | |||
| 222 | #[test] | ||
| 223 | fn collab_abbrev_applies_to_patches_too() { | ||
| 224 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 225 | repo.git(&["checkout", "-b", "feature"]); | ||
| 226 | std::fs::write(repo.dir.path().join("a.txt"), "hello").unwrap(); | ||
| 227 | repo.git(&["add", "a.txt"]); | ||
| 228 | repo.git(&["commit", "-m", "work"]); | ||
| 229 | repo.run_ok(&["patch", "create", "-t", "a patch", "--base", "main"]); | ||
| 230 | set_abbrev(&repo, "14"); | ||
| 231 | |||
| 232 | let listed = repo.run_ok(&["patch", "list"]); | ||
| 233 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 234 | assert_eq!( | ||
| 235 | first_column(line).len(), | ||
| 236 | 14, | ||
| 237 | "patch list ignored collab.abbrev: {}", | ||
| 238 | line | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | /// `no` is git's spelling for "do not abbreviate". | ||
| 244 | #[test] | ||
| 245 | fn collab_abbrev_no_prints_whole_ids() { | ||
| 246 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 247 | open_issues(&repo, 2); | ||
| 248 | set_abbrev(&repo, "no"); | ||
| 249 | |||
| 250 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 251 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 252 | assert_eq!(first_column(line).len(), 40, "line: {}", line); | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | /// git clamps `core.abbrev` to a minimum of 4; below that the setting stops | ||
| 257 | /// being a display preference and starts producing ids nobody can use. | ||
| 258 | #[test] | ||
| 259 | fn collab_abbrev_is_clamped_to_a_usable_minimum() { | ||
| 260 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 261 | open_issues(&repo, 2); | ||
| 262 | set_abbrev(&repo, "1"); | ||
| 263 | |||
| 264 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 265 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 266 | assert_eq!( | ||
| 267 | first_column(line).len(), | ||
| 268 | 4, | ||
| 269 | "collab.abbrev=1 should clamp to 4: {}", | ||
| 270 | line | ||
| 271 | ); | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 275 | /// A narrowed width is still never allowed to print an ambiguous id: the | ||
| 276 | /// per-id widening runs on top of whatever width is configured. | ||
| 277 | #[test] | ||
| 278 | fn a_narrow_configured_width_still_prints_resolvable_ids() { | ||
| 279 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 280 | open_issues(&repo, 20); | ||
| 281 | set_abbrev(&repo, "4"); | ||
| 282 | |||
| 283 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 284 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 285 | let id = first_column(line); | ||
| 286 | repo.run_ok(&["issue", "show", id]); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | /// Nonsense in the config falls back to the automatic width rather than | ||
| 291 | /// breaking every command that prints an id. | ||
| 292 | #[test] | ||
| 293 | fn an_unparseable_collab_abbrev_falls_back_to_the_automatic_width() { | ||
| 294 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 295 | open_issues(&repo, 2); | ||
| 296 | set_abbrev(&repo, "banana"); | ||
| 297 | |||
| 298 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 299 | for line in listed.lines().filter(|l| !l.trim().is_empty()) { | ||
| 300 | assert_eq!(first_column(line).len(), 8, "line: {}", line); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | |||
| 304 | // --------------------------------------------------------------------------- | ||
| 305 | // The set the width is computed from | ||
| 306 | // --------------------------------------------------------------------------- | ||
| 307 | |||
| 308 | /// The abbreviation has to be computed over every object of a kind. If it were | ||
| 309 | /// computed over just the rows being listed, `issue list` and | ||
| 310 | /// `issue list --all` could print different strings for the same issue, and a | ||
| 311 | /// displayed id could name two objects the moment a different flag is used. | ||
| 312 | #[test] | ||
| 313 | fn the_id_set_covers_closed_and_archived_issues() { | ||
| 314 | use git2::Repository; | ||
| 315 | |||
| 316 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 317 | let ids = open_issues(&repo, 3); | ||
| 318 | repo.run_ok(&["issue", "close", &ids[1]]); | ||
| 319 | |||
| 320 | let git = Repository::open(repo.dir.path()).unwrap(); | ||
| 321 | let (_, full) = git_collab::state::resolve_issue_ref(&git, &ids[2]).unwrap(); | ||
| 322 | git_collab::state::archive_issue_ref(&git, &full).unwrap(); | ||
| 323 | |||
| 324 | let all = git_collab::state::all_issue_ids(&git).unwrap(); | ||
| 325 | assert_eq!(all.len(), 3, "closed and archived ids must be included"); | ||
| 326 | for id in &ids { | ||
| 327 | assert!( | ||
| 328 | all.iter().any(|found| found.starts_with(id)), | ||
| 329 | "'{}' missing from the id set: {:?}", | ||
| 330 | id, | ||
| 331 | all | ||
| 332 | ); | ||
| 333 | } | ||
| 334 | } | ||
| 335 | |||
| 336 | #[test] | ||
| 337 | fn the_id_set_covers_archived_patches() { | ||
| 338 | use git2::Repository; | ||
| 339 | |||
| 340 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 341 | repo.git(&["checkout", "-b", "feature"]); | ||
| 342 | std::fs::write(repo.dir.path().join("a.txt"), "hello").unwrap(); | ||
| 343 | repo.git(&["add", "a.txt"]); | ||
| 344 | repo.git(&["commit", "-m", "work"]); | ||
| 345 | let out = repo.run_ok(&["patch", "create", "-t", "a patch", "--base", "main"]); | ||
| 346 | let short = out.trim().rsplit(' ').next().unwrap().to_string(); | ||
| 347 | |||
| 348 | let git = Repository::open(repo.dir.path()).unwrap(); | ||
| 349 | let (_, full) = git_collab::state::resolve_patch_ref(&git, &short).unwrap(); | ||
| 350 | git_collab::state::archive_patch_ref(&git, &full).unwrap(); | ||
| 351 | |||
| 352 | let all = git_collab::state::all_patch_ids(&git).unwrap(); | ||
| 353 | assert!( | ||
| 354 | all.contains(&full), | ||
| 355 | "archived patch '{}' missing from {:?}", | ||
| 356 | full, | ||
| 357 | all | ||
| 358 | ); | ||
| 359 | } | ||
tests/alias_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,244 @@ | |||
| 1 | //! Verb aliases across the command surface. | ||
| 2 | //! | ||
| 3 | //! The cost of a wrong verb guess is not the guess: it is that clap writes | ||
| 4 | //! usage text to stderr, exits 2, and a script that captured the output keeps | ||
| 5 | //! going with usage text where an id should be. Every alias here exists so a | ||
| 6 | //! plausible guess *succeeds* rather than failing several steps downstream. | ||
| 7 | //! | ||
| 8 | //! These tests assert at the parser: an alias must resolve to the same | ||
| 9 | //! subcommand, with the same fields, as the canonical spelling. Comparing the | ||
| 10 | //! parsed value rather than the exit status is what makes "resolves to the | ||
| 11 | //! same subcommand" a real claim. | ||
| 12 | |||
| 13 | use clap::Parser; | ||
| 14 | use git_collab::cli::Cli; | ||
| 15 | |||
| 16 | /// Parse an argv and render the resulting command for comparison. | ||
| 17 | fn parse(args: &[&str]) -> String { | ||
| 18 | let mut argv = vec!["git-collab"]; | ||
| 19 | argv.extend_from_slice(args); | ||
| 20 | let cli = Cli::try_parse_from(&argv) | ||
| 21 | .unwrap_or_else(|e| panic!("failed to parse {:?}: {}", args, e)); | ||
| 22 | format!("{:?}", cli.command) | ||
| 23 | } | ||
| 24 | |||
| 25 | /// The alias and the canonical spelling must produce an identical command. | ||
| 26 | #[track_caller] | ||
| 27 | fn assert_same(alias: &[&str], canonical: &[&str]) { | ||
| 28 | assert_eq!( | ||
| 29 | parse(alias), | ||
| 30 | parse(canonical), | ||
| 31 | "{:?} should resolve to the same command as {:?}", | ||
| 32 | alias, | ||
| 33 | canonical | ||
| 34 | ); | ||
| 35 | } | ||
| 36 | |||
| 37 | // --------------------------------------------------------------------------- | ||
| 38 | // The three asymmetries named in the issue | ||
| 39 | // --------------------------------------------------------------------------- | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn issue_create_is_issue_open() { | ||
| 43 | assert_same( | ||
| 44 | &["issue", "create", "-t", "title", "-b", "body"], | ||
| 45 | &["issue", "open", "-t", "title", "-b", "body"], | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | #[test] | ||
| 50 | fn patch_open_is_patch_create() { | ||
| 51 | assert_same( | ||
| 52 | &["patch", "open", "-t", "title", "-b", "body"], | ||
| 53 | &["patch", "create", "-t", "title", "-b", "body"], | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | #[test] | ||
| 58 | fn keys_is_key() { | ||
| 59 | assert_same(&["keys", "list"], &["key", "list"]); | ||
| 60 | assert_same(&["keys", "add", "--self"], &["key", "add", "--self"]); | ||
| 61 | assert_same(&["keys", "remove", "abc"], &["key", "remove", "abc"]); | ||
| 62 | } | ||
| 63 | |||
| 64 | /// Key *generation* lives at the top level as `init-key` while key | ||
| 65 | /// *management* lives under `key`. Someone who found `key add` will look for | ||
| 66 | /// `key generate` next, so it has to be there. | ||
| 67 | #[test] | ||
| 68 | fn key_generate_reaches_the_same_generator_as_init_key() { | ||
| 69 | // A distinct variant by necessity — clap aliases cannot cross enums — so | ||
| 70 | // this asserts the routing rather than the parse. | ||
| 71 | use git_collab::cli::{Commands, KeyCmd}; | ||
| 72 | let generated = Cli::try_parse_from(["git-collab", "key", "generate", "--force"]).unwrap(); | ||
| 73 | assert!(matches!( | ||
| 74 | generated.command, | ||
| 75 | Commands::Key(KeyCmd::Generate { force: true }) | ||
| 76 | )); | ||
| 77 | assert_same(&["key", "init"], &["key", "generate"]); | ||
| 78 | assert_same(&["key", "init-key"], &["key", "generate"]); | ||
| 79 | } | ||
| 80 | |||
| 81 | // --------------------------------------------------------------------------- | ||
| 82 | // The same asymmetries elsewhere on the surface | ||
| 83 | // --------------------------------------------------------------------------- | ||
| 84 | |||
| 85 | #[test] | ||
| 86 | fn new_is_a_synonym_for_open_and_create() { | ||
| 87 | assert_same(&["issue", "new", "-t", "t"], &["issue", "open", "-t", "t"]); | ||
| 88 | assert_same(&["patch", "new", "-t", "t"], &["patch", "create", "-t", "t"]); | ||
| 89 | } | ||
| 90 | |||
| 91 | #[test] | ||
| 92 | fn ls_is_list_everywhere_there_is_a_list() { | ||
| 93 | assert_same(&["issue", "ls"], &["issue", "list"]); | ||
| 94 | assert_same(&["patch", "ls"], &["patch", "list"]); | ||
| 95 | assert_same(&["key", "ls"], &["key", "list"]); | ||
| 96 | assert_same(&["release", "ls"], &["release", "list"]); | ||
| 97 | assert_same(&["identity", "ls"], &["identity", "list"]); | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Issues and patches spell removal `delete`; trusted keys spell it `remove`. | ||
| 101 | /// Both spellings, plus `rm`, work wherever removal exists. | ||
| 102 | #[test] | ||
| 103 | fn delete_remove_and_rm_are_interchangeable() { | ||
| 104 | assert_same(&["issue", "remove", "abc"], &["issue", "delete", "abc"]); | ||
| 105 | assert_same(&["issue", "rm", "abc"], &["issue", "delete", "abc"]); | ||
| 106 | assert_same(&["patch", "remove", "abc"], &["patch", "delete", "abc"]); | ||
| 107 | assert_same(&["patch", "rm", "abc"], &["patch", "delete", "abc"]); | ||
| 108 | assert_same(&["key", "delete", "abc"], &["key", "remove", "abc"]); | ||
| 109 | assert_same(&["key", "rm", "abc"], &["key", "remove", "abc"]); | ||
| 110 | assert_same(&["release", "rm", "v1"], &["release", "delete", "v1"]); | ||
| 111 | assert_same(&["release", "remove", "v1"], &["release", "delete", "v1"]); | ||
| 112 | } | ||
| 113 | |||
| 114 | #[test] | ||
| 115 | fn view_and_info_are_show() { | ||
| 116 | assert_same(&["issue", "view", "abc"], &["issue", "show", "abc"]); | ||
| 117 | assert_same(&["issue", "info", "abc"], &["issue", "show", "abc"]); | ||
| 118 | assert_same(&["patch", "view", "abc"], &["patch", "show", "abc"]); | ||
| 119 | assert_same(&["patch", "info", "abc"], &["patch", "show", "abc"]); | ||
| 120 | } | ||
| 121 | |||
| 122 | /// `hooks` is the only plural noun at the top level; `hook` must work too, | ||
| 123 | /// for the same reason `keys` must. | ||
| 124 | #[test] | ||
| 125 | fn hook_is_hooks() { | ||
| 126 | assert_same(&["hook", "status"], &["hooks", "status"]); | ||
| 127 | assert_same(&["hook", "install"], &["hooks", "install"]); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | fn init_key_answers_to_the_obvious_guesses() { | ||
| 132 | assert_same(&["keygen"], &["init-key"]); | ||
| 133 | assert_same(&["generate-key"], &["init-key"]); | ||
| 134 | } | ||
| 135 | |||
| 136 | #[test] | ||
| 137 | fn patch_update_is_patch_revise() { | ||
| 138 | assert_same(&["patch", "update", "abc"], &["patch", "revise", "abc"]); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn patch_co_is_patch_checkout() { | ||
| 143 | assert_same(&["patch", "co", "abc"], &["patch", "checkout", "abc"]); | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn release_upload_and_create_are_release_publish() { | ||
| 148 | assert_same( | ||
| 149 | &["release", "upload", "v1", "f"], | ||
| 150 | &["release", "publish", "v1", "f"], | ||
| 151 | ); | ||
| 152 | assert_same( | ||
| 153 | &["release", "create", "v1", "f"], | ||
| 154 | &["release", "publish", "v1", "f"], | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | /// `identity alias`/`unalias` are the odd verbs out: every other collection on | ||
| 159 | /// the surface is managed with add/remove. | ||
| 160 | #[test] | ||
| 161 | fn identity_add_and_remove_are_alias_and_unalias() { | ||
| 162 | assert_same(&["identity", "add", "a@b"], &["identity", "alias", "a@b"]); | ||
| 163 | assert_same( | ||
| 164 | &["identity", "remove", "a@b"], | ||
| 165 | &["identity", "unalias", "a@b"], | ||
| 166 | ); | ||
| 167 | assert_same(&["identity", "rm", "a@b"], &["identity", "unalias", "a@b"]); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn find_and_grep_are_search() { | ||
| 172 | assert_same(&["find", "needle"], &["search", "needle"]); | ||
| 173 | assert_same(&["grep", "needle"], &["search", "needle"]); | ||
| 174 | } | ||
| 175 | |||
| 176 | // --------------------------------------------------------------------------- | ||
| 177 | // Aliases must not shadow anything, and must stay out of the advertised help. | ||
| 178 | // --------------------------------------------------------------------------- | ||
| 179 | |||
| 180 | /// An alias that collided with a real subcommand name would silently reroute a | ||
| 181 | /// correct invocation, which is worse than the problem being fixed. clap | ||
| 182 | /// panics on a duplicate, so building the command at all proves the point — | ||
| 183 | /// but assert it explicitly so the reason is on the record. | ||
| 184 | #[test] | ||
| 185 | fn the_command_tree_builds_without_alias_collisions() { | ||
| 186 | use clap::CommandFactory; | ||
| 187 | let cmd = Cli::command(); | ||
| 188 | check_no_duplicate_names(&cmd); | ||
| 189 | } | ||
| 190 | |||
| 191 | fn check_no_duplicate_names(cmd: &clap::Command) { | ||
| 192 | let mut seen: Vec<String> = Vec::new(); | ||
| 193 | for sub in cmd.get_subcommands() { | ||
| 194 | for name in std::iter::once(sub.get_name()).chain(sub.get_all_aliases()) { | ||
| 195 | assert!( | ||
| 196 | !seen.contains(&name.to_string()), | ||
| 197 | "'{}' appears twice under '{}'", | ||
| 198 | name, | ||
| 199 | cmd.get_name() | ||
| 200 | ); | ||
| 201 | seen.push(name.to_string()); | ||
| 202 | } | ||
| 203 | check_no_duplicate_names(sub); | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | /// Aliases are a safety net for wrong guesses, not a second vocabulary to | ||
| 208 | /// learn: `--help` must keep advertising exactly one spelling per command. | ||
| 209 | #[test] | ||
| 210 | fn every_alias_is_hidden_from_help() { | ||
| 211 | use clap::CommandFactory; | ||
| 212 | check_aliases_hidden(&Cli::command()); | ||
| 213 | } | ||
| 214 | |||
| 215 | fn check_aliases_hidden(cmd: &clap::Command) { | ||
| 216 | for sub in cmd.get_subcommands() { | ||
| 217 | let visible: Vec<&str> = sub.get_visible_aliases().collect(); | ||
| 218 | assert!( | ||
| 219 | visible.is_empty(), | ||
| 220 | "'{} {}' advertises aliases {:?}; aliases should be hidden", | ||
| 221 | cmd.get_name(), | ||
| 222 | sub.get_name(), | ||
| 223 | visible | ||
| 224 | ); | ||
| 225 | check_aliases_hidden(sub); | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | /// …and the canonical spellings must still be advertised, so hiding aliases | ||
| 230 | /// has not accidentally hidden a command. | ||
| 231 | #[test] | ||
| 232 | fn canonical_commands_are_still_listed_in_help() { | ||
| 233 | let mut cmd = <Cli as clap::CommandFactory>::command(); | ||
| 234 | let help = cmd.render_long_help().to_string(); | ||
| 235 | for advertised in ["issue", "patch", "release", "key", "status", "search"] { | ||
| 236 | assert!( | ||
| 237 | help.lines() | ||
| 238 | .any(|l| l.trim_start().starts_with(&format!("{} ", advertised))), | ||
| 239 | "help should still list '{}':\n{}", | ||
| 240 | advertised, | ||
| 241 | help | ||
| 242 | ); | ||
| 243 | } | ||
| 244 | } | ||
tests/version_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,172 @@ | |||
| 1 | //! `--version` and build provenance. | ||
| 2 | //! | ||
| 3 | //! The point of these tests is not that a version string exists but that it | ||
| 4 | //! names the *commit the binary was built from*. A binary that reports only | ||
| 5 | //! `0.1.0` cannot be distinguished from one built three weeks ago, which is | ||
| 6 | //! exactly the failure this is meant to make impossible. | ||
| 7 | |||
| 8 | mod common; | ||
| 9 | |||
| 10 | use std::process::Command; | ||
| 11 | |||
| 12 | use common::TestRepo; | ||
| 13 | use git_collab::cli::{format_version, version_string, BUILD_COMMIT}; | ||
| 14 | |||
| 15 | /// A commit captured at build time must look like a git object name. | ||
| 16 | fn assert_looks_like_commit(commit: &str) { | ||
| 17 | assert!( | ||
| 18 | commit.len() >= 7, | ||
| 19 | "build commit '{}' is too short to identify anything", | ||
| 20 | commit | ||
| 21 | ); | ||
| 22 | assert!( | ||
| 23 | commit.chars().all(|c| c.is_ascii_hexdigit()), | ||
| 24 | "build commit '{}' is not hex", | ||
| 25 | commit | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | /// Both binaries must answer `--version`, and when the build had a git | ||
| 30 | /// checkout the answer must carry the commit. | ||
| 31 | fn assert_version_output(binary: &str, stdout: &str) { | ||
| 32 | assert!( | ||
| 33 | stdout.contains(env!("CARGO_PKG_VERSION")), | ||
| 34 | "{} --version should name the crate version: {}", | ||
| 35 | binary, | ||
| 36 | stdout | ||
| 37 | ); | ||
| 38 | match BUILD_COMMIT { | ||
| 39 | Some(commit) => { | ||
| 40 | assert_looks_like_commit(commit); | ||
| 41 | assert!( | ||
| 42 | stdout.contains(commit), | ||
| 43 | "{} --version should name the build commit '{}': {}", | ||
| 44 | binary, | ||
| 45 | commit, | ||
| 46 | stdout | ||
| 47 | ); | ||
| 48 | } | ||
| 49 | // Built from a tarball with no `.git`. The contract is that the | ||
| 50 | // version degrades to the crate version alone rather than failing. | ||
| 51 | None => assert_eq!(stdout.trim(), format!("{} {}", binary, version_string())), | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | #[test] | ||
| 56 | fn cli_binary_reports_version_with_build_commit() { | ||
| 57 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 58 | .arg("--version") | ||
| 59 | .output() | ||
| 60 | .expect("failed to run git-collab"); | ||
| 61 | assert!( | ||
| 62 | output.status.success(), | ||
| 63 | "git-collab --version should exit 0: {}", | ||
| 64 | String::from_utf8_lossy(&output.stderr) | ||
| 65 | ); | ||
| 66 | assert_version_output("git-collab", &String::from_utf8_lossy(&output.stdout)); | ||
| 67 | } | ||
| 68 | |||
| 69 | #[test] | ||
| 70 | fn server_binary_reports_version_with_build_commit() { | ||
| 71 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | ||
| 72 | .arg("--version") | ||
| 73 | .output() | ||
| 74 | .expect("failed to run git-collab-server"); | ||
| 75 | assert!( | ||
| 76 | output.status.success(), | ||
| 77 | "git-collab-server --version should exit 0: {}", | ||
| 78 | String::from_utf8_lossy(&output.stderr) | ||
| 79 | ); | ||
| 80 | assert_version_output("git-collab-server", &String::from_utf8_lossy(&output.stdout)); | ||
| 81 | } | ||
| 82 | |||
| 83 | /// `--version` is a question about the binary, not about a repository, so it | ||
| 84 | /// must answer outside a git checkout. `git-collab` otherwise exits 1 with | ||
| 85 | /// "could not find repository". | ||
| 86 | #[test] | ||
| 87 | fn version_works_outside_a_git_repo() { | ||
| 88 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 89 | .arg("--version") | ||
| 90 | .current_dir(std::env::temp_dir()) | ||
| 91 | .output() | ||
| 92 | .expect("failed to run git-collab"); | ||
| 93 | assert!( | ||
| 94 | output.status.success(), | ||
| 95 | "--version should not need a repo: {}", | ||
| 96 | String::from_utf8_lossy(&output.stderr) | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | /// `-V` is the conventional short form and clap wires it alongside `--version`. | ||
| 101 | #[test] | ||
| 102 | fn short_version_flag_works_on_both_binaries() { | ||
| 103 | for binary in [ | ||
| 104 | env!("CARGO_BIN_EXE_git-collab"), | ||
| 105 | env!("CARGO_BIN_EXE_git-collab-server"), | ||
| 106 | ] { | ||
| 107 | let output = Command::new(binary) | ||
| 108 | .arg("-V") | ||
| 109 | .output() | ||
| 110 | .expect("failed to run binary"); | ||
| 111 | assert!(output.status.success(), "-V should exit 0 for {}", binary); | ||
| 112 | assert!( | ||
| 113 | String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION")), | ||
| 114 | "-V should name the crate version for {}", | ||
| 115 | binary | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | /// `status` is where someone looks when behaviour is inexplicable, so the | ||
| 121 | /// build identity belongs there too. | ||
| 122 | #[test] | ||
| 123 | fn status_names_the_build_version() { | ||
| 124 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 125 | let out = repo.run_ok(&["status"]); | ||
| 126 | assert!( | ||
| 127 | out.contains(env!("CARGO_PKG_VERSION")), | ||
| 128 | "status should name the build version: {}", | ||
| 129 | out | ||
| 130 | ); | ||
| 131 | if let Some(commit) = BUILD_COMMIT { | ||
| 132 | assert!( | ||
| 133 | out.contains(commit), | ||
| 134 | "status should name the build commit '{}': {}", | ||
| 135 | commit, | ||
| 136 | out | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | // --------------------------------------------------------------------------- | ||
| 142 | // The formatting contract, including the degraded no-git case, which cannot be | ||
| 143 | // exercised through the built binary because this checkout always has a `.git`. | ||
| 144 | // --------------------------------------------------------------------------- | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn version_without_a_commit_is_the_bare_crate_version() { | ||
| 148 | assert_eq!(format_version("0.1.0", None, false), "0.1.0"); | ||
| 149 | } | ||
| 150 | |||
| 151 | /// A dirty marker with no commit would claim more than is known: without a | ||
| 152 | /// commit there is nothing for "dirty" to be relative to. | ||
| 153 | #[test] | ||
| 154 | fn version_without_a_commit_ignores_the_dirty_marker() { | ||
| 155 | assert_eq!(format_version("0.1.0", None, true), "0.1.0"); | ||
| 156 | } | ||
| 157 | |||
| 158 | #[test] | ||
| 159 | fn version_with_a_clean_commit_names_it() { | ||
| 160 | assert_eq!( | ||
| 161 | format_version("0.1.0", Some("29768e2"), false), | ||
| 162 | "0.1.0 (29768e2)" | ||
| 163 | ); | ||
| 164 | } | ||
| 165 | |||
| 166 | #[test] | ||
| 167 | fn version_with_a_dirty_tree_says_so() { | ||
| 168 | assert_eq!( | ||
| 169 | format_version("0.1.0", Some("29768e2"), true), | ||
| 170 | "0.1.0 (29768e2-dirty)" | ||
| 171 | ); | ||
| 172 | } | ||