choir_cli/propose.rs
1//! One-command proposal: the inference that turns a git checkout into a
2//! change, a pushed ref and a review request.
3//!
4//! The five steps a contributor runs by hand today — provision a
5//! workspace with an owner-signed base, commit, push, checkpoint,
6//! request review — are all already endpoints. What made them five was
7//! that each one needed an identifier the previous one produced, and
8//! nothing derived those identifiers from what the contributor already
9//! had. Everything here is that derivation, kept pure so it can be
10//! tested without a node: the remote URL carries the API base and the
11//! repository, and the branch name carries the change identity.
12//!
13//! # Why the branch name is the change identity (D52)
14//!
15//! A proposal has to survive being amended and rebased, or every push
16//! forks a second change for one unit of work. Gerrit solves this with a
17//! `Change-Id` trailer in the commit message and jj with a change id
18//! kept beside the commit; both need the client to write something
19//! durable. The branch name is the one durable label a git contributor
20//! already maintains across a rebase, and it is what GitHub keys a pull
21//! request on. So it is the external identity fingerprinted by
22//! [`crate::runner::Identity::from_external`], and re-proposing from the
23//! same branch reaches the same change.
24//!
25//! The cost is stated rather than hidden: rename the branch and you have
26//! a second proposal. `--change` exists for that case.
27//!
28//! # Examples
29//!
30//! ```
31//! let remote = choir_cli::propose::Remote::parse("https://ci:tok@node.example/agents/demo.git")
32//! .expect("a choir remote");
33//! assert_eq!(remote.api, "https://node.example");
34//! assert_eq!(remote.repo, "agents/demo");
35//! ```
36//!
37//! The workflow this command collapses into one step:
38//!
39#![doc = include_str!("../../../docs/using/workflow.md")]
40
41use crate::runner::{safe_segment, split_repo, Failure, Identity};
42
43/// The namespace every `choir propose` binding is derived under.
44///
45/// Separates proposals from an orchestrator's changes in the same
46/// repository, so the two can never converge onto one another's change
47/// id by deriving the same fingerprint.
48pub const NAMESPACE: &str = "propose";
49
50/// The generation component of a proposal's fingerprint.
51///
52/// Fixed at one because a proposal's retries are meant to *converge*:
53/// pushing again after an amend must reach the change that already
54/// exists, which is exactly what holding the generation constant does.
55const GENERATION: &str = "1";
56
57/// The API base and repository recovered from a git remote URL.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Remote {
60 /// Daemon base URL, with any credentials removed.
61 pub api: String,
62 /// `owner/repo`, without the `.git` suffix.
63 pub repo: String,
64}
65
66impl Remote {
67 /// Recovers the API base and repository from a clone URL.
68 ///
69 /// Credentials in the authority are dropped rather than carried:
70 /// this value is printed in progress output and passed to `curl` on
71 /// an argv, and a password on an argv is readable by every process
72 /// on the host through `ps`. The token the request actually needs
73 /// comes from `--auth-file`.
74 ///
75 /// # Errors
76 ///
77 /// Returns a [`Failure`] when the URL carries no scheme, names no
78 /// `owner/repo` path, or uses a scheme with no HTTP API base to
79 /// derive — `ssh://` being the one that reaches a choir node but
80 /// says nothing about where its API listens.
81 pub fn parse(url: &str) -> Result<Self, Failure> {
82 // The URL is quoted back so the contributor can see what was
83 // wrong with it -- but with any userinfo removed first. A remote
84 // of the old `https://user:token@host/...` shape is exactly the
85 // one most likely to fail this parse, and printing it verbatim
86 // would put the token on a terminal, in a scrollback, and in
87 // whatever the contributor pastes into a bug report.
88 let shown = redact(url);
89 let invalid = move |message: &str| {
90 Failure::terminal(
91 "invalid_config",
92 format!(
93 "{message}: {shown}. \
94 Name the node explicitly with --api <url> --repo <owner/repo>."
95 ),
96 )
97 };
98 let Some((scheme, rest)) = url.split_once("://") else {
99 return Err(invalid("remote is not an http(s) choir URL"));
100 };
101 if scheme != "http" && scheme != "https" {
102 return Err(invalid("remote scheme carries no API base"));
103 }
104 let (authority, path) = rest
105 .split_once('/')
106 .ok_or_else(|| invalid("remote names no repository"))?;
107 // Everything before the last `@` is userinfo. Splitting on the
108 // last one and not the first is what keeps a password containing
109 // `@` from leaving its tail in the host.
110 let host = authority
111 .rsplit_once('@')
112 .map_or(authority, |(_, host)| host);
113 if host.is_empty() {
114 return Err(invalid("remote names no host"));
115 }
116 let repo = path.strip_suffix('/').unwrap_or(path);
117 let repo = repo.strip_suffix(".git").unwrap_or(repo);
118 // Wrapped rather than propagated: `split_repo`'s own message is
119 // about a config field, and the reader here is looking at a git
120 // remote. Routing it through `invalid` also means every refusal
121 // from this function is redacted by one piece of code.
122 split_repo(repo).map_err(|_| invalid("remote path is not owner/repo"))?;
123 Ok(Self {
124 api: format!("{scheme}://{host}"),
125 repo: repo.to_string(),
126 })
127 }
128}
129
130/// Everything one proposal is bound to, derived from the checkout.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Proposal {
133 /// The change, workspace and idempotency key, derived together.
134 pub identity: Identity,
135 /// Branch the proposal asks to land on, as a full refname.
136 pub target_ref: String,
137}
138
139impl Proposal {
140 /// Derives a proposal's identifiers from the checkout's branch and
141 /// the branch it wants to land on.
142 ///
143 /// # Errors
144 ///
145 /// Returns a [`Failure`] when the repository, branch or target is
146 /// not a usable identifier.
147 pub fn derive(repo: &str, branch: &str, onto: &str) -> Result<Self, Failure> {
148 if branch.is_empty() {
149 return Err(Failure::terminal(
150 "invalid_request",
151 "HEAD is detached, so there is no branch name to identify this change by. \
152 Check out a branch, or name the change with --change.",
153 ));
154 }
155 if !safe_segment(onto) {
156 return Err(Failure::terminal(
157 "invalid_request",
158 "--onto must name a branch, not a full refname or a path",
159 ));
160 }
161 // The branch is the external identity; a sanitized copy is only
162 // the readable prefix of the directory name, so two branches
163 // that sanitize alike still fingerprint apart.
164 let key = workspace_key(branch);
165 let identity = Identity::from_external(NAMESPACE, repo, &key, branch, GENERATION)?;
166 Ok(Self {
167 target_ref: format!("refs/heads/{onto}"),
168 identity,
169 })
170 }
171
172 /// The refname one revision of this proposal is pushed to.
173 ///
174 /// Named by the commit, under the proposal's own namespace, and so
175 /// **append-only**: re-proposing after an amend or a rebase adds a
176 /// ref rather than moving one. Two properties follow, and both are
177 /// why this is not simply a branch.
178 ///
179 /// First, no force-push. A branch would refuse an amended commit as
180 /// a non-fast-forward, and the repair for that is the force-push
181 /// this project refuses everywhere else.
182 ///
183 /// Second, and the reason worth the odd-looking refname: every
184 /// revision the op log records stays fetchable. A checkpoint puts a
185 /// revision hash in the log permanently; if the ref that made its
186 /// objects reachable were overwritten, the log would go on naming a
187 /// revision the repository could no longer produce. Gerrit keeps
188 /// each patchset under `refs/changes/NN/NNNN/P` for the same reason.
189 ///
190 /// The namespace is deliberately outside `refs/heads/`: these are
191 /// object anchors, not branches, and a clone should not grow one
192 /// local branch per revision of every open proposal. The current
193 /// revision of a change is `revision_id` in the view, never the
194 /// newest ref.
195 #[must_use]
196 pub fn revision_ref(&self, commit: &str) -> String {
197 format!("refs/proposals/{}/{commit}", self.identity.workspace_name)
198 }
199
200 /// The `repo:ref` spelling `RequestReview` records as the
201 /// destination, which is what per-ref review policy reads.
202 #[must_use]
203 pub fn review_target(&self, repo: &str) -> String {
204 format!("{repo}:{}", self.target_ref)
205 }
206}
207
208/// Replaces any userinfo in a URL with `<redacted>`.
209///
210/// Applied before a URL is quoted into a message, never after: there is
211/// no second place that scrubs these, so a message assembled without
212/// this one is a message that leaks.
213fn redact(url: &str) -> String {
214 let Some((scheme, rest)) = url.split_once("://") else {
215 return url.to_string();
216 };
217 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
218 match authority.rsplit_once('@') {
219 None => url.to_string(),
220 Some((_, host)) => format!("{scheme}://<redacted>@{host}/{path}"),
221 }
222}
223
224/// Reduces a branch name to a readable path segment.
225///
226/// Branch names carry `/`, and a workspace name is one path component.
227/// This is deliberately lossy and deliberately not the identity: it
228/// names the directory, while the fingerprint over the *original*
229/// branch name keeps `feat/x` and `feat-x` apart.
230fn workspace_key(branch: &str) -> String {
231 let mapped: String = branch
232 .chars()
233 .map(|c| {
234 if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
235 c
236 } else {
237 '-'
238 }
239 })
240 .collect();
241 let trimmed = mapped.trim_start_matches(|c: char| !c.is_ascii_alphanumeric());
242 if trimmed.is_empty() {
243 "branch".to_string()
244 } else {
245 trimmed.to_string()
246 }
247}
248
249/// Renders a change id for a progress line.
250///
251/// A change id ends in a 64-character fingerprint, which is a record
252/// rather than a sentence: at the width of a terminal it pushes the part
253/// a reader recognises -- the namespace and the repository -- off the
254/// line. The JSON summary still carries the id whole, so nothing anyone
255/// has to copy is shortened here.
256#[must_use]
257pub fn short_change_id(id: &str) -> String {
258 match id.rsplit_once(':') {
259 Some((head, digest)) if digest.chars().count() > SHORT_FINGERPRINT => {
260 let short: String = digest.chars().take(SHORT_FINGERPRINT).collect();
261 format!("{head}:{short}\u{2026}")
262 }
263 _ => id.to_string(),
264 }
265}
266
267/// Fingerprint characters kept by [`short_change_id`].
268const SHORT_FINGERPRINT: usize = 12;
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn parses_a_plain_remote() {
276 let remote = Remote::parse("http://127.0.0.1:8080/agents/demo.git").unwrap();
277 assert_eq!(remote.api, "http://127.0.0.1:8080");
278 assert_eq!(remote.repo, "agents/demo");
279 }
280
281 #[test]
282 fn drops_credentials_from_the_api_base() {
283 // The whole reason this function exists rather than a split on
284 // '/': a token in the remote must not reach an argv.
285 let remote = Remote::parse("https://ana:s3cr@t@node.example/agents/demo.git").unwrap();
286 assert_eq!(remote.api, "https://node.example");
287 assert!(!remote.api.contains("s3cr"));
288 }
289
290 #[test]
291 fn accepts_a_remote_without_the_git_suffix() {
292 let remote = Remote::parse("https://node.example/agents/demo").unwrap();
293 assert_eq!(remote.repo, "agents/demo");
294 }
295
296 #[test]
297 fn a_refusal_never_quotes_the_credential_back() {
298 // The remote most likely to fail this parse is the one carrying
299 // a token, so the refusal is where a leak would happen.
300 let failure = Remote::parse("https://ana:s3cr3t@node.example/a/b/c.git").unwrap_err();
301 assert!(
302 !failure.message.contains("s3cr3t"),
303 "the refusal quoted the token: {}",
304 failure.message
305 );
306 assert!(
307 failure.message.contains("<redacted>"),
308 "{}",
309 failure.message
310 );
311 assert!(
312 failure.message.contains("node.example"),
313 "{}",
314 failure.message
315 );
316 }
317
318 #[test]
319 fn refuses_a_scheme_with_no_api_base() {
320 let failure = Remote::parse("ssh://git@node.example/agents/demo.git").unwrap_err();
321 assert_eq!(failure.code, "invalid_config");
322 assert!(failure.message.contains("--api"), "{}", failure.message);
323 }
324
325 #[test]
326 fn refuses_a_path_that_is_not_owner_repo() {
327 assert!(Remote::parse("https://node.example/demo.git").is_err());
328 assert!(Remote::parse("https://node.example/a/b/c.git").is_err());
329 }
330
331 #[test]
332 fn the_same_branch_derives_the_same_change() {
333 let first = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
334 let second = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
335 assert_eq!(first.identity.change_id, second.identity.change_id);
336 assert_eq!(first.revision_ref("abc"), second.revision_ref("abc"));
337 }
338
339 #[test]
340 fn a_different_branch_derives_a_different_change() {
341 let first = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
342 let other = Proposal::derive("agents/demo", "fix-lexer", "main").unwrap();
343 assert_ne!(first.identity.change_id, other.identity.change_id);
344 }
345
346 #[test]
347 fn branches_that_sanitize_alike_stay_distinct() {
348 // `feat/x` and `feat-x` share a workspace key prefix; the
349 // fingerprint is over the branch name, so the changes differ.
350 let slashed = Proposal::derive("agents/demo", "feat/x", "main").unwrap();
351 let dashed = Proposal::derive("agents/demo", "feat-x", "main").unwrap();
352 assert_ne!(slashed.identity.change_id, dashed.identity.change_id);
353 assert_ne!(slashed.revision_ref("abc"), dashed.revision_ref("abc"));
354 }
355
356 #[test]
357 fn the_target_branch_does_not_change_the_identity() {
358 // Retargeting a proposal must not fork it into a second change.
359 let main = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
360 let release = Proposal::derive("agents/demo", "fix-parser", "release").unwrap();
361 assert_eq!(main.identity.change_id, release.identity.change_id);
362 assert_eq!(release.target_ref, "refs/heads/release");
363 }
364
365 #[test]
366 fn a_slashed_branch_becomes_one_path_segment() {
367 let proposal = Proposal::derive("agents/demo", "feat/deep/name", "main").unwrap();
368 assert!(safe_segment(&proposal.identity.workspace_name));
369 assert!(!proposal.identity.workspace_name.contains('/'));
370 }
371
372 #[test]
373 fn each_revision_gets_its_own_ref_outside_refs_heads() {
374 let proposal = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
375 let first = proposal.revision_ref("1111111111111111111111111111111111111111");
376 let amended = proposal.revision_ref("2222222222222222222222222222222222222222");
377 assert_ne!(first, amended, "an amend would have overwritten a revision");
378 assert!(first.starts_with("refs/proposals/"));
379 assert!(!first.starts_with("refs/heads/"));
380 }
381
382 #[test]
383 fn a_detached_head_is_refused_with_the_repair() {
384 let failure = Proposal::derive("agents/demo", "", "main").unwrap_err();
385 assert!(failure.message.contains("--change"), "{}", failure.message);
386 }
387
388 #[test]
389 fn review_target_names_the_destination_not_the_proposal() {
390 let proposal = Proposal::derive("agents/demo", "fix-parser", "main").unwrap();
391 assert_eq!(
392 proposal.review_target("agents/demo"),
393 "agents/demo:refs/heads/main"
394 );
395 assert_ne!(
396 proposal.review_target("agents/demo"),
397 proposal.revision_ref("abc")
398 );
399 }
400
401 #[test]
402 fn a_shortened_change_id_keeps_the_part_a_reader_recognises() {
403 let long = "propose:agents/demo:".to_string() + &"a1".repeat(32);
404 let short = short_change_id(&long);
405 assert!(short.starts_with("propose:agents/demo:a1a1a1a1a1a1"));
406 assert!(short.ends_with('\u{2026}'));
407 assert!(short.chars().count() < long.chars().count());
408 }
409
410 #[test]
411 fn an_id_with_no_room_to_shorten_is_returned_whole() {
412 // Truncating here would produce an id shorter than the one it
413 // stands for while still claiming to elide something.
414 for id in ["propose:agents/demo:abc", "no-colons-at-all"] {
415 assert_eq!(short_change_id(id), id);
416 }
417 }
418}