Skip to main content

whycodes_session/
history.rs

1//! Session undo/redo with optional git file restore.
2
3use 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/// A working-tree snapshot that may still be computing on its own thread.
14///
15/// The `git` spawns cost ~150 ms on Windows, and the TUI ran them on its
16/// event loop at every submit. The snapshot owns the thread: an undo after a
17/// cancelled or force-stopped turn still waits for the real snapshot.
18#[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    /// The snapshot, waiting for the background `git` if it still runs.
42    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            // A panicked `git` thread leaves no snapshot to restore from.
46            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/// What `/undo` or `/redo` went back to.
57#[derive(Debug)]
58pub struct Restored {
59    pub messages: Vec<Message>,
60    /// Why the working tree was not put back, when it was not. Show it:
61    /// the conversation moved but the files did not.
62    pub files_note: Option<String>,
63}
64
65/// Default for `[session] undo_depth`: turns `/undo` can step back.
66pub const DEFAULT_UNDO_DEPTH: usize = 50;
67
68/// Conversation + working tree for one undo step.
69///
70/// The transcript is not copied per turn: `len` names a prefix of the shared
71/// message log `store` (see [`SessionHistory::capture`]). A full copy made
72/// before every turn grew memory with the square of the turn count.
73#[derive(Debug, Clone)]
74struct HistorySnapshot {
75    store: u64,
76    len: usize,
77    /// The tree before the turn (see [`StashSlot`]).
78    stash: StashSlot,
79}
80
81/// One copy of the transcript that snapshots index into by length.
82///
83/// Turns only append, so one log serves every snapshot until a compaction,
84/// spill or prune rewrites a message an older snapshot still needs.
85#[derive(Debug, Clone)]
86struct Store {
87    id: u64,
88    messages: Vec<Message>,
89}
90
91/// Undo/redo stacks for a session.
92#[derive(Debug, Clone)]
93pub struct SessionHistory {
94    undo_stack: VecDeque<HistorySnapshot>,
95    redo_stack: Vec<HistorySnapshot>,
96    /// Message logs still referenced; the last one is extended next.
97    stores: Vec<Store>,
98    next_store: u64,
99    /// Most undo entries kept; `0` = unlimited.
100    depth: usize,
101    /// The cap dropped at least one entry.
102    dropped: bool,
103}
104
105impl Default for SessionHistory {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl SessionHistory {
112    /// History capped at [`DEFAULT_UNDO_DEPTH`] turns.
113    pub fn new() -> Self {
114        Self::with_depth(DEFAULT_UNDO_DEPTH)
115    }
116
117    /// History keeping at most `depth` undo entries (`0` = unlimited). The
118    /// oldest entries drop first.
119    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    /// Forget every entry (new or switched session), keeping the depth cap.
131    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    /// `Some(undo_depth)` when nothing is left to undo because the cap
144    /// dropped older turns, so `/undo` can say why it stops there.
145    pub fn exhausted_depth(&self) -> Option<usize> {
146        (self.dropped && self.undo_stack.is_empty()).then_some(self.depth)
147    }
148
149    /// Capture current messages before a user turn and start the working
150    /// tree snapshot on a background thread. Wait on the returned slot
151    /// before the turn can edit files, so it shows the tree the user had.
152    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    /// Pop undo: returns previous messages. Puts files back as they were
166    /// before that turn, new files removed, when git can do so safely.
167    pub fn undo(&mut self, current_messages: &[Message], project_path: &Path) -> Option<Restored> {
168        let snap = self.undo_stack.pop_back()?;
169        // Copy out before `capture` may reuse the log past this prefix.
170        let messages = self.messages_of(&snap);
171        // Save current state for redo, files included: the undo below may
172        // delete files the turn created, and redo brings them back.
173        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    /// Pop redo: returns messages after redo, files put back the same way.
190    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    /// Record `messages` as `(store, len)` without copying the prefix the
210    /// live log already holds.
211    ///
212    /// The live log is cut back to the shared prefix and extended when no
213    /// remaining snapshot reads past that prefix (appended turns, or a new
214    /// turn after `/undo`). Otherwise an earlier message was rewritten while
215    /// a snapshot still needs the old one, and a new log starts.
216    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    /// Drop logs no snapshot reads any more (the live one stays).
264    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
273/// List untracked + modified files relative to project (for diagnostics).
274pub 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    /// Text bytes every message log holds (what the history keeps alive).
329    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        // A full copy per turn kept 199 * 200 / 2 messages (~40 MB here).
355        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        // Compaction rewrote the transcript; older snapshots keep the old log.
377        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        // A new turn clears redo; only the live log survives.
390        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        // A different second message after undo replaces `b` in place.
409        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        // Before the turn: one message on the stack.
470        h.push_before_turn(&[msg("m1")], dir.path());
471        assert!(h.can_undo());
472        assert!(!h.can_redo());
473
474        // Turn added m2; undo restores the pre-turn snapshot.
475        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        // Redo moves forward again.
483        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        // A new turn invalidates redo.
499        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        // No git repo (or no git binary) → empty diagnostic list, no panic.
515        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        // Outside a repo the background `git` yields no snapshot; a second
537        // wait reads the stored result instead of joining again.
538        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    // --- Working tree restore against real git -------------------------
579
580    /// `git` in `dir` with a fixed identity; panics with stderr on failure.
581    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    /// `git status` including every untracked and ignored file.
594    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    /// A repository with `a.txt` committed and `*.log` ignored.
617    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    /// One turn: snapshot, let `edit` change files, return the history.
630    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        // The user's own state before the turn: an unstaged edit, a staged
651        // file, an untracked file and an ignored one.
652        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    /// An editor's background `git status` holds `index.lock` as the turn
694    /// starts. The snapshot reads a copy of the index, so it still works.
695    #[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        // An index git cannot read: the snapshot must fail, not guess.
751        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    /// `side` adds `side.txt`; with `conflict` both branches change `a.txt`.
781    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        // CI runners have no global identity; without one `git merge` stops
818        // before it starts and leaves no merge state behind.
819        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        // The user's own entry still applies on top.
852        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        // Someone edits a file outside the project after the turn.
870        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    /// `core.autocrlf=true` and an `eol=crlf` rule used to turn a restored LF
878    /// file into CRLF. Bytes come back exactly as they were.
879    #[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}