a73x

7a13f30a

Name a binary that exists, once per sync

a73x   2026-08-13 10:23

Commit message
Name a binary that exists, once per sync

The keyless-clone trust warning told the reader to run `collab key add
--self`. There is no `collab` binary. This is the second time the same
mistake shipped -- bf187d71 fixed `collab sync` in a push diagnostic --
and this one sat on a security instruction, where the reader has the
least ability to notice the command is wrong.

It also printed twice for a single remote. The suppression flag lived in
`reconcile_refs`, which `sync` calls once for issues and once for
patches, so a clone with both kinds to fetch was told the same thing per
kind. The flag now belongs to the sync run: `TrustWarning` is threaded
from `sync_remote` through both `reconcile_refs` calls, and `sync_all`
holds one across every remote, because the warning describes the local
trust store and no remote in that loop has anything to do with it.
Carried rather than made static so `sync` stays callable more than once
in a single test binary.

The behaviour itself is unchanged: a keyless clone still accepts every
valid signature, and still says so.

Also fixed, from a sweep of every user-facing string:

- `src/error.rs` -- KeyNotFound said `collab init-key`.
- `src/server/main.rs` -- the governance hook's refusal, which goes back
  over SSH as the reason a push was rejected, prefixed itself
  `git-collab:` while running as `git-collab-server`.

Those were the only wrong names left; every other cited command and flag
already resolved against the real surface.

To stop the class recurring, `tests/cli_surface_test.rs` scans the source
text of `src/` and the README for delimited command citations and checks
each against clap's own command tree. Reading source text rather than
runtime output is the point: both shipped instances were typos in string
literals, which are only observable at runtime if that error path happens
to be exercised, and neither was. Asking clap rather than a list kept
here means the test cannot drift from the binary, and `clap_mangen`
renders the man pages from the same tree, so those stay correct by
construction.

A constant, `git_collab::BINARY_NAME`, exists alongside it and the fixed
messages build from it -- but the constant only helps an author who
thinks to reach for it, so the lint is the actual guard. The lint
understands a leading `{}` as the binary name so building a message from
the constant does not hide it from the scan.

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

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 }