Skip to main content

majordomus_cli/session/
mod.rs

1//! The session domain: one typed account of what an episode is, what identifies it, how it
2//! moves, and how it is closed exactly once.
3//!
4//! # Why this module exists
5//!
6//! This repository runs two programs over one subsystem. The shell tool (`lib/session.sh`,
7//! `lib/capture.sh`, `lib/checkpoint.sh`, `lib/handover.sh`) owns the writes, the lifecycle
8//! and the state machine; this executable owns the typed read model, the server, the HTTP
9//! API, MCP and the Cockpit. [`crate::capability::builtin::continuity`] opens by declaring
10//! the split — *"It is read, never written. The lifecycle is the shell tool's."*
11//!
12//! ADR 0052 found what the split costs. For six days this repository wrote no checkpoint
13//! and no handover while `doctor` and `watch` reported health, because the invariant that
14//! would have caught it — *lifecycle events are arriving, therefore derived state must
15//! advance* — spans both halves and is owned by neither. The reader faithfully reported the
16//! newest record it could find; the writer faithfully refused to write one. ADR 0052 names
17//! the target: **one canonical session/continuity domain service in this crate**, with
18//! `.envrc`, the provider hook shims, the CLI, MCP, HTTP and the Cockpit as adapters over
19//! it. This module is that service's domain.
20//!
21//! # What this change is, and what it is not
22//!
23//! It is **additive**. The shell keeps every write it has. Nothing here changes the
24//! behaviour of the running lifecycle: the projections are read-only queries, and the write
25//! path ([`SessionStore::close`]) exists, is tested, and is reachable only through
26//! [`SessionStore::writable`] — a constructor no surface calls. ADR 0047 records the cutover
27//! and its order. Four workers are editing `lib/*.sh` as this is written, and a cutover in
28//! the same change would be a merge whose conflicts are semantic rather than textual, in the
29//! one subsystem where a wrong merge is invisible until somebody resumes from a record that
30//! is not theirs.
31//!
32//! # The four things it settles
33//!
34//! **[Identity](identity).** Six things the stores spell as three words become six types
35//! with no conversion between them. [`RepositoryId`] and [`CheckoutId`] wrap the digests
36//! this crate already computes for its lease, index and worktree topology, rather than
37//! inventing a seventh notion of "which repository".
38//!
39//! **[The aggregates](episode).** An [`Episode`] carries `Option<TaskId>` and nothing else
40//! of a task. ADR 0052's defect — a task's state deciding whether an episode's artefact is
41//! written — is unrepresentable, because [`EpisodeState::may_move_to`] takes no task.
42//!
43//! **[The machine](state).** Four states, six transitions, one terminal state, declared
44//! once and projected as data by `session.machine` so that no surface draws its own.
45//!
46//! **[Persistence](store).** Closing takes an `O_EXCL` claim before the expensive part, so
47//! four repeated closes and eight simultaneous ones yield exactly one record. The episode
48//! that has four records in this repository is the reason.
49//!
50//! **[Events](ledger).** Typed, over the ledger that already exists, against the vocabulary
51//! `share/events.yaml` already declares. No second store: locked append-only JSONL meets
52//! concurrency, corruption detection and recovery, and ADR 0047 records why a database was
53//! refused rather than merely not chosen.
54//!
55//! # Freshness is consumed, not restated
56//!
57//! `fresh | aging | stale | unknown | invalid`, `Thresholds::judge` and `epoch_seconds`
58//! arrive with ADR 0052 in `continuity.rs`. This domain does not define a second vocabulary
59//! for age: it takes Unix seconds and answers the single predicate the machine needs,
60//! [`Episode::stranded_after`].
61//!
62//! ```
63//! use majordomus_cli::session::{EpisodeState, Machine, open_episodes};
64//!
65//! // the machine is derived from the types and has one terminal state
66//! let machine = Machine::describe();
67//! assert_eq!(machine.states.len(), EpisodeState::ALL.len());
68//! assert_eq!(machine.states.iter().filter(|s| s.terminal).count(), 1);
69//!
70//! // and a checkout that has never run the lifecycle reports absence, not a fault
71//! let dir = tempfile::tempdir().unwrap();
72//! assert!(open_episodes(dir.path()).is_empty());
73//! ```
74
75pub mod episode;
76pub mod identity;
77pub mod ledger;
78pub mod state;
79pub mod store;
80
81pub use episode::{Episode, Outcome};
82pub use identity::{
83    CheckoutId, EpisodeId, IdentityFacet, ProviderSessionId, RecordedSpelling, RepositoryId,
84    Spelling, TaskId, WorkerId,
85};
86pub use ledger::{
87    Corruption, DeclaredEvent, Envelope, Ledger, LedgerError, LedgerEvent, LedgerRead, Vocabulary,
88};
89pub use state::{EpisodeState, Machine, StateView, Transition, TransitionView};
90pub use store::{CloseOutcome, CloseReport, SessionStore, StoreError};
91
92use std::path::Path;
93
94use crate::metadata::yaml;
95
96/// Every event name this domain writes.
97///
98/// It is a list of *names*, not of declarations: `share/events.yaml` is the vocabulary and
99/// this is the domain's claim on part of it. A test in [`ledger`] asserts that every name
100/// here is declared there, which is what fails when somebody adds an event and forgets the
101/// vocabulary — rather than a durable line every reader silently ignores, which is what
102/// `mj_ledger_append` produced before the vocabulary existed.
103///
104/// ```
105/// use majordomus_cli::session::WRITTEN_EVENTS;
106/// assert!(WRITTEN_EVENTS.contains(&"session.closed"));
107/// ```
108pub const WRITTEN_EVENTS: &[&str] = &["session.started", "session.closed"];
109
110/// The open episodes of one checkout, as the store holds them.
111///
112/// Read from `.ai/local/state/sessions-open/`, which is a file per provider session rather
113/// than a singleton: an episode used to be one `session-current.yaml` per checkout, and a
114/// second provider session opening one was folded into the first. Measured in this
115/// repository on 2026-09-09: seven concurrent sessions, one record between them.
116///
117/// `last_sign_of_life` is the later of the episode's `started_at` and the newest ledger
118/// line it stamped, which is what makes "nobody has been here for a day" a measurement
119/// rather than an inference from the open record's own age.
120///
121/// ```
122/// use majordomus_cli::session::open_episodes;
123///
124/// let dir = tempfile::tempdir().unwrap();
125/// let open = dir.path().join(".ai/local/state/sessions-open");
126/// std::fs::create_dir_all(&open).unwrap();
127/// std::fs::write(
128///     open.join("01Bv2gJsf.yaml"),
129///     "session_id: s-20260910205542-e2a6\nstarted_at: 2026-09-10T20:55:42Z\nowner: \"korczis\"\n\
130///      provider: \"claude-code\"\nprovider_session: \"01Bv2gJsf\"\nbranch: master\nstart_head: 9f3a2a0\n",
131/// ).unwrap();
132///
133/// let episodes = open_episodes(dir.path());
134/// assert_eq!(episodes.len(), 1);
135/// assert_eq!(episodes[0].id.as_str(), "s-20260910205542-e2a6");
136/// assert!(episodes[0].task.is_none(), "an episode without a task is ordinary");
137/// assert_eq!(episodes[0].provider_session.as_ref().map(|p| p.as_str()), Some("01Bv2gJsf"));
138/// assert_eq!(episodes[0].last_sign_of_life, Some(1_789_073_742));
139/// ```
140pub fn open_episodes(root: &Path) -> Vec<Episode> {
141    let dir = root.join(store::OPEN_DIR);
142    let Ok(entries) = std::fs::read_dir(&dir) else {
143        return Vec::new();
144    };
145    let stamps = ledger_stamps(root);
146    let mut out: Vec<Episode> = Vec::new();
147    for entry in entries.filter_map(|e| e.ok()) {
148        let path = entry.path();
149        if path.extension().is_none_or(|x| x != "yaml") {
150            continue;
151        }
152        let Ok(text) = std::fs::read_to_string(&path) else {
153            continue;
154        };
155        let Ok(map) = yaml::parse_mapping(&text) else {
156            continue;
157        };
158        let s = |k: &str| map.get(k).and_then(yaml::scalar_string).unwrap_or_default();
159        let Some(id) = EpisodeId::parse(&s("session_id")) else {
160            continue;
161        };
162        let started_at = s("started_at");
163        let provider_session = {
164            let raw = s("provider_session");
165            (!raw.is_empty()).then(|| ProviderSessionId::new(raw))
166        };
167        let opened = crate::peers::epoch_seconds(&started_at);
168        let newest = stamps
169            .iter()
170            .filter(|(episode, _)| episode == id.as_str())
171            .filter_map(|(_, at)| *at)
172            .max();
173        let last = match (opened, newest) {
174            (Some(a), Some(b)) => Some(a.max(b)),
175            (a, b) => a.or(b),
176        };
177        let mut episode = Episode {
178            id,
179            state: EpisodeState::Open,
180            repository: RepositoryId::of(root),
181            checkout: CheckoutId::of(root),
182            provider: s("provider"),
183            provider_session,
184            worker: WorkerId::supplied(&s("worker")),
185            task: None,
186            started_at,
187            branch: s("branch"),
188            start_head: s("start_head"),
189            last_sign_of_life: last,
190        };
191        // The task, when the checkout has one open. A relation the episode carries; its
192        // absence is ordinary and is never a reason to skip anything (ADR 0052).
193        if let Some(task) = active_task(root) {
194            episode.task = Some(task);
195        }
196        out.push(episode);
197    }
198    crate::order::canonical(&mut out);
199    out
200}
201
202/// The task this checkout has open, when it has one. Read from `state/current.yaml`, the
203/// active task record — which `lib/start.sh` writes and check, checkpoint, handover and
204/// finish update.
205///
206/// It reads the id and nothing else. An episode relates to a task; it does not hold one,
207/// and a copy of the task's outcome on this side would be the second account that ADR 0052
208/// spent six days of silence proving is a defect.
209fn active_task(root: &Path) -> Option<TaskId> {
210    let text = std::fs::read_to_string(root.join(".ai/local/state/current.yaml")).ok()?;
211    let map = yaml::parse_mapping(&text).ok()?;
212    TaskId::parse(&map.get("id").and_then(yaml::scalar_string)?)
213}
214
215/// The URL of `origin`, which is what `mj_repository_id` writes into a shared record.
216///
217/// A subprocess, and deliberately not a read of `.git/config`: a linked worktree's config
218/// is the common one and an `includeIf` can put the remote somewhere else, so asking git is
219/// the only answer that is right in every checkout this tool runs in. One call per reading
220/// of a capability whose cache is two seconds wide.
221fn remote_url(root: &Path) -> Option<String> {
222    let out = crate::git::read_only(root)
223        .args(["config", "--get", "remote.origin.url"])
224        .output()
225        .ok()?;
226    if !out.status.success() {
227        return None;
228    }
229    let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
230    (!url.is_empty()).then(|| public_url(&url))
231}
232
233/// A URL as it may be shown and written down: without its credentials.
234///
235/// A remote configured as `https://x-access-token:<token>@host/o/r.git` is how a CI clone
236/// and many a laptop are configured, and until this function the whole string was what
237/// `session.identity` answered — to an MCP client, over HTTP, in the Cockpit. Which
238/// repository this is does not depend on who is allowed to push to it.
239///
240/// Over HTTP the whole userinfo goes, because a token is as often the user name as the
241/// password. Over any other scheme only a password does: `ssh://git@host/...` names a
242/// login that is no secret and is part of how the remote is reached. The scp form has no
243/// scheme and carries no password. The same rule as `mj_url_public` in `lib/common.sh`,
244/// which writes this value into shared records.
245///
246/// ```
247/// use majordomus_cli::session::public_url;
248/// assert_eq!(public_url("https://x-access-token:ghs_secret@github.com/o/r.git"), "https://github.com/o/r.git");
249/// assert_eq!(public_url("https://ghp_secret@github.com/o/r.git"), "https://github.com/o/r.git");
250/// assert_eq!(public_url("ssh://git:hunter2@host:22/o/r.git"), "ssh://git@host:22/o/r.git");
251/// // nothing to remove, nothing changed
252/// assert_eq!(public_url("ssh://git@github.com/o/r.git"), "ssh://git@github.com/o/r.git");
253/// assert_eq!(public_url("git@github.com:o/r.git"), "git@github.com:o/r.git");
254/// assert_eq!(public_url("https://github.com/o/r.git"), "https://github.com/o/r.git");
255/// // an at sign in the path is not userinfo
256/// assert_eq!(public_url("https://host/o/r@v1.git"), "https://host/o/r@v1.git");
257/// ```
258pub fn public_url(url: &str) -> String {
259    let Some((scheme, rest)) = url.split_once("://") else {
260        return url.to_string();
261    };
262    let (authority, path) = match rest.find('/') {
263        Some(at) => rest.split_at(at),
264        None => (rest, ""),
265    };
266    let Some((userinfo, host)) = authority.rsplit_once('@') else {
267        return url.to_string();
268    };
269    if scheme == "http" || scheme == "https" {
270        return format!("{scheme}://{host}{path}");
271    }
272    let user = userinfo.split(':').next().unwrap_or_default();
273    format!("{scheme}://{user}@{host}{path}")
274}
275
276/// Free text as it may be shown and written down: every URL in it without its credentials.
277///
278/// For what this tool relays rather than composes — the standard error of a `git` or a
279/// `gh` that failed, which names the remote it could not reach in whatever form the remote
280/// was configured. That text is recorded in the integration trail, and the trail is served.
281/// Whether the subprocess anonymised its own message is a property of someone else's tool
282/// at someone else's version; this is the same rule as [`public_url`], applied to each URL
283/// the text contains, and everything that is not a URL's userinfo is left exactly as it
284/// was.
285///
286/// ```
287/// use majordomus_cli::session::public_text;
288/// assert_eq!(
289///     public_text("fatal: unable to access 'https://user:secret@host/x/': timeout"),
290///     "fatal: unable to access 'https://host/x/': timeout"
291/// );
292/// assert_eq!(
293///     public_text("a https://t0ken@h/a and ssh://git:pw@h:22/b, then c"),
294///     "a https://h/a and ssh://git@h:22/b, then c"
295/// );
296/// // nothing to remove, nothing changed: no URL, a clean URL, an address in a sentence
297/// for same in ["no url here", "see https://host/o/r@v1 now", "mail me@example.org", "a://", "://x@y"] {
298///     assert_eq!(public_text(same), same);
299/// }
300/// ```
301pub fn public_text(text: &str) -> String {
302    let scheme_char = |c: char| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.');
303    let mut out = String::with_capacity(text.len());
304    let mut rest = text;
305    while let Some(at) = rest.find("://") {
306        // the scheme is the run of scheme characters that ends where `://` begins
307        let scheme_start = rest[..at]
308            .char_indices()
309            .rev()
310            .take_while(|(_, c)| scheme_char(*c))
311            .last()
312            .map_or(at, |(i, _)| i);
313        let after = &rest[at + 3..];
314        // a URL ends where text that cannot be part of one begins
315        let end = after
316            .find(|c: char| c.is_whitespace() || matches!(c, '\'' | '"' | '<' | '>' | '`'))
317            .unwrap_or(after.len());
318        out.push_str(&rest[..scheme_start]);
319        let url = &rest[scheme_start..at + 3 + end];
320        if scheme_start == at {
321            out.push_str(url);
322        } else {
323            out.push_str(&public_url(url));
324        }
325        rest = &after[end..];
326    }
327    out.push_str(rest);
328    out
329}
330
331/// `(episode id, its timestamp in Unix seconds)` for every ledger line that carries one.
332///
333/// Selection is by the session id the writer stamped on each line, never by a time range.
334/// The ledger is one file per repository; a window of "everything after this episode
335/// opened" collects another worker's events, because two sessions write to one repository
336/// at once and nothing in a timestamp can tell them apart.
337fn ledger_stamps(root: &Path) -> Vec<(String, Option<i64>)> {
338    Ledger::of(root)
339        .read()
340        .events
341        .iter()
342        .filter_map(|line| {
343            let obj = line.as_object()?;
344            let session = obj.get("session")?.as_str()?.to_string();
345            let ts = obj
346                .get("ts")
347                .and_then(|v| v.as_str())
348                .and_then(crate::peers::epoch_seconds);
349            Some((session, ts))
350        })
351        .collect()
352}
353
354/// How this checkout spells each of the identities the audit found conflated, beside the
355/// canonical value this executable computes.
356///
357/// The point is not to choose a winner. It is to stop pretending the spellings are one
358/// value: a reader that knows a string is the *local* spelling can compare it with other
359/// local spellings and refuse to compare it with a shared one, which is exactly the
360/// comparison that made two records of one repository, written four minutes apart, look
361/// like records of two repositories.
362///
363/// ```
364/// use majordomus_cli::session::identities;
365///
366/// let dir = tempfile::tempdir().unwrap();
367/// let facets = identities(dir.path());
368/// let subjects: Vec<&str> = facets.iter().map(|f| f.subject.as_str()).collect();
369/// assert_eq!(subjects, ["repository", "checkout", "episode", "provider_session"]);
370///
371/// // a plain directory belongs to no git repository, and that is reported as absence
372/// assert!(facets[0].canonical.is_empty());
373/// // a checkout always has an identity: the question has to be answerable for a record
374/// // written on a disk that has since been unmounted
375/// assert_eq!(facets[1].canonical.len(), 32);
376/// ```
377pub fn identities(root: &Path) -> Vec<IdentityFacet> {
378    let git = crate::repository::git_identity(root);
379    let mut repository_spellings = vec![
380        RecordedSpelling {
381            spelling: Spelling::Local,
382            writer: "mj_git_repo_id (lib/common.sh)".to_string(),
383            value: git
384                .as_ref()
385                .map(|g| g.common_dir.display().to_string())
386                .unwrap_or_default(),
387        },
388        RecordedSpelling {
389            spelling: Spelling::Shared,
390            writer: "mj_repository_id (lib/common.sh)".to_string(),
391            value: remote_url(root).unwrap_or_default(),
392        },
393    ];
394    crate::order::canonical(&mut repository_spellings);
395
396    let mut checkout_spellings = vec![
397        RecordedSpelling {
398            spelling: Spelling::Local,
399            writer: "mj_record_front_matter `worktree:` (lib/common.sh)".to_string(),
400            value: root.display().to_string(),
401        },
402        RecordedSpelling {
403            spelling: Spelling::Shared,
404            writer: "mj_worktree_id `worktree_id:` (lib/common.sh)".to_string(),
405            value: String::new(),
406        },
407    ];
408    crate::order::canonical(&mut checkout_spellings);
409
410    let open = open_episodes(root);
411    let episode_spellings = open
412        .first()
413        .map(|e| {
414            vec![RecordedSpelling {
415                spelling: Spelling::Local,
416                writer: "session_id (state/sessions-open/<key>.yaml)".to_string(),
417                value: e.id.as_str().to_string(),
418            }]
419        })
420        .unwrap_or_default();
421    let provider_spellings = open
422        .first()
423        .and_then(|e| e.provider_session.clone())
424        .map(|p| {
425            vec![RecordedSpelling {
426                spelling: Spelling::Local,
427                writer: "provider_session (state/sessions-open/<key>.yaml)".to_string(),
428                value: p.as_str().to_string(),
429            }]
430        })
431        .unwrap_or_default();
432
433    vec![
434        IdentityFacet {
435            subject: "repository".to_string(),
436            canonical: git.map(|g| g.id).unwrap_or_default(),
437            spellings: repository_spellings,
438            note: "one value for every checkout of one repository. The local half writes a \
439                   path and the shared half writes a remote URL; neither can equal the other, \
440                   so a reader that compares them concludes two records of one repository are \
441                   about two repositories."
442                .to_string(),
443        },
444        IdentityFacet {
445            subject: "checkout".to_string(),
446            canonical: CheckoutId::of(root).as_str().to_string(),
447            spellings: checkout_spellings,
448            note: "one worktree. Two checkouts of one repository share a repository identity \
449                   and differ here, which is the first tier of every record resolution in the \
450                   tool: a record from another worktree is never silently offered as your \
451                   context."
452                .to_string(),
453        },
454        IdentityFacet {
455            subject: "episode".to_string(),
456            canonical: open
457                .first()
458                .map(|e| e.id.as_str().to_string())
459                .unwrap_or_default(),
460            spellings: episode_spellings,
461            note: "the canonical session identity and the only one. Minted at open, stamped \
462                   on every ledger line of the episode, and what `session_id:` names in the \
463                   closed record."
464                .to_string(),
465        },
466        IdentityFacet {
467            subject: "provider_session".to_string(),
468            canonical: String::new(),
469            spellings: provider_spellings,
470            note: "an external correlation id, never the session identity. It is how a \
471                   provider's hook finds the episode it opened. An episode opened by hand has \
472                   none; two providers may name their sessions alike; and a worker that \
473                   reconnects keeps its work while the value changes under it."
474                .to_string(),
475        },
476    ]
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn two_open_episodes_in_one_checkout_are_two_episodes() {
485        // The singleton defect, as a test: before `state/sessions-open/` existed, a second
486        // provider session opening an episode in one checkout was folded into the first,
487        // and either window's end event closed the episode for both.
488        let dir = tempfile::tempdir().expect("a temp dir");
489        let open = dir.path().join(store::OPEN_DIR);
490        std::fs::create_dir_all(&open).expect("the store");
491        for (key, id) in [
492            ("first", "s-20260910205542-e2a6"),
493            ("second", "s-20260910210510-8f20"),
494        ] {
495            std::fs::write(
496                open.join(format!("{key}.yaml")),
497                format!(
498                    "session_id: {id}\nstarted_at: 2026-09-10T20:55:42Z\nprovider_session: \"{key}\"\n"
499                ),
500            )
501            .expect("an open record");
502        }
503        let episodes = open_episodes(dir.path());
504        assert_eq!(episodes.len(), 2);
505        let ids: std::collections::BTreeSet<&str> =
506            episodes.iter().map(|e| e.id.as_str()).collect();
507        assert_eq!(ids.len(), 2, "two identities, not one");
508    }
509
510    #[test]
511    fn the_newest_ledger_line_of_an_episode_is_its_sign_of_life() {
512        let dir = tempfile::tempdir().expect("a temp dir");
513        let open = dir.path().join(store::OPEN_DIR);
514        std::fs::create_dir_all(&open).expect("the store");
515        std::fs::write(
516            open.join("k.yaml"),
517            "session_id: s-20260910205542-e2a6\nstarted_at: 2026-09-10T20:55:42Z\n",
518        )
519        .expect("an open record");
520        let ledger = dir.path().join(ledger::LEDGER_PATH);
521        std::fs::create_dir_all(ledger.parent().expect("a parent")).expect("the state dir");
522        std::fs::write(
523            &ledger,
524            "{\"ts\":\"2026-09-10T22:00:00Z\",\"event\":\"task.started\",\"session\":\"s-20260910205542-e2a6\"}\n\
525             {\"ts\":\"2026-09-11T09:00:00Z\",\"event\":\"task.started\",\"session\":\"s-20260910210510-8f20\"}\n",
526        )
527        .expect("a ledger");
528
529        let episodes = open_episodes(dir.path());
530        assert_eq!(episodes.len(), 1);
531        let opened = crate::peers::epoch_seconds("2026-09-10T20:55:42Z").expect("an instant");
532        let stamped = crate::peers::epoch_seconds("2026-09-10T22:00:00Z").expect("an instant");
533        assert_eq!(
534            episodes[0].last_sign_of_life,
535            Some(stamped),
536            "the later of the two, and never another episode's line"
537        );
538        assert!(stamped > opened);
539    }
540
541    #[test]
542    fn a_checkout_that_has_never_run_the_lifecycle_reports_absence_and_not_a_fault() {
543        let dir = tempfile::tempdir().expect("a temp dir");
544        assert!(open_episodes(dir.path()).is_empty());
545        assert!(Ledger::of(dir.path()).read().events.is_empty());
546    }
547
548    // The examples on `public_url` and `public_text` state the rule and run as doc tests,
549    // which the coverage measurement does not count. These hold the same rule where it
550    // does: each branch is reached by a test that would fail if the branch answered
551    // differently.
552
553    #[test]
554    fn over_http_the_whole_userinfo_goes() {
555        for dirty in [
556            "https://x-access-token:ghs_secret@github.com/o/r.git",
557            "https://ghp_secret@github.com/o/r.git",
558        ] {
559            assert_eq!(public_url(dirty), "https://github.com/o/r.git");
560        }
561        assert_eq!(
562            public_url("http://u:p@host:8080/o/r"),
563            "http://host:8080/o/r"
564        );
565    }
566
567    #[test]
568    fn over_any_other_scheme_the_login_stays_and_the_password_goes() {
569        assert_eq!(
570            public_url("ssh://git:hunter2@host:22/o/r.git"),
571            "ssh://git@host:22/o/r.git"
572        );
573        // a URL that stops at its authority has no path to carry over
574        assert_eq!(public_url("ssh://git:hunter2@host"), "ssh://git@host");
575        assert_eq!(public_url("https://token@host"), "https://host");
576    }
577
578    #[test]
579    fn a_url_with_nothing_to_remove_is_returned_as_it_was() {
580        for same in [
581            // no scheme: the scp form, which carries no password
582            "git@github.com:o/r.git",
583            // no userinfo
584            "https://github.com/o/r.git",
585            "ssh://git@github.com/o/r.git",
586            // an at sign in the path is not userinfo
587            "https://host/o/r@v1.git",
588        ] {
589            assert_eq!(public_url(same), same);
590        }
591    }
592
593    #[test]
594    fn every_url_in_a_text_loses_its_credential_and_nothing_else_changes() {
595        assert_eq!(
596            public_text("fatal: unable to access 'https://user:secret@host/x/': timeout"),
597            "fatal: unable to access 'https://host/x/': timeout"
598        );
599        assert_eq!(
600            public_text("a https://t0ken@h/a and ssh://git:pw@h:22/b, then c"),
601            "a https://h/a and ssh://git@h:22/b, then c"
602        );
603        for same in [
604            "no url here",
605            "see https://host/o/r@v1 now",
606            "mail me@example.org",
607            "a://",
608            // `://` with no scheme before it is not a URL, and what follows is left alone
609            "://x@y",
610            "see ://user:secret@host and go on",
611        ] {
612            assert_eq!(public_text(same), same);
613        }
614    }
615
616    #[test]
617    fn the_remote_a_repository_names_is_read_without_its_credential() {
618        let dir = tempfile::tempdir().expect("a temp dir");
619        let git = |args: &[&str]| {
620            let status = std::process::Command::new("git")
621                .args(args)
622                .current_dir(dir.path())
623                .status()
624                .expect("git");
625            assert!(status.success(), "git {args:?}");
626        };
627        git(&["init", "-q", "."]);
628        // no remote at all is no URL, not an empty one
629        assert_eq!(remote_url(dir.path()), None);
630        git(&[
631            "remote",
632            "add",
633            "origin",
634            "https://x-access-token:ghs_secret@github.com/o/r.git",
635        ]);
636        assert_eq!(
637            remote_url(dir.path()).as_deref(),
638            Some("https://github.com/o/r.git")
639        );
640    }
641}