Skip to main content

oxicode/tui_vt/git_tui/
mod.rs

1//! Git TUI — interactive overlay for `git status`, diff viewing, staging, commit.
2//!
3//! Module layout (built incrementally across tasks 11a/11b):
4//!
5//! * [`diff_doc`] — unified diff parser + whitespace/formatting filters (pure)
6//! * [`state`] — `git status --porcelain -z` parser (pure)
7//! * [`keys`] — keymap → [`GitKeyAction`] (pure)
8//! * [`git_io`] — thin `std::process::Command` wrappers around `git` (impure)
9//! * [`render`] — ratatui pane layout + pairing math (pure layout, impure draw)
10//!
11//! `GitTuiState` here is the interactive overlay's source of truth — it
12//! composes the pure data modules above and exposes mutating methods that
13//! shell out to `git_io` for state changes (`add`/`restore --staged`/`commit`).
14
15pub mod diff_doc;
16pub mod git_io;
17pub mod keys;
18pub mod render;
19pub mod state;
20
21pub use diff_doc::{
22    DiffDocument, DiffFile, DiffLine, DiffLineKind, DiffViewMode, Hunk, WhitespaceMode,
23    filter_whitespace, parse_unified_diff,
24};
25pub use git_io::{diff_head, run_git, status_porcelain_z};
26pub use keys::{GitKeyAction, match_git_key};
27pub use render::{
28    RenderPlan, hunk_scroll_offset, pair_split_view, plan_overlay, render_overlay_lines,
29    render_sidebar_rows,
30};
31pub use state::{StatusEntry, parse_status_porcelain_z};
32
33use std::collections::HashSet;
34use std::path::Path;
35
36/// Interactive overlay state. One instance per open `/git` session.
37///
38/// `entries` are kept in the order produced by `git status --porcelain -z` —
39/// the sidebar renders them as-is. `staged` mirrors the porcelain index
40/// column: paths whose `xy[0]` is neither `' '` (unstaged) nor `'?'`
41/// (untracked) are staged. It is re-derived on every [`GitTuiState::load`]
42/// and [`GitTuiState::refresh`], so files staged outside the overlay
43/// (plain `git add` in another terminal) are operable too.
44///
45/// `needs_refresh` is available for callers that batch mutations and want
46/// to force a refresh on the next frame; it is consumed (cleared) by
47/// [`GitTuiState::refresh`]. The built-in mutators
48/// ([`GitTuiState::toggle_stage`], [`GitTuiState::commit`]) refresh
49/// inline after their git command succeeds, so the overlay reflects the
50/// new state on the very next frame without it.
51#[derive(Debug, Clone)]
52pub struct GitTuiState {
53    pub doc: DiffDocument,
54    pub entries: Vec<StatusEntry>,
55    pub view: DiffViewMode,
56    pub ws: WhitespaceMode,
57    pub selected_file: usize,
58    pub selected_hunk: usize,
59    pub sidebar_focus: bool,
60    pub staged: HashSet<String>,
61    pub commit_mode: bool,
62    pub commit_msg: String,
63    pub needs_refresh: bool,
64    pub width: u16,
65    pub height: u16,
66    /// Cached `git branch --show-current` captured at [`GitTuiState::load`].
67    /// Refreshed by [`GitTuiState::refresh`].
68    pub branch: Option<String>,
69    /// Soft-wrap toggle for the diff pane (the brief's `w` binding).
70    pub wrap: bool,
71}
72
73impl GitTuiState {
74    /// Load status + diff for `cwd`. Builds the entry list from
75    /// `git status --porcelain -z` and the diff document from `git diff HEAD
76    /// --no-ext-diff`. Untracked entries (`XY[0]=='?'`) get a placeholder
77    /// [`DiffFile`] with no hunks so selection still works.
78    pub fn load(cwd: &Path) -> anyhow::Result<Self> {
79        let entries = status_porcelain_z(cwd)?;
80        let diff_text = diff_head(cwd)?;
81        let mut doc = parse_unified_diff(&diff_text);
82        // Insert placeholders for untracked entries (`??`) so the sidebar
83        // can select them and render "(untracked)".
84        for entry in &entries {
85            if entry.xy[0] == '?' && !doc.files.iter().any(|f| f.path == entry.path) {
86                doc.files.push(DiffFile {
87                    path: entry.path.clone(),
88                    old_path: None,
89                    hunks: Vec::new(),
90                    binary: false,
91                });
92            }
93        }
94        let branch = crate::util::git_utils::get_current_branch(cwd);
95        Ok(Self {
96            doc,
97            staged: Self::staged_from_entries(&entries),
98            entries,
99            view: DiffViewMode::default(),
100            ws: WhitespaceMode::default(),
101            selected_file: 0,
102            selected_hunk: 0,
103            sidebar_focus: false,
104            commit_mode: false,
105            commit_msg: String::new(),
106            needs_refresh: false,
107            width: 80,
108            height: 24,
109            branch,
110            wrap: false,
111        })
112    }
113
114    /// Re-derive staged-ness from the porcelain XY index column
115    /// (final-review finding 4): a path is staged when `xy[0]` is
116    /// neither `' '` (not in the index) nor `'?'` (untracked). This
117    /// sees external `git add`s too, not just overlay-issued ones.
118    fn staged_from_entries(entries: &[StatusEntry]) -> HashSet<String> {
119        entries
120            .iter()
121            .filter(|e| e.xy[0] != ' ' && e.xy[0] != '?')
122            .map(|e| e.path.clone())
123            .collect()
124    }
125
126    /// Re-run `git status` + `git diff HEAD` and rebuild the document.
127    /// Preserves the current selection where possible (clamped to the new
128    /// entry count).
129    pub fn refresh(&mut self, cwd: &Path) -> anyhow::Result<()> {
130        let entries = status_porcelain_z(cwd)?;
131        let diff_text = diff_head(cwd)?;
132        let mut doc = parse_unified_diff(&diff_text);
133        for entry in &entries {
134            if entry.xy[0] == '?' && !doc.files.iter().any(|f| f.path == entry.path) {
135                doc.files.push(DiffFile {
136                    path: entry.path.clone(),
137                    old_path: None,
138                    hunks: Vec::new(),
139                    binary: false,
140                });
141            }
142        }
143        let branch = crate::util::git_utils::get_current_branch(cwd);
144        self.doc = doc;
145        self.staged = Self::staged_from_entries(&entries);
146        self.entries = entries;
147        self.branch = branch;
148        if self.selected_file >= self.entries.len() {
149            self.selected_file = self.entries.len().saturating_sub(1);
150        }
151        self.needs_refresh = false;
152        Ok(())
153    }
154
155    /// Toggle staging for `path`. Staged entries leave the unstaged list
156    /// (status output will show them with `X` != space); unstaged entries
157    /// reappear in the unstaged list. Refreshes inline on success so
158    /// `entries` / `doc` / `staged` reflect the git state immediately
159    /// (final-review finding 3 — the overlay used to keep showing the
160    /// pre-mutation diff until a manual `r`).
161    pub fn toggle_stage(&mut self, cwd: &Path, path: &str) -> anyhow::Result<()> {
162        if self.staged.contains(path) {
163            // Currently staged → unstage.
164            run_git(cwd, &["restore", "--staged", "--", path])?;
165        } else {
166            run_git(cwd, &["add", "--", path])?;
167        }
168        // `refresh` re-derives `staged` from the porcelain output, so
169        // the mirror stays in sync with reality (including external
170        // staging) without manual bookkeeping here.
171        self.refresh(cwd)
172    }
173
174    /// Commit with the current `commit_msg`. Clears the message on
175    /// success and refreshes inline so the committed change leaves the
176    /// diff view on the next frame. Empty messages are rejected
177    /// (`git commit -m ""` would otherwise open an editor and hang the
178    /// overlay).
179    pub fn commit(&mut self, cwd: &Path) -> anyhow::Result<()> {
180        let msg = self.commit_msg.trim();
181        if msg.is_empty() {
182            anyhow::bail!("commit message is empty");
183        }
184        run_git(cwd, &["commit", "-m", msg])?;
185        self.commit_msg.clear();
186        self.commit_mode = false;
187        self.refresh(cwd)
188    }
189
190    /// Apply a [`GitKeyAction`] (or raw char for commit-mode text input).
191    /// Returns `true` if the action was consumed.
192    pub fn apply_action(&mut self, cwd: &Path, action: GitKeyAction) -> anyhow::Result<bool> {
193        match action {
194            GitKeyAction::Close => return Ok(false), // closed by main_loop
195            GitKeyAction::Down => {
196                if self.sidebar_focus {
197                    if !self.entries.is_empty() {
198                        self.selected_file = (self.selected_file + 1).min(self.entries.len() - 1);
199                    }
200                } else if let Some(file) = self.doc.files.get(self.selected_file)
201                    && !file.hunks.is_empty()
202                {
203                    self.selected_hunk = (self.selected_hunk + 1).min(file.hunks.len() - 1);
204                }
205            }
206            GitKeyAction::Up => {
207                if self.sidebar_focus {
208                    self.selected_file = self.selected_file.saturating_sub(1);
209                } else {
210                    self.selected_hunk = self.selected_hunk.saturating_sub(1);
211                }
212            }
213            GitKeyAction::GotoTop => {
214                if self.sidebar_focus {
215                    self.selected_file = 0;
216                } else {
217                    self.selected_hunk = 0;
218                }
219            }
220            GitKeyAction::GotoBottom => {
221                if self.sidebar_focus {
222                    self.selected_file = self.entries.len().saturating_sub(1);
223                } else if let Some(file) = self.doc.files.get(self.selected_file) {
224                    self.selected_hunk = file.hunks.len().saturating_sub(1);
225                }
226            }
227            GitKeyAction::HunkNext => {
228                if let Some(file) = self.doc.files.get(self.selected_file)
229                    && !file.hunks.is_empty()
230                {
231                    self.selected_hunk = (self.selected_hunk + 1).min(file.hunks.len() - 1);
232                }
233            }
234            GitKeyAction::HunkPrev => {
235                self.selected_hunk = self.selected_hunk.saturating_sub(1);
236            }
237            GitKeyAction::FileNext => {
238                if !self.entries.is_empty() {
239                    self.selected_file = (self.selected_file + 1).min(self.entries.len() - 1);
240                    self.selected_hunk = 0;
241                }
242            }
243            GitKeyAction::FilePrev => {
244                self.selected_file = self.selected_file.saturating_sub(1);
245                self.selected_hunk = 0;
246            }
247            GitKeyAction::ViewMode(mode) => {
248                self.view = match mode {
249                    1 => DiffViewMode::Split,
250                    2 => DiffViewMode::Inline,
251                    3 => DiffViewMode::Hunks,
252                    4 => DiffViewMode::Files,
253                    _ => self.view,
254                };
255            }
256            GitKeyAction::ToggleSidebar => {
257                self.sidebar_focus = !self.sidebar_focus;
258            }
259            GitKeyAction::CycleWhitespace => {
260                self.ws = match self.ws {
261                    WhitespaceMode::Off => WhitespaceMode::IgnoreWhitespace,
262                    WhitespaceMode::IgnoreWhitespace => WhitespaceMode::IgnoreFormatting,
263                    WhitespaceMode::IgnoreFormatting => WhitespaceMode::Off,
264                };
265            }
266            GitKeyAction::ToggleWrap => {
267                self.wrap = !self.wrap;
268            }
269            GitKeyAction::Stage | GitKeyAction::Unstage => {
270                if let Some(entry) = self.entries.get(self.selected_file) {
271                    let path = entry.path.clone();
272                    let add = matches!(action, GitKeyAction::Stage);
273                    let already_staged = self.staged.contains(&path);
274                    if add != already_staged {
275                        self.toggle_stage(cwd, &path)?;
276                    }
277                }
278            }
279            GitKeyAction::Commit => {
280                self.commit_mode = true;
281            }
282            GitKeyAction::Refresh => {
283                self.refresh(cwd)?;
284            }
285            GitKeyAction::Left | GitKeyAction::Right => {
286                // Sidebar tree collapse/expand deferred to v1.1 — the brief
287                // marks the sidebar as a flat list.
288            }
289        }
290        Ok(true)
291    }
292
293    /// Append one char to the commit message (commit-mode text input).
294    pub fn commit_input_char(&mut self, ch: char) {
295        if self.commit_mode {
296            self.commit_msg.push(ch);
297        }
298    }
299
300    /// Backspace one char from the commit message (commit-mode).
301    pub fn commit_backspace(&mut self) {
302        if self.commit_mode {
303            self.commit_msg.pop();
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Tests (TDD — written first, made green by impl)
310// ---------------------------------------------------------------------------
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::path::PathBuf;
316
317    /// `git --version` succeeds — skip the binary-dependent tests when git
318    /// is missing (CI runners without git, offline sandboxes, etc.).
319    fn git_available() -> bool {
320        std::process::Command::new("git")
321            .arg("--version")
322            .output()
323            .map(|o| o.status.success())
324            .unwrap_or(false)
325    }
326
327    /// Make a unique temp directory; caller is responsible for cleanup.
328    fn temp_repo_dir(label: &str) -> PathBuf {
329        let nanos = std::time::SystemTime::now()
330            .duration_since(std::time::UNIX_EPOCH)
331            .map(|d| d.as_nanos())
332            .unwrap_or(0);
333        let dir = std::env::temp_dir().join(format!("oxicode-git-tui-{label}-{nanos}"));
334        std::fs::create_dir_all(&dir).expect("create temp repo dir");
335        dir
336    }
337
338    /// Init a temp repo, set git identity, commit an initial file.
339    /// Returns the repo root.
340    fn init_repo(label: &str, filename: &str, content: &str) -> PathBuf {
341        let dir = temp_repo_dir(label);
342        let run = |args: &[&str]| {
343            let status = std::process::Command::new("git")
344                .args(args)
345                .current_dir(&dir)
346                .env("GIT_AUTHOR_NAME", "tester")
347                .env("GIT_AUTHOR_EMAIL", "tester@example.com")
348                .env("GIT_COMMITTER_NAME", "tester")
349                .env("GIT_COMMITTER_EMAIL", "tester@example.com")
350                .status()
351                .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
352            assert!(status.success(), "git {args:?} failed: {status}");
353        };
354        run(&["init", "--quiet"]);
355        // Local identity: the app's own `git commit` runs WITHOUT the
356        // fixture's env vars, so on CI runners with no global git identity
357        // the commit fails with "Author identity unknown".
358        run(&["config", "user.name", "tester"]);
359        run(&["config", "user.email", "tester@example.com"]);
360        std::fs::write(dir.join(filename), content).unwrap();
361        run(&["add", "--", filename]);
362        run(&["commit", "--quiet", "-m", "init"]);
363        dir
364    }
365
366    fn cleanup(dir: &Path) {
367        let _ = std::fs::remove_dir_all(dir);
368    }
369
370    #[test]
371    fn load_populates_entries_and_doc() {
372        if !git_available() {
373            eprintln!("git binary not available — skipping");
374            return;
375        }
376        let dir = init_repo("load", "hello.txt", "first\n");
377        // Modify + add a new file so we get both `M` and `??` statuses.
378        std::fs::write(dir.join("hello.txt"), "first\nsecond\n").unwrap();
379        std::fs::write(dir.join("new.txt"), "untracked body\n").unwrap();
380
381        let state = GitTuiState::load(&dir).expect("load");
382        let paths: Vec<&str> = state.entries.iter().map(|e| e.path.as_str()).collect();
383        assert!(
384            paths.contains(&"hello.txt"),
385            "entries missing hello.txt: {paths:?}"
386        );
387        assert!(
388            paths.contains(&"new.txt"),
389            "entries missing new.txt: {paths:?}"
390        );
391        // `hello.txt` is modified (in the diff), so the doc carries it.
392        let doc_paths: Vec<&str> = state.doc.files.iter().map(|f| f.path.as_str()).collect();
393        assert!(
394            doc_paths.contains(&"hello.txt"),
395            "doc missing hello.txt: {doc_paths:?}"
396        );
397        // `new.txt` is untracked → placeholder DiffFile must exist.
398        assert!(
399            state
400                .doc
401                .files
402                .iter()
403                .any(|f| f.path == "new.txt" && f.hunks.is_empty()),
404            "untracked placeholder missing"
405        );
406
407        cleanup(&dir);
408    }
409
410    #[test]
411    fn toggle_stage_moves_path_between_staged_and_entries() {
412        if !git_available() {
413            return;
414        }
415        let dir = init_repo("stage", "a.txt", "alpha\n");
416        std::fs::write(dir.join("a.txt"), "alpha\nbeta\n").unwrap();
417
418        let mut state = GitTuiState::load(&dir).expect("load");
419        // Find index of a.txt in entries.
420        let idx = state
421            .entries
422            .iter()
423            .position(|e| e.path == "a.txt")
424            .expect("a.txt entry");
425        state.selected_file = idx;
426
427        // Stage it via the public method (mirrors `s` keypress).
428        // Mutations refresh inline (final-review finding 3): entries /
429        // staged must already reflect the change — no manual `r`.
430        state.toggle_stage(&dir, "a.txt").expect("stage");
431        assert!(
432            state.staged.contains("a.txt"),
433            "expected a.txt in staged set right after toggle_stage"
434        );
435        let staged_entry = state.entries.iter().find(|e| e.path == "a.txt").unwrap();
436        assert_eq!(
437            staged_entry.xy,
438            ['M', ' '],
439            "after stage a.txt should be staged-modification (XY=['M',' '])"
440        );
441
442        // Toggle again → unstage. Inline refresh re-derives; the
443        // worktree slot carries `M` again.
444        state.toggle_stage(&dir, "a.txt").expect("unstage");
445        assert!(
446            !state.staged.contains("a.txt"),
447            "expected a.txt removed from staged"
448        );
449        let unstaged_entry = state.entries.iter().find(|e| e.path == "a.txt").unwrap();
450        assert_eq!(
451            unstaged_entry.xy,
452            [' ', 'M'],
453            "after unstage a.txt should be worktree-modification (XY=[' ','M'])"
454        );
455
456        cleanup(&dir);
457    }
458
459    #[test]
460    fn externally_staged_files_are_operable() {
461        // Final-review finding 4: the `staged` mirror is derived from
462        // the porcelain XY index column, so a file staged OUTSIDE the
463        // overlay (plain `git add` in a shell) is visible as staged
464        // and `u` (unstage) actually works on it.
465        if !git_available() {
466            return;
467        }
468        let dir = init_repo("extstage", "e.txt", "epsilon\n");
469        std::fs::write(dir.join("e.txt"), "epsilon\nzeta\n").unwrap();
470        // External staging — NOT via the overlay.
471        run_git(&dir, &["add", "--", "e.txt"]).expect("external git add");
472
473        let mut state = GitTuiState::load(&dir).expect("load");
474        assert!(
475            state.staged.contains("e.txt"),
476            "externally staged e.txt must be in the staged mirror: {:?}",
477            state.entries
478        );
479
480        // Unstage via the overlay: previously `already_staged` was
481        // false for external staging, so `u` was a silent no-op.
482        state.toggle_stage(&dir, "e.txt").expect("unstage external");
483        let entry = state.entries.iter().find(|e| e.path == "e.txt").unwrap();
484        assert_eq!(
485            entry.xy[0], ' ',
486            "after unstage the index column must be blank again: {:?}",
487            entry.xy
488        );
489        assert!(!state.staged.contains("e.txt"));
490
491        cleanup(&dir);
492    }
493
494    #[test]
495    fn commit_clears_message_on_success() {
496        if !git_available() {
497            return;
498        }
499        let dir = init_repo("commit", "c.txt", "gamma\n");
500        std::fs::write(dir.join("c.txt"), "gamma\ndelta\n").unwrap();
501
502        let mut state = GitTuiState::load(&dir).expect("load");
503        state.toggle_stage(&dir, "c.txt").expect("stage");
504        state.commit_msg = "feat: add delta".to_string();
505        state.commit(&dir).expect("commit");
506        assert!(
507            state.commit_msg.is_empty(),
508            "commit_msg must clear on success"
509        );
510        assert!(!state.commit_mode);
511        // Inline refresh after commit (final-review finding 3): the
512        // committed change is gone from status and the diff doc.
513        assert!(
514            state.entries.iter().all(|e| e.path != "c.txt"),
515            "committed file must leave the status entries: {:?}",
516            state.entries
517        );
518        assert!(
519            state.doc.files.iter().all(|f| f.path != "c.txt"),
520            "committed file must leave the diff document"
521        );
522
523        // Empty commit message must error.
524        state.commit_msg.clear();
525        let err = state.commit(&dir).expect_err("empty message rejected");
526        assert!(err.to_string().contains("empty"));
527
528        cleanup(&dir);
529    }
530
531    #[test]
532    fn untracked_file_gets_placeholder_doc_entry() {
533        if !git_available() {
534            return;
535        }
536        let dir = init_repo("untracked", "x.txt", "x body\n");
537        std::fs::write(dir.join("y.txt"), "new untracked\n").unwrap();
538
539        let state = GitTuiState::load(&dir).expect("load");
540        let untracked = state
541            .doc
542            .files
543            .iter()
544            .find(|f| f.path == "y.txt")
545            .expect("placeholder for y.txt");
546        assert!(untracked.hunks.is_empty());
547        assert!(!untracked.binary);
548
549        cleanup(&dir);
550    }
551}