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}