Skip to main content

whycodes_session/
worktree_snapshot.rs

1//! Whole-working-tree git snapshots for `/undo` and `/redo`.
2//!
3//! `git stash create` sees tracked files only: a file a turn created stayed
4//! after `/undo`, and `/redo` could not bring one back. A snapshot here is
5//! written through a throwaway index instead: a copy of the real index (so
6//! git reuses its stat cache), `git add -A`, `git write-tree`. That records
7//! tracked and untracked files alike and skips ignored ones.
8//!
9//! A snapshot writes only loose objects. HEAD, refs, `refs/stash` (the
10//! user's stash list) and the real index stay untouched, so a held
11//! `index.lock` does not stop it. Restoring writes only the paths that
12//! differ between two snapshots, and refuses when HEAD moved or a merge,
13//! rebase, cherry-pick or revert is in progress.
14
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::sync::OnceLock;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21/// `--attr-source` at the empty tree: no `.gitattributes` rules apply.
22const NO_ATTRS_SHA1: &str = "--attr-source=4b825dc642cb6eb9a060e54bf8d69288fbee4904";
23const NO_ATTRS_SHA256: &str =
24    "--attr-source=6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
25
26/// Git state files that mean an operation is half done, with its name.
27const BUSY_MARKERS: [(&str, &str); 5] = [
28    ("MERGE_HEAD", "merge"),
29    ("CHERRY_PICK_HEAD", "cherry-pick"),
30    ("REVERT_HEAD", "revert"),
31    ("rebase-merge", "rebase"),
32    ("rebase-apply", "rebase"),
33];
34
35/// The working tree as recorded before a turn (or before an undo/redo).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum TreeSnapshot {
38    /// The tree, staging area and HEAD at that moment.
39    Git(GitTree),
40    /// Not inside a git work tree; undo leaves files alone.
41    NoRepo,
42    /// A repository, but git could not take the snapshot (unmerged paths,
43    /// a broken index). Undo must leave files alone: nothing records what
44    /// they held.
45    Failed(String),
46}
47
48/// Trees git wrote for one snapshot.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GitTree {
51    /// Commit HEAD named, `None` before the first commit.
52    head: Option<String>,
53    /// The staging area.
54    index: String,
55    /// The working tree: tracked and untracked files, ignored ones left out.
56    work: String,
57    /// A merge, rebase, cherry-pick or revert was half done.
58    busy: Option<&'static str>,
59    /// `--attr-source` option for this repository's hash (see [`raw_flags`]).
60    no_attrs: &'static str,
61}
62
63/// Record the working tree of `dir` without changing it.
64///
65/// Each `git` spawn costs ~50 ms on Windows and this runs before every
66/// turn, so independent calls run side by side: the untracked listing with
67/// the repository probe, then the staging-area tree with `add` + the
68/// working-tree tree.
69pub(crate) fn snapshot(dir: &Path) -> TreeSnapshot {
70    std::thread::scope(|s| {
71        let untracked = s.spawn(|| git(dir, &UNTRACKED, None, None));
72        let Some(probe) = probe(dir) else {
73            return TreeSnapshot::NoRepo;
74        };
75        let untracked = untracked
76            .join()
77            .unwrap_or(Err(String::from("ls-files thread panicked")));
78        match write_trees(dir, &probe, untracked) {
79            Ok(tree) => TreeSnapshot::Git(tree),
80            Err(why) => TreeSnapshot::Failed(why),
81        }
82    })
83}
84
85/// Untracked, not ignored files under `dir`, NUL-separated.
86const UNTRACKED: [&str; 4] = ["ls-files", "-z", "--others", "--exclude-standard"];
87
88/// Object format, index path, busy markers and HEAD, one per line; `None`
89/// outside a work tree.
90fn probe(dir: &Path) -> Option<String> {
91    let mut args = vec!["rev-parse", "--show-object-format", "--git-path", "index"];
92    for (marker, _) in BUSY_MARKERS {
93        args.extend(["--git-path", marker]);
94    }
95    // One spawn names HEAD too. Before the first commit `HEAD` is fatal, so
96    // ask again without it; outside a work tree both fail.
97    args.push("HEAD");
98    let probe = git(dir, &args, None, None).or_else(|_unborn| {
99        args.pop();
100        git(dir, &args, None, None)
101    });
102    probe.ok()
103}
104
105/// Write the staging area and the working tree as trees, each from its own
106/// copy of the index.
107fn write_trees(
108    dir: &Path,
109    probe: &str,
110    untracked: Result<String, String>,
111) -> Result<GitTree, String> {
112    let lines: Vec<&str> = probe.lines().collect();
113    let no_attrs = match lines.first() {
114        Some(&"sha256") => NO_ATTRS_SHA256,
115        _ => NO_ATTRS_SHA1,
116    };
117    let real_index = dir.join(lines.get(1).unwrap_or(&""));
118    let busy = BUSY_MARKERS
119        .iter()
120        .zip(lines.iter().skip(2))
121        .find(|(_, path)| dir.join(path).exists())
122        .map(|((_, name), _)| *name);
123    let head = lines.get(2 + BUSY_MARKERS.len()).map(ToString::to_string);
124
125    let for_index = ScratchIndex::copy_of(&real_index)?;
126    let for_work = ScratchIndex::copy_of(&real_index)?;
127    let (index, work) = std::thread::scope(|s| {
128        let index = s.spawn(|| git(dir, &["write-tree"], Some(&for_index.0), None));
129        let work = untracked.and_then(|list| write_work_tree(dir, &for_work.0, no_attrs, &list));
130        (index.join(), work)
131    });
132    let index = index.unwrap_or(Err(String::from("write-tree thread panicked")))?;
133    let work = work?;
134    Ok(GitTree {
135        head,
136        index,
137        work,
138        busy,
139        no_attrs,
140    })
141}
142
143/// Untracked files above this size stay out of snapshots. The scratch index
144/// starts from the real one, which does not list untracked files, so git
145/// would hash such a file again before every turn and keep a copy in
146/// `.git/objects`. `/undo` leaves these files as they are.
147pub(crate) const MAX_UNTRACKED_BYTES: u64 = 20 * 1024 * 1024;
148
149/// `git add -A` into `index`, minus oversized `untracked` files; the tree id.
150fn write_work_tree(
151    dir: &Path,
152    index: &Path,
153    no_attrs: &'static str,
154    untracked: &str,
155) -> Result<String, String> {
156    let skip: Vec<String> = untracked
157        .split('\0')
158        .filter(|rel| too_large(&dir.join(rel)))
159        .map(|rel| format!(":(exclude,literal){rel}"))
160        .collect();
161    let mut add = raw_flags(no_attrs);
162    add.extend(["add", "-A", "--", "."]);
163    add.extend(skip.iter().map(String::as_str));
164    git(dir, &add, Some(index), None)?;
165    git(dir, &["write-tree"], Some(index), None)
166}
167
168fn too_large(path: &Path) -> bool {
169    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > MAX_UNTRACKED_BYTES)
170}
171
172/// Put the files of `dir` back to `target`; `now` is the tree they hold.
173/// Returns why they were left as they are, or only partly restored.
174pub(crate) fn restore_files(
175    target: &TreeSnapshot,
176    now: &TreeSnapshot,
177    dir: &Path,
178) -> Option<String> {
179    match (target, now) {
180        (TreeSnapshot::NoRepo, _) => None,
181        (TreeSnapshot::Failed(why), _) => Some(format!(
182            "files left as they are: git could not snapshot them at that point ({why})"
183        )),
184        (TreeSnapshot::Git(_), TreeSnapshot::NoRepo) => {
185            Some("files left as they are: the project is no longer a git work tree".into())
186        }
187        (TreeSnapshot::Git(_), TreeSnapshot::Failed(why)) => Some(format!(
188            "files left as they are: git could not snapshot the current files ({why})"
189        )),
190        (TreeSnapshot::Git(target), TreeSnapshot::Git(now)) => restore(target, now, dir).err(),
191    }
192}
193
194fn restore(target: &GitTree, now: &GitTree, dir: &Path) -> Result<(), String> {
195    if let Some(op) = now.busy {
196        return Err(format!(
197            "files left as they are: a {op} is in progress; finish or abort it first"
198        ));
199    }
200    if target.head != now.head {
201        return Err("files left as they are: HEAD moved since then \
202                    (commit, checkout, merge, rebase or reset)"
203            .into());
204    }
205    let left = |e: String| format!("files left as they are: {e}");
206    let changes = changed_paths(dir, &target.work, &now.work).map_err(left)?;
207    let staged = git(
208        dir,
209        &[
210            "diff-tree",
211            "-r",
212            "--name-only",
213            "--relative",
214            &target.index,
215            &now.index,
216        ],
217        None,
218        None,
219    )
220    .map_err(left)?;
221    if !staged.is_empty() {
222        let source = format!("--source={}", target.index);
223        git(
224            dir,
225            &["restore", "--staged", &source, "--", "."],
226            None,
227            None,
228        )
229        .map_err(|e| left(format!("could not restore the staging area ({e})")))?;
230    }
231
232    let partly = |e: String| format!("files only partly restored: {e}");
233    if !changes.write.is_empty() {
234        let scratch = ScratchIndex::new();
235        git(dir, &["read-tree", &target.work], Some(&scratch.0), None).map_err(partly)?;
236        let mut checkout = raw_flags(target.no_attrs);
237        checkout.extend(["checkout-index", "-f", "-z", "--stdin"]);
238        let list: String = changes.write.iter().map(|p| format!("{p}\0")).collect();
239        git(dir, &checkout, Some(&scratch.0), Some(list.as_bytes())).map_err(partly)?;
240    }
241    for rel in &changes.delete {
242        remove_created(dir, rel).map_err(partly)?;
243    }
244    Ok(())
245}
246
247/// Paths to write back from the target, and paths it did not have.
248#[derive(Debug, Default, PartialEq, Eq)]
249struct Changes {
250    write: Vec<String>,
251    delete: Vec<String>,
252}
253
254fn changed_paths(dir: &Path, target: &str, now: &str) -> Result<Changes, String> {
255    let raw = git(
256        dir,
257        &[
258            "diff-tree",
259            "-r",
260            "-z",
261            "--no-renames",
262            "--relative",
263            target,
264            now,
265        ],
266        None,
267        None,
268    )?;
269    Ok(parse_raw_diff(&raw))
270}
271
272/// Parse `diff-tree -r -z` raw records: `:old new oldsha newsha S\0path\0`.
273/// Submodule entries (mode 160000) are skipped: a nested repository is
274/// not a file to write or delete.
275fn parse_raw_diff(raw: &str) -> Changes {
276    let fields: Vec<&str> = raw.split('\0').filter(|f| !f.is_empty()).collect();
277    let mut changes = Changes::default();
278    for [meta, path] in fields.as_chunks::<2>().0 {
279        let mut meta = meta.split(' ');
280        let old_mode = meta.next().unwrap_or_default();
281        let new_mode = meta.next().unwrap_or_default();
282        let status = meta.next_back().unwrap_or_default();
283        let path = path.to_string();
284        if status == "A" {
285            if new_mode != "160000" {
286                changes.delete.push(path);
287            }
288        } else if old_mode != ":160000" {
289            changes.write.push(path);
290        }
291    }
292    changes
293}
294
295/// Remove a file the target did not have, then any directories that only
296/// held it, up to the project root.
297fn remove_created(dir: &Path, rel: &str) -> Result<(), String> {
298    let path = dir.join(rel);
299    match std::fs::remove_file(&path) {
300        Ok(()) => {}
301        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
302        Err(e) => return Err(format!("remove {rel}: {e}")),
303    }
304    let mut parent = path.parent();
305    while let Some(p) = parent.filter(|p| *p != dir && p.starts_with(dir)) {
306        if std::fs::remove_dir(p).is_err() {
307            break; // not empty: it holds other files
308        }
309        parent = p.parent();
310    }
311    Ok(())
312}
313
314/// Options that store and write file bytes as they are: no `autocrlf`, no
315/// `.gitattributes` eol or filter rules (the attribute source is the empty
316/// tree). Without this, an LF file in a `core.autocrlf=true` checkout came
317/// back as CRLF. Git before 2.40 has no `--attr-source`; it keeps the rest.
318fn raw_flags(no_attrs: &'static str) -> Vec<&'static str> {
319    flags_for(no_attrs, attr_source_supported())
320}
321
322fn flags_for(no_attrs: &'static str, attr_source: bool) -> Vec<&'static str> {
323    let mut flags = vec!["-c", "core.autocrlf=false", "-c", "core.safecrlf=false"];
324    if attr_source {
325        flags.insert(0, no_attrs);
326    }
327    flags
328}
329
330fn attr_source_supported() -> bool {
331    static SUPPORTED: OnceLock<bool> = OnceLock::new();
332    *SUPPORTED.get_or_init(|| {
333        let args = [NO_ATTRS_SHA1, "version"];
334        git(&std::env::temp_dir(), &args, None, None).is_ok()
335    })
336}
337
338/// A private index file in the temp dir, removed on drop.
339struct ScratchIndex(PathBuf);
340
341impl ScratchIndex {
342    fn new() -> Self {
343        static NEXT: AtomicU64 = AtomicU64::new(0);
344        let n = NEXT.fetch_add(1, Ordering::Relaxed);
345        let name = format!("whycodes-undo-{}-{n}.index", std::process::id());
346        Self(std::env::temp_dir().join(name))
347    }
348
349    /// A scratch copy of `real`; empty (no file) when the repo has none yet.
350    fn copy_of(real: &Path) -> Result<Self, String> {
351        let scratch = Self::new();
352        if real.exists() {
353            std::fs::copy(real, &scratch.0).map_err(|e| format!("copy index: {e}"))?;
354        }
355        Ok(scratch)
356    }
357}
358
359impl Drop for ScratchIndex {
360    fn drop(&mut self) {
361        for path in [self.0.clone(), self.0.with_extension("index.lock")] {
362            if let Err(e) = std::fs::remove_file(&path) {
363                note_cleanup(&path, &e);
364            }
365        }
366    }
367}
368
369/// A scratch index that did not exist is fine; anything else is logged.
370fn note_cleanup(path: &Path, e: &std::io::Error) {
371    if e.kind() != std::io::ErrorKind::NotFound {
372        tracing::debug!(path = %path.display(), error = %e, "scratch index cleanup");
373    }
374}
375
376/// Run `git` in `dir`: stdout without trailing whitespace (a leading space
377/// in a `-z` path list survives), or the first stderr line on failure.
378/// `index` sets `GIT_INDEX_FILE`; `stdin` is fed to the process.
379pub(crate) fn git(
380    dir: &Path,
381    args: &[&str],
382    index: Option<&Path>,
383    stdin: Option<&[u8]>,
384) -> Result<String, String> {
385    let mut cmd = Command::new("git");
386    cmd.args(args)
387        .current_dir(dir)
388        .stdin(if stdin.is_some() {
389            Stdio::piped()
390        } else {
391            Stdio::null()
392        })
393        .stdout(Stdio::piped())
394        .stderr(Stdio::piped());
395    if let Some(index) = index {
396        cmd.env("GIT_INDEX_FILE", index);
397    }
398    let mut child = cmd.spawn().map_err(io_error)?;
399    if let (Some(bytes), Some(mut pipe)) = (stdin, child.stdin.take()) {
400        pipe.write_all(bytes).map_err(io_error)?;
401    }
402    let output = child.wait_with_output().map_err(io_error)?;
403    if output.status.success() {
404        return Ok(String::from_utf8_lossy(&output.stdout)
405            .trim_end()
406            .to_string());
407    }
408    let stderr = String::from_utf8_lossy(&output.stderr);
409    let first = stderr.lines().next().unwrap_or("git failed").trim();
410    Err(first.to_string())
411}
412
413fn io_error(e: std::io::Error) -> String {
414    e.to_string()
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn git_ok(dir: &Path, args: &[&str]) -> String {
422        let mut all = vec!["-c", "user.name=t", "-c", "user.email=t@example.com"];
423        all.extend(args);
424        let out = git(dir, &all, None, None);
425        assert!(out.is_ok(), "git {args:?}: {out:?}");
426        out.unwrap_or_default()
427    }
428
429    #[test]
430    fn a_turn_that_only_created_files_is_undone_by_deleting_them() {
431        let dir = tempfile::tempdir().unwrap();
432        let d = dir.path();
433        git_ok(d, &["init", "-q"]);
434        std::fs::write(d.join("kept.txt"), "kept").unwrap();
435        let before = snapshot(d);
436        std::fs::write(d.join("made.txt"), "made").unwrap();
437        assert_eq!(restore_files(&before, &snapshot(d), d), None);
438        assert!(!d.join("made.txt").exists());
439        assert_eq!(std::fs::read_to_string(d.join("kept.txt")).unwrap(), "kept");
440    }
441
442    #[test]
443    fn raw_diff_records_split_into_writes_and_deletes() {
444        let z = "0".repeat(40);
445        let raw = format!(
446            ":100644 100644 {z} {z} M\0mod.txt\0\
447             :000000 100644 {z} {z} A\0new.txt\0\
448             :100644 000000 {z} {z} D\0gone.txt\0\
449             :100644 120000 {z} {z} T\0now-link\0\
450             :000000 160000 {z} {z} A\0new-submodule\0\
451             :160000 160000 {z} {z} M\0bumped-submodule\0"
452        );
453        assert_eq!(
454            parse_raw_diff(&raw),
455            Changes {
456                write: vec!["mod.txt".into(), "gone.txt".into(), "now-link".into()],
457                delete: vec!["new.txt".into()],
458            }
459        );
460        assert_eq!(parse_raw_diff(""), Changes::default());
461    }
462
463    #[test]
464    fn flags_turn_conversion_off_with_and_without_attr_source() {
465        assert_eq!(
466            flags_for(NO_ATTRS_SHA1, true),
467            [
468                NO_ATTRS_SHA1,
469                "-c",
470                "core.autocrlf=false",
471                "-c",
472                "core.safecrlf=false"
473            ]
474        );
475        assert!(!flags_for(NO_ATTRS_SHA1, false).contains(&NO_ATTRS_SHA1));
476        assert_eq!(raw_flags(NO_ATTRS_SHA1)[0], NO_ATTRS_SHA1, "git 2.40+ here");
477    }
478
479    #[test]
480    fn restore_explains_every_snapshot_it_cannot_use() {
481        let dir = tempfile::tempdir().unwrap();
482        let d = dir.path();
483        let git_tree = TreeSnapshot::Git(GitTree {
484            head: None,
485            index: String::new(),
486            work: String::new(),
487            busy: None,
488            no_attrs: NO_ATTRS_SHA1,
489        });
490        let failed = TreeSnapshot::Failed("boom".into());
491        assert_eq!(restore_files(&TreeSnapshot::NoRepo, &git_tree, d), None);
492        let note = restore_files(&failed, &git_tree, d).unwrap();
493        assert!(note.contains("at that point (boom)"), "{note}");
494        let note = restore_files(&git_tree, &TreeSnapshot::NoRepo, d).unwrap();
495        assert!(note.contains("no longer a git work tree"), "{note}");
496        let note = restore_files(&git_tree, &failed, d).unwrap();
497        assert!(note.contains("the current files (boom)"), "{note}");
498    }
499
500    #[test]
501    fn a_bad_tree_id_is_reported_before_any_file_changes() {
502        let dir = tempfile::tempdir().unwrap();
503        let d = dir.path();
504        git_ok(d, &["init", "-q"]);
505        std::fs::write(d.join("f.txt"), "now").unwrap();
506        let now = snapshot(d);
507        let mut gone = now.clone();
508        if let TreeSnapshot::Git(tree) = &mut gone {
509            tree.work = "0".repeat(40);
510        }
511        let note = restore_files(&gone, &now, d).unwrap();
512        assert!(note.starts_with("files left as they are: "), "{note}");
513        assert_eq!(std::fs::read_to_string(d.join("f.txt")).unwrap(), "now");
514    }
515
516    /// A dataset or download the user keeps untracked is not hashed into
517    /// `.git/objects` before every turn, and undo leaves it alone.
518    #[test]
519    fn oversized_untracked_files_stay_out_of_the_snapshot() {
520        let dir = tempfile::tempdir().unwrap();
521        let d = dir.path();
522        git_ok(d, &["init", "-q"]);
523        std::fs::write(d.join("f.txt"), "before").unwrap();
524        let big = std::fs::File::create(d.join("data.bin")).unwrap();
525        big.set_len(MAX_UNTRACKED_BYTES + 1).unwrap();
526        drop(big);
527        let before = snapshot(d);
528        assert!(matches!(before, TreeSnapshot::Git(_)), "{before:?}");
529        let blob = git_ok(d, &["hash-object", "data.bin"]);
530        let stored = git(d, &["cat-file", "-e", &blob], None, None);
531        assert!(stored.is_err(), "no copy in .git/objects");
532
533        std::fs::write(d.join("f.txt"), "after").unwrap();
534        assert_eq!(restore_files(&before, &snapshot(d), d), None);
535        assert_eq!(std::fs::read_to_string(d.join("f.txt")).unwrap(), "before");
536        let size = std::fs::metadata(d.join("data.bin")).unwrap().len();
537        assert_eq!(size, MAX_UNTRACKED_BYTES + 1, "left as it was");
538    }
539
540    #[test]
541    fn a_name_that_starts_with_a_space_round_trips() {
542        let dir = tempfile::tempdir().unwrap();
543        let d = dir.path();
544        git_ok(d, &["init", "-q"]);
545        let before = snapshot(d);
546        std::fs::write(d.join(" leading.txt"), "made").unwrap();
547        assert_eq!(restore_files(&before, &snapshot(d), d), None);
548        assert!(!d.join(" leading.txt").exists());
549    }
550
551    /// A blob the target needs is gone (pruned): what was written stays
552    /// written, and the note says the restore stopped part way.
553    #[test]
554    fn a_missing_blob_is_reported_as_a_partial_restore() {
555        let dir = tempfile::tempdir().unwrap();
556        let d = dir.path();
557        git_ok(d, &["init", "-q"]);
558        std::fs::write(d.join("f.txt"), "only in the snapshot\n").unwrap();
559        let before = snapshot(d);
560        let blob = git_ok(d, &["hash-object", "f.txt"]);
561        let (fan, rest) = blob.split_at(2);
562        std::fs::remove_file(d.join(".git/objects").join(fan).join(rest)).unwrap();
563        std::fs::write(d.join("f.txt"), "changed\n").unwrap();
564        let note = restore_files(&before, &snapshot(d), d).unwrap();
565        assert!(note.starts_with("files only partly restored: "), "{note}");
566    }
567
568    #[test]
569    fn sha256_repositories_snapshot_and_restore() {
570        let dir = tempfile::tempdir().unwrap();
571        let d = dir.path();
572        git_ok(d, &["init", "-q", "--object-format=sha256"]);
573        std::fs::write(d.join("f.txt"), "before\n").unwrap();
574        let before = snapshot(d);
575        assert!(
576            matches!(&before, TreeSnapshot::Git(t) if t.no_attrs == NO_ATTRS_SHA256),
577            "{before:?}"
578        );
579        std::fs::write(d.join("f.txt"), "after\n").unwrap();
580        std::fs::write(d.join("g.txt"), "new\n").unwrap();
581        assert_eq!(restore_files(&before, &snapshot(d), d), None);
582        assert_eq!(
583            std::fs::read_to_string(d.join("f.txt")).unwrap(),
584            "before\n"
585        );
586        assert!(!d.join("g.txt").exists());
587    }
588
589    #[test]
590    fn removing_a_created_file_prunes_only_emptied_directories() {
591        let dir = tempfile::tempdir().unwrap();
592        let d = dir.path();
593        std::fs::create_dir_all(d.join("a/b/c")).unwrap();
594        std::fs::write(d.join("a/keep.txt"), "x").unwrap();
595        std::fs::write(d.join("a/b/c/made.txt"), "x").unwrap();
596        assert_eq!(remove_created(d, "a/b/c/made.txt"), Ok(()));
597        assert!(!d.join("a/b").exists(), "emptied directories go");
598        assert!(
599            d.join("a/keep.txt").exists(),
600            "a directory with files stays"
601        );
602        assert_eq!(remove_created(d, "already/gone.txt"), Ok(()));
603        // A directory where a file was expected is an error, not a skip.
604        std::fs::create_dir(d.join("dir.txt")).unwrap();
605        let err = remove_created(d, "dir.txt").unwrap_err();
606        assert!(err.starts_with("remove dir.txt: "), "{err}");
607    }
608
609    #[test]
610    fn scratch_index_cleans_up_after_itself() {
611        let scratch = ScratchIndex::new();
612        let path = scratch.0.clone();
613        std::fs::write(&path, "x").unwrap();
614        std::fs::write(path.with_extension("index.lock"), "x").unwrap();
615        drop(scratch);
616        assert!(!path.exists());
617        assert!(!path.with_extension("index.lock").exists());
618        let missing = std::io::Error::from(std::io::ErrorKind::NotFound);
619        note_cleanup(&path, &missing);
620        let denied = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
621        note_cleanup(&path, &denied);
622    }
623
624    #[test]
625    fn git_runner_feeds_stdin_and_reports_the_first_stderr_line() {
626        let dir = tempfile::tempdir().unwrap();
627        let d = dir.path();
628        let id = git(d, &["hash-object", "--stdin"], None, Some(b"hello\n")).unwrap();
629        assert_eq!(id, "ce013625030ba8dba906f756967f9e9ca394464a");
630        let err = git(d, &["no-such-command"], None, None).unwrap_err();
631        assert!(err.contains("no-such-command"), "{err}");
632        let missing = d.join("missing");
633        assert!(git(&missing, &["version"], None, None).is_err());
634    }
635}