1pub 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#[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 pub branch: Option<String>,
69 pub wrap: bool,
71}
72
73impl GitTuiState {
74 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 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 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 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 pub fn toggle_stage(&mut self, cwd: &Path, path: &str) -> anyhow::Result<()> {
162 if self.staged.contains(path) {
163 run_git(cwd, &["restore", "--staged", "--", path])?;
165 } else {
166 run_git(cwd, &["add", "--", path])?;
167 }
168 self.refresh(cwd)
172 }
173
174 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 pub fn apply_action(&mut self, cwd: &Path, action: GitKeyAction) -> anyhow::Result<bool> {
193 match action {
194 GitKeyAction::Close => return Ok(false), 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 }
289 }
290 Ok(true)
291 }
292
293 pub fn commit_input_char(&mut self, ch: char) {
295 if self.commit_mode {
296 self.commit_msg.push(ch);
297 }
298 }
299
300 pub fn commit_backspace(&mut self) {
302 if self.commit_mode {
303 self.commit_msg.pop();
304 }
305 }
306}
307
308#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::path::PathBuf;
316
317 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}