amont-runtime 1.60.0

The amont hook logic: registry, dispatchers, checks and the trust model
Documentation
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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! A quiet check's output: the whole of it on disk, a bounded excerpt in
//! memory (fleet plan 2026-10-11 "test output keeps its status", shared
//! rules).
//!
//! A captured check used to grow `slot.buf` with every byte its tool
//! printed and pour it all out at the end — a passing clippy was 3.4 KB of
//! download lines in an agent's context. Under quiet, the bytes go to
//! `<git-dir>/amont/logs/<check>.<pid>.log.tmp` as they arrive (tailable
//! while the check runs), and through the excerpt accumulator, which holds
//! at most 64 KB whatever the tool prints. When the check ends the file is
//! renamed to `.log`, and the slot prints one line on a pass or the
//! failure blocks on anything else.
//!
//! Storage is never allowed to change the verdict: a log that cannot be
//! created, written or renamed stops being written, the accumulator keeps
//! being fed, and the result says where the log is not.
//!
//! holds-until: amont installs no SIGINT/SIGTERM handler of its own for
//! this. `staged_only` already does, to bring back the user's parked
//! unstaged changes during the index-fidelity hold, and a second handler on
//! the commit path would compete with it. So an interrupted run leaves its
//! `<check>.<pid>.log.tmp` — complete up to the interrupt, and named by the
//! running line — and pruning never removes a `.tmp`. Revisit when amont
//! has one signal path that both can register with.

use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::excerpt::Accumulator;

/// Finished logs kept per check. `*.tmp` (a run in flight, or one a killed
/// process left) are never pruned.
const KEEP: usize = 10;

/// Where a check's log ended up.
pub enum Stored {
    Saved(PathBuf),
    /// The run is complete in the `.tmp`; only the rename failed.
    RenameFailed(PathBuf),
    NotWritten(String),
}

impl Stored {
    /// The line to print after the result, when the log is not where the
    /// result says it is.
    pub fn note(&self) -> Option<String> {
        match self {
            Stored::Saved(_) => None,
            Stored::RenameFailed(tmp) => Some(format!("log left at {}", tmp.display())),
            Stored::NotWritten(why) => Some(format!("log not written: {why}")),
        }
    }

    /// The path a reader can open, if any.
    pub fn path(&self) -> Option<&Path> {
        match self {
            Stored::Saved(p) | Stored::RenameFailed(p) => Some(p),
            Stored::NotWritten(_) => None,
        }
    }
}

pub struct Capture {
    pub tmp: PathBuf,
    done: PathBuf,
    file: Option<File>,
    err: Option<String>,
    written: u64,
    /// Bytes the tool printed, whether or not the file kept them.
    pub seen: u64,
    #[cfg(feature = "test-hooks")]
    fail_after: Option<u64>,
    pub acc: Accumulator,
    /// The active-run entry relais's pane tails this log from, while the run
    /// is live (see [`amont_quiet`]). `None` when it could not be written.
    entry: Option<amont_quiet::Entry>,
}

/// The repository's top level, asked of git once per process: an entry is
/// written per check, and a pre-commit runs twenty of them.
fn repo() -> &'static str {
    static TOP: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    TOP.get_or_init(|| {
        crate::git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| {
            std::env::current_dir()
                .map(|d| d.display().to_string())
                .unwrap_or_default()
        })
    })
}

/// `<git-dir>/amont/logs`, created 0700. `None` outside a repository.
pub fn log_dir() -> Option<PathBuf> {
    let git_dir = crate::git::stdout(&["rev-parse", "--absolute-git-dir"])?;
    Some(PathBuf::from(git_dir).join("amont").join("logs"))
}

impl Capture {
    /// Open `<dir>/<check>.<pid>.log.tmp`. A failure is remembered, not
    /// returned: the excerpt still works without a file.
    pub fn open(dir: Option<PathBuf>, check: &str) -> Capture {
        #[cfg(feature = "test-hooks")]
        let fail_after = std::env::var("AMONT_TEST_LOG_FAIL_AFTER")
            .ok()
            .and_then(|v| v.parse().ok());
        #[cfg(not(feature = "test-hooks"))]
        let fail_after = None;
        Capture::open_failing_after(dir, check, fail_after)
    }

    /// [`Capture::open`], with the write failure the tests inject passed in
    /// (only honoured under the `test-hooks` feature).
    pub fn open_failing_after(
        dir: Option<PathBuf>,
        check: &str,
        #[cfg_attr(not(feature = "test-hooks"), allow(unused_variables))] fail_after: Option<u64>,
    ) -> Capture {
        let name = file_name(check);
        let pid = std::process::id();
        let (tmp, done, file, err) = match dir {
            None => (
                PathBuf::new(),
                PathBuf::new(),
                None,
                Some("not inside a git repository".to_string()),
            ),
            Some(dir) => {
                let done = dir.join(format!("{name}.{pid}.log"));
                let tmp = dir.join(format!("{name}.{pid}.log.tmp"));
                match create(&dir, &tmp) {
                    Ok(f) => (tmp, done, Some(f), None),
                    Err(e) => (tmp, done, None, Some(format!("cannot create the log: {e}"))),
                }
            }
        };
        // Only a log that exists can be tailed.
        let session = std::env::var("CLAUDE_CODE_SESSION_ID").unwrap_or_default();
        let entry = file.as_ref().and_then(|_| {
            amont_quiet::Entry::start(
                &amont_quiet::dir_from_env()?,
                "amont",
                &session,
                check,
                repo,
                &tmp,
            )
        });
        Capture {
            tmp,
            done,
            file,
            err,
            written: 0,
            seen: 0,
            #[cfg(feature = "test-hooks")]
            fail_after,
            acc: Accumulator::new(crate::excerpt::DEFAULT_BOUND),
            entry,
        }
    }

    /// End the active-run entry: `exit` is the check's result (`Some(0)` for
    /// anything but a failure, `None` when the check died), and `log` where
    /// the log now is. A capture dropped without this ends with no exit.
    pub fn end_entry(&mut self, exit: Option<i32>, log: Option<&Path>) {
        if let Some(entry) = self.entry.as_mut() {
            entry.end(exit, log);
        }
    }

    /// Whether a log file is being written.
    pub fn writing(&self) -> bool {
        self.file.is_some()
    }

    pub fn take(&mut self, bytes: &[u8]) {
        self.seen += bytes.len() as u64;
        self.acc.feed(bytes);
        let (chunk, injected) = self.clip(bytes);
        let Some(file) = self.file.as_mut() else {
            return;
        };
        match file.write_all(chunk) {
            Ok(()) => self.written += chunk.len() as u64,
            Err(e) => self.err = Some(format!("write failed: {e}")),
        }
        if injected && self.err.is_none() {
            self.err = Some(format!(
                "injected write failure after {} bytes",
                self.written
            ));
        }
        if self.err.is_some() {
            // Stop writing; the accumulator keeps being fed.
            self.file = None;
        }
    }

    #[cfg(feature = "test-hooks")]
    fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
        match self.fail_after {
            Some(limit) => {
                let left = limit.saturating_sub(self.written) as usize;
                if left < bytes.len() {
                    (&bytes[..left], true)
                } else {
                    (bytes, false)
                }
            }
            None => (bytes, false),
        }
    }

    #[cfg(not(feature = "test-hooks"))]
    fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
        (bytes, false)
    }

    /// Close the file and move it into place.
    pub fn finish(&mut self) -> Stored {
        let file = self.file.take();
        drop(file);
        if let Some(why) = self.err.take() {
            // A prefix of the run is not the run: do not leave it looking
            // like a log.
            if !self.tmp.as_os_str().is_empty() {
                let _ = fs::remove_file(&self.tmp);
            }
            return Stored::NotWritten(why);
        }
        match fs::rename(&self.tmp, &self.done) {
            Ok(()) => {
                if let Some(dir) = self.done.parent() {
                    prune(dir, &self.done);
                }
                Stored::Saved(self.done.clone())
            }
            Err(_) => Stored::RenameFailed(self.tmp.clone()),
        }
    }
}

fn create(dir: &Path, tmp: &Path) -> std::io::Result<File> {
    fs::create_dir_all(dir)?;
    let mut open = OpenOptions::new();
    open.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
        open.mode(0o600);
    }
    open.open(tmp)
}

/// A check name as a file name: `[A-Za-z0-9._-]`, everything else `_`.
fn file_name(check: &str) -> String {
    let clean: String = check
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .take(60)
        .collect();
    if clean.is_empty() {
        "check".into()
    } else {
        clean
    }
}

/// Keep the newest [`KEEP`] finished logs of the check `done` belongs to.
/// Best-effort: a log that cannot be listed or removed is left for the next
/// run, and nothing this run prints depends on it.
fn prune(dir: &Path, done: &Path) {
    let Some(name) = done.file_name().and_then(|n| n.to_str()) else {
        return;
    };
    // `<check>.<pid>.log` → `<check>.`
    let Some(stem) = name.strip_suffix(".log").and_then(|s| s.rsplit_once('.')) else {
        return;
    };
    let prefix = format!("{}.", stem.0);
    let Ok(read) = fs::read_dir(dir) else { return };
    let mut logs: Vec<(std::time::SystemTime, PathBuf)> = read
        .filter_map(|e| e.ok())
        .filter(|e| {
            let n = e.file_name();
            let n = n.to_string_lossy();
            n.starts_with(&prefix)
                && n.ends_with(".log")
                && n[prefix.len()..n.len() - 4]
                    .bytes()
                    .all(|b| b.is_ascii_digit())
        })
        .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
        .collect();
    logs.sort();
    let excess = logs.len().saturating_sub(KEEP);
    for (_, old) in logs.into_iter().take(excess) {
        let _ = fs::remove_file(old);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn quiet_entries_never_reach_the_real_state_dir() {
        // .cargo/config.toml: an agent running this suite must not put its
        // fixtures in the person's live relais pane.
        assert_eq!(std::env::var("CLAUDE_CODE_SESSION_ID").as_deref(), Ok(""));
        assert!(std::env::var("QUIET_ACTIVE_DIR").is_ok_and(|d| d.contains("target")));
    }

    fn scratch(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("amont-capture-{tag}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&d);
        d
    }

    #[test]
    fn a_tmp_is_renamed_on_finish() {
        let dir = scratch("rename");
        let mut c = Capture::open(Some(dir.clone()), "pre-push-cargo-test");
        assert!(c.tmp.exists());
        c.take(b"hello\n");
        let stored = c.finish();
        let path = stored.path().unwrap().to_path_buf();
        assert!(path.exists() && !c.tmp.exists());
        assert_eq!(fs::read_to_string(path).unwrap(), "hello\n");
        assert!(stored.note().is_none());
    }

    #[test]
    fn no_directory_still_gives_an_excerpt() {
        let mut c = Capture::open(None, "x");
        c.take(b"thread 'a' panicked at src/a.rs:1:1:\nboom\n");
        let stored = c.finish();
        assert!(stored.note().unwrap().starts_with("log not written:"));
        assert!(c.acc.finish("-").contains("panicked at"));
    }

    #[test]
    fn pruning_keeps_ten_and_never_a_tmp_or_another_check() {
        let dir = scratch("prune");
        fs::create_dir_all(&dir).unwrap();
        for i in 0..15 {
            fs::write(dir.join(format!("clippy.{}.log", 1000 + i)), "x").unwrap();
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        fs::write(dir.join("clippy.1.log.tmp"), "x").unwrap();
        fs::write(dir.join("clippy-extra.5.log"), "x").unwrap();
        let mut c = Capture::open(Some(dir.clone()), "clippy");
        c.finish();
        let names: Vec<String> = fs::read_dir(&dir)
            .unwrap()
            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
            .collect();
        let clippy = names
            .iter()
            .filter(|n| n.starts_with("clippy.") && n.ends_with(".log"))
            .count();
        assert_eq!(clippy, KEEP, "{names:?}");
        assert!(names.iter().any(|n| n == "clippy.1.log.tmp"));
        assert!(names.iter().any(|n| n == "clippy-extra.5.log"));
    }

    #[cfg(unix)]
    #[test]
    fn the_log_is_private() {
        use std::os::unix::fs::PermissionsExt;
        let dir = scratch("mode");
        let c = Capture::open(Some(dir), "x");
        let mode = fs::metadata(&c.tmp).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600);
    }

    #[cfg(feature = "test-hooks")]
    #[test]
    fn a_failed_write_keeps_the_excerpt() {
        let dir = scratch("inject");
        let mut c = Capture::open_failing_after(Some(dir), "x", Some(10));
        c.take(b"0123456789abcdef\nthread 'a' panicked at src/a.rs:1:1:\n");
        let stored = c.finish();
        assert!(stored.note().unwrap().contains("injected write failure"));
        assert!(!c.tmp.exists());
        assert!(c.acc.finish("-").contains("panicked at"));
    }

    #[test]
    /// Bounded, not merely small at the end: the holding never passes 64 KiB
    /// at any point of a run 64 times that long. (It fed 50 MiB, checked
    /// once, and was the slowest unit test by 6 s.)
    fn a_long_run_keeps_the_accumulator_bounded() {
        let mut c = Capture::open(None, "x");
        let chunk: String = (0..1000).map(|i| format!("line {i} ok\n")).collect();
        let mut fed = 0u64;
        while fed < 4 * 1024 * 1024 {
            c.take(chunk.as_bytes());
            fed += chunk.len() as u64;
            assert!(c.acc.held() <= 64 * 1024, "{} after {fed}", c.acc.held());
        }
        assert_eq!(c.seen, fed);
    }
}