Skip to main content

amont_runtime/
staged_only.rs

1//! Make the checks see what is being committed.
2//!
3//! `staged_files()` asks the index for the PATH LIST, which is right, and then
4//! hands those paths to tools that open them from the WORKING TREE. Eleven of
5//! the fifteen pre-commit checks do this. So `git add -p` half a file, commit,
6//! and prettier reads the whole working-tree file: it fails on lines you did not
7//! stage, or passes on lines you did.
8//!
9//! `pre-commit` fixes this for everyone by stashing the unstaged changes for the
10//! duration of the run, and their wording names both directions:
11//!
12//! > Running hooks on unstaged changes can lead to both false-positives and
13//! > false-negatives during committing.
14//!
15//! ## This is the most dangerous code in the repository
16//!
17//! A stash taken and not restored loses uncommitted work. That is worse than
18//! either failure that overwrote tracked files, because there is nothing on disk
19//! to recover from. Hence, in order of how likely each is to bite:
20//!
21//! 1. **Nothing to stash → nothing happens.** The common case never touches the
22//!    tree.
23//! 2. **Restore on a SIGNAL.** `Drop` runs on unwind and on early return; it
24//!    does NOT run when Ctrl-C kills the process, and interrupting a slow
25//!    pre-commit is the most probable route to an orphaned stash, not the least.
26//! 3. **Restore in `Drop`** for panics and early returns.
27//! 4. **Never mid-operation.** A merge or rebase in progress means the tree is
28//!    already holding somebody else's work — `GitState` (PR 2) answers this.
29//! 5. **Restore failure is loud and fatal**, and prints the store's path, so
30//!    the work is findable on disk rather than silently gone. (Not `git stash
31//!    list`: nothing here is a stash ref — see the `impl` comment below for
32//!    why byte-exact copies beat both `stash --keep-index` and a patch.)
33//! 6. **`amont restore`** for when even the handler was interrupted.
34//!
35//! The store describes itself in an `index` file, and every repo-controlled
36//! path lives under `files/`. Both because the store used to encode its
37//! metadata in the payload FILENAMES, which a repository is allowed to collide
38//! with — and did, deleting a tracked file and planting a symlink outside the
39//! worktree. See [`Held`].
40
41use std::path::Path;
42use std::sync::atomic::{AtomicBool, Ordering};
43// Only the unix signal path keeps integer statics (the self-pipe fd and the
44// pending signal); Windows hands its handler a fresh thread and needs
45// neither, so an unconditional import is an unused one there.
46#[cfg(unix)]
47use std::sync::atomic::AtomicI32;
48use std::sync::Mutex;
49
50use crate::ui::{error_sign, warning_sign};
51
52/// Whether this process is holding a patch. Global because a signal handler
53/// cannot be handed a reference, and because there is at most one per process.
54static HELD: AtomicBool = AtomicBool::new(false);
55
56/// Guards `checkout` through `HELD.store(true, ..)` in `enter()` against a
57/// `restore()` racing in concurrently from the signal-watcher thread.
58///
59/// Without this, a signal landing mid-`checkout` lets `restore()` read `HELD`
60/// as still `false`, no-op, and kill the process — while `checkout`'s child
61/// keeps running, ORPHANED, and eventually finishes writing the tree anyway.
62/// The parked files are never put back and nothing said so. This is not
63/// hypothetical: it is what running the checkout-then-signal race a dozen
64/// times over actually produced, once `restore()` moved off the interrupted
65/// call stack and onto a thread of its own — see `install_signal_handler`.
66/// `restore()` blocking here until `enter()` finishes is exactly the fix: by
67/// the time it can read `HELD`, `checkout` has definitely either succeeded
68/// (and it restores) or never called (and it no-ops), never "maybe".
69static ENTER_LOCK: Mutex<()> = Mutex::new(());
70
71/// Where the unstaged changes are parked. Inside `$GIT_DIR` so they are never
72/// committed, never seen by a check, and findable by hand.
73pub const STORE: &str = "amont-held";
74
75/// Our metadata, beside the payloads rather than encoded into their names.
76const INDEX: &str = "index";
77
78/// Everything repo-controlled lives under here. See [`Held`].
79const FILES: &str = "files";
80
81/// The index's first field. Present so an older or newer store is recognised
82/// as such instead of being half-understood.
83const FORMAT: &str = "amont-held-v1";
84
85/// What each held path's ON-DISK content looked like right after `enter()`'s
86/// checkout — i.e. the index content the checks were meant to see. `rel NUL
87/// hex NUL` pairs. Absent (an older store, a crash before the write) means no
88/// mid-check-edit protection, exactly as before the file existed.
89const EXPECTED: &str = "expected";
90
91/// Where a file the user edited MID-CHECK parks the held copy instead of
92/// clobbering the edit. Beside the store, not inside it: the store is deleted
93/// on a clean restore and this must survive to be read.
94const PRESERVED: &str = "amont-preserved";
95
96/// FNV-1a over the content. Not a security boundary — the question is "did
97/// an editor save land here while the checks ran", and an accidental
98/// collision needs an accidental 64-bit fixed point.
99fn content_hash(bytes: &[u8]) -> u64 {
100    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
101    for &b in bytes {
102        h ^= u64::from(b);
103        h = h.wrapping_mul(0x0000_0100_0000_01b3);
104    }
105    h
106}
107
108/// One parked path, and what it was.
109///
110/// This used to be encoded in the FILENAME — `<name>.amont-absent` and
111/// `<name>.amont-symlink` beside the payload copies — and a repository is
112/// allowed to contain files with those names. It cost exactly what you would
113/// expect: a repo tracking `notes` and `notes.amont-absent`, with the second
114/// modified, had `notes` DELETED from the working tree on restore, because the
115/// suffix strip turned one file's payload into a statement about another. The
116/// symlink form was worse: the repo chose both the link name and an arbitrary
117/// ABSOLUTE target, so committing in it planted a symlink pointing anywhere on
118/// the machine, and anything that later wrote that path wrote through it.
119///
120/// Marker-by-name cannot be made safe by escaping, because the store is also
121/// read by `amont restore` from a later process that has only the filenames
122/// to go on. So the metadata moved out of band, and every repo-controlled path
123/// moved under `files/`, where it cannot collide with `index` no matter what it
124/// is called.
125#[derive(Debug, Clone, PartialEq, Eq)]
126enum Held {
127    /// Content differs from the index. The bytes are at `files/<rel>`.
128    ///
129    /// `mode` is the working tree's, captured before `checkout` resets it.
130    /// `git diff --name-only` lists a pure `chmod +x` with IDENTICAL content,
131    /// so without this a `chmod +x deploy.sh` you had not staged came back
132    /// non-executable and nothing said so — invisible to every content-based
133    /// assertion, which is why it survived so long.
134    Modified { rel: String, mode: Option<u32> },
135    /// Deleted in the tree but not staged. Restore deletes it again.
136    Absent { rel: String },
137    /// A symlink, and where it pointed. No payload file is written at all.
138    Symlink { rel: String, target: String },
139}
140
141/// A repo-relative path that cannot escape the tree it came from.
142///
143/// Applied on the way OUT of the store as well as in: the index is a file on
144/// disk that a later `amont restore` trusts, so `..`, an absolute path or a
145/// drive prefix must not survive being written into it by any route.
146fn safe_rel(rel: &str) -> Option<std::path::PathBuf> {
147    use std::path::Component;
148    if rel.is_empty() {
149        return None;
150    }
151    let mut out = std::path::PathBuf::new();
152    for c in Path::new(rel).components() {
153        match c {
154            Component::Normal(part) => out.push(part),
155            // RootDir, Prefix, ParentDir, CurDir: all of them mean this path
156            // is not a plain location inside the worktree.
157            _ => return None,
158        }
159    }
160    (!out.as_os_str().is_empty()).then_some(out)
161}
162
163fn push_field(out: &mut Vec<u8>, s: &str) {
164    out.extend_from_slice(s.as_bytes());
165    out.push(0);
166}
167
168/// NUL-delimited, for the reason `git.rs` gives for `-z`: a path may contain a
169/// newline, a tab, a quote or a backslash, and may not contain a NUL. There is
170/// no escaping here to get wrong, which is the point.
171fn encode_index(entries: &[Held]) -> Vec<u8> {
172    let mut out = Vec::new();
173    push_field(&mut out, FORMAT);
174    for e in entries {
175        match e {
176            Held::Modified { rel, mode } => {
177                push_field(&mut out, "file");
178                push_field(&mut out, rel);
179                push_field(&mut out, &mode.map(|m| m.to_string()).unwrap_or_default());
180            }
181            Held::Absent { rel } => {
182                push_field(&mut out, "absent");
183                push_field(&mut out, rel);
184            }
185            Held::Symlink { rel, target } => {
186                push_field(&mut out, "symlink");
187                push_field(&mut out, rel);
188                push_field(&mut out, target);
189            }
190        }
191    }
192    out
193}
194
195/// Parse, or say why not. A store we cannot read in full is never partially
196/// applied: the caller keeps it and tells the user where it is.
197fn parse_index(raw: &[u8]) -> Result<Vec<Held>, String> {
198    let mut fields: Vec<&[u8]> = raw.split(|b| *b == 0).collect();
199    // Every field is NUL-TERMINATED, so a well-formed store leaves exactly one
200    // trailing empty slice. Any other empty field is a truncation.
201    if fields.last().is_some_and(|f| f.is_empty()) {
202        fields.pop();
203    }
204    let text = |f: &[u8]| -> Result<String, String> {
205        std::str::from_utf8(f)
206            .map(|s| s.to_string())
207            .map_err(|_| "held store index is not valid UTF-8".to_string())
208    };
209
210    let header = fields
211        .first()
212        .ok_or_else(|| "held store index is empty".to_string())?;
213    if *header != FORMAT.as_bytes() {
214        return Err(format!(
215            "held store index is not {FORMAT} — refusing to guess at its shape"
216        ));
217    }
218
219    let mut out = Vec::new();
220    let mut i = 1;
221    let take = |i: &mut usize, what: &str| -> Result<String, String> {
222        let f = fields
223            .get(*i)
224            .ok_or_else(|| format!("held store index ends mid-record, expected {what}"))?;
225        *i += 1;
226        text(f)
227    };
228    while i < fields.len() {
229        let kind = take(&mut i, "a record kind")?;
230        match kind.as_str() {
231            "file" => {
232                let rel = take(&mut i, "a path")?;
233                let mode = take(&mut i, "a mode")?;
234                let mode = if mode.is_empty() {
235                    None
236                } else {
237                    Some(
238                        mode.parse::<u32>()
239                            .map_err(|_| format!("held store index has a bad mode: {mode:?}"))?,
240                    )
241                };
242                out.push(Held::Modified { rel, mode });
243            }
244            "absent" => out.push(Held::Absent {
245                rel: take(&mut i, "a path")?,
246            }),
247            "symlink" => {
248                let rel = take(&mut i, "a path")?;
249                let target = take(&mut i, "a link target")?;
250                out.push(Held::Symlink { rel, target });
251            }
252            other => return Err(format!("held store index has an unknown record: {other:?}")),
253        }
254    }
255    Ok(out)
256}
257
258#[cfg(unix)]
259fn mode_of(meta: &std::fs::Metadata) -> Option<u32> {
260    use std::os::unix::fs::PermissionsExt;
261    Some(meta.permissions().mode())
262}
263
264#[cfg(not(unix))]
265fn mode_of(_meta: &std::fs::Metadata) -> Option<u32> {
266    None // No execute bit to lose.
267}
268
269#[cfg(unix)]
270fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
271    use std::os::unix::fs::PermissionsExt;
272    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
273}
274
275#[cfg(not(unix))]
276fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
277    Ok(())
278}
279
280/// Unstaged changes, set aside for the duration of a stage.
281pub struct StagedOnly {
282    held: bool,
283}
284
285/// `amont.fix`, read the moment a restore needs it and not before.
286///
287/// `Drop` and the signal watcher both restore, and neither can hold a borrow
288/// of the process's `Settings`. Reading through a config-only `Settings` is
289/// EXACT here, not an approximation: `amont.fix` is not in
290/// [`crate::manifest::SETTABLE`], so no policy can set it and the answer is
291/// git config's alone. Lazy on purpose — the spawn budget counts every read
292/// on the happy path, and a restore is the unhappy one.
293fn fixing_from_config() -> bool {
294    crate::hooks::common::fixing_requested(&crate::config::Settings::default())
295}
296
297/// Why COPIES and not `git stash --keep-index`, and not a patch either.
298///
299/// Saving is the easy half; restoring is the whole problem.
300///
301/// `stash pop` MERGES into a tree that already holds the staged content, so it
302/// writes conflict markers into the user's file. Measured on the first attempt.
303///
304/// `git diff` + `git apply` is deterministic on Unix and is what `pre-commit`
305/// does — but it applies PATCH semantics to text, and Git for Windows converts
306/// line endings by default. Measured on the second attempt: every restore test
307/// failed on Windows and passed everywhere else, which is the worst possible
308/// shape for the one routine in this codebase that can lose somebody's work.
309///
310/// So: byte-exact copies. Read the file, put it back. No patch to apply, no
311/// newline policy to agree about, and binary files need no special case. It
312/// costs a temporary copy of only the files that have unstaged changes.
313impl StagedOnly {
314    /// The three early exits, and THE ORDER IS THE DESIGN.
315    ///
316    /// Both of the first two used to return "nothing held" with NO OUTPUT,
317    /// which meant the checks silently judged the working tree — the exact
318    /// failure this module exists to prevent, announced as a clean run.
319    /// `docs/index-fidelity-and-run-modes.md` §1 says conflicted paths ABORT
320    /// the stage; they did not.
321    ///
322    /// 1. **Conflicts first**, and they are an `Err`: a conflicted path has no
323    ///    staged and unstaged halves to separate, so there is nothing this can
324    ///    honestly do. `pre_commit` turns the `Err` into a printed message and
325    ///    `Verdict::Block`. Safe by construction: git itself refuses a commit
326    ///    with unmerged entries, so nothing that would have succeeded now
327    ///    fails.
328    /// 2. **Nothing unstaged** ⇒ nothing held, SILENTLY. The common case must
329    ///    stay free, and it is not a degraded run: there is no unstaged content
330    ///    for a check to be confused by.
331    /// 3. **Mid-operation LAST**, and only when there IS unstaged content, with
332    ///    one printed line. Checked after the conflict test rather than before
333    ///    it, deliberately: the other order made the ordinary conflicted-merge
334    ///    case take the mid-operation branch and warn instead of aborting.
335    ///    Checked after the emptiness test because with a clean tree fidelity
336    ///    is not actually off, and `registry.rs:64-67` calls out on purpose
337    ///    that a resolution commit still runs its checks.
338    pub fn enter() -> Result<StagedOnly, String> {
339        // Conflicted paths cannot be split into staged and unstaged halves.
340        let conflicted: Vec<String> =
341            crate::git::stdout_paths(&["diff", "--name-only", "--diff-filter=U"])
342                .unwrap_or_default();
343        if !conflicted.is_empty() {
344            return Err(format!(
345                "{} unmerged paths — resolve and stage them first:\n    {}",
346                error_sign(),
347                conflicted.join("\n    ")
348            ));
349        }
350        // Tracked files only: an untracked file is not part of this commit and
351        // moving it would surprise everyone.
352        let changed: Vec<String> =
353            crate::git::stdout_paths(&["diff", "--name-only"]).unwrap_or_default();
354        if changed.is_empty() {
355            return Ok(StagedOnly { held: false });
356        }
357        // A tree mid-merge is already holding work that is not the author's,
358        // so taking a copy of it would be the wrong instrument entirely — but
359        // the checks are then reading the tree, and that has to be said.
360        let in_progress = crate::git_states_in_progress();
361        if !in_progress.is_empty() {
362            println!(
363                "{} {} in progress — checks see the working tree, not just the index",
364                warning_sign(),
365                in_progress
366                    .iter()
367                    .map(|s| s.as_str())
368                    .collect::<Vec<_>>()
369                    .join(" and ")
370            );
371            return Ok(StagedOnly { held: false });
372        }
373
374        let Some(store) = store_dir() else {
375            return Ok(StagedOnly { held: false });
376        };
377        // A stash left behind by an interrupted restore still holds work
378        // nobody has recovered — point 5 in the module doc. Clearing it to
379        // make room for a new one would be exactly the loss this module
380        // exists to prevent, so refuse instead of silently deleting it.
381        if has_contents(&store) {
382            return Err(format!(
383                "{} a previous stash was left behind at {} — recover it with \
384                 `amont restore`, then retry",
385                error_sign(),
386                store.display()
387            ));
388        }
389        let root = crate::hooks::common::repo_root();
390        let root = Path::new(&root);
391
392        // Explicitly, because a run whose every entry is a deletion or a
393        // symlink writes no payload file and so would otherwise never create
394        // the directory the index goes in.
395        if std::fs::create_dir_all(&store).is_err() {
396            return Err(held_nothing(&store));
397        }
398
399        let mut entries: Vec<Held> = Vec::with_capacity(changed.len());
400        for rel in &changed {
401            // git gave us this path, but it is repo-controlled and it is about
402            // to be joined onto a root, so it is checked like anything else.
403            let Some(relp) = safe_rel(rel) else {
404                return Err(held_nothing(&store));
405            };
406            let from = root.join(&relp);
407
408            // A symlink must be read as a link, not opened: `fs::read` follows
409            // it, which copies the TARGET's bytes instead of the link, and
410            // silently mistakes a dangling link (a normal mid-edit state) for
411            // a deleted file — restore would then delete the link rather than
412            // put it back.
413            match std::fs::symlink_metadata(&from) {
414                Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(&from) {
415                    Ok(target) => entries.push(Held::Symlink {
416                        rel: rel.clone(),
417                        target: target.to_string_lossy().into_owned(),
418                    }),
419                    Err(_) => return Err(held_nothing(&store)),
420                },
421                Ok(meta) => {
422                    // It exists, so we must be able to reproduce it. Failing to
423                    // read it now means failing to restore it later, and the
424                    // one thing this module may not do is park work it cannot
425                    // put back.
426                    let Ok(bytes) = std::fs::read(&from) else {
427                        return Err(held_nothing(&store));
428                    };
429                    let to = store.join(FILES).join(&relp);
430                    if let Some(parent) = to.parent() {
431                        if std::fs::create_dir_all(parent).is_err() {
432                            return Err(held_nothing(&store));
433                        }
434                    }
435                    if std::fs::write(&to, bytes).is_err() {
436                        return Err(held_nothing(&store));
437                    }
438                    entries.push(Held::Modified {
439                        rel: rel.clone(),
440                        mode: mode_of(&meta),
441                    });
442                }
443                // Deleted in the tree but not staged: record the absence, so
444                // the restore deletes it again rather than resurrecting it.
445                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
446                    entries.push(Held::Absent { rel: rel.clone() })
447                }
448                // Something else is wrong with the path. Do not guess.
449                Err(_) => return Err(held_nothing(&store)),
450            }
451        }
452
453        // The index lands BEFORE the tree is touched, so a store can never be
454        // half-described while the working tree has already moved.
455        if std::fs::write(store.join(INDEX), encode_index(&entries)).is_err() {
456            return Err(held_nothing(&store));
457        }
458
459        // Tree := index, for exactly the paths being parked. Scoped, not
460        // `checkout -- .`: the repo-wide form makes git traverse and lstat
461        // the entire working tree to reset the handful of files just
462        // enumerated — seconds on a large tree, on every dirty commit — and
463        // the pathspec list is BY CONSTRUCTION the complete set of tracked
464        // paths that differ. `:(literal)` per path, because a path is data
465        // here and `*`/`:` are pathspec syntax. Locked against a concurrent
466        // `restore()` — see `ENTER_LOCK`.
467        {
468            let _guard = ENTER_LOCK.lock().unwrap_or_else(|p| p.into_inner());
469            let mut spec = String::new();
470            for p in &changed {
471                spec.push_str(":(literal)");
472                spec.push_str(p);
473                spec.push('\0');
474            }
475            if crate::git::stdout_piped_raw(
476                &["checkout", "--pathspec-from-file=-", "--pathspec-file-nul"],
477                &spec,
478            )
479            .is_none()
480            {
481                let _ = std::fs::remove_dir_all(&store);
482                return Err(format!(
483                    "{} could not set the unstaged changes aside; nothing was changed",
484                    error_sign()
485                ));
486            }
487            // What the checks will see, recorded so `put_back` can tell an
488            // editor save made MID-CHECK apart from its own checkout — the
489            // clobber this module used to commit silently. Best-effort: a
490            // failure to record only means the old unconditional restore.
491            let mut expected = String::new();
492            for e in &entries {
493                if let Held::Modified { rel, .. } = e {
494                    if let Some(relp) = safe_rel(rel) {
495                        if let Ok(bytes) = std::fs::read(root.join(&relp)) {
496                            expected.push_str(rel);
497                            expected.push('\0');
498                            expected.push_str(&format!("{:016x}", content_hash(&bytes)));
499                            expected.push('\0');
500                        }
501                    }
502                }
503            }
504            let _ = std::fs::write(store.join(EXPECTED), expected);
505            HELD.store(true, Ordering::SeqCst);
506        }
507        Ok(StagedOnly { held: true })
508    }
509
510    /// Put them back. Idempotent, and safe to call from the watcher thread
511    /// `install_signal_handler` starts — never from the signal handler itself.
512    /// `fixing` rather than the whole configuration: this runs from `Drop` and
513    /// from the signal thread, neither of which can hold a borrow.
514    pub fn restore(fixing: bool) {
515        // Blocks until `enter()` has definitely finished its own checkout —
516        // see `ENTER_LOCK`. The common case (no `enter()` active) is
517        // uncontended.
518        let _guard = ENTER_LOCK.lock().unwrap_or_else(|p| p.into_inner());
519        if !HELD.swap(false, Ordering::SeqCst) {
520            return;
521        }
522        let Some(store) = store_dir() else {
523            return;
524        };
525        if !store.is_dir() {
526            return;
527        }
528        let root = crate::hooks::common::repo_root();
529        match put_back(fixing, &store, Path::new(&root)) {
530            Ok(()) => {
531                let _ = std::fs::remove_dir_all(&store);
532            }
533            Err(_) => {
534                // The one message in this codebase that must never be swallowed.
535                eprintln!(
536                    "{} YOUR UNSTAGED CHANGES COULD NOT BE PUT BACK AUTOMATICALLY.",
537                    error_sign()
538                );
539                eprintln!("    They are safe, in: {}", store.display());
540                eprintln!("    Recover them with: amont restore");
541            }
542        }
543    }
544}
545
546/// Whether `dir` exists and holds at least one entry.
547fn has_contents(dir: &Path) -> bool {
548    std::fs::read_dir(dir)
549        .map(|mut entries| entries.next().is_some())
550        .unwrap_or(false)
551}
552
553fn held_nothing(store: &Path) -> String {
554    let _ = std::fs::remove_dir_all(store);
555    format!(
556        "{} could not hold the unstaged changes aside; nothing was changed",
557        error_sign()
558    )
559}
560
561/// Put every held path back over the tree.
562///
563/// Dispatches on the index: a store written by this version describes itself,
564/// and one left behind by an older binary mid-upgrade is still recoverable
565/// through [`put_back_legacy`]. Nobody's work is stranded by the format change.
566fn put_back(fixing: bool, store: &Path, root: &Path) -> std::io::Result<()> {
567    match std::fs::read(store.join(INDEX)) {
568        Ok(raw) => {
569            let entries = parse_index(&raw).map_err(std::io::Error::other)?;
570            put_back_v1(fixing, store, root, &entries)
571        }
572        Err(e) if e.kind() == std::io::ErrorKind::NotFound => put_back_legacy(store, root),
573        Err(e) => Err(e),
574    }
575}
576
577fn put_back_v1(fixing: bool, store: &Path, root: &Path, entries: &[Held]) -> std::io::Result<()> {
578    let expected = read_expected(store);
579    // The guard stands down when fixing is ON: a repo-wide fixer (`prettier
580    // --write .`) legitimately rewrites held files mid-run, and telling its
581    // writes apart from an editor save is not possible from here. `amont.fix`
582    // is an explicit opt-in whose documented contract has always been "the
583    // tree returns to your unstaged version"; the guard protects the default
584    // population, which is everyone else.
585    let guard = !fixing;
586    let mut preserved: Vec<(String, std::path::PathBuf)> = Vec::new();
587    let escapes = |rel: &str| {
588        std::io::Error::other(format!(
589            "held store names a path outside the worktree: {rel:?}"
590        ))
591    };
592    for e in entries {
593        match e {
594            Held::Absent { rel } => {
595                let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
596                // It was deleted in the tree; `checkout` brought it back.
597                let _ = std::fs::remove_file(root.join(relp));
598            }
599            Held::Symlink { rel, target } => {
600                let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
601                let link = root.join(relp);
602                // `checkout` put the staged content there — a regular file,
603                // or a link of its own; replace it, don't merge with it.
604                let _ = remove_link(&link);
605                create_symlink(target, &link)?;
606            }
607            Held::Modified { rel, mode } => {
608                let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
609                let target = root.join(&relp);
610                if let Some(parent) = target.parent() {
611                    std::fs::create_dir_all(parent)?;
612                }
613                let held = std::fs::read(store.join(FILES).join(&relp))?;
614                // What `checkout` put here may be a SYMLINK: the staged content
615                // of a path whose unstaged version is a regular file. Both
616                // `fs::read` below and `fs::write` FOLLOW a link, so restoring
617                // through it would compare against, and then overwrite, the
618                // link's TARGET — a file anywhere on the machine, outside the
619                // worktree included, that this module was never asked to
620                // touch. The link is the index's content, not the author's;
621                // the author's is the regular file in the store. Remove the
622                // link so the write lands on the path itself.
623                if is_symlink(&target) {
624                    remove_link(&target)?;
625                }
626                // The clobber guard. If the file on disk no longer holds what
627                // `enter()`'s checkout put there, somebody wrote it while the
628                // checks ran — an editor save is WORK, and this write used to
629                // destroy it silently. Keep the newer file; park the held
630                // copy where the warning says.
631                if guard
632                    && expected.get(rel.as_str()).is_some_and(|want| {
633                        std::fs::read(&target)
634                            .is_ok_and(|now| content_hash(&now) != *want && now != held)
635                    })
636                {
637                    let kept = preserve(store, &relp, &held)?;
638                    preserved.push((rel.clone(), kept));
639                    continue;
640                }
641                std::fs::write(&target, held)?;
642                // AFTER the write: a recorded mode without `u+w` applied first
643                // would make writing the content fail.
644                if let Some(m) = mode {
645                    set_mode(&target, *m)?;
646                }
647            }
648        }
649    }
650    if !preserved.is_empty() {
651        eprintln!(
652            "{} {} file(s) changed while the checks ran — the newer content was KEPT.",
653            crate::ui::warning_sign(),
654            preserved.len()
655        );
656        eprintln!("    The unstaged version each held before the commit is parked at:");
657        for (rel, kept) in &preserved {
658            eprintln!("      {} -> {}", crate::ui::sanitize(rel), kept.display());
659        }
660        eprintln!("    Compare and merge by hand; the parked copies are yours to delete.");
661    }
662    Ok(())
663}
664
665/// Park `held` beside the store, never inside it — the store is deleted on a
666/// clean restore and this must survive to be read. A previous incident's copy
667/// is not overwritten; the name grows a counter instead.
668fn preserve(store: &Path, relp: &Path, held: &[u8]) -> std::io::Result<std::path::PathBuf> {
669    let base = store
670        .parent()
671        .map(|p| p.join(PRESERVED))
672        .ok_or_else(|| std::io::Error::other("store has no parent"))?;
673    let mut to = base.join(relp);
674    let mut n = 0;
675    while to.exists() {
676        n += 1;
677        to = base.join(relp).with_extension(format!("kept-{n}"));
678    }
679    if let Some(parent) = to.parent() {
680        std::fs::create_dir_all(parent)?;
681    }
682    std::fs::write(&to, held)?;
683    Ok(to)
684}
685
686/// The `EXPECTED` record, or empty when it is absent or unreadable — which
687/// simply disables the clobber guard, the pre-existing behaviour.
688fn read_expected(store: &Path) -> std::collections::HashMap<String, u64> {
689    let Ok(raw) = std::fs::read_to_string(store.join(EXPECTED)) else {
690        return Default::default();
691    };
692    let mut map = std::collections::HashMap::new();
693    let mut it = raw.split('\0');
694    while let (Some(rel), Some(hex)) = (it.next(), it.next()) {
695        if rel.is_empty() {
696            break;
697        }
698        if let Ok(h) = u64::from_str_radix(hex, 16) {
699            map.insert(rel.to_string(), h);
700        }
701    }
702    map
703}
704
705/// A store from before the index existed, where the kind of each entry was
706/// encoded in its file NAME.
707///
708/// Kept only so that upgrading mid-hold cannot strand somebody's work. The
709/// ambiguity that made this format unsafe — a repository may contain files
710/// called `x.amont-absent` — is not fixable at recovery time: by the time we
711/// are reading it, the statement and the payload are already indistinguishable.
712/// This is a best-effort read of a format we no longer write.
713fn put_back_legacy(store: &Path, root: &Path) -> std::io::Result<()> {
714    for entry in walk(store)? {
715        let rel = entry.strip_prefix(store).unwrap_or(&entry).to_path_buf();
716        let rel_str = rel.to_string_lossy().to_string();
717        let escapes = || {
718            std::io::Error::other(format!(
719                "held store names a path outside the worktree: {rel_str:?}"
720            ))
721        };
722        if let Some(original) = rel_str.strip_suffix(".amont-absent") {
723            let relp = safe_rel(original).ok_or_else(escapes)?;
724            let _ = std::fs::remove_file(root.join(relp));
725            continue;
726        }
727        if let Some(original) = rel_str.strip_suffix(".amont-symlink") {
728            let link_target = std::fs::read_to_string(&entry)?;
729            let relp = safe_rel(original).ok_or_else(escapes)?;
730            let link_path = root.join(relp);
731            let _ = std::fs::remove_file(&link_path);
732            create_symlink(&link_target, &link_path)?;
733            continue;
734        }
735        let relp = safe_rel(&rel_str).ok_or_else(escapes)?;
736        let target = root.join(&relp);
737        if let Some(parent) = target.parent() {
738            std::fs::create_dir_all(parent)?;
739        }
740        std::fs::write(&target, std::fs::read(&entry)?)?;
741    }
742    Ok(())
743}
744
745#[cfg(unix)]
746fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
747    std::os::unix::fs::symlink(target, link)
748}
749
750/// Windows distinguishes file and directory symlinks at creation time. The
751/// target usually still exists (it was the staged content `checkout` left
752/// behind, untouched by this whole dance), so ask it; a dangling link falls
753/// back to `symlink_file`, the more common case.
754#[cfg(windows)]
755fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
756    let resolved = link
757        .parent()
758        .map(|parent| parent.join(target))
759        .unwrap_or_else(|| Path::new(target).to_path_buf());
760    if resolved.is_dir() {
761        std::os::windows::fs::symlink_dir(target, link)
762    } else {
763        std::os::windows::fs::symlink_file(target, link)
764    }
765}
766
767/// Is the path itself a symlink? `symlink_metadata`, never `metadata`: the
768/// latter follows the link and answers about whatever it points at.
769fn is_symlink(path: &Path) -> bool {
770    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
771}
772
773/// Remove a symlink — the LINK, never its target. On Windows a link to a
774/// directory is removed with `remove_dir`, and `remove_file` refuses it; on
775/// unix `remove_file` unlinks either kind.
776fn remove_link(link: &Path) -> std::io::Result<()> {
777    match std::fs::remove_file(link) {
778        Ok(()) => Ok(()),
779        Err(first) => match std::fs::remove_dir(link) {
780            Ok(()) => Ok(()),
781            Err(_) => Err(first),
782        },
783    }
784}
785
786#[cfg(not(any(unix, windows)))]
787fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
788    Err(std::io::Error::other(format!(
789        "no symlink support on this platform: {} -> {target}",
790        link.display()
791    )))
792}
793
794fn walk(dir: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
795    let mut out = Vec::new();
796    for entry in std::fs::read_dir(dir)? {
797        let path = entry?.path();
798        if path.is_dir() {
799            out.extend(walk(&path)?);
800        } else {
801            out.push(path);
802        }
803    }
804    Ok(out)
805}
806
807/// Where the store lives, agreeing with [`StagedOnly::restore`] and
808/// [`restore_command`] BY CONSTRUCTION — all three call this one function
809/// rather than each asking git their own way. That used to be
810/// `hooks_dir.parent()` in `enter()` against `git rev-parse --git-dir`
811/// everywhere else: correct for the main worktree, where `.git/hooks`'s
812/// parent IS `$GIT_DIR`, but wrong for a LINKED worktree, where hooks
813/// dispatch from the COMMON directory's shared `hooks/` while `--git-dir`
814/// names the worktree's own PRIVATE gitdir. The mismatch parked files in one
815/// directory and looked for them in the other — silently, since a missing
816/// store reads as "nothing to do" — which is how a real commit in a real
817/// worktree lost real unstaged content. Sharing one function instead of one
818/// convention makes that class of drift impossible rather than merely fixed.
819fn store_dir() -> Option<std::path::PathBuf> {
820    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
821    Some(Path::new(&dir).join(STORE))
822}
823
824impl Drop for StagedOnly {
825    fn drop(&mut self) {
826        if self.held {
827            StagedOnly::restore(fixing_from_config());
828        }
829    }
830}
831
832/// Put back files this tool parked, from a later invocation.
833///
834/// For when even the signal handler was interrupted.
835pub fn restore_command(settings: &crate::config::Settings) -> Result<(), String> {
836    let store = store_dir().ok_or_else(|| "not inside a git repository".to_string())?;
837    if !store.is_dir() {
838        println!("{} nothing of ours to restore", warning_sign());
839        return Ok(());
840    }
841    // `store_dir()` already established we are in a repository, so this cannot
842    // fail here — asked the checked way anyway, because `repo_root()`'s "."
843    // would make `put_back` write held files relative to the current directory
844    // rather than the work tree, and a restore that lands in the wrong place is
845    // the failure this whole module exists to prevent.
846    let root = crate::hooks::common::repo_root_checked()?;
847    put_back(
848        crate::hooks::common::fixing_requested(settings),
849        &store,
850        Path::new(&root),
851    )
852    .map_err(|e| format!("could not put {} back: {e}", store.display()))?;
853    let _ = std::fs::remove_dir_all(&store);
854    println!("restored your unstaged changes");
855    Ok(())
856}
857
858/// Restore before dying on a signal.
859///
860/// `Drop` does not run when the process is killed, and Ctrl-C during a slow
861/// pre-commit is the most likely way to reach an orphaned stash. Installed only
862/// when a stash is actually held.
863///
864/// The handler itself does almost nothing. `restore()` runs `git`, walks the
865/// filesystem, writes files and prints — none of that is async-signal-safe,
866/// and running it IN the handler risks a deadlock: if the thread the signal
867/// interrupted already held a lock the handler's own code would then wait on
868/// forever (the allocator's, or stdio's), the process hangs instead of
869/// exiting, which is worse than either failure `restore` exists to prevent.
870///
871/// So the handler only records which signal arrived and writes one byte down
872/// a pipe — both on POSIX's async-signal-safe list — and a plain background
873/// thread, blocked reading that pipe, does the actual restore once it wakes,
874/// in ordinary thread context where none of those restrictions apply. This is
875/// the standard "self-pipe" pattern for getting work out of a signal handler.
876#[cfg(unix)]
877pub fn install_signal_handler() {
878    // Once per process: `tree_run` arms it before every gate it spawns, and a
879    // second call would open another pipe, start another watcher and leave
880    // the first one blocked forever.
881    static INSTALLED: std::sync::Once = std::sync::Once::new();
882    INSTALLED.call_once(install_signal_handler_once);
883}
884
885#[cfg(unix)]
886fn install_signal_handler_once() {
887    let mut fds = [-1i32; 2];
888    if unsafe { libc_pipe(fds.as_mut_ptr()) } != 0 {
889        // No pipe, no watcher, no handler: Ctrl-C falls back to the default
890        // action. Losing the safety net is better than building it on a
891        // primitive that just failed us.
892        return;
893    }
894    let (read_fd, write_fd) = (fds[0], fds[1]);
895    SIGNAL_PIPE_WRITE.store(write_fd, Ordering::SeqCst);
896
897    std::thread::spawn(move || loop {
898        let mut byte = 0u8;
899        let n = unsafe { libc_read(read_fd, &mut byte as *mut u8, 1) };
900        if n <= 0 {
901            return; // pipe closed, or a real error: nothing left to watch for
902        }
903        // Gates first: they run in their own process groups, so the signal
904        // that is killing amont never reached them (amont#302).
905        crate::tree_run::reap_live();
906        StagedOnly::restore(fixing_from_config());
907        // Re-raise with the default handler so the exit status is honest
908        // about having been killed.
909        let sig = PENDING_SIGNAL.load(Ordering::SeqCst);
910        if sig != 0 {
911            unsafe {
912                libc_signal(sig, 0); // SIG_DFL
913                libc_raise(sig);
914            }
915        }
916    });
917
918    extern "C" fn on_signal(sig: i32) {
919        PENDING_SIGNAL.store(sig, Ordering::SeqCst);
920        let fd = SIGNAL_PIPE_WRITE.load(Ordering::SeqCst);
921        if fd >= 0 {
922            let byte = 1u8;
923            unsafe {
924                libc_write(fd, &byte as *const u8, 1);
925            }
926        }
927    }
928    // Numeric literals rather than a `libc` import: this module deliberately
929    // ships dependency-free (see the `extern` block below and
930    // `scripts/check-no-deps.sh`), and unlike `sigset_t`-based APIs these four
931    // numbers are fixed by POSIX on every platform this runs on.
932    //
933    // SIGHUP was missing and is at least as likely as Ctrl-C: it is the
934    // terminal-closed and SSH-connection-dropped case, and a pre-commit
935    // interrupted that way orphaned the held store with nothing said. SIGQUIT
936    // is the Ctrl-\ sibling of SIGINT.
937    const SIGHUP: i32 = 1;
938    const SIGINT: i32 = 2;
939    const SIGQUIT: i32 = 3;
940    const SIGTERM: i32 = 15;
941    unsafe {
942        for sig in [SIGHUP, SIGINT, SIGQUIT, SIGTERM] {
943            libc_signal(sig, on_signal as *const () as usize);
944        }
945    }
946}
947
948/// The Windows twin: `SetConsoleCtrlHandler` instead of `signal(2)`. The
949/// system already runs the handler on a fresh thread, so there is no
950/// self-pipe dance — `restore()` is called directly (its `ENTER_LOCK`
951/// synchronisation against a mid-`enter()` signal is the same on both
952/// platforms), and returning FALSE hands the event to the default handler,
953/// which terminates the process: the exit stays honest about being killed.
954/// Before this, Ctrl-C mid-check on Windows orphaned the held patch and the
955/// recovery was "your next commit is blocked, run `amont restore`" — the
956/// platform with the fewest git-hooks veterans got the scariest failure.
957///
958/// Raw FFI rather than a crate for the same reason as the unix externs
959/// below: this binary ships dependency-free, and this is one kernel32 call
960/// with a stable signature. Covers Ctrl-C, Ctrl-Break, and console-closed.
961#[cfg(windows)]
962pub fn install_signal_handler() {
963    extern "system" fn on_ctrl(_event: u32) -> i32 {
964        StagedOnly::restore(fixing_from_config());
965        0 // FALSE: pass to the default handler, which terminates
966    }
967    #[link(name = "kernel32")]
968    extern "system" {
969        fn SetConsoleCtrlHandler(handler: extern "system" fn(u32) -> i32, add: i32) -> i32;
970    }
971    // Once per process, as on unix: `tree_run` arms it before every gate, and
972    // each extra registration would run the restore again.
973    static INSTALLED: std::sync::Once = std::sync::Once::new();
974    INSTALLED.call_once(|| unsafe {
975        SetConsoleCtrlHandler(on_ctrl, 1);
976    });
977}
978
979#[cfg(not(any(unix, windows)))]
980pub fn install_signal_handler() {}
981
982/// The write end of the self-pipe a signal handler wakes the watcher thread
983/// through. `-1` until `install_signal_handler` has run.
984#[cfg(unix)]
985static SIGNAL_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
986
987/// Which signal woke the watcher, so it can re-raise the right one.
988#[cfg(unix)]
989static PENDING_SIGNAL: AtomicI32 = AtomicI32::new(0);
990
991// Externs rather than a dependency: `scripts/check-no-deps.sh` keeps this
992// binary crate-free, and these are five libc calls with stable signatures —
993// `pipe`/`read`/`write` need nothing beyond plain integers and byte pointers,
994// so unlike `sigset_t`-based APIs there is no opaque, platform-varying struct
995// layout to get wrong by hand.
996#[cfg(unix)]
997extern "C" {
998    #[link_name = "signal"]
999    fn libc_signal_raw(sig: i32, handler: usize) -> usize;
1000    #[link_name = "raise"]
1001    fn libc_raise_raw(sig: i32) -> i32;
1002    #[link_name = "pipe"]
1003    fn libc_pipe_raw(fds: *mut i32) -> i32;
1004    #[link_name = "read"]
1005    fn libc_read_raw(fd: i32, buf: *mut u8, count: usize) -> isize;
1006    #[link_name = "write"]
1007    fn libc_write_raw(fd: i32, buf: *const u8, count: usize) -> isize;
1008}
1009
1010#[cfg(unix)]
1011unsafe fn libc_signal(sig: i32, handler: usize) {
1012    unsafe {
1013        libc_signal_raw(sig, handler);
1014    }
1015}
1016
1017#[cfg(unix)]
1018unsafe fn libc_raise(sig: i32) {
1019    unsafe {
1020        libc_raise_raw(sig);
1021    }
1022}
1023
1024#[cfg(unix)]
1025unsafe fn libc_pipe(fds: *mut i32) -> i32 {
1026    unsafe { libc_pipe_raw(fds) }
1027}
1028
1029#[cfg(unix)]
1030unsafe fn libc_read(fd: i32, buf: *mut u8, count: usize) -> isize {
1031    unsafe { libc_read_raw(fd, buf, count) }
1032}
1033
1034#[cfg(unix)]
1035unsafe fn libc_write(fd: i32, buf: *const u8, count: usize) -> isize {
1036    unsafe { libc_write_raw(fd, buf, count) }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    fn modified(rel: &str) -> Held {
1044        Held::Modified {
1045            rel: rel.to_string(),
1046            mode: Some(0o100_644),
1047        }
1048    }
1049
1050    /// The store has to survive names a repository is allowed to choose, and
1051    /// those include every character a filename may hold except NUL — which is
1052    /// exactly why the index is NUL-delimited rather than line-based.
1053    #[test]
1054    fn the_index_round_trips_hostile_names() {
1055        let entries = vec![
1056            modified("a\nb.txt"),
1057            modified("a\tb"),
1058            modified("a\\b"),
1059            modified("é.json"),
1060            modified("quote\"and'apostrophe"),
1061            // The names that used to BE the metadata.
1062            modified("notes.amont-absent"),
1063            Held::Absent {
1064                rel: "gone.amont-symlink".to_string(),
1065            },
1066            Held::Symlink {
1067                rel: "link\nname".to_string(),
1068                target: "target\nwith\nnewlines".to_string(),
1069            },
1070        ];
1071        let raw = encode_index(&entries);
1072        assert_eq!(parse_index(&raw).expect("round trip"), entries);
1073    }
1074
1075    #[test]
1076    fn an_empty_index_round_trips() {
1077        let raw = encode_index(&[]);
1078        assert_eq!(parse_index(&raw).expect("round trip"), Vec::<Held>::new());
1079    }
1080
1081    /// A store we do not recognise is never half-understood.
1082    #[test]
1083    fn parse_index_rejects_a_foreign_header() {
1084        let err = parse_index(b"amont-held-v99\0file\0a\0\0").expect_err("must refuse");
1085        assert!(err.contains("amont-held-v1"), "{err}");
1086    }
1087
1088    #[test]
1089    fn parse_index_rejects_a_truncated_record() {
1090        // A `file` record promises a path and a mode.
1091        let raw = b"amont-held-v1\0file\0a.txt\0";
1092        let err = parse_index(raw).expect_err("must refuse");
1093        assert!(err.contains("ends mid-record"), "{err}");
1094    }
1095
1096    #[test]
1097    fn parse_index_rejects_an_unknown_record_kind() {
1098        let raw = b"amont-held-v1\0execute\0rm -rf\0";
1099        assert!(parse_index(raw).is_err());
1100    }
1101
1102    /// The guard that makes the store unable to name anything outside the
1103    /// worktree, applied on the way out as well as in.
1104    #[test]
1105    fn safe_rel_refuses_anything_that_leaves_the_tree() {
1106        for bad in [
1107            "",
1108            "..",
1109            "../x",
1110            "a/../../b",
1111            "/etc/passwd",
1112            "/",
1113            ".",
1114            "./a",
1115        ] {
1116            assert!(safe_rel(bad).is_none(), "{bad:?} must be refused");
1117        }
1118        for good in ["a", "a/b.txt", "é.json", "a\nb", "notes.amont-absent"] {
1119            assert!(safe_rel(good).is_some(), "{good:?} should be allowed");
1120        }
1121    }
1122
1123    /// Windows drive-qualified paths are absolute even when they do not start
1124    /// with a separator, and `Path::join` honours them.
1125    #[cfg(windows)]
1126    #[test]
1127    fn safe_rel_refuses_a_drive_prefix() {
1128        assert!(safe_rel("C:\\Windows\\System32").is_none());
1129        assert!(safe_rel("C:x").is_none());
1130    }
1131}