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