Skip to main content

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}