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