grit_lib/diagnostics.rs
1//! Git-style diagnostic prefixes and typed warning sinks for embedders and the CLI.
2//!
3//! Prefix helpers keep `fatal:` / `warning:` literals out of call sites. Structured
4//! [`Warning`] values flow through [`DiagnosticSink`] implementations; human-readable
5//! text is formatted by embedders (for example `grit-cli`), not by `grit-lib`.
6//!
7//! Library guide: [diagnostics](https://grit-scm.com/docs/library/embedding)
8//!
9//! ```rust
10//! use grit_lib::repo::{InitOptions, Repository};
11//! # fn main() -> grit_lib::error::Result<()> {
12//! let dir = tempfile::tempdir()?;
13//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
14//! # Ok(())
15//! # }
16//! ```
17
18use std::collections::HashSet;
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21
22/// Prefix a body with Git's `warning: ` diagnostic marker.
23#[must_use]
24pub fn warning_line(body: &str) -> String {
25 ["warning", ": ", body].concat()
26}
27
28/// Prefix a body with Git's `error: ` diagnostic marker.
29#[must_use]
30pub fn error_line(body: &str) -> String {
31 ["error", ": ", body].concat()
32}
33
34/// Prefix a body with Git's `fatal: ` diagnostic marker.
35#[must_use]
36pub fn fatal_line(body: &str) -> String {
37 ["fatal", ": ", body].concat()
38}
39
40/// A non-fatal condition worth surfacing to the user or an embedder.
41#[non_exhaustive]
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub enum Warning {
44 /// A ref name could mean both a branch/tag ref and an object id prefix.
45 AmbiguousRefname {
46 /// User-supplied revision or pathspec fragment.
47 spec: String,
48 },
49 /// A symbolic ref pointed at a missing or invalid target (except bare `HEAD`).
50 DanglingSymref {
51 /// Pack file name or config identifier.
52 name: String,
53 },
54 /// A commit-graph chunk was smaller than required.
55 CommitGraphChunkTooSmall {
56 /// Commit-graph layer identifier in the warning.
57 layer: String,
58 /// Commit-graph chunk name that failed validation.
59 chunk: String,
60 },
61 /// Bloom filters were disabled for a commit-graph layer due to incompatible settings.
62 CommitGraphBloomDisabled {
63 /// Commit-graph layer identifier in the warning.
64 layer: String,
65 },
66 /// Changed-path Bloom index chunk does not cover all commits in the layer.
67 CommitGraphChangedPathIndexTooSmall,
68 /// Changed-path Bloom data chunk header is too small.
69 CommitGraphChangedPathChunkTooSmall {
70 /// Observed size or offset in bytes.
71 actual: usize,
72 /// Minimum required size in bytes.
73 minimum: usize,
74 },
75 /// Changed-path Bloom index entry points past the chunk.
76 CommitGraphChangedPathOffsetOutOfRange {
77 /// Byte offset into a commit-graph chunk.
78 offset: usize,
79 /// Index of the commit-graph entry being validated.
80 position: usize,
81 /// Path or label of the commit-graph file.
82 graph: String,
83 /// Total size of the commit-graph chunk in bytes.
84 chunk_size: usize,
85 },
86 /// Changed-path Bloom index offsets decrease between adjacent entries.
87 CommitGraphChangedPathOffsetsDecreasing {
88 /// Starting offset for an adjacent pair of entries.
89 start: usize,
90 /// Ending offset for an adjacent pair of entries.
91 end: usize,
92 /// Commit index for the start offset.
93 position_start: usize,
94 /// Commit index for the end offset.
95 position_end: usize,
96 /// Path or label of the commit-graph file.
97 graph: String,
98 },
99 /// Repository config was skipped because the git directory uses an unsupported format.
100 IgnoredGitDir {
101 /// Repository-relative or filesystem path related to the failure.
102 path: PathBuf,
103 /// Why the value or operation was rejected.
104 reason: String,
105 },
106 /// `core.bare=true` while `core.worktree` is also set.
107 CoreBareWithWorktree,
108 /// A config value looked boolean but could not be parsed.
109 BadBooleanConfig {
110 /// Config key name that failed validation.
111 key: String,
112 /// Raw config value that could not be parsed.
113 value: String,
114 },
115 /// Submodule name failed validation.
116 SuspiciousSubmoduleName {
117 /// Pack file name or config identifier.
118 name: String,
119 },
120 /// Submodule config value looked like a command-line flag.
121 SubmoduleConfigLooksLikeOption {
122 /// Config key name that failed validation.
123 key: String,
124 /// Raw config value that could not be parsed.
125 value: String,
126 },
127 /// Duplicate submodule keys in `.gitmodules` for one commit tree.
128 SubmoduleMultipleConfigs {
129 /// Commit object id or label tied to the warning.
130 commit: String,
131 /// Pack file name or config identifier.
132 name: String,
133 /// Config option or submodule key name.
134 option: String,
135 },
136 /// `.gitmodules` could not be parsed at a given line.
137 GitmodulesBadConfig {
138 /// Free-form diagnostic message body.
139 message: String,
140 },
141 /// Split index was requested while `core.splitIndex` disables it.
142 SplitIndexDisabledWhileConfigEnabled,
143 /// Split index was disabled on the command line while config enables it.
144 SplitIndexEnabledWhileConfigDisabled,
145 /// `GIT_INDEX_VERSION` was set but invalid.
146 IndexVersionEnvInvalid {
147 /// Index version used after the invalid setting was ignored.
148 fallback: u32,
149 },
150 /// `index.version` in config was invalid.
151 IndexVersionConfigInvalid {
152 /// Index version used after the invalid setting was ignored.
153 fallback: u32,
154 },
155 /// `mailmap.blob` (or the bare-repository `HEAD:.mailmap` default) names an object that
156 /// is not a blob; it was skipped.
157 MailmapUnreadable {
158 /// The configured blob revision, as written.
159 spec: String,
160 /// Kind of the object the revision resolved to.
161 kind: crate::objects::ObjectKind,
162 },
163 /// A ref was updated but its reflog line could not be appended; the update stands.
164 ReflogNotWritten {
165 /// Ref whose reflog was not updated (for example `refs/heads/main` or `HEAD`).
166 refname: String,
167 /// Why the append failed.
168 detail: String,
169 },
170 /// Reflog `@{date}` cutoff is older than every reflog entry; the oldest entry was used.
171 ReflogDateBeforeOldestEntry {
172 /// Human-readable ref label from the revision spec.
173 ref_display: String,
174 /// Parsed cutoff instant (Unix seconds).
175 cutoff_sec: i64,
176 },
177 /// A traditional hook script exists but is not executable.
178 NonExecutableHookIgnored {
179 /// Hook script basename that was skipped.
180 hook_name: String,
181 /// Resolved path to the hook script, when known.
182 hook_path: Option<PathBuf>,
183 },
184 /// A hook whose outcome does not affect the operation (such as `post-commit`) could not be
185 /// run; the operation already completed.
186 HookNotRun {
187 /// Hook event name (`post-commit`, …).
188 hook_name: String,
189 /// Why it did not run, including the underlying OS error.
190 detail: String,
191 },
192 /// Returned when midx checksum mismatch.
193 MidxChecksumMismatch,
194 /// Returned when midx ignoring existing checksum mismatch.
195 MidxIgnoringExistingChecksumMismatch,
196 /// Returned when midx chunk table error.
197 MidxChunkTableError {
198 /// Additional human-readable detail for the failure.
199 detail: String,
200 },
201 /// Returned when midx rev index wrong size.
202 MidxRevIndexWrongSize,
203 /// Returned when midx bitmap missing reverse index.
204 MidxBitmapMissingReverseIndex,
205 /// Returned when midx pack index unavailable.
206 MidxPackIndexUnavailable {
207 /// Pack file basename (`.pack` suffix).
208 pack: String,
209 },
210 /// Returned when midx unknown preferred pack.
211 MidxUnknownPreferredPack {
212 /// Pack file name or config identifier.
213 name: String,
214 },
215 /// A `.bitmap` sidecar was present but could not be loaded; reachability falls back without it.
216 PackBitmapIgnored {
217 /// Repository-relative or filesystem path related to the failure.
218 path: PathBuf,
219 /// Why the value or operation was rejected.
220 reason: String,
221 },
222 /// A work-tree path could not be accessed (for example permission denied); the scan continued.
223 WorktreeAccessFailed {
224 /// Repository-relative path (directories often end with `/`).
225 path: String,
226 /// OS error detail.
227 detail: String,
228 },
229}
230
231/// Optional trace events (network debugging, etc.), separate from [`Warning`].
232#[non_exhaustive]
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Trace {
235 /// Returned when network.
236 Network {
237 /// Human-readable message body.
238 message: String,
239 },
240}
241
242/// Receives warnings and optional trace lines from `grit-lib`.
243pub trait DiagnosticSink: Send + Sync {
244 /// Record a non-fatal warning.
245 fn warn(&self, warning: Warning);
246 /// Record a trace event (no-op by default).
247 fn trace(&self, event: Trace) {
248 let _ = event;
249 }
250}
251
252/// Discards all diagnostics (default for [`crate::repo::Repository`]).
253#[derive(Debug, Default, Clone, Copy)]
254pub struct NullDiagnostics;
255
256impl DiagnosticSink for NullDiagnostics {
257 fn warn(&self, _warning: Warning) {}
258}
259
260/// Collects warnings (and traces) for tests and the CLI.
261#[derive(Debug)]
262pub struct CollectingDiagnostics {
263 warnings: Mutex<Vec<Warning>>,
264 traces: Mutex<Vec<Trace>>,
265 dedupe: bool,
266 seen: Mutex<HashSet<Warning>>,
267}
268
269impl Default for CollectingDiagnostics {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275impl CollectingDiagnostics {
276 /// Create a collector that records every warning.
277 #[must_use]
278 pub fn new() -> Self {
279 Self {
280 warnings: Mutex::new(Vec::new()),
281 traces: Mutex::new(Vec::new()),
282 dedupe: false,
283 seen: Mutex::new(HashSet::new()),
284 }
285 }
286
287 /// When `dedupe` is true, identical [`Warning`] values are stored at most once.
288 #[must_use]
289 pub fn with_dedupe(dedupe: bool) -> Self {
290 Self {
291 dedupe,
292 ..Self::new()
293 }
294 }
295
296 /// Snapshot collected warnings (clone of each entry).
297 #[must_use]
298 pub fn warnings(&self) -> Vec<Warning> {
299 self.warnings
300 .lock()
301 .unwrap_or_else(|e| e.into_inner())
302 .clone()
303 }
304
305 /// Snapshot collected trace events.
306 #[must_use]
307 pub fn traces(&self) -> Vec<Trace> {
308 self.traces
309 .lock()
310 .unwrap_or_else(|e| e.into_inner())
311 .clone()
312 }
313
314 /// Drop all collected warnings and traces.
315 pub fn clear(&self) {
316 self.warnings
317 .lock()
318 .unwrap_or_else(|e| e.into_inner())
319 .clear();
320 self.traces
321 .lock()
322 .unwrap_or_else(|e| e.into_inner())
323 .clear();
324 self.seen.lock().unwrap_or_else(|e| e.into_inner()).clear();
325 }
326}
327
328impl DiagnosticSink for CollectingDiagnostics {
329 fn warn(&self, warning: Warning) {
330 if self.dedupe {
331 let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
332 if !seen.insert(warning.clone()) {
333 return;
334 }
335 }
336 self.warnings
337 .lock()
338 .unwrap_or_else(|e| e.into_inner())
339 .push(warning);
340 }
341
342 fn trace(&self, event: Trace) {
343 self.traces
344 .lock()
345 .unwrap_or_else(|e| e.into_inner())
346 .push(event);
347 }
348}
349
350/// Shared handle used when threading diagnostics through load paths.
351pub type DiagnosticsHandle = Arc<dyn DiagnosticSink + Send + Sync>;
352
353/// Emit `warning` through `handle`.
354pub fn warn(handle: &DiagnosticsHandle, warning: Warning) {
355 handle.warn(warning);
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn collecting_diagnostics_records_warnings() {
364 let sink = CollectingDiagnostics::new();
365 sink.warn(Warning::AmbiguousRefname { spec: "abc".into() });
366 assert_eq!(sink.warnings().len(), 1);
367 }
368
369 #[test]
370 fn collecting_diagnostics_dedupes_identical_warnings() {
371 let sink = CollectingDiagnostics::with_dedupe(true);
372 let w = Warning::CommitGraphChunkTooSmall {
373 layer: "layer-a".into(),
374 chunk: "base graphs".into(),
375 };
376 sink.warn(w.clone());
377 sink.warn(w);
378 assert_eq!(sink.warnings().len(), 1);
379 }
380}