Skip to main content

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}