1use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::sync::OnceLock;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21const NO_ATTRS_SHA1: &str = "--attr-source=4b825dc642cb6eb9a060e54bf8d69288fbee4904";
23const NO_ATTRS_SHA256: &str =
24 "--attr-source=6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
25
26const 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#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum TreeSnapshot {
38 Git(GitTree),
40 NoRepo,
42 Failed(String),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GitTree {
51 head: Option<String>,
53 index: String,
55 work: String,
57 busy: Option<&'static str>,
59 no_attrs: &'static str,
61}
62
63pub(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
85const UNTRACKED: [&str; 4] = ["ls-files", "-z", "--others", "--exclude-standard"];
87
88fn 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 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
105fn 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
143pub(crate) const MAX_UNTRACKED_BYTES: u64 = 20 * 1024 * 1024;
148
149fn 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
172pub(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#[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
272fn 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
295fn 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; }
309 parent = p.parent();
310 }
311 Ok(())
312}
313
314fn 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
338struct 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 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
369fn 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
376pub(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 #[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 #[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 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}