Skip to main content

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}