7a13f30a
Name a binary that exists, once per sync
a73x 2026-08-13 10:23
Commit message
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -20,7 +20,7 @@ pub enum Error { | |||
| 20 | #[error("verification error: {0}")] | 20 | #[error("verification error: {0}")] |
| 21 | Verification(String), | 21 | Verification(String), |
| 22 | 22 | ||
| 23 | #[error("no signing key found — run 'collab init-key' to generate one")] | 23 | #[error("no signing key found — run `{} init-key` to generate one", crate::BINARY_NAME)] |
| 24 | KeyNotFound, | 24 | KeyNotFound, |
| 25 | 25 | ||
| 26 | #[error("untrusted key: {0}")] | 26 | #[error("untrusted key: {0}")] |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,23 @@ use cli::{Commands, HookCmd, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd | |||
| 31 | use event::ReviewVerdict; | 31 | use event::ReviewVerdict; |
| 32 | use git2::Repository; | 32 | use git2::Repository; |
| 33 | 33 | ||
| 34 | /// The name of the CLI binary, as a user must type it. | ||
| 35 | /// | ||
| 36 | /// Messages that tell someone to run a command should build the name from | ||
| 37 | /// this rather than spell it out, because spelling it out has been got wrong | ||
| 38 | /// twice: a push diagnostic (bf187d71) and the keyless-clone trust warning | ||
| 39 | /// (a18e9b76) both dropped the `git-` prefix. The trust warning is the worse | ||
| 40 | /// of the two — a reader following a security instruction is being asked to | ||
| 41 | /// take an action they may not already know how to take, so they have nothing | ||
| 42 | /// to check the command against. | ||
| 43 | /// | ||
| 44 | /// The constant alone would only help an author who thought to reach for it, | ||
| 45 | /// so it is not the real guard. `tests/cli_surface_test.rs` is: it scans the | ||
| 46 | /// source text for command citations and fails the build on any that name a | ||
| 47 | /// binary or a subcommand that does not exist, whether or not the message was | ||
| 48 | /// built from here. | ||
| 49 | pub const BINARY_NAME: &str = "git-collab"; | ||
| 50 | |||
| 34 | /// Generate the local Ed25519 signing keypair. | 51 | /// Generate the local Ed25519 signing keypair. |
| 35 | /// | 52 | /// |
| 36 | /// Shared by the top-level `init-key` and by `key generate`, which exist as | 53 | /// Shared by the top-level `init-key` and by `key generate`, which exist as |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -70,7 +70,10 @@ async fn main() { | |||
| 70 | if let Some(hook_args) = args.governance_hook.as_deref() { | 70 | if let Some(hook_args) = args.governance_hook.as_deref() { |
| 71 | // clap's num_args = 3 guarantees the arity. | 71 | // clap's num_args = 3 guarantees the arity. |
| 72 | if let Err(reason) = governance::hook::run(&hook_args[0], &hook_args[1], &hook_args[2]) { | 72 | if let Err(reason) = governance::hook::run(&hook_args[0], &hook_args[1], &hook_args[2]) { |
| 73 | eprintln!("git-collab: {reason}"); | 73 | // Named for the binary that is actually running. This goes back |
| 74 | // over SSH as the reason a push was refused, so it is the one | ||
| 75 | // thing the pusher can use to work out what rejected them. | ||
| 76 | eprintln!("git-collab-server: {reason}"); | ||
| 74 | std::process::exit(1); | 77 | std::process::exit(1); |
| 75 | } | 78 | } |
| 76 | return; | 79 | return; |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -541,11 +541,15 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 541 | 541 | ||
| 542 | let mut succeeded = Vec::new(); | 542 | let mut succeeded = Vec::new(); |
| 543 | let mut failed = Vec::new(); | 543 | let mut failed = Vec::new(); |
| 544 | // One trust warning for the whole run. It describes the local trust store, | ||
| 545 | // which no remote in this loop has anything to do with, so repeating it per | ||
| 546 | // remote would be as wrong as repeating it per ref kind was. | ||
| 547 | let mut warned = TrustWarning::default(); | ||
| 544 | for remote_name in &remotes { | 548 | for remote_name in &remotes { |
| 545 | if multiple { | 549 | if multiple { |
| 546 | outln!(); | 550 | outln!(); |
| 547 | } | 551 | } |
| 548 | match sync(repo, remote_name) { | 552 | match sync_remote(repo, remote_name, &mut warned) { |
| 549 | Ok(()) => succeeded.push(remote_name.clone()), | 553 | Ok(()) => succeeded.push(remote_name.clone()), |
| 550 | Err(e) => { | 554 | Err(e) => { |
| 551 | errln!("error: sync with '{}' failed: {}", remote_name, e); | 555 | errln!("error: sync with '{}' failed: {}", remote_name, e); |
| @@ -649,8 +653,46 @@ fn sync_local_only(repo: &Repository) -> Result<(), Error> { | |||
| 649 | Ok(()) | 653 | Ok(()) |
| 650 | } | 654 | } |
| 651 | 655 | ||
| 656 | /// Whether this sync run has already said that no trusted keys are configured. | ||
| 657 | /// | ||
| 658 | /// The warning is one statement about one local trust store, so it belongs to | ||
| 659 | /// the *run*, not to any ref kind or remote inside it. It used to be a local in | ||
| 660 | /// [`reconcile_refs`], which `sync_remote` calls once for issues and once for | ||
| 661 | /// patches — so a clone with both kinds to fetch was told the same thing twice | ||
| 662 | /// (issue a18e9b76). Carrying the flag rather than reaching for a process-wide | ||
| 663 | /// static keeps `sync` callable more than once in a single test binary. | ||
| 664 | #[derive(Default)] | ||
| 665 | struct TrustWarning { | ||
| 666 | emitted: bool, | ||
| 667 | } | ||
| 668 | |||
| 669 | impl TrustWarning { | ||
| 670 | /// Emit the warning unless this run already has. Idempotent by design — | ||
| 671 | /// every caller may call it unconditionally. | ||
| 672 | fn warn_once(&mut self) { | ||
| 673 | if self.emitted { | ||
| 674 | return; | ||
| 675 | } | ||
| 676 | self.emitted = true; | ||
| 677 | errln!( | ||
| 678 | "warning: no trusted keys configured — all valid signatures accepted. \ | ||
| 679 | Run `{} key add --self` to start.", | ||
| 680 | crate::BINARY_NAME | ||
| 681 | ); | ||
| 682 | } | ||
| 683 | } | ||
| 684 | |||
| 652 | /// Sync with a specific remote: fetch, reconcile, push. | 685 | /// Sync with a specific remote: fetch, reconcile, push. |
| 653 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | 686 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { |
| 687 | sync_remote(repo, remote_name, &mut TrustWarning::default()) | ||
| 688 | } | ||
| 689 | |||
| 690 | /// `sync` with the run-scoped state that [`sync_all`] shares across remotes. | ||
| 691 | fn sync_remote( | ||
| 692 | repo: &Repository, | ||
| 693 | remote_name: &str, | ||
| 694 | warned: &mut TrustWarning, | ||
| 695 | ) -> Result<(), Error> { | ||
| 654 | // Acquire advisory lock — held until _lock is dropped (RAII) | 696 | // Acquire advisory lock — held until _lock is dropped (RAII) |
| 655 | let _lock = SyncLock::acquire(repo)?; | 697 | let _lock = SyncLock::acquire(repo)?; |
| 656 | 698 | ||
| @@ -705,8 +747,8 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 705 | state::migrate_patch_layout(&repo); | 747 | state::migrate_patch_layout(&repo); |
| 706 | 748 | ||
| 707 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 749 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 708 | reconcile_refs(&repo, "issues", &author, &sk)?; | 750 | reconcile_refs(&repo, "issues", &author, &sk, warned)?; |
| 709 | reconcile_refs(&repo, "patches", &author, &sk)?; | 751 | reconcile_refs(&repo, "patches", &author, &sk, warned)?; |
| 710 | 752 | ||
| 711 | // Step 2.5/2.6: the local trailer scans. Shared verbatim with the | 753 | // Step 2.5/2.6: the local trailer scans. Shared verbatim with the |
| 712 | // no-remote path — they are the half of sync that needs no remote, which | 754 | // no-remote path — they are the half of sync that needs no remote, which |
| @@ -912,6 +954,7 @@ fn reconcile_refs( | |||
| 912 | kind: &str, | 954 | kind: &str, |
| 913 | author: &crate::event::Author, | 955 | author: &crate::event::Author, |
| 914 | signing_key: &ed25519_dalek::SigningKey, | 956 | signing_key: &ed25519_dalek::SigningKey, |
| 957 | warned: &mut TrustWarning, | ||
| 915 | ) -> Result<(), Error> { | 958 | ) -> Result<(), Error> { |
| 916 | // Both namespaces, because a close moves a ref between them while the id | 959 | // Both namespaces, because a close moves a ref between them while the id |
| 917 | // stays the same. A peer that closed an object publishes it under | 960 | // stays the same. A peer that closed an object publishes it under |
| @@ -937,7 +980,6 @@ fn reconcile_refs( | |||
| 937 | 980 | ||
| 938 | // Load trust policy once for all refs of this kind | 981 | // Load trust policy once for all refs of this kind |
| 939 | let trust_policy = trust::load_trust_policy(repo)?; | 982 | let trust_policy = trust::load_trust_policy(repo)?; |
| 940 | let mut warned_unconfigured = false; | ||
| 941 | let mut reconciled: Vec<String> = Vec::new(); | 983 | let mut reconciled: Vec<String> = Vec::new(); |
| 942 | 984 | ||
| 943 | for (remote_ref, id, classified) in &sync_refs { | 985 | for (remote_ref, id, classified) in &sync_refs { |
| @@ -959,10 +1001,8 @@ fn reconcile_refs( | |||
| 959 | // Apply trust checking | 1001 | // Apply trust checking |
| 960 | let results = trust::check_trust(&results, &trust_policy); | 1002 | let results = trust::check_trust(&results, &trust_policy); |
| 961 | 1003 | ||
| 962 | if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured | 1004 | if matches!(trust_policy, trust::TrustPolicy::Unconfigured) { |
| 963 | { | 1005 | warned.warn_once(); |
| 964 | errln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start."); | ||
| 965 | warned_unconfigured = true; | ||
| 966 | } | 1006 | } |
| 967 | 1007 | ||
| 968 | let failures: Vec<_> = results | 1008 | let failures: Vec<_> = results |
tests/cli_surface_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,352 @@ | |||
| 1 | //! Every command this project tells someone to run must be a command that | ||
| 2 | //! exists. | ||
| 3 | //! | ||
| 4 | //! This is a lint, not a behavioural test. It reads the *source text* rather | ||
| 5 | //! than running anything, because the bug it exists to catch is a typo in a | ||
| 6 | //! string literal, and a typo in a string literal is only observable at | ||
| 7 | //! runtime if that particular error path happens to be exercised. Both real | ||
| 8 | //! instances reached a user before any test did: | ||
| 9 | //! | ||
| 10 | //! - `bf187d71` — a push diagnostic said `` `collab sync --remote X` ``. | ||
| 11 | //! - `a18e9b76` — the keyless-clone trust warning said `'collab key add | ||
| 12 | //! --self'`, on a security-related instruction, where the reader has the | ||
| 13 | //! least ability to notice that the command is wrong. | ||
| 14 | //! | ||
| 15 | //! The oracle is clap's own command tree, not a list maintained here. A list | ||
| 16 | //! would need updating in the same commit that adds a subcommand, which is | ||
| 17 | //! exactly the discipline that failed twice; asking clap means the test cannot | ||
| 18 | //! disagree with the binary. `clap_mangen` generates the man pages from the | ||
| 19 | //! same tree, so those are correct by construction and only their prose — the | ||
| 20 | //! doc comments in `src/cli.rs` — needs scanning, which happens below with | ||
| 21 | //! every other source file. | ||
| 22 | //! | ||
| 23 | //! # What counts as a citation | ||
| 24 | //! | ||
| 25 | //! Only *delimited* text: a backtick or single-quote run opening with the | ||
| 26 | //! binary name. That is how this codebase writes commands, and the delimiter | ||
| 27 | //! is what keeps the scan free of false positives — undelimited prose is full | ||
| 28 | //! of phrases like "the collab refs" and "a collab id" that are not commands | ||
| 29 | //! at all. README fenced-block lines starting with `$ ` count too, since the | ||
| 30 | //! quickstart is the most-copied text the project has. | ||
| 31 | //! | ||
| 32 | //! `{}` at the head of a citation is read as the binary name, so building a | ||
| 33 | //! message from [`git_collab::BINARY_NAME`] does not hide it from this scan. | ||
| 34 | |||
| 35 | use std::collections::BTreeSet; | ||
| 36 | use std::path::{Path, PathBuf}; | ||
| 37 | |||
| 38 | use clap::CommandFactory; | ||
| 39 | |||
| 40 | use git_collab::cli::Cli; | ||
| 41 | |||
| 42 | // --------------------------------------------------------------------------- | ||
| 43 | // Which files | ||
| 44 | // --------------------------------------------------------------------------- | ||
| 45 | |||
| 46 | fn repo_root() -> PathBuf { | ||
| 47 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Every file whose strings can reach a user: all of `src/` (message literals, | ||
| 51 | /// clap `about` text, and the doc comments the man pages are rendered from) | ||
| 52 | /// plus the README. | ||
| 53 | /// | ||
| 54 | /// `tests/` is deliberately excluded: a regression test's job is to assert the | ||
| 55 | /// wrong spelling is *absent*, so it has to contain the wrong spelling. | ||
| 56 | fn scanned_files() -> Vec<PathBuf> { | ||
| 57 | let root = repo_root(); | ||
| 58 | let mut files = vec![root.join("README.md")]; | ||
| 59 | collect_rs(&root.join("src"), &mut files); | ||
| 60 | files.sort(); | ||
| 61 | assert!( | ||
| 62 | files.len() > 20, | ||
| 63 | "file walk found only {} files — the scan is not reaching src/", | ||
| 64 | files.len() | ||
| 65 | ); | ||
| 66 | files | ||
| 67 | } | ||
| 68 | |||
| 69 | fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) { | ||
| 70 | let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {}: {}", dir.display(), e)); | ||
| 71 | for entry in entries { | ||
| 72 | let path = entry.unwrap().path(); | ||
| 73 | if path.is_dir() { | ||
| 74 | collect_rs(&path, out); | ||
| 75 | } else if path.extension().is_some_and(|e| e == "rs") { | ||
| 76 | out.push(path); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | // --------------------------------------------------------------------------- | ||
| 82 | // Finding citations | ||
| 83 | // --------------------------------------------------------------------------- | ||
| 84 | |||
| 85 | /// One place the source text tells a reader to run something. | ||
| 86 | #[derive(Debug)] | ||
| 87 | struct Citation { | ||
| 88 | file: PathBuf, | ||
| 89 | line_no: usize, | ||
| 90 | /// The text after the binary name, up to the closing delimiter or the end | ||
| 91 | /// of the source line — whichever comes first. Ending at the line is fine | ||
| 92 | /// and intentional: the subcommand path always sits immediately after the | ||
| 93 | /// binary name, so a citation split across a Rust string continuation | ||
| 94 | /// still has everything this test reads on its first line. | ||
| 95 | rest: String, | ||
| 96 | /// The whole citation, for the failure message. | ||
| 97 | full: String, | ||
| 98 | } | ||
| 99 | |||
| 100 | /// The spellings that open a command citation, longest first so `git-collab` | ||
| 101 | /// is never matched as a bare `collab` with a `git-` prefix left behind. | ||
| 102 | const BINARY_HEADS: [&str; 3] = ["git-collab ", "{} ", "collab "]; | ||
| 103 | |||
| 104 | const DELIMITERS: [char; 2] = ['`', '\'']; | ||
| 105 | |||
| 106 | fn citations_in(file: &Path) -> (Vec<Citation>, Vec<Citation>) { | ||
| 107 | let text = std::fs::read_to_string(file).unwrap_or_else(|e| panic!("read {}: {}", file.display(), e)); | ||
| 108 | let mut good = Vec::new(); | ||
| 109 | let mut wrong_binary = Vec::new(); | ||
| 110 | |||
| 111 | for (i, line) in text.lines().enumerate() { | ||
| 112 | for (start, head, delim) in openings(line) { | ||
| 113 | let after_head = &line[start + head.len()..]; | ||
| 114 | let rest = match delim { | ||
| 115 | Some(d) => after_head.split(d).next().unwrap_or(after_head), | ||
| 116 | // A `$ ` shell line in the README runs to end of line. | ||
| 117 | None => after_head, | ||
| 118 | }; | ||
| 119 | let citation = Citation { | ||
| 120 | file: file.to_path_buf(), | ||
| 121 | line_no: i + 1, | ||
| 122 | rest: rest.to_string(), | ||
| 123 | full: format!("{}{}", head, rest), | ||
| 124 | }; | ||
| 125 | if head == "collab " { | ||
| 126 | wrong_binary.push(citation); | ||
| 127 | } else { | ||
| 128 | good.push(citation); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | (good, wrong_binary) | ||
| 133 | } | ||
| 134 | |||
| 135 | /// Byte offsets in `line` where a command citation opens, with the binary | ||
| 136 | /// spelling used and the delimiter that will close it (`None` for a README | ||
| 137 | /// `$ ` shell line, which closes at the newline). | ||
| 138 | fn openings(line: &str) -> Vec<(usize, &'static str, Option<char>)> { | ||
| 139 | let mut found = Vec::new(); | ||
| 140 | |||
| 141 | // Delimited: a backtick or quote immediately followed by a binary name. | ||
| 142 | for (idx, ch) in line.char_indices() { | ||
| 143 | if !DELIMITERS.contains(&ch) { | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | let after = &line[idx + ch.len_utf8()..]; | ||
| 147 | if let Some(head) = BINARY_HEADS.iter().find(|h| after.starts_with(**h)) { | ||
| 148 | found.push((idx + ch.len_utf8(), *head, Some(ch))); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | // A README shell-prompt line. `{}` is not a shell thing, so only the two | ||
| 153 | // real spellings are looked for here. | ||
| 154 | let trimmed = line.trim_start(); | ||
| 155 | if let Some(cmd) = trimmed.strip_prefix("$ ") { | ||
| 156 | if let Some(head) = ["git-collab ", "collab "].iter().find(|h| cmd.starts_with(**h)) { | ||
| 157 | let offset = line.len() - cmd.len(); | ||
| 158 | found.push((offset, *head, None)); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | found | ||
| 163 | } | ||
| 164 | |||
| 165 | // --------------------------------------------------------------------------- | ||
| 166 | // The oracle: clap's own tree | ||
| 167 | // --------------------------------------------------------------------------- | ||
| 168 | |||
| 169 | /// Walk `rest` down the command tree, returning an error string if some token | ||
| 170 | /// names a subcommand that does not exist. | ||
| 171 | /// | ||
| 172 | /// Descent stops at the first command with no subcommands of its own: from | ||
| 173 | /// there on every token is a positional argument, and `git-collab issue close | ||
| 174 | /// a1b2c3d4` must not be read as an `a1b2c3d4` subcommand of `close`. | ||
| 175 | fn check_path(rest: &str, root: &clap::Command) -> Result<(), String> { | ||
| 176 | let mut cmd = root; | ||
| 177 | let mut walked: Vec<String> = Vec::new(); | ||
| 178 | |||
| 179 | for token in rest.split_whitespace() { | ||
| 180 | if cmd.get_subcommands().next().is_none() { | ||
| 181 | break; | ||
| 182 | } | ||
| 183 | if !is_subcommand_shaped(token) { | ||
| 184 | break; | ||
| 185 | } | ||
| 186 | match find_sub(cmd, token) { | ||
| 187 | Some(sub) => { | ||
| 188 | walked.push(token.to_string()); | ||
| 189 | cmd = sub; | ||
| 190 | } | ||
| 191 | None => { | ||
| 192 | let mut names: BTreeSet<&str> = | ||
| 193 | cmd.get_subcommands().map(|s| s.get_name()).collect(); | ||
| 194 | names.remove("help"); | ||
| 195 | let under = if walked.is_empty() { | ||
| 196 | "git-collab".to_string() | ||
| 197 | } else { | ||
| 198 | format!("git-collab {}", walked.join(" ")) | ||
| 199 | }; | ||
| 200 | return Err(format!( | ||
| 201 | "`{}` has no subcommand `{}` (it has: {})", | ||
| 202 | under, | ||
| 203 | token, | ||
| 204 | names.into_iter().collect::<Vec<_>>().join(", ") | ||
| 205 | )); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
| 209 | Ok(()) | ||
| 210 | } | ||
| 211 | |||
| 212 | fn find_sub<'a>(cmd: &'a clap::Command, token: &str) -> Option<&'a clap::Command> { | ||
| 213 | cmd.get_subcommands() | ||
| 214 | .find(|s| s.get_name() == token || s.get_all_aliases().any(|a| a == token)) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// Whether a token could be a subcommand name at all. Flags, format | ||
| 218 | /// placeholders, shell metacharacters and `<PLACEHOLDER>`s all end the walk. | ||
| 219 | fn is_subcommand_shaped(token: &str) -> bool { | ||
| 220 | !token.is_empty() | ||
| 221 | && token | ||
| 222 | .chars() | ||
| 223 | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | ||
| 224 | && !token.starts_with('-') | ||
| 225 | } | ||
| 226 | |||
| 227 | // --------------------------------------------------------------------------- | ||
| 228 | // Tests | ||
| 229 | // --------------------------------------------------------------------------- | ||
| 230 | |||
| 231 | /// The exact defect from a18e9b76 and bf187d71: the binary is `git-collab`, | ||
| 232 | /// and no message may call it `collab`. | ||
| 233 | #[test] | ||
| 234 | fn no_user_facing_string_calls_the_binary_collab() { | ||
| 235 | let mut offences = Vec::new(); | ||
| 236 | for file in scanned_files() { | ||
| 237 | let (_, wrong) = citations_in(&file); | ||
| 238 | for c in wrong { | ||
| 239 | offences.push(format!( | ||
| 240 | "{}:{}: `{}` — the binary is `git-collab`", | ||
| 241 | c.file.display(), | ||
| 242 | c.line_no, | ||
| 243 | c.full.trim_end() | ||
| 244 | )); | ||
| 245 | } | ||
| 246 | } | ||
| 247 | assert!( | ||
| 248 | offences.is_empty(), | ||
| 249 | "user-facing text names a binary that does not exist:\n {}", | ||
| 250 | offences.join("\n ") | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | /// Every command citation names a subcommand path that clap actually has. | ||
| 255 | #[test] | ||
| 256 | fn every_cited_command_exists() { | ||
| 257 | let root = Cli::command(); | ||
| 258 | let mut offences = Vec::new(); | ||
| 259 | for file in scanned_files() { | ||
| 260 | let (cited, _) = citations_in(&file); | ||
| 261 | for c in cited { | ||
| 262 | if let Err(why) = check_path(&c.rest, &root) { | ||
| 263 | offences.push(format!("{}:{}: {}", c.file.display(), c.line_no, why)); | ||
| 264 | } | ||
| 265 | } | ||
| 266 | } | ||
| 267 | assert!( | ||
| 268 | offences.is_empty(), | ||
| 269 | "user-facing text cites a command that does not exist:\n {}", | ||
| 270 | offences.join("\n ") | ||
| 271 | ); | ||
| 272 | } | ||
| 273 | |||
| 274 | /// The scan is worthless if it matches nothing, and a refactor that moved | ||
| 275 | /// every message into a format argument would silently empty it. Assert it | ||
| 276 | /// still has real work to do. | ||
| 277 | #[test] | ||
| 278 | fn the_scan_actually_finds_commands() { | ||
| 279 | let root = Cli::command(); | ||
| 280 | let mut total = 0; | ||
| 281 | let mut distinct: BTreeSet<String> = BTreeSet::new(); | ||
| 282 | for file in scanned_files() { | ||
| 283 | let (cited, _) = citations_in(&file); | ||
| 284 | for c in cited { | ||
| 285 | total += 1; | ||
| 286 | if let Some(first) = c.rest.split_whitespace().next() { | ||
| 287 | if find_sub(&root, first).is_some() { | ||
| 288 | distinct.insert(first.to_string()); | ||
| 289 | } | ||
| 290 | } | ||
| 291 | } | ||
| 292 | } | ||
| 293 | assert!( | ||
| 294 | total >= 30, | ||
| 295 | "only {} command citations found — the scanner has stopped matching", | ||
| 296 | total | ||
| 297 | ); | ||
| 298 | assert!( | ||
| 299 | distinct.len() >= 8, | ||
| 300 | "citations only cover {} distinct subcommands: {:?}", | ||
| 301 | distinct.len(), | ||
| 302 | distinct | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | |||
| 306 | /// The lint has to be able to fail. Feeding it the two spellings that actually | ||
| 307 | /// shipped proves it is checking something, rather than passing because the | ||
| 308 | /// walk breaks out early on every input. | ||
| 309 | #[test] | ||
| 310 | fn the_lint_rejects_the_spellings_that_shipped() { | ||
| 311 | let root = Cli::command(); | ||
| 312 | |||
| 313 | // bf187d71 and a18e9b76, as the citation scanner would see them. | ||
| 314 | for wrong in ["`collab sync --remote origin`", "'collab key add --self'"] { | ||
| 315 | let (good, bad) = openings(wrong) | ||
| 316 | .into_iter() | ||
| 317 | .partition::<Vec<_>, _>(|(_, head, _)| *head != "collab "); | ||
| 318 | assert!( | ||
| 319 | good.is_empty() && bad.len() == 1, | ||
| 320 | "scanner did not flag the wrong binary name in {:?}", | ||
| 321 | wrong | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | |||
| 325 | // A subcommand that does not exist, under a group that does. | ||
| 326 | assert!( | ||
| 327 | check_path("key trust --self", &root).is_err(), | ||
| 328 | "lint accepted `git-collab key trust`, which does not exist" | ||
| 329 | ); | ||
| 330 | assert!( | ||
| 331 | check_path("frobnicate", &root).is_err(), | ||
| 332 | "lint accepted a top-level subcommand that does not exist" | ||
| 333 | ); | ||
| 334 | |||
| 335 | // And the real ones still pass, including a positional that looks like a | ||
| 336 | // word and a hidden alias. | ||
| 337 | for ok in [ | ||
| 338 | "key add --self", | ||
| 339 | "sync --remote origin", | ||
| 340 | "patch merge <id>", | ||
| 341 | "issue close a1b2c3d4", | ||
| 342 | "patch diff {} --revision {}", | ||
| 343 | "hooks install", | ||
| 344 | ] { | ||
| 345 | assert!( | ||
| 346 | check_path(ok, &root).is_ok(), | ||
| 347 | "lint rejected the real command `git-collab {}`: {:?}", | ||
| 348 | ok, | ||
| 349 | check_path(ok, &root) | ||
| 350 | ); | ||
| 351 | } | ||
| 352 | } | ||
tests/sync_diagnostics_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -278,3 +278,68 @@ fn init_says_already_configured_on_a_second_run() { | |||
| 278 | out | 278 | out |
| 279 | ); | 279 | ); |
| 280 | } | 280 | } |
| 281 | |||
| 282 | // --------------------------------------------------------------------------- | ||
| 283 | // Fix 3 — the keyless-clone trust warning (issue a18e9b76) | ||
| 284 | // --------------------------------------------------------------------------- | ||
| 285 | |||
| 286 | /// A second `TestRepo` pointed at an existing bare remote, with collab | ||
| 287 | /// refspecs configured and no trusted keys — the state a fresh clone is in. | ||
| 288 | fn peer_of(bare: &TempDir, name: &str, email: &str) -> TestRepo { | ||
| 289 | let repo = TestRepo::new(name, email); | ||
| 290 | repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]); | ||
| 291 | repo.git(&["fetch", "origin"]); | ||
| 292 | repo.run_ok(&["init"]); | ||
| 293 | repo | ||
| 294 | } | ||
| 295 | |||
| 296 | /// The warning is one statement about one local trust store, so it fires once | ||
| 297 | /// per sync however many *kinds* of collab ref that sync reconciles. The bug | ||
| 298 | /// was a per-kind flag inside `reconcile_refs`, which is called once for | ||
| 299 | /// issues and once for patches: a clone with both to fetch was told twice. | ||
| 300 | #[test] | ||
| 301 | fn trust_warning_appears_once_per_sync_and_names_a_real_command() { | ||
| 302 | let (alice, bare) = repo_with_origin(); | ||
| 303 | // Both kinds, because the duplicate only appeared when there were issue | ||
| 304 | // refs *and* patch refs to reconcile. | ||
| 305 | alice.issue_open("An issue to fetch"); | ||
| 306 | alice.patch_create("A patch to fetch"); | ||
| 307 | alice.run_ok(&["sync", "--remote", "origin"]); | ||
| 308 | |||
| 309 | let bob = peer_of(&bare, "Bob", "bob@example.com"); | ||
| 310 | let output = bob.run(&["sync", "--remote", "origin"]); | ||
| 311 | assert!( | ||
| 312 | output.status.success(), | ||
| 313 | "bob's sync failed:\nstdout: {}\nstderr: {}", | ||
| 314 | String::from_utf8_lossy(&output.stdout), | ||
| 315 | String::from_utf8_lossy(&output.stderr) | ||
| 316 | ); | ||
| 317 | let stderr = String::from_utf8(output.stderr).unwrap(); | ||
| 318 | |||
| 319 | let warnings = stderr.matches("no trusted keys configured").count(); | ||
| 320 | assert_eq!( | ||
| 321 | warnings, 1, | ||
| 322 | "expected exactly one trust warning for a single-remote sync, got {}:\n{}", | ||
| 323 | warnings, stderr | ||
| 324 | ); | ||
| 325 | |||
| 326 | // The behaviour being described must not have changed: a keyless clone | ||
| 327 | // still accepts every valid signature, and still says so. | ||
| 328 | assert!( | ||
| 329 | !stderr.contains("Rejecting"), | ||
| 330 | "a keyless clone rejected something it should have accepted:\n{}", | ||
| 331 | stderr | ||
| 332 | ); | ||
| 333 | |||
| 334 | // And the remedy it names must be a command that exists. | ||
| 335 | assert!( | ||
| 336 | stderr.contains("git-collab key add --self"), | ||
| 337 | "trust warning does not name the real command:\n{}", | ||
| 338 | stderr | ||
| 339 | ); | ||
| 340 | assert!( | ||
| 341 | !stderr.contains("'collab "), | ||
| 342 | "trust warning uses the wrong binary name `collab`:\n{}", | ||
| 343 | stderr | ||
| 344 | ); | ||
| 345 | } | ||