majordomus_cli/convergence/mod.rs
1//! Where the work of this repository is being held, and whether any of it is held
2//! somewhere it can be lost.
3//!
4//! # The problem this exists for
5//!
6//! `project.a-worker-that-stops-leaves-its-work-behind` is written down as advisory, and
7//! says why: *"nothing can see the uncommitted half except somebody looking, which is
8//! exactly why the responsibility is written down rather than gated."* That premise was
9//! true when the rule was written and is not true now. [`crate::worktree`] already counts
10//! the uncommitted work of every registered worktree, and git answers in one call which
11//! commits of this repository have never reached a remote. What was missing was not a
12//! measurement but a **verdict** over the repository: one word that says whether every
13//! unit of work is somewhere another worker could find it.
14//!
15//! # What a holding is
16//!
17//! A holding is anything that can hold work: a worktree (its uncommitted files), a branch
18//! (its commits), a stash (its diff). Each is given a [`Disposition`] from the evidence,
19//! and a holding whose disposition is [`Disposition::LocalOnly`] or
20//! [`Disposition::Uncommitted`] is **at risk**: it exists on one disk, it is invisible to
21//! every other worker, and a worker that stops is not a rollback.
22//!
23//! # What this is not
24//!
25//! It is not a second reading of the topology — [`crate::worktree::WorktreeService`] is
26//! the only one, and this composes its answer. It is not a pull-request inventory: a pull
27//! request lives on a forge, reaching one is a network call, and the completion invariant
28//! already asks whether the trunk reaches a commit (`integrated`). This answers the half
29//! that is decidable offline, from this repository alone, in one pass.
30//!
31//! ```
32//! use majordomus_cli::convergence::Disposition;
33//!
34//! // the two dispositions that mean "this exists on one disk only"
35//! assert!(Disposition::LocalOnly.at_risk() && Disposition::Uncommitted.at_risk());
36//! // and the two that mean somebody else could pick it up
37//! assert!(!Disposition::Integrated.at_risk() && !Disposition::Published.at_risk());
38//! ```
39
40use std::collections::{BTreeMap, BTreeSet};
41use std::path::Path;
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46use crate::worktree::{git, Detail, Result, WorktreeService};
47
48/// The schema this module answers under.
49pub const SCHEMA: &str = "convergence/v1";
50
51// ---------------------------------------------------------------- vocabulary
52
53/// What kind of thing is holding the work.
54///
55/// ```
56/// use majordomus_cli::convergence::HoldingKind;
57///
58/// // the word a report and a persisted record use; renaming a variant must not rename it
59/// assert_eq!(HoldingKind::Worktree.as_str(), "worktree");
60/// assert_eq!(serde_json::to_string(&HoldingKind::Stash).unwrap(), "\"stash\"");
61/// ```
62#[derive(
63 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum HoldingKind {
67 /// A registered work tree, holding files that are not committed.
68 Worktree,
69 /// A local branch, holding commits.
70 Branch,
71 /// An entry of `git stash`.
72 Stash,
73}
74
75impl HoldingKind {
76 /// The word this kind is reported under — the same word its serialisation carries.
77 ///
78 /// ```
79 /// use majordomus_cli::convergence::HoldingKind;
80 ///
81 /// for kind in [HoldingKind::Worktree, HoldingKind::Branch, HoldingKind::Stash] {
82 /// let json = serde_json::to_string(&kind).unwrap();
83 /// assert_eq!(json, format!("\"{}\"", kind.as_str()));
84 /// }
85 /// ```
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 HoldingKind::Worktree => "worktree",
89 HoldingKind::Branch => "branch",
90 HoldingKind::Stash => "stash",
91 }
92 }
93}
94
95/// Where the work a holding carries can be reached from.
96///
97/// The order is the order of safety: everything above [`Disposition::LocalOnly`] is
98/// reachable by somebody who is not the worker that made it.
99///
100/// ```
101/// use majordomus_cli::convergence::Disposition;
102///
103/// // declared in the order of safety, so the derived order says which is worse
104/// assert!(Disposition::Integrated < Disposition::Published);
105/// assert!(Disposition::Published < Disposition::LocalOnly);
106/// assert_eq!(serde_json::to_string(&Disposition::LocalOnly).unwrap(), "\"local_only\"");
107/// ```
108#[derive(
109 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
110)]
111#[serde(rename_all = "snake_case")]
112pub enum Disposition {
113 /// The trunk reaches it: it is in the repository's own history.
114 Integrated,
115 /// A remote reaches it: another checkout can fetch it, and it survives this disk.
116 Published,
117 /// Committed here and nowhere else. One disk away from being lost.
118 LocalOnly,
119 /// Not committed at all: files in a work tree, or a stash entry.
120 Uncommitted,
121}
122
123impl Disposition {
124 /// The word this disposition is reported under — the same word its serialisation
125 /// carries, so a tally keyed by it and a holding carrying it agree.
126 ///
127 /// ```
128 /// use majordomus_cli::convergence::Disposition;
129 ///
130 /// assert_eq!(Disposition::LocalOnly.as_str(), "local_only");
131 /// let json = serde_json::to_string(&Disposition::Uncommitted).unwrap();
132 /// assert_eq!(json, format!("\"{}\"", Disposition::Uncommitted.as_str()));
133 /// ```
134 pub fn as_str(&self) -> &'static str {
135 match self {
136 Disposition::Integrated => "integrated",
137 Disposition::Published => "published",
138 Disposition::LocalOnly => "local_only",
139 Disposition::Uncommitted => "uncommitted",
140 }
141 }
142
143 /// This holding's work exists on one disk only: the verdict refuses over exactly these.
144 ///
145 /// ```
146 /// use majordomus_cli::convergence::Disposition;
147 ///
148 /// let risky: Vec<_> = [
149 /// Disposition::Integrated,
150 /// Disposition::Published,
151 /// Disposition::LocalOnly,
152 /// Disposition::Uncommitted,
153 /// ]
154 /// .into_iter()
155 /// .filter(Disposition::at_risk)
156 /// .collect();
157 /// assert_eq!(risky, [Disposition::LocalOnly, Disposition::Uncommitted]);
158 /// ```
159 pub fn at_risk(&self) -> bool {
160 matches!(self, Disposition::LocalOnly | Disposition::Uncommitted)
161 }
162}
163
164// ---------------------------------------------------------------- the documents
165
166/// One thing holding work, and what is known about where that work can be reached from.
167///
168/// ```
169/// use majordomus_cli::convergence::{Disposition, Holding, HoldingKind};
170///
171/// let branch = Holding {
172/// kind: HoldingKind::Branch,
173/// identity: "feature/x".into(),
174/// disposition: Disposition::LocalOnly,
175/// evidence: "abc123 is on no remote-tracking ref of this checkout".into(),
176/// remedy: "git push -u origin feature/x".into(),
177/// at_risk: true,
178/// };
179/// // an at-risk holding always carries the command that would move it out of danger
180/// assert!(branch.at_risk && !branch.remedy.is_empty());
181/// // and a reachable one carries none, so the field is left out of the document
182/// let safe = Holding { disposition: Disposition::Published, remedy: String::new(), at_risk: false, ..branch };
183/// assert!(!serde_json::to_string(&safe).unwrap().contains("remedy"));
184/// ```
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
186pub struct Holding {
187 /// What kind of thing holds it.
188 pub kind: HoldingKind,
189 /// How it is named: a branch name, an absolute worktree path, a stash ref.
190 pub identity: String,
191 /// Where the work can be reached from.
192 pub disposition: Disposition,
193 /// What was actually read — never a restatement of the disposition.
194 pub evidence: String,
195 /// The command that would move it out of danger. Empty when it is not at risk.
196 #[serde(default, skip_serializing_if = "String::is_empty")]
197 pub remedy: String,
198 /// This holding's work exists on one disk only.
199 pub at_risk: bool,
200}
201
202/// The repository's convergence: every holding, and the one verdict over them.
203///
204/// ```
205/// use majordomus_cli::convergence::{ConvergenceReport, SCHEMA};
206///
207/// let empty = ConvergenceReport {
208/// schema: SCHEMA.into(),
209/// repository: "/tmp/r".into(),
210/// trunk: Some("master".into()),
211/// holdings: vec![],
212/// tallies: Default::default(),
213/// at_risk: 0,
214/// converged: true,
215/// };
216/// // the verdict is the one word a gate reads; the count is what a person reads next to it
217/// assert!(empty.converged && empty.at_risk == 0);
218/// assert_eq!(serde_json::to_value(&empty).unwrap()["schema"], "convergence/v1");
219/// ```
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
221pub struct ConvergenceReport {
222 /// [`SCHEMA`].
223 pub schema: String,
224 /// The primary checkout this was measured from.
225 pub repository: String,
226 /// The trunk the containment was decided against, when there is one.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub trunk: Option<String>,
229 /// Every holding, at-risk ones first, then by kind and identity.
230 pub holdings: Vec<Holding>,
231 /// How many holdings carry each disposition, by its word.
232 pub tallies: BTreeMap<String, usize>,
233 /// The holdings that exist on one disk only.
234 pub at_risk: usize,
235 /// Nothing is held where it can be lost.
236 pub converged: bool,
237}
238
239impl ConvergenceReport {
240 /// The one line a person or a gate reads first, stating the count it refuses over.
241 ///
242 /// ```
243 /// use majordomus_cli::convergence::{ConvergenceReport, SCHEMA};
244 ///
245 /// let mut report = ConvergenceReport {
246 /// schema: SCHEMA.into(),
247 /// repository: "/tmp/r".into(),
248 /// trunk: None,
249 /// holdings: vec![],
250 /// tallies: Default::default(),
251 /// at_risk: 0,
252 /// converged: true,
253 /// };
254 /// assert!(report.summary().starts_with("converged"));
255 /// report.at_risk = 2;
256 /// report.converged = false;
257 /// assert!(report.summary().starts_with("not converged: 2 of"));
258 /// ```
259 pub fn summary(&self) -> String {
260 if self.converged {
261 return format!(
262 "converged: {} holding(s), every one reachable by somebody else",
263 self.holdings.len()
264 );
265 }
266 format!(
267 "not converged: {} of {} holding(s) exist on this disk only",
268 self.at_risk,
269 self.holdings.len()
270 )
271 }
272}
273
274/// One collection, one order, declared once: at-risk holdings first (the group), then by
275/// kind in the order a worker meets them (a work tree, its branch, a stash), then by
276/// identity. Every surface that lists holdings shows this order, because none of them sorts.
277impl crate::order::Ordered for Holding {
278 fn order_key(&self) -> crate::order::OrderKey<'_> {
279 let group = if self.at_risk { "at-risk" } else { "reachable" };
280 let rank = match self.kind {
281 HoldingKind::Worktree => 1,
282 HoldingKind::Branch => 2,
283 HoldingKind::Stash => 3,
284 };
285 crate::order::OrderKey::grouped(group, &self.identity, &self.identity).ranked(rank)
286 }
287}
288
289// ---------------------------------------------------------------- the measurement
290
291/// Measure the repository holding `root`.
292///
293/// One topology read (the same service the hooks and the Cockpit ask), one `git rev-list`
294/// for the commits no remote reaches, one `git stash list`. Nothing here writes, and
295/// nothing here reaches the network: a remote-tracking ref is what this checkout last
296/// fetched, which is exactly the question — whether the work left this disk, not whether
297/// the remote still has it.
298///
299/// ```
300/// use std::process::Command;
301/// use majordomus_cli::convergence::{report, Disposition};
302///
303/// let dir = std::env::temp_dir().join(format!("mj-convergence-doc-{}", std::process::id()));
304/// let _ = std::fs::remove_dir_all(&dir);
305/// std::fs::create_dir_all(&dir).unwrap();
306/// let git = |args: &[&str]| {
307/// let ok = Command::new("git").current_dir(&dir).args(args).status().unwrap().success();
308/// assert!(ok, "git {args:?}");
309/// };
310/// git(&["init", "-q", "-b", "master"]);
311/// let commit = |msg: &str| git(&["-c", "user.email=d@example.com", "-c", "user.name=doc",
312/// "commit", "-q", "--allow-empty", "-m", msg]);
313/// commit("base");
314/// git(&["checkout", "-q", "-b", "feature/doc"]);
315/// commit("work nobody else has");
316/// git(&["checkout", "-q", "master"]);
317///
318/// // a commit on a branch the trunk does not reach and no remote has seen is on one disk
319/// let verdict = report(&dir).unwrap();
320/// assert!(!verdict.converged);
321/// assert!(verdict.holdings.iter().any(|h| h.disposition == Disposition::LocalOnly));
322///
323/// // and a file never committed is the other half
324/// std::fs::write(dir.join("draft.txt"), "half written").unwrap();
325/// let verdict = report(&dir).unwrap();
326/// assert!(verdict.holdings.iter().any(|h| h.disposition == Disposition::Uncommitted));
327/// std::fs::remove_dir_all(&dir).unwrap();
328/// ```
329pub fn report(root: &Path) -> Result<ConvergenceReport> {
330 let topology = WorktreeService::open(root).and_then(|s| s.topology(Detail::Full))?;
331 let primary = topology.repository.primary_worktree.clone();
332 let unreachable = commits_no_remote_reaches(Path::new(&primary))?;
333 let stashes = stash_entries(Path::new(&primary))?;
334
335 let mut holdings = Vec::new();
336
337 for worktree in &topology.worktrees {
338 let Some(dirty) = worktree.dirty.as_ref() else {
339 continue;
340 };
341 if dirty.clean {
342 continue;
343 }
344 holdings.push(Holding {
345 kind: HoldingKind::Worktree,
346 identity: worktree.path.clone(),
347 disposition: Disposition::Uncommitted,
348 evidence: format!("{} — {}", worktree.label, dirty.summary()),
349 remedy: format!(
350 "git -C {} add -A && git -C {} commit -m 'checkpoint: unreviewed'",
351 worktree.path, worktree.path
352 ),
353 at_risk: true,
354 });
355 }
356
357 for branch in &topology.branches {
358 let (disposition, evidence) = if branch.merged_into_trunk == Some(true) {
359 (
360 Disposition::Integrated,
361 format!("the trunk reaches {}", short(&branch.head)),
362 )
363 } else if unreachable.contains(&branch.head) {
364 (
365 Disposition::LocalOnly,
366 format!(
367 "{} is on no remote-tracking ref of this checkout",
368 short(&branch.head)
369 ),
370 )
371 } else {
372 (
373 Disposition::Published,
374 format!("a remote-tracking ref reaches {}", short(&branch.head)),
375 )
376 };
377 let at_risk = disposition.at_risk();
378 holdings.push(Holding {
379 kind: HoldingKind::Branch,
380 identity: branch.name.clone(),
381 disposition,
382 evidence,
383 remedy: if at_risk {
384 format!("git push -u origin {}", branch.name)
385 } else {
386 String::new()
387 },
388 at_risk,
389 });
390 }
391
392 for (reference, subject) in stashes {
393 holdings.push(Holding {
394 kind: HoldingKind::Stash,
395 identity: reference.clone(),
396 disposition: Disposition::Uncommitted,
397 evidence: subject,
398 remedy: format!("git stash show -p {reference}"),
399 at_risk: true,
400 });
401 }
402
403 crate::order::canonical(&mut holdings);
404
405 let mut tallies: BTreeMap<String, usize> = BTreeMap::new();
406 for holding in &holdings {
407 *tallies
408 .entry(holding.disposition.as_str().to_string())
409 .or_default() += 1;
410 }
411 let at_risk = holdings.iter().filter(|h| h.at_risk).count();
412
413 Ok(ConvergenceReport {
414 schema: SCHEMA.to_string(),
415 repository: primary,
416 trunk: topology.trunk.branch.clone(),
417 holdings,
418 tallies,
419 at_risk,
420 converged: at_risk == 0,
421 })
422}
423
424/// The commits that are on a local branch and on no remote-tracking ref.
425///
426/// One call: `git rev-list --branches --not --remotes`. Asking per branch would be one
427/// subprocess per branch, and this repository has three hundred of them. `--no-walk` is
428/// deliberately *not* used — it drops the exclusion, and the probe then reports that
429/// nothing is unpublished no matter what is unpublished.
430fn commits_no_remote_reaches(primary: &Path) -> Result<BTreeSet<String>> {
431 text_of(primary, &["rev-list", "--branches", "--not", "--remotes"]).map(|text| {
432 text.lines()
433 .map(str::trim)
434 .filter(|l| !l.is_empty())
435 .map(str::to_string)
436 .collect()
437 })
438}
439
440/// Every stash entry: its ref and the subject git records for it.
441fn stash_entries(primary: &Path) -> Result<Vec<(String, String)>> {
442 text_of(primary, &["stash", "list", "--format=%gd%x09%gs"]).map(|text| stash_list(&text))
443}
444
445/// What git answered to `args`, as text. A command that failed and an answer that is not
446/// UTF-8 are both refusals: neither is an empty answer.
447fn text_of(primary: &Path, args: &[&str]) -> Result<String> {
448 git::run(primary, args)?.text()
449}
450
451/// The entries of `git stash list --format=%gd%x09%gs`: a reference and its subject per
452/// line. A line with no reference names no entry and is skipped rather than invented.
453fn stash_list(text: &str) -> Vec<(String, String)> {
454 text.lines()
455 .filter_map(|line| {
456 let (reference, subject) = line.split_once('\t')?;
457 if reference.trim().is_empty() {
458 return None;
459 }
460 Some((reference.trim().to_string(), subject.trim().to_string()))
461 })
462 .collect()
463}
464
465/// A commit, abbreviated the way git abbreviates it in prose.
466fn short(commit: &str) -> &str {
467 let end = commit.len().min(9);
468 &commit[..end]
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 /// The vocabulary is the contract: a reader that persists these words must see the
476 /// same ones tomorrow.
477 #[test]
478 fn the_words_are_stable() {
479 assert_eq!(Disposition::Integrated.as_str(), "integrated");
480 assert_eq!(Disposition::Published.as_str(), "published");
481 assert_eq!(Disposition::LocalOnly.as_str(), "local_only");
482 assert_eq!(Disposition::Uncommitted.as_str(), "uncommitted");
483 assert_eq!(HoldingKind::Worktree.as_str(), "worktree");
484 assert_eq!(HoldingKind::Branch.as_str(), "branch");
485 assert_eq!(HoldingKind::Stash.as_str(), "stash");
486 }
487
488 /// Exactly the two dispositions that mean "one disk" are at risk. This is the
489 /// judgement the gate rests on, so it is asserted rather than assumed.
490 #[test]
491 fn at_risk_is_exactly_the_unreachable_two() {
492 let all = [
493 Disposition::Integrated,
494 Disposition::Published,
495 Disposition::LocalOnly,
496 Disposition::Uncommitted,
497 ];
498 let at_risk: Vec<&str> = all
499 .iter()
500 .filter(|d| d.at_risk())
501 .map(|d| d.as_str())
502 .collect();
503 assert_eq!(at_risk, vec!["local_only", "uncommitted"]);
504 }
505
506 /// A summary states the count it refuses over, so a person reading the one line knows
507 /// how much is in danger without reading the list.
508 #[test]
509 fn the_summary_states_the_count_it_refuses_over() {
510 let mut report = ConvergenceReport {
511 schema: SCHEMA.into(),
512 repository: "/tmp/r".into(),
513 trunk: Some("master".into()),
514 holdings: vec![Holding {
515 kind: HoldingKind::Branch,
516 identity: "feature/x".into(),
517 disposition: Disposition::LocalOnly,
518 evidence: "abc on no remote".into(),
519 remedy: "git push -u origin feature/x".into(),
520 at_risk: true,
521 }],
522 tallies: BTreeMap::new(),
523 at_risk: 1,
524 converged: false,
525 };
526 assert!(report.summary().contains("1 of 1"));
527 report.at_risk = 0;
528 report.converged = true;
529 assert!(report.summary().starts_with("converged"));
530 }
531
532 /// An abbreviation never panics on a short or empty commit: the topology can answer
533 /// with an empty head for a branch git could not resolve.
534 #[test]
535 fn abbreviating_a_short_commit_is_not_a_panic() {
536 assert_eq!(short(""), "");
537 assert_eq!(short("abc"), "abc");
538 assert_eq!(short("0123456789abcdef"), "012345678");
539 }
540
541 /// Run git in `dir` and insist it succeeded: a fixture that half-built is a test that
542 /// asserts about something else.
543 fn git(dir: &Path, args: &[&str]) -> String {
544 let out = std::process::Command::new("git")
545 .current_dir(dir)
546 .args([
547 "-c",
548 "user.email=t@example.com",
549 "-c",
550 "user.name=t",
551 "-c",
552 "commit.gpgsign=false",
553 ])
554 .args(args)
555 .output()
556 .expect("git runs");
557 assert!(
558 out.status.success(),
559 "git {args:?}: {}",
560 String::from_utf8_lossy(&out.stderr)
561 );
562 String::from_utf8_lossy(&out.stdout).trim().to_string()
563 }
564
565 /// A repository with one commit on `master` and a tracked file to change.
566 fn repository() -> tempfile::TempDir {
567 let dir = tempfile::tempdir().unwrap();
568 let repo = dir.path().join("repo");
569 std::fs::create_dir_all(&repo).unwrap();
570 git(&repo, &["init", "-q", "-b", "master"]);
571 std::fs::write(repo.join("a.txt"), "one\n").unwrap();
572 git(&repo, &["add", "a.txt"]);
573 git(&repo, &["commit", "-q", "-m", "base"]);
574 dir
575 }
576
577 /// Point a ref at an object this repository does not have, the way a crashed write or a
578 /// half-copied `.git` leaves one.
579 fn break_ref(repo: &Path, name: &str) {
580 let path = repo.join(".git").join(name);
581 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
582 std::fs::write(path, "1111111111111111111111111111111111111111\n").unwrap();
583 }
584
585 /// Every kind of holding gets the disposition git's evidence gives it: the trunk is
586 /// integrated, a pushed branch is published, a branch no remote has seen is local only,
587 /// and a stash and a dirty work tree are uncommitted. A registered work tree whose
588 /// directory is gone holds nothing that could be lost and is not a holding.
589 #[test]
590 fn every_holding_is_given_the_disposition_its_evidence_supports() {
591 let dir = repository();
592 let repo = dir.path().join("repo");
593 let origin = dir.path().join("origin.git");
594 git(
595 dir.path(),
596 &["init", "-q", "--bare", origin.to_str().unwrap()],
597 );
598 git(
599 &repo,
600 &["remote", "add", "origin", origin.to_str().unwrap()],
601 );
602 git(&repo, &["push", "-q", "origin", "master"]);
603
604 git(&repo, &["checkout", "-q", "-b", "feature/pushed"]);
605 git(&repo, &["commit", "-q", "--allow-empty", "-m", "shared"]);
606 git(&repo, &["push", "-q", "-u", "origin", "feature/pushed"]);
607 git(&repo, &["checkout", "-q", "-b", "feature/local"]);
608 git(
609 &repo,
610 &[
611 "commit",
612 "-q",
613 "--allow-empty",
614 "-m",
615 "nobody else has this",
616 ],
617 );
618 let local_head = git(&repo, &["rev-parse", "HEAD"]);
619 git(&repo, &["checkout", "-q", "master"]);
620
621 // a registered work tree whose directory was removed by hand
622 let gone = dir.path().join("gone");
623 git(
624 &repo,
625 &[
626 "worktree",
627 "add",
628 "-q",
629 "-b",
630 "feature/gone",
631 gone.to_str().unwrap(),
632 ],
633 );
634 std::fs::remove_dir_all(&gone).unwrap();
635
636 std::fs::write(repo.join("a.txt"), "two\n").unwrap();
637 git(&repo, &["stash", "push", "-q", "-m", "parked work"]);
638 std::fs::write(repo.join("draft.txt"), "half written\n").unwrap();
639
640 let verdict = report(&repo).unwrap();
641 let of = |kind: HoldingKind, identity: &str| {
642 verdict
643 .holdings
644 .iter()
645 .find(|h| h.kind == kind && h.identity == identity)
646 .unwrap_or_else(|| panic!("no {identity}: {:#?}", verdict.holdings))
647 .clone()
648 };
649
650 let local = of(HoldingKind::Branch, "feature/local");
651 assert_eq!(local.disposition, Disposition::LocalOnly);
652 assert!(local.at_risk);
653 assert_eq!(
654 local.evidence,
655 format!(
656 "{} is on no remote-tracking ref of this checkout",
657 &local_head[..9]
658 )
659 );
660 assert_eq!(local.remedy, "git push -u origin feature/local");
661
662 let pushed = of(HoldingKind::Branch, "feature/pushed");
663 assert_eq!(pushed.disposition, Disposition::Published);
664 assert!(!pushed.at_risk);
665 assert!(
666 pushed
667 .evidence
668 .starts_with("a remote-tracking ref reaches "),
669 "{}",
670 pushed.evidence
671 );
672 assert_eq!(
673 pushed.remedy, "",
674 "nothing to do for what is already shared"
675 );
676
677 assert_eq!(
678 of(HoldingKind::Branch, "master").disposition,
679 Disposition::Integrated
680 );
681
682 let stash = of(HoldingKind::Stash, "stash@{0}");
683 assert_eq!(stash.disposition, Disposition::Uncommitted);
684 assert!(stash.at_risk);
685 assert_eq!(stash.evidence, "On master: parked work");
686 assert_eq!(stash.remedy, "git stash show -p stash@{0}");
687
688 let worktrees: Vec<&Holding> = verdict
689 .holdings
690 .iter()
691 .filter(|h| h.kind == HoldingKind::Worktree)
692 .collect();
693 assert_eq!(worktrees.len(), 1, "only the dirty one: {worktrees:#?}");
694 assert_eq!(worktrees[0].disposition, Disposition::Uncommitted);
695 assert!(!worktrees[0].identity.ends_with("gone"));
696
697 assert!(!verdict.converged);
698 assert_eq!(
699 verdict.at_risk, 3,
700 "the local branch, the stash, the work tree"
701 );
702 assert_eq!(verdict.tallies.get("local_only"), Some(&1));
703 assert_eq!(verdict.tallies.get("uncommitted"), Some(&2));
704 assert_eq!(verdict.tallies.get("published"), Some(&1));
705 // what is at risk is listed before what is not, and within the at-risk group the
706 // order is the kind's rank: the work tree, then the branch, then the stash
707 let at_risk: Vec<HoldingKind> = verdict
708 .holdings
709 .iter()
710 .take_while(|h| h.at_risk)
711 .map(|h| h.kind)
712 .collect();
713 assert_eq!(
714 at_risk,
715 vec![
716 HoldingKind::Worktree,
717 HoldingKind::Branch,
718 HoldingKind::Stash
719 ]
720 );
721 }
722
723 /// A repository git cannot read in full is an error, never a verdict over the part it
724 /// could read: a broken branch ref stops the topology, a broken remote-tracking ref stops
725 /// the reachability question, and a broken stash ref stops the stash list.
726 #[test]
727 fn a_repository_git_cannot_read_is_an_error_and_never_a_verdict() {
728 for broken in [
729 "refs/heads/broken",
730 "refs/remotes/origin/broken",
731 "refs/stash",
732 ] {
733 let dir = repository();
734 let repo = dir.path().join("repo");
735 break_ref(&repo, broken);
736 let refused = report(&repo).expect_err(broken);
737 assert_eq!(refused.code(), "GitCommandFailed", "{broken}: {refused}");
738 }
739 }
740
741 /// The stash list is read line by line; a line that carries no reference is not an
742 /// entry, and the subject keeps everything after the first tab.
743 #[test]
744 fn a_stash_line_without_a_reference_names_no_entry() {
745 assert_eq!(
746 stash_list("stash@{0}\tOn master: a\tb\nno tab here\n\tOn master: orphan\n"),
747 vec![("stash@{0}".to_string(), "On master: a\tb".to_string())]
748 );
749 assert!(stash_list("").is_empty());
750 }
751}