a73x

2b8da3a0

Report build provenance, accept guessed verbs, size ids to the repo

a73x   2026-08-10 16:41

Commit message
Report build provenance, accept guessed verbs, size ids to the repo

Three CLI-surface fixes, grouped because they all concern what the tool
tells a caller about itself.

--version on both binaries (5519937d). build.rs captures the commit with
cargo:rustc-env and every step of it is allowed to fail: no .git, no git
on the machine, or a checkout that turns out to belong to some enclosing
project all mean "emit nothing", option_env! yields None, and the version
degrades to the crate version alone. The commit is the full object name so
that comparing an installed binary against a checkout is one comparison
against `git rev-parse HEAD`, which is the question that prompted this.
`status` leads with the same line.

Hidden aliases across the surface (550726af). `issue create`, `patch open`
and `keys` as asked, plus the same asymmetries wherever else they appear:
ls/rm/remove/view/info, hook, keygen, patch co and update, release upload,
identity add/remove, find and grep. `key generate` is a hidden variant
rather than an alias, since clap aliases cannot cross subcommand enums; it
and `init-key` now share one function. All hidden, so --help still
advertises one spelling per command.

Id display width (32ccbab7). Ids are now abbreviated against every object
of their kind — open, closed and archived — at a uniform width that starts
at 8 and grows with the object count on git's core.abbrev rule, with any
id still ambiguous at that width printed wider. The issue proposed the
shortest unambiguous prefix instead, on the premise that this is what
`git log --abbrev-commit` does; it is not, and the difference matters
here. Shortest-unambiguous is by definition the minimum that works this
instant, and displayed collab ids leave the tool for commit trailers and
scripts, where they are permanent. So display width keeps a collision
margin while accepted-prefix width stays as permissive as it was: they are
deliberately different numbers. `collab.abbrev` overrides the width, taking
a number clamped to 4-40 or `no` for whole ids.

Existing output is unchanged below ~32k objects of a kind, the floor being
the 8 that was hard-coded before. Two visible changes: `status` gains a
leading version line, and `issue list`'s renderer in run() was a copy of
issue::list_to_writer's body and is now a call to it. --json is untouched
and still carries full ids.

Fixes: 5519937d

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 }