1use std::collections::VecDeque;
4use std::path::Path;
5use std::process::Command;
6use std::sync::{Arc, Mutex};
7use std::thread::JoinHandle;
8
9use whycodes_core::types::Message;
10
11use crate::worktree_snapshot::{TreeSnapshot, restore_files, snapshot};
12
13#[derive(Debug, Clone)]
19pub struct StashSlot(Arc<Mutex<Stash>>);
20
21#[derive(Debug)]
22enum Stash {
23 Running(JoinHandle<TreeSnapshot>),
24 Done(TreeSnapshot),
25}
26
27impl StashSlot {
28 fn spawn(project_path: &Path) -> Self {
29 let dir = project_path.to_path_buf();
30 Self::with(Stash::Running(std::thread::spawn(move || snapshot(&dir))))
31 }
32
33 fn done(stash: TreeSnapshot) -> Self {
34 Self::with(Stash::Done(stash))
35 }
36
37 fn with(stash: Stash) -> Self {
38 Self(Arc::new(Mutex::new(stash)))
39 }
40
41 pub fn wait(&self) -> TreeSnapshot {
43 let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
44 let stash = match std::mem::replace(&mut *state, Stash::Done(TreeSnapshot::NoRepo)) {
45 Stash::Running(handle) => handle
47 .join()
48 .unwrap_or_else(|_| TreeSnapshot::Failed("snapshot thread panicked".into())),
49 Stash::Done(stash) => stash,
50 };
51 *state = Stash::Done(stash.clone());
52 stash
53 }
54}
55
56#[derive(Debug)]
58pub struct Restored {
59 pub messages: Vec<Message>,
60 pub files_note: Option<String>,
63}
64
65pub const DEFAULT_UNDO_DEPTH: usize = 50;
67
68#[derive(Debug, Clone)]
74struct HistorySnapshot {
75 store: u64,
76 len: usize,
77 stash: StashSlot,
79}
80
81#[derive(Debug, Clone)]
86struct Store {
87 id: u64,
88 messages: Vec<Message>,
89}
90
91#[derive(Debug, Clone)]
93pub struct SessionHistory {
94 undo_stack: VecDeque<HistorySnapshot>,
95 redo_stack: Vec<HistorySnapshot>,
96 stores: Vec<Store>,
98 next_store: u64,
99 depth: usize,
101 dropped: bool,
103}
104
105impl Default for SessionHistory {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl SessionHistory {
112 pub fn new() -> Self {
114 Self::with_depth(DEFAULT_UNDO_DEPTH)
115 }
116
117 pub fn with_depth(depth: usize) -> Self {
120 Self {
121 undo_stack: VecDeque::new(),
122 redo_stack: Vec::new(),
123 stores: Vec::new(),
124 next_store: 0,
125 depth,
126 dropped: false,
127 }
128 }
129
130 pub fn clear(&mut self) {
132 *self = Self::with_depth(self.depth);
133 }
134
135 pub fn can_undo(&self) -> bool {
136 !self.undo_stack.is_empty()
137 }
138
139 pub fn can_redo(&self) -> bool {
140 !self.redo_stack.is_empty()
141 }
142
143 pub fn exhausted_depth(&self) -> Option<usize> {
146 (self.dropped && self.undo_stack.is_empty()).then_some(self.depth)
147 }
148
149 pub fn push_before_turn(&mut self, messages: &[Message], project_path: &Path) -> StashSlot {
153 let stash = StashSlot::spawn(project_path);
154 self.record_turn(messages, stash.clone());
155 stash
156 }
157
158 fn record_turn(&mut self, messages: &[Message], stash: StashSlot) {
159 self.redo_stack.clear();
160 let (store, len) = self.capture(messages);
161 self.push_undo(HistorySnapshot { store, len, stash });
162 self.gc();
163 }
164
165 pub fn undo(&mut self, current_messages: &[Message], project_path: &Path) -> Option<Restored> {
168 let snap = self.undo_stack.pop_back()?;
169 let messages = self.messages_of(&snap);
171 let (store, len) = self.capture(current_messages);
174 let now = snapshot(project_path);
175 self.redo_stack.push(HistorySnapshot {
176 store,
177 len,
178 stash: StashSlot::done(now.clone()),
179 });
180 self.gc();
181
182 let files_note = restore_files(&snap.stash.wait(), &now, project_path);
183 Some(Restored {
184 messages,
185 files_note,
186 })
187 }
188
189 pub fn redo(&mut self, current_messages: &[Message], project_path: &Path) -> Option<Restored> {
191 let snap = self.redo_stack.pop()?;
192 let messages = self.messages_of(&snap);
193 let (store, len) = self.capture(current_messages);
194 let now = snapshot(project_path);
195 self.push_undo(HistorySnapshot {
196 store,
197 len,
198 stash: StashSlot::done(now.clone()),
199 });
200 self.gc();
201
202 let files_note = restore_files(&snap.stash.wait(), &now, project_path);
203 Some(Restored {
204 messages,
205 files_note,
206 })
207 }
208
209 fn capture(&mut self, messages: &[Message]) -> (u64, usize) {
217 if let Some(live) = self.stores.last_mut() {
218 let needed = self
219 .undo_stack
220 .iter()
221 .chain(&self.redo_stack)
222 .filter(|snap| snap.store == live.id)
223 .map(|snap| snap.len)
224 .max()
225 .unwrap_or(0);
226 let shared = live
227 .messages
228 .iter()
229 .zip(messages)
230 .take_while(|(kept, now)| kept == now)
231 .count();
232 if shared >= needed {
233 live.messages.truncate(shared);
234 live.messages.extend_from_slice(&messages[shared..]);
235 return (live.id, messages.len());
236 }
237 }
238 self.next_store += 1;
239 self.stores.push(Store {
240 id: self.next_store,
241 messages: messages.to_vec(),
242 });
243 (self.next_store, messages.len())
244 }
245
246 fn messages_of(&self, snap: &HistorySnapshot) -> Vec<Message> {
247 self.stores
248 .iter()
249 .find(|store| store.id == snap.store)
250 .and_then(|store| store.messages.get(..snap.len))
251 .map(<[Message]>::to_vec)
252 .unwrap_or_default()
253 }
254
255 fn push_undo(&mut self, snap: HistorySnapshot) {
256 self.undo_stack.push_back(snap);
257 while self.depth > 0 && self.undo_stack.len() > self.depth {
258 self.undo_stack.pop_front();
259 self.dropped = true;
260 }
261 }
262
263 fn gc(&mut self) {
265 let live = self.stores.last().map(|store| store.id);
266 let (undo, redo) = (&self.undo_stack, &self.redo_stack);
267 self.stores.retain(|store| {
268 Some(store.id) == live || undo.iter().chain(redo).any(|snap| snap.store == store.id)
269 });
270 }
271}
272
273pub fn git_changed_files(project_path: &Path) -> Vec<String> {
275 let output = Command::new("git")
276 .args(["status", "--porcelain"])
277 .current_dir(project_path)
278 .output();
279 match output {
280 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
281 .lines()
282 .filter(|l| !l.is_empty())
283 .map(|l| l.to_string())
284 .collect(),
285 _ => Vec::new(),
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use whycodes_core::types::{Message, MessageContent, Role};
293
294 fn msg(content: &str) -> Message {
295 Message {
296 role: Role::User,
297 content: MessageContent::text(content),
298 tool_call_id: None,
299 name: None,
300 created_at: None,
301 }
302 }
303
304 fn git_available() -> bool {
305 Command::new("git")
306 .arg("--version")
307 .output()
308 .map(|o| o.status.success())
309 .unwrap_or(false)
310 }
311
312 fn run_git(dir: &Path, args: &[&str]) -> bool {
313 Command::new("git")
314 .args(args)
315 .current_dir(dir)
316 .output()
317 .map(|o| o.status.success())
318 .unwrap_or(false)
319 }
320
321 fn texts(messages: &[Message]) -> Vec<&str> {
322 messages
323 .iter()
324 .map(|m| m.content.as_text().unwrap_or_default())
325 .collect()
326 }
327
328 fn retained_bytes(h: &SessionHistory) -> usize {
330 h.stores
331 .iter()
332 .flat_map(|store| &store.messages)
333 .map(|m| m.content.as_text().unwrap_or_default().len())
334 .sum()
335 }
336
337 fn record(h: &mut SessionHistory, messages: &[Message]) {
338 h.record_turn(messages, StashSlot::done(TreeSnapshot::NoRepo));
339 }
340
341 #[test]
342 fn two_hundred_turns_retain_linear_bytes() {
343 let mut h = SessionHistory::with_depth(0);
344 let mut transcript = Vec::new();
345 for turn in 0..200 {
346 record(&mut h, &transcript);
347 transcript.push(msg(&format!("{turn:04}{}", "x".repeat(2044))));
348 }
349 let transcript_bytes: usize = transcript
350 .iter()
351 .map(|m| m.content.as_text().unwrap_or_default().len())
352 .sum();
353 assert_eq!(transcript_bytes, 200 * 2048);
354 let retained = retained_bytes(&h);
356 assert!(retained < 3 * transcript_bytes, "{retained} retained");
357 assert_eq!(h.stores.len(), 1, "appended turns share one log");
358 assert_eq!(h.undo_stack.len(), 200);
359
360 let dir = tempfile::tempdir().expect("tempdir");
361 let back = h.undo(&transcript, dir.path()).expect("undo").messages;
362 assert_eq!(back.as_slice(), &transcript[..199]);
363 let back = h.undo(&back, dir.path()).expect("undo again").messages;
364 assert_eq!(back.as_slice(), &transcript[..198]);
365 let forward = h.redo(&back, dir.path()).expect("redo").messages;
366 assert_eq!(forward.as_slice(), &transcript[..199]);
367 assert!(retained_bytes(&h) < 3 * transcript_bytes);
368 }
369
370 #[test]
371 fn rewritten_prefix_keeps_both_logs_until_unreferenced() {
372 let dir = tempfile::tempdir().expect("tempdir");
373 let mut h = SessionHistory::with_depth(0);
374 record(&mut h, &[msg("a")]);
375 record(&mut h, &[msg("a"), msg("b")]);
376 record(&mut h, &[msg("summary")]);
378 assert_eq!(h.stores.len(), 2);
379
380 let now = [msg("summary"), msg("c")];
381 let back = h.undo(&now, dir.path()).expect("undo 1").messages;
382 assert_eq!(texts(&back), ["summary"]);
383 let back = h.undo(&back, dir.path()).expect("undo 2").messages;
384 assert_eq!(texts(&back), ["a", "b"]);
385 let back = h.undo(&back, dir.path()).expect("undo 3").messages;
386 assert_eq!(texts(&back), ["a"]);
387 assert!(!h.can_undo());
388
389 record(&mut h, &back);
391 assert_eq!(h.stores.len(), 1);
392 h.clear();
393 assert!(h.stores.is_empty());
394 assert!(!h.can_undo() && !h.can_redo());
395 }
396
397 #[test]
398 fn new_turn_after_undo_reuses_the_log() {
399 let dir = tempfile::tempdir().expect("tempdir");
400 let mut h = SessionHistory::new();
401 record(&mut h, &[msg("a")]);
402 let back = h
403 .undo(&[msg("a"), msg("b")], dir.path())
404 .expect("undo")
405 .messages;
406 assert_eq!(texts(&back), ["a"]);
407
408 record(&mut h, &back);
410 assert_eq!(h.stores.len(), 1);
411 let back = h
412 .undo(&[msg("a"), msg("c")], dir.path())
413 .expect("undo")
414 .messages;
415 assert_eq!(texts(&back), ["a"]);
416 let forward = h.redo(&back, dir.path()).expect("redo").messages;
417 assert_eq!(texts(&forward), ["a", "c"]);
418 assert_eq!(h.stores.len(), 1);
419 }
420
421 #[test]
422 fn depth_cap_drops_oldest_and_reports_exhaustion() {
423 let dir = tempfile::tempdir().expect("tempdir");
424 let mut h = SessionHistory::with_depth(2);
425 record(&mut h, &[msg("1")]);
426 record(&mut h, &[msg("1"), msg("2")]);
427 assert_eq!(h.exhausted_depth(), None);
428 record(&mut h, &[msg("1"), msg("2"), msg("3")]);
429 assert_eq!(h.undo_stack.len(), 2, "oldest entry dropped");
430 assert_eq!(h.exhausted_depth(), None, "entries remain");
431
432 let now = [msg("1"), msg("2"), msg("3"), msg("4")];
433 let back = h.undo(&now, dir.path()).expect("undo 1").messages;
434 assert_eq!(texts(&back), ["1", "2", "3"]);
435 let back = h.undo(&back, dir.path()).expect("undo 2").messages;
436 assert_eq!(texts(&back), ["1", "2"]);
437 assert!(h.undo(&back, dir.path()).is_none());
438 assert_eq!(h.exhausted_depth(), Some(2));
439
440 h.clear();
441 assert_eq!(h.exhausted_depth(), None, "clear forgets the drop");
442 assert_eq!(h.depth, 2, "clear keeps the cap");
443 }
444
445 #[test]
446 fn default_depth_is_fifty_and_zero_is_unlimited() {
447 assert_eq!(SessionHistory::new().depth, DEFAULT_UNDO_DEPTH);
448 assert_eq!(SessionHistory::default().depth, 50);
449 let mut h = SessionHistory::with_depth(0);
450 for n in 0..60 {
451 record(&mut h, &vec![msg("m"); n]);
452 }
453 assert_eq!(h.undo_stack.len(), 60);
454 assert_eq!(h.exhausted_depth(), None);
455 }
456
457 #[test]
458 fn new_history_has_no_undo_or_redo() {
459 let h = SessionHistory::new();
460 assert!(!h.can_undo());
461 assert!(!h.can_redo());
462 }
463
464 #[test]
465 fn push_then_undo_then_redo_round_trips_messages() {
466 let dir = tempfile::tempdir().expect("tempdir");
467 let mut h = SessionHistory::new();
468
469 h.push_before_turn(&[msg("m1")], dir.path());
471 assert!(h.can_undo());
472 assert!(!h.can_redo());
473
474 let restored = h.undo(&[msg("m1"), msg("m2")], dir.path());
476 let restored = restored.expect("undo returns a snapshot").messages;
477 assert_eq!(restored.len(), 1);
478 assert_eq!(restored[0].content.as_text(), Some("m1"));
479 assert!(!h.can_undo(), "undo popped the only snapshot");
480 assert!(h.can_redo());
481
482 let forward = h.redo(&[msg("m1")], dir.path());
484 let forward = forward.expect("redo returns a snapshot").messages;
485 assert_eq!(forward.len(), 2);
486 assert!(!h.can_redo());
487 assert!(h.can_undo());
488 }
489
490 #[test]
491 fn push_clears_the_redo_stack() {
492 let dir = tempfile::tempdir().expect("tempdir");
493 let mut h = SessionHistory::new();
494 h.push_before_turn(&[msg("a")], dir.path());
495 let _ = h.undo(&[msg("a"), msg("b")], dir.path());
496 assert!(h.can_redo());
497
498 h.push_before_turn(&[msg("a")], dir.path());
500 assert!(!h.can_redo(), "new turn clears redo");
501 }
502
503 #[test]
504 fn undo_and_redo_on_empty_stacks_return_none() {
505 let dir = tempfile::tempdir().expect("tempdir");
506 let mut h = SessionHistory::new();
507 assert!(h.undo(&[], dir.path()).is_none());
508 assert!(h.redo(&[], dir.path()).is_none());
509 }
510
511 #[test]
512 fn git_changed_files_empty_outside_repo() {
513 let dir = tempfile::tempdir().expect("tempdir");
514 assert!(git_changed_files(dir.path()).is_empty());
516 }
517
518 #[test]
519 fn stash_slot_wait_survives_a_poisoned_lock() {
520 let slot = StashSlot::done(TreeSnapshot::Failed("kept".into()));
521 let held = slot.clone();
522 let poisoned = std::thread::spawn(move || {
523 let _guard = held.0.lock().expect("first lock");
524 panic!("poison the slot");
525 })
526 .join();
527 assert!(poisoned.is_err());
528 assert_eq!(slot.wait(), TreeSnapshot::Failed("kept".into()));
529 }
530
531 #[test]
532 fn stash_slot_waits_once_and_keeps_the_result() {
533 let dir = tempfile::tempdir().expect("tempdir");
534 let mut h = SessionHistory::new();
535 let slot = h.push_before_turn(&[msg("m")], dir.path());
536 assert_eq!(slot.wait(), TreeSnapshot::NoRepo);
539 assert_eq!(slot.wait(), TreeSnapshot::NoRepo);
540 let done = StashSlot::done(TreeSnapshot::Failed("abc".into()));
541 assert_eq!(done.clone().wait(), TreeSnapshot::Failed("abc".into()));
542 assert_eq!(done.wait(), TreeSnapshot::Failed("abc".into()));
543 }
544
545 #[test]
546 fn panicked_snapshot_thread_counts_as_failed() {
547 let slot = StashSlot::with(Stash::Running(std::thread::spawn(|| -> TreeSnapshot {
548 panic!("git thread died")
549 })));
550 assert!(matches!(slot.wait(), TreeSnapshot::Failed(_)));
551 }
552
553 #[test]
554 fn changed_files_reports_porcelain_lines_in_repo() {
555 assert!(git_available(), "git is required by the session test suite");
556 let dir = tempfile::tempdir().expect("tempdir");
557 assert!(run_git(dir.path(), &["init", "-q"]));
558 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
559 let changed = git_changed_files(dir.path());
560 assert_eq!(changed, vec!["?? new.txt"]);
561 }
562
563 #[test]
564 fn git_helpers_handle_disappearing_working_directory() {
565 let dir = tempfile::tempdir().unwrap();
566 let path = dir.path().to_path_buf();
567 drop(dir);
568 assert!(git_changed_files(&path).is_empty());
569
570 let mut history = SessionHistory::new();
571 history.push_before_turn(&[msg("before")], &path);
572 let undone = history.undo(&[msg("after")], &path).unwrap();
573 assert_eq!((undone.messages.len(), undone.files_note), (1, None));
574 let redone = history.redo(&[msg("before")], &path).unwrap();
575 assert_eq!((redone.messages.len(), redone.files_note), (1, None));
576 }
577
578 fn git_ok(dir: &Path, args: &[&str]) -> String {
582 let out = Command::new("git")
583 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
584 .args(args)
585 .current_dir(dir)
586 .output()
587 .expect("spawn git");
588 let stderr = String::from_utf8_lossy(&out.stderr);
589 assert!(out.status.success(), "git {args:?}: {stderr}");
590 String::from_utf8_lossy(&out.stdout).into_owned()
591 }
592
593 fn status(dir: &Path) -> String {
595 git_ok(
596 dir,
597 &[
598 "status",
599 "--porcelain",
600 "--untracked-files=all",
601 "--ignored",
602 ],
603 )
604 }
605
606 fn write(dir: &Path, rel: &str, body: &str) {
607 let path = dir.join(rel);
608 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
609 std::fs::write(path, body).unwrap();
610 }
611
612 fn read(dir: &Path, rel: &str) -> Option<String> {
613 std::fs::read_to_string(dir.join(rel)).ok()
614 }
615
616 fn repo() -> tempfile::TempDir {
618 assert!(git_available(), "git is required by the session test suite");
619 let dir = tempfile::tempdir().unwrap();
620 git_ok(dir.path(), &["init", "-q"]);
621 git_ok(dir.path(), &["config", "core.autocrlf", "false"]);
622 write(dir.path(), "a.txt", "v1\n");
623 write(dir.path(), ".gitignore", "*.log\n");
624 git_ok(dir.path(), &["add", "-A"]);
625 git_ok(dir.path(), &["commit", "-q", "-m", "init"]);
626 dir
627 }
628
629 fn turn(dir: &Path, edit: impl FnOnce(&Path)) -> SessionHistory {
631 let mut h = SessionHistory::new();
632 let before = h.push_before_turn(&[msg("m")], dir).wait();
633 assert!(matches!(before, TreeSnapshot::Git(_)), "{before:?}");
634 edit(dir);
635 h
636 }
637
638 fn undo_files(h: &mut SessionHistory, dir: &Path) -> Option<String> {
639 h.undo(&[msg("m"), msg("m2")], dir).unwrap().files_note
640 }
641
642 fn redo_files(h: &mut SessionHistory, dir: &Path) -> Option<String> {
643 h.redo(&[msg("m")], dir).unwrap().files_note
644 }
645
646 #[test]
647 fn undo_restores_the_whole_tree_and_redo_brings_the_turn_back() {
648 let dir = repo();
649 let d = dir.path();
650 write(d, "a.txt", "user wip\n");
653 write(d, "s.txt", "staged\n");
654 git_ok(d, &["add", "s.txt"]);
655 write(d, "mine.txt", "untracked\n");
656 write(d, "keep.log", "ignored\n");
657 let before = status(d);
658
659 let mut h = turn(d, |d| {
660 write(d, "a.txt", "turn edit\n");
661 write(d, "new/deep/n.txt", "created\n");
662 write(d, "name with space ü.txt", "created\n");
663 std::fs::remove_file(d.join("mine.txt")).unwrap();
664 write(d, "added.txt", "staged by the turn\n");
665 git_ok(d, &["add", "added.txt"]);
666 write(d, "build.log", "ignored, made by the turn\n");
667 });
668 let after = status(d);
669
670 assert_eq!(undo_files(&mut h, d), None);
671 assert_eq!(read(d, "a.txt").as_deref(), Some("user wip\n"));
672 assert_eq!(read(d, "mine.txt").as_deref(), Some("untracked\n"));
673 assert_eq!(read(d, "s.txt").as_deref(), Some("staged\n"));
674 assert!(!d.join("new").exists(), "created directories go too");
675 assert!(!d.join("name with space ü.txt").exists());
676 assert!(!d.join("added.txt").exists());
677 assert_eq!(
678 read(d, "build.log").as_deref(),
679 Some("ignored, made by the turn\n"),
680 "ignored files are never touched"
681 );
682 std::fs::remove_file(d.join("build.log")).unwrap();
683 assert_eq!(status(d), before);
684 write(d, "build.log", "ignored, made by the turn\n");
685
686 assert_eq!(redo_files(&mut h, d), None);
687 assert_eq!(status(d), after);
688 assert_eq!(read(d, "a.txt").as_deref(), Some("turn edit\n"));
689 assert_eq!(read(d, "new/deep/n.txt").as_deref(), Some("created\n"));
690 assert!(!d.join("mine.txt").exists());
691 }
692
693 #[test]
696 fn a_held_index_lock_does_not_stop_the_snapshot() {
697 let dir = repo();
698 let d = dir.path();
699 write(d, "a.txt", "user wip\n");
700 let lock = d.join(".git/index.lock");
701 std::fs::write(&lock, "").unwrap();
702 let mut h = turn(d, |d| write(d, "a.txt", "turn edit\n"));
703 std::fs::remove_file(&lock).unwrap();
704 assert_eq!(undo_files(&mut h, d), None);
705 assert_eq!(read(d, "a.txt").as_deref(), Some("user wip\n"));
706 }
707
708 #[test]
709 fn a_held_index_lock_at_undo_leaves_files_alone() {
710 let dir = repo();
711 let d = dir.path();
712 let mut h = turn(d, |d| {
713 write(d, "a.txt", "turn edit\n");
714 git_ok(d, &["add", "a.txt"]);
715 });
716 std::fs::write(d.join(".git/index.lock"), "").unwrap();
717 let note = undo_files(&mut h, d).expect("undo explains");
718 assert!(
719 note.starts_with("files left as they are: could not restore the staging area"),
720 "{note}"
721 );
722 assert_eq!(read(d, "a.txt").as_deref(), Some("turn edit\n"));
723 }
724
725 #[test]
726 fn a_repo_without_commits_restores_too() {
727 assert!(git_available(), "git is required by the session test suite");
728 let dir = tempfile::tempdir().unwrap();
729 let d = dir.path();
730 git_ok(d, &["init", "-q"]);
731 write(d, "b.txt", "staged\n");
732 git_ok(d, &["add", "b.txt"]);
733 write(d, "b.txt", "staged + unstaged\n");
734 let before = status(d);
735 let mut h = turn(d, |d| {
736 write(d, "b.txt", "turn edit\n");
737 write(d, "c.txt", "created\n");
738 });
739 assert_eq!(undo_files(&mut h, d), None);
740 assert_eq!(read(d, "b.txt").as_deref(), Some("staged + unstaged\n"));
741 assert!(!d.join("c.txt").exists());
742 assert_eq!(status(d), before);
743 }
744
745 #[test]
746 fn a_failed_snapshot_leaves_files_alone() {
747 let dir = repo();
748 let d = dir.path();
749 write(d, "a.txt", "user wip\n");
750 std::fs::remove_file(d.join(".git/index")).unwrap();
752 std::fs::create_dir(d.join(".git/index")).unwrap();
753 let mut h = SessionHistory::new();
754 let before = h.push_before_turn(&[msg("m")], d).wait();
755 assert!(matches!(before, TreeSnapshot::Failed(_)), "{before:?}");
756 write(d, "a.txt", "turn edit\n");
757 let note = undo_files(&mut h, d).expect("undo explains");
758 assert!(
759 note.starts_with("files left as they are: git could not snapshot"),
760 "{note}"
761 );
762 assert_eq!(read(d, "a.txt").as_deref(), Some("turn edit\n"));
763 }
764
765 #[test]
766 fn undo_refuses_after_head_moved() {
767 let dir = repo();
768 let d = dir.path();
769 let mut h = turn(d, |d| {
770 write(d, "a.txt", "committed by the turn\n");
771 write(d, "new.txt", "created\n");
772 git_ok(d, &["commit", "-qam", "turn"]);
773 });
774 let note = undo_files(&mut h, d).expect("undo explains");
775 assert!(note.contains("HEAD moved"), "{note}");
776 assert_eq!(read(d, "a.txt").as_deref(), Some("committed by the turn\n"));
777 assert!(d.join("new.txt").exists());
778 }
779
780 fn diverged_repo(conflict: bool) -> tempfile::TempDir {
782 let dir = repo();
783 let d = dir.path();
784 git_ok(d, &["checkout", "-qb", "side"]);
785 write(d, "side.txt", "side\n");
786 if conflict {
787 write(d, "a.txt", "side\n");
788 }
789 git_ok(d, &["add", "-A"]);
790 git_ok(d, &["commit", "-qm", "side"]);
791 git_ok(d, &["checkout", "-q", "-"]);
792 write(d, "a.txt", "main\n");
793 git_ok(d, &["commit", "-qam", "main"]);
794 dir
795 }
796
797 #[test]
798 fn undo_refuses_while_a_merge_is_in_progress() {
799 let dir = diverged_repo(false);
800 let d = dir.path();
801 let mut h = turn(d, |d| write(d, "z.txt", "turn\n"));
802 git_ok(d, &["merge", "-q", "--no-commit", "--no-ff", "side"]);
803 let note = undo_files(&mut h, d).expect("undo explains");
804 assert!(note.contains("a merge is in progress"), "{note}");
805 assert!(d.join("z.txt").exists());
806 assert!(
807 d.join(".git/MERGE_HEAD").exists(),
808 "the merge is left alone"
809 );
810 }
811
812 #[test]
813 fn undo_refuses_with_unmerged_paths() {
814 let dir = diverged_repo(true);
815 let d = dir.path();
816 let mut h = turn(d, |d| write(d, "z.txt", "turn\n"));
817 let merge = Command::new("git")
820 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
821 .args(["merge", "-q", "side"])
822 .current_dir(d)
823 .output()
824 .unwrap();
825 assert!(!merge.status.success(), "the merge conflicts");
826 assert!(d.join(".git/MERGE_HEAD").exists(), "a conflicted merge");
827 let conflicted = read(d, "a.txt").unwrap();
828 let note = undo_files(&mut h, d).expect("undo explains");
829 assert!(
830 note.contains("could not snapshot the current files"),
831 "{note}"
832 );
833 assert_eq!(read(d, "a.txt").unwrap(), conflicted);
834 assert!(d.join("z.txt").exists());
835 }
836
837 #[test]
838 fn the_users_stash_list_is_never_touched() {
839 let dir = repo();
840 let d = dir.path();
841 write(d, "a.txt", "stashed by the user\n");
842 git_ok(d, &["stash", "-q"]);
843 let stash_list = git_ok(d, &["stash", "list"]);
844 write(d, "a.txt", "user wip\n");
845 let mut h = turn(d, |d| write(d, "a.txt", "turn\n"));
846 assert_eq!(undo_files(&mut h, d), None);
847 assert_eq!(redo_files(&mut h, d), None);
848 assert_eq!(undo_files(&mut h, d), None);
849 assert_eq!(git_ok(d, &["stash", "list"]), stash_list);
850 assert_eq!(read(d, "a.txt").as_deref(), Some("user wip\n"));
851 git_ok(d, &["checkout", "-q", "--", "a.txt"]);
853 git_ok(d, &["stash", "pop", "-q"]);
854 assert_eq!(read(d, "a.txt").as_deref(), Some("stashed by the user\n"));
855 }
856
857 #[test]
858 fn a_project_in_a_subdirectory_only_restores_its_own_files() {
859 let dir = repo();
860 let root = dir.path();
861 write(root, "app/main.txt", "v1\n");
862 git_ok(root, &["add", "-A"]);
863 git_ok(root, &["commit", "-qm", "app"]);
864 let app = root.join("app");
865 let mut h = turn(&app, |app| {
866 write(app, "main.txt", "turn\n");
867 write(app, "made.txt", "turn\n");
868 });
869 write(root, "a.txt", "outside\n");
871 assert_eq!(undo_files(&mut h, &app), None);
872 assert_eq!(read(&app, "main.txt").as_deref(), Some("v1\n"));
873 assert!(!app.join("made.txt").exists());
874 assert_eq!(read(root, "a.txt").as_deref(), Some("outside\n"));
875 }
876
877 #[test]
880 fn line_endings_come_back_byte_for_byte() {
881 let dir = repo();
882 let d = dir.path();
883 git_ok(d, &["config", "core.autocrlf", "true"]);
884 write(d, ".gitattributes", "*.txt text eol=crlf\n");
885 write(d, "lf.txt", "one\ntwo\n");
886 write(d, "crlf.txt", "one\r\ntwo\r\n");
887 let mut h = turn(d, |d| {
888 write(d, "lf.txt", "changed\n");
889 write(d, "crlf.txt", "changed\n");
890 });
891 assert_eq!(undo_files(&mut h, d), None);
892 assert_eq!(std::fs::read(d.join("lf.txt")).unwrap(), b"one\ntwo\n");
893 assert_eq!(
894 std::fs::read(d.join("crlf.txt")).unwrap(),
895 b"one\r\ntwo\r\n"
896 );
897 }
898}