Skip to main content

git_policy_cli/worktree/
worktree_slow_route.rs

1//! What:
2//!  The plain sentence that tells a person why a `git worktree add` was not made as a snapshot, and the places it goes:
3//!  one line on standard error unless `--quiet` was given, one warning entry in the journal, and the debug log that already
4//!  holds the detail.
5//! Why:
6//!  A snapshot of the main checkout takes seconds and Git's checkout with the ignored-state copy takes minutes. The choice
7//!  between them was made silently, so two slow adds could not be explained afterwards. The sentence names the cause in the
8//!  person's words (a change, a running Git command, a stale index, a missing subvolume) and what makes the next add quick,
9//!  without the names of the ladder's steps. A filesystem that is not btrfs gets no sentence: nothing the person does there
10//!  would make the add a snapshot, so the line would only repeat itself.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const route = explainRefusal({ context, identity, refusal });
15//! route?.tell(plan); // stderr unless quiet, and the journal
16//! ```
17
18use crate::client_journal::record_entry;
19use crate::diagnostic_log::debug;
20use crate::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
21use crate::journald_native::Priority;
22use crate::transaction_git::GitContext;
23use crate::worktree_add_plan::AddPlan;
24use crate::worktree_identity::WorktreeIdentity;
25use crate::worktree_clean::CleanRefusalKind;
26use crate::worktree_snapshot_source::{ChangedPaths, Refusal, RefusalKind, changed_paths, plain};
27use std::io::Write;
28use std::path::Path;
29
30/// Tag used for diagnostics from this module.
31const TAG: &str = "worktreeSlowRoute";
32
33/// The start of the line on standard error and of the journal message; the cause and the remedy follow.
34pub const LEAD: &str = "cli-git: this worktree was not made as a snapshot, so it took longer, because ";
35
36/// What:
37///  What kept the add from being a snapshot, as a sentence for the person and a word for the journal.
38/// Why:
39///   One sentence and one word per cause, chosen by what the cause is and never by the ladder's step that found it.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// type SlowRoute = { word: string; detail: string; sentence: string };
44/// ```
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct SlowRoute {
47    /// The journal field `CLI_GIT_SLOW_CAUSE`: a short word a reader can filter on.
48    word: &'static str,
49    /// The journal field `CLI_GIT_SLOW_DETAIL`: the check's own reason, as the debug log has it.
50    detail: String,
51    /// The rest of the line after `LEAD`: the cause, then what makes the next add quick.
52    sentence: String,
53}
54
55impl SlowRoute {
56    /// The journal word of the cause.
57    pub fn word(&self) -> &'static str {
58        return self.word;
59    }
60
61    /// The check's own reason.
62    pub fn detail(&self) -> &str {
63        return self.detail.as_str();
64    }
65
66    /// The whole line, without its newline.
67    pub fn line(&self) -> String {
68        return format!("{LEAD}{}", self.sentence);
69    }
70
71    /// What:
72    ///  Tell the person on standard error unless the add was quiet, and tell the journal either way.
73    /// Why:
74    ///   `--quiet` silences the terminal and nothing else; the journal is where the cause is found afterwards. A closed
75    ///  standard error is no failure of the add.
76    ///
77    /// In TS you'd write (pseudocode):
78    /// ```ts
79    /// if (!plan.quiet) console.error(route.line()); journal.warn(route.line(), fields);
80    /// ```
81    pub fn tell(&self, plan: &AddPlan) {
82        let line: String = self.line();
83        debug(TAG, format!("{}: told ({}; {})", plan.destination.display(), self.word, self.detail).as_str());
84        if !plan.quiet {
85            // A closed standard error leaves nowhere to print; the journal entry below still records the cause.
86            let _written = std::io::stderr().write_all(format!("{line}\n").as_bytes());
87        }
88        let fields: [(&str, String); 3] = [
89            ("CLI_GIT_WORKTREE", plan.destination.to_string_lossy().into_owned()),
90            ("CLI_GIT_SLOW_CAUSE", self.word.to_string()),
91            ("CLI_GIT_SLOW_DETAIL", self.detail.clone()),
92        ];
93        record_entry(Priority::Warning, line.as_str(), &fields);
94    }
95}
96
97/// What:
98///  How the source is called in a sentence: the main checkout, or the worktree the person ran the add from.
99/// Why:
100///   The person knows which checkout they sit in; the sentence names it the way they would.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// identity.kind === 'main' ? 'the main checkout' : 'this worktree'
105/// ```
106fn subject(identity: &WorktreeIdentity) -> &'static str {
107    return match identity {
108        WorktreeIdentity::MainWorktree { .. } => "the main checkout",
109        _ => "this worktree",
110    };
111}
112
113/// The closing words of a remedy: what the next add becomes.
114const THEN_QUICK: &str = "and the next add is a snapshot that takes seconds";
115
116/// What:
117///  The sentence for a source whose Git status could not be read, with the run that failed and what the person can do.
118/// Why:
119///   The status is what tells a real change from a record of file times that is out of date; without it neither cause can be
120///  named, and the person can read Git's own error by running the same command.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// const route = statusFailed({ subject, root, reason });
125/// ```
126fn status_failed(subject: &str, root: &Path, reason: &str) -> SlowRoute {
127    return SlowRoute {
128        word: "status-failed",
129        detail: reason.to_string(),
130        sentence: format!(
131            "Git could not list the uncommitted changes of {subject} ({}). Run `git -C {} status` to read its error and fix what it names, or wait if another Git command is running there, {THEN_QUICK}.",
132            plain(reason),
133            plain(root.to_string_lossy().as_ref())
134        ),
135    };
136}
137
138/// What:
139///  The cause and the sentence for an unclean source, chosen by the kind of the refusal.
140/// Why:
141///   A running command and an unfinished operation are told as such; an entry no reset can undo is told as that; otherwise
142///  Git's own status decides between a real change and a record of file times that is out of date.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// const route = explainUnclean({ context, root, subject, kind, reason });
147/// ```
148fn explain_unclean(context: &GitContext, root: &Path, subject: &str, kind: CleanRefusalKind, reason: &str) -> SlowRoute {
149    match kind {
150        CleanRefusalKind::OperationInProgress("index.lock") => {
151            return SlowRoute { word: "git-running", detail: reason.to_string(), sentence: format!("another Git command is running in {subject} (its index.lock exists). Add again when it has finished, {THEN_QUICK}.") };
152        }
153        CleanRefusalKind::OperationInProgress(name) => {
154            return SlowRoute { word: "operation-unfinished", detail: reason.to_string(), sentence: format!("{subject} is in the middle of an operation ({name} exists in its Git directory). Finish or abort it, {THEN_QUICK}.") };
155        }
156        CleanRefusalKind::NotPlain => {
157            return SlowRoute { word: "not-restorable", detail: reason.to_string(), sentence: format!("{subject} has an index entry that no reset can undo, a skip-worktree, assume-unchanged, intent-to-add, unmerged or submodule entry ({}).", plain(reason)) };
158        }
159        CleanRefusalKind::Unreadable => {
160            return SlowRoute { word: "unproven", detail: reason.to_string(), sentence: format!("{subject} could not be read to prove it unchanged ({}).", plain(reason)) };
161        }
162        CleanRefusalKind::StagedChange | CleanRefusalKind::FileChanged | CleanRefusalKind::StatStale | CleanRefusalKind::Racy | CleanRefusalKind::Untracked => {}
163    }
164    let listed: ChangedPaths = match changed_paths(context, root) {
165        Ok(found) => found,
166        Err(failure) => return status_failed(subject, root, failure.as_str()),
167    };
168    if let ChangedPaths { count, first: Some(first), .. } = listed {
169        let paths: &str = if count == 1 { "path" } else { "paths" };
170        return SlowRoute {
171            word: "uncommitted-changes",
172            detail: reason.to_string(),
173            sentence: format!("{subject} has uncommitted changes in {count} {paths} (for example {first}). Commit or stash them, {THEN_QUICK}."),
174        };
175    }
176    if matches!(kind, CleanRefusalKind::Racy | CleanRefusalKind::StatStale) {
177        return SlowRoute {
178            word: "stale-index",
179            detail: reason.to_string(),
180            sentence: format!("Git's record of file times in {subject} is out of date. Run `git status` there once, {THEN_QUICK}."),
181        };
182    }
183    return SlowRoute { word: "unproven", detail: reason.to_string(), sentence: format!("{subject} could not be proven unchanged ({}).", plain(reason)) };
184}
185
186/// What:
187///  The sentence for a source the expensive checks refused, or nothing when no sentence would help.
188/// Why:
189///   The kind of the refusal chooses between the unclean sentence, which reads Git's status, and the plain statement of an
190///  unsupported feature.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// const route = explainRefusal({ context, identity, refusal });
195/// ```
196pub fn explain_refusal(context: &GitContext, identity: &WorktreeIdentity, refusal: &Refusal) -> Option<SlowRoute> {
197    let root: &Path = match identity {
198        WorktreeIdentity::MainWorktree { worktree_root, .. } | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => worktree_root.as_path(),
199        WorktreeIdentity::BareRepository { .. } | WorktreeIdentity::OutsideWorktree => return None,
200    };
201    return Some(match refusal.kind {
202        RefusalKind::Unclean(kind) => explain_unclean(context, root, subject(identity), kind, refusal.reason.as_str()),
203        RefusalKind::Unsupported => explain_unsupported(refusal.reason.as_str()),
204        RefusalKind::StatusFailed => status_failed(subject(identity), root, refusal.reason.as_str()),
205    });
206}
207
208/// What:
209///  The sentence for a feature of the source that a snapshot does not reproduce.
210/// Why:
211///   These reasons are already plain statements ("core.sparseCheckout is set"); the sentence adds nothing to them, and names
212///  no remedy because the person's choice of feature is not the wrapper's to undo.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// const route = explainUnsupported('core.sparseCheckout is set');
217/// ```
218pub fn explain_unsupported(reason: &str) -> SlowRoute {
219    return SlowRoute { word: "unsupported", detail: reason.to_string(), sentence: format!("{}.", plain(reason)) };
220}
221
222/// What:
223///  The sentence for a snapshot that started and could not be finished.
224/// Why:
225///   The add is whole, made by Git's checkout; the sentence says why the quick way ended, in the check's own words.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// const route = explainFailure('the stage could not be swapped into place');
230/// ```
231pub fn explain_failure(reason: &str) -> SlowRoute {
232    return SlowRoute { word: "snapshot-failed", detail: reason.to_string(), sentence: format!("the snapshot could not be finished ({}), so Git's own checkout made the worktree.", plain(reason)) };
233}
234
235/// What:
236///  The sentence for the cheap refusals (the source is no subvolume, or the two sides are not on one filesystem), or nothing
237///  where the filesystem is not btrfs.
238/// Why:
239///   On a btrfs source that is no subvolume the person can convert it; on another filesystem nothing makes the add a
240///  snapshot, and a line on every add would be noise.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// const route = explainCheapRefusal({ identity, plan, reason });
245/// ```
246pub fn explain_cheap_refusal(identity: &WorktreeIdentity, reason: &str) -> Option<SlowRoute> {
247    let root: &Path = match identity {
248        WorktreeIdentity::MainWorktree { worktree_root, .. } | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => worktree_root.as_path(),
249        WorktreeIdentity::BareRepository { .. } | WorktreeIdentity::OutsideWorktree => return None,
250    };
251    if filesystem_kind(root) != Ok(FilesystemKind::Btrfs) {
252        return None;
253    }
254    let subject: &str = subject(identity);
255    // The main checkout is migrated with the repository's other worktrees in view; a linked worktree is converted alone.
256    let command: &str = if matches!(identity, WorktreeIdentity::MainWorktree { .. }) { "migrate-to-subvolume" } else { "convert-worktree-to-subvolume" };
257    if !is_subvolume_root(root).unwrap_or(false) {
258        return Some(SlowRoute {
259            word: "not-subvolume",
260            detail: reason.to_string(),
261            sentence: format!("{subject} is not a btrfs subvolume. Run `git cli-git {command}` in it, {THEN_QUICK}."),
262        });
263    }
264    return Some(SlowRoute {
265        word: "other-filesystem",
266        detail: reason.to_string(),
267        sentence: format!("the new worktree is not on the same btrfs filesystem as {subject}. Put it on the same one, {THEN_QUICK}."),
268    });
269}
270
271/// Explanation controls stay out of the release executable.
272#[cfg(test)]
273#[path = "worktree_slow_route_tests.rs"]
274mod tests;