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