1#![deny(unsafe_code)]
12#![deny(unused_imports, unused_must_use, dead_code, unused_assignments)]
13#![deny(clippy::all, clippy::perf)]
14#![allow(clippy::collapsible_if, clippy::collapsible_else_if)]
15#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::panic))]
16
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20use git2::{Repository, StatusOptions, StatusShow};
21
22mod process;
23pub use process::{
24 detached_command, git_command, spawn_pipe_reader, ssh_command_val, summarize_stderr,
25 tool_command,
26};
27
28#[derive(Debug, Clone)]
35pub enum ItemStatus {
36 Loading,
37 Missing,
38 Directory,
39 GitRepo(Option<RepoSummary>),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum RepoState {
44 #[default]
45 Clean,
46 Merge,
47 Revert,
48 CherryPick,
49 Bisect,
50 Rebase,
51 ApplyMailbox,
52}
53
54#[derive(Debug, Default, Clone)]
57pub struct RepoSummary {
58 pub branch: Option<String>,
61 pub staged: usize,
62 pub modified: usize,
63 pub untracked: usize,
64 pub conflicted: usize,
65 pub ahead: usize,
66 pub behind: usize,
67 pub state: RepoState,
68 pub last_commit_time: Option<i64>,
69}
70
71impl RepoSummary {
72 pub fn is_clean(&self) -> bool {
73 self.staged + self.modified + self.untracked + self.conflicted == 0
74 }
75 pub fn is_synced(&self) -> bool {
76 self.ahead + self.behind == 0
77 }
78 pub fn unchanged(&self) -> bool {
79 self.is_clean() && self.is_synced()
80 }
81}
82
83#[derive(Debug, Clone)]
86pub enum ItemDetail {
87 Missing { resolved: PathBuf },
88 Directory { resolved: PathBuf },
89 Repo { resolved: PathBuf, info: Box<RepoInfo> },
90 Error { resolved: PathBuf, message: String },
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct BranchInfo {
95 pub name: String,
96 pub is_head: bool,
97 pub short_sha: String,
98 pub short_message: String,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct FileRevision {
103 pub commit_oid: String,
104 pub author: String,
105 pub date: String,
106 pub when: String,
107 pub summary: String,
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct StashInfo {
112 pub index: usize,
113 pub message: String,
114 pub commit_id: String,
115 pub files: Vec<FileEntry>,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct CommitterStat {
120 pub name: String,
121 pub email: String,
122 pub count: usize,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub enum TabData<T> {
127 #[default]
128 NotLoaded,
129 Loading,
130 Loaded(T),
131 Error(String),
132}
133
134impl<T> TabData<T> {
135 pub fn is_not_loaded(&self) -> bool {
136 matches!(self, TabData::NotLoaded)
137 }
138 pub fn is_loading(&self) -> bool {
139 matches!(self, TabData::Loading)
140 }
141 #[allow(dead_code)]
142 pub fn is_loaded(&self) -> bool {
143 matches!(self, TabData::Loaded(_))
144 }
145 pub fn as_ref(&self) -> Option<&T> {
146 match self {
147 TabData::Loaded(val) => Some(val),
148 _ => None,
149 }
150 }
151}
152
153impl<T> TabData<Vec<T>> {
154 pub fn len(&self) -> usize {
155 self.as_ref().map(|v| v.len()).unwrap_or(0)
156 }
157 pub fn is_empty(&self) -> bool {
158 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
159 }
160 pub fn first(&self) -> Option<&T> {
161 self.as_ref().and_then(|v| v.first())
162 }
163 pub fn get(&self, index: usize) -> Option<&T> {
164 self.as_ref().and_then(|v| v.get(index))
165 }
166 pub fn iter(&self) -> std::slice::Iter<'_, T> {
167 match self {
168 TabData::Loaded(v) => v.iter(),
169 _ => [].iter(),
170 }
171 }
172 pub fn as_slice(&self) -> &[T] {
173 match self {
174 TabData::Loaded(v) => v.as_slice(),
175 _ => &[],
176 }
177 }
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct WorktreeInfo {
182 pub name: String,
183 pub path: PathBuf,
184 pub branch: Option<String>,
185 pub is_locked: bool,
186 pub lock_reason: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct SubmoduleInfo {
191 pub name: String,
192 pub path: PathBuf,
193 pub url: String,
194 pub commit_id: Option<String>,
195 pub head_id: Option<String>,
196 pub is_initialized: bool,
197 pub is_dirty: bool,
198}
199
200impl SubmoduleInfo {
201 pub fn is_removal_staged(&self) -> bool {
203 self.commit_id.is_none() && self.head_id.is_some()
204 }
205
206 pub fn is_modified(&self) -> bool {
209 !self.is_removal_staged() && self.is_initialized && self.is_dirty
210 }
211}
212
213#[derive(Debug, Clone)]
214pub enum TabPayload {
215 Files(Result<Vec<String>, String>),
216 Graph(Result<Vec<GraphLine>, String>),
217 Branches {
218 local: Result<Vec<BranchInfo>, String>,
219 remote: Result<Vec<BranchInfo>, String>,
220 },
221 Tags {
222 local: Result<Vec<BranchInfo>, String>,
223 remote: Result<Vec<BranchInfo>, String>,
224 },
225 Remotes(Result<Vec<RemoteInfo>, String>),
226 Stashes(Result<Vec<StashInfo>, String>),
227 Overview(Result<(Vec<CommitterStat>, bool), String>),
228 Worktrees(Result<Vec<WorktreeInfo>, String>),
229 Submodules(Result<Vec<SubmoduleInfo>, String>),
230 Reflog(Result<Vec<ReflogEntry>, String>),
231 Notice(String),
234 ForgeIssues(Result<Vec<ForgeIssue>, String>),
235 ForgePRs(Result<Vec<ForgePR>, String>),
236 PRComments {
239 pr_number: u32,
240 result: Result<Vec<ForgePRComment>, String>,
241 },
242}
243
244#[derive(Debug, Default, Clone)]
245pub struct RepoInfo {
246 pub branch: Option<String>,
247 pub head: Option<HeadInfo>,
248 pub remotes: TabData<Vec<RemoteInfo>>,
249 pub upstream: Option<String>,
251 pub summary: RepoSummary,
252 pub changes: WorktreeChanges,
254 pub commits: Vec<CommitEntry>,
256 pub graph_lines: TabData<Vec<GraphLine>>,
258 pub local_branches: TabData<Vec<BranchInfo>>,
260 pub remote_branches: TabData<Vec<BranchInfo>>,
262 pub local_tags: TabData<Vec<BranchInfo>>,
264 pub remote_tags: TabData<Vec<BranchInfo>>,
266 pub remote_tags_loaded: bool,
268 pub remote_tags_attempted: bool,
270 pub files: TabData<Vec<String>>,
272 pub stashes: TabData<Vec<StashInfo>>,
274 pub worktrees: TabData<Vec<WorktreeInfo>>,
276 pub submodules: TabData<Vec<SubmoduleInfo>>,
278 pub reflog: TabData<Vec<ReflogEntry>>,
280 pub forge_issues: TabData<Vec<ForgeIssue>>,
281 pub forge_prs: TabData<Vec<ForgePR>>,
282 pub committer_stats: TabData<Vec<CommitterStat>>,
284 pub committer_stats_limit_reached: bool,
286 pub tab_loaded_at: [Option<std::time::Instant>; 12],
288 pub tab_loading: [bool; 12],
290 pub lfs_files: std::collections::HashSet<String>,
292 pub lfs_installed: bool,
294 pub lfs_storage_size: Option<u64>,
296}
297
298impl RepoInfo {
299 pub fn reset_in_flight_loads(&mut self) {
304 fn unstick<T>(data: &mut TabData<T>) {
305 if data.is_loading() {
306 *data = TabData::NotLoaded;
307 }
308 }
309 self.tab_loading = [false; 12];
310 unstick(&mut self.remotes);
311 unstick(&mut self.graph_lines);
312 unstick(&mut self.local_branches);
313 unstick(&mut self.remote_branches);
314 unstick(&mut self.local_tags);
315 unstick(&mut self.remote_tags);
316 unstick(&mut self.files);
317 unstick(&mut self.stashes);
318 unstick(&mut self.worktrees);
319 unstick(&mut self.submodules);
320 unstick(&mut self.reflog);
321 unstick(&mut self.forge_issues);
322 unstick(&mut self.forge_prs);
323 unstick(&mut self.committer_stats);
324 }
325}
326
327#[derive(Debug, Clone)]
328pub struct HeadInfo {
329 pub short_id: String,
330 pub summary: String,
331 pub author: String,
332 pub when: String,
333}
334
335#[derive(Debug, Clone)]
336pub struct ReflogEntry {
337 pub index: usize,
338 pub target_oid: String,
339 pub selector: String,
340 pub command: String,
341 pub message: String,
342 pub when: String,
343 pub date: String,
344}
345
346#[derive(Debug, Clone)]
347pub struct RemoteInfo {
348 pub name: String,
349 pub url: String,
350 pub push_url: Option<String>,
351 pub refspecs: Vec<String>,
352}
353
354#[derive(Debug, Clone)]
355pub struct CommitEntry {
356 pub id: String,
358 pub oid: String,
360 pub author: String,
361 pub when: String,
362 pub date: String,
363 pub summary: String,
364 pub message: String,
365 pub refs: Vec<String>,
368 pub files: Vec<FileEntry>,
370 pub signature_status: String,
372}
373
374#[derive(Debug, Clone)]
375pub struct GraphLine {
376 pub graph: String,
377 pub commit: Option<GraphCommit>,
378}
379
380#[derive(Debug, Clone)]
381pub struct GraphCommit {
382 pub oid: String,
383 pub decoration: String,
384 pub summary: String,
385 pub author: String,
386 pub date: String,
387 pub signature_status: String,
389}
390
391#[derive(Debug, Clone)]
393pub struct FileEntry {
394 pub path: String,
396 pub label: &'static str,
398}
399
400#[derive(Debug, Default, Clone)]
403pub struct WorktreeChanges {
404 pub staged: Vec<FileEntry>,
405 pub unstaged: Vec<FileEntry>,
406 pub untracked: Vec<FileEntry>,
407 pub conflicted: Vec<FileEntry>,
408}
409
410#[derive(Debug, Clone, PartialEq)]
414pub enum DiffLineKind {
415 Header,
417 Added,
419 Removed,
421 Context,
423 ConflictOurs,
425 ConflictTheirs,
427 ConflictSeparator,
429}
430
431#[derive(Debug, Clone)]
433pub struct DiffLine {
434 pub kind: DiffLineKind,
435 pub content: String,
437 pub old_lineno: Option<u32>,
438 pub new_lineno: Option<u32>,
439 pub hunk_idx: Option<usize>,
440}
441
442pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
446 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
447}
448
449pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
456 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
457}
458
459pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
462 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
463 let mut index = repo.index().map_err(|e| e.to_string())?;
464 let full_path = repo_path.join(file_path);
465 if full_path.exists() {
466 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
467 } else {
468 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
469 }
470 index.write().map_err(|e| e.to_string())?;
471 Ok(())
472}
473
474pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
479 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
480 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
482 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
483 .map_err(|e| e.to_string())?;
484 } else {
485 let mut index = repo.index().map_err(|e| e.to_string())?;
487 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
488 index.write().map_err(|e| e.to_string())?;
489 }
490 Ok(())
491}
492
493pub fn sanitize_text(s: &str) -> String {
494 let mut result = String::with_capacity(s.len());
495 let mut chars = s.chars().peekable();
496 while let Some(c) = chars.next() {
497 if c == '\x1B' {
498 if let Some(&'[') = chars.peek() {
500 chars.next();
501 for seq_c in chars.by_ref() {
502 if seq_c.is_ascii_alphabetic() {
504 break;
505 }
506 }
507 } else {
508 for seq_c in chars.by_ref() {
510 if seq_c == ' ' || seq_c.is_ascii_alphabetic() {
511 break;
512 }
513 }
514 }
515 } else if (c < ' ' || c == '\x7F') && c != '\n' && c != '\r' && c != '\t' {
516 result.push(' ');
518 } else {
519 result.push(c);
520 }
521 }
522 result
523}
524
525pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
526 let mut end = len.min(sha.len());
527 while end > 0 && !sha.is_char_boundary(end) {
528 end -= 1;
529 }
530 &sha[..end]
531}
532
533const GIT_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
535
536fn run_git_with_timeout(
543 mut cmd: std::process::Command,
544 timeout: Duration,
545) -> Result<std::process::Output, Box<dyn std::error::Error>> {
546 if timeout.is_zero() {
547 return Ok(cmd.output()?);
548 }
549
550 cmd.stdin(std::process::Stdio::null())
551 .stdout(std::process::Stdio::piped())
552 .stderr(std::process::Stdio::piped());
553
554 let mut child = cmd.spawn()?;
555
556 let mut out_pipe = child.stdout.take();
557 let mut err_pipe = child.stderr.take();
558 let out_reader = std::thread::spawn(move || {
559 let mut buf = Vec::new();
560 if let Some(p) = out_pipe.as_mut() {
561 let _ = std::io::Read::read_to_end(p, &mut buf);
562 }
563 buf
564 });
565 let err_reader = std::thread::spawn(move || {
566 let mut buf = Vec::new();
567 if let Some(p) = err_pipe.as_mut() {
568 let _ = std::io::Read::read_to_end(p, &mut buf);
569 }
570 buf
571 });
572
573 let started = Instant::now();
574 let status = loop {
575 match child.try_wait() {
576 Ok(Some(status)) => break Some(status),
577 Ok(None) => {
578 if started.elapsed() >= timeout {
579 let _ = child.kill();
580 let _ = child.wait();
581 break None;
582 }
583 std::thread::sleep(GIT_TIMEOUT_POLL_INTERVAL);
584 }
585 Err(e) => {
586 let _ = child.kill();
587 let _ = child.wait();
588 return Err(e.into());
589 }
590 }
591 };
592
593 let stdout = out_reader.join().unwrap_or_default();
594 let stderr = err_reader.join().unwrap_or_default();
595
596 match status {
597 Some(status) => Ok(std::process::Output { status, stdout, stderr }),
598 None => {
599 Err(format!("git did not respond within {}s and was cancelled", timeout.as_secs())
600 .into())
601 }
602 }
603}
604
605pub fn safe_ref(r: &str) -> Result<&str, String> {
606 let trimmed = r.trim();
607 if trimmed.starts_with('-') {
608 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
609 }
610 if trimmed.is_empty() {
611 return Err("Ref name cannot be empty".to_string());
612 }
613 Ok(trimmed)
614}
615
616pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
618 let output = git_command()
619 .arg("add")
620 .arg("-A")
621 .current_dir(repo_path)
622 .output()
623 .map_err(|e| e.to_string())?;
624
625 if output.status.success() {
626 Ok(())
627 } else {
628 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
629 }
630}
631
632pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
634 let output =
635 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
636
637 if output.status.success() {
638 Ok(())
639 } else {
640 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
641 }
642}
643
644pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
646 let _ = git_command().arg("reset").current_dir(repo_path).output();
648
649 let checkout_out = git_command()
651 .arg("checkout")
652 .arg("--")
653 .arg(".")
654 .current_dir(repo_path)
655 .output()
656 .map_err(|e| e.to_string())?;
657
658 if !checkout_out.status.success() {
659 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
660 }
661
662 let clean_out = git_command()
664 .arg("clean")
665 .arg("-fd")
666 .current_dir(repo_path)
667 .output()
668 .map_err(|e| e.to_string())?;
669
670 if !clean_out.status.success() {
671 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
672 }
673
674 Ok(())
675}
676
677pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
679 apply_hunk_patch(repo_path, file_path, hunk, false, true)
680}
681
682pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
684 apply_hunk_patch(repo_path, file_path, hunk, true, true)
685}
686
687pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
689 apply_hunk_patch(repo_path, file_path, hunk, true, false)
690}
691
692pub fn stage_line(
694 repo_path: &Path,
695 file_path: &str,
696 hunk: &[DiffLine],
697 selected_line_idx: usize,
698) -> Result<(), String> {
699 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
700}
701
702pub fn unstage_line(
704 repo_path: &Path,
705 file_path: &str,
706 hunk: &[DiffLine],
707 selected_line_idx: usize,
708) -> Result<(), String> {
709 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
710}
711
712pub fn discard_line(
714 repo_path: &Path,
715 file_path: &str,
716 hunk: &[DiffLine],
717 selected_line_idx: usize,
718) -> Result<(), String> {
719 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
720}
721
722fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
723 if !header.starts_with("@@") {
724 return None;
725 }
726 let parts: Vec<&str> = header.split("@@").collect();
727 if parts.len() < 3 {
728 return None;
729 }
730 let meta = parts[1].trim();
731 let subparts: Vec<&str> = meta.split_whitespace().collect();
732 if subparts.len() < 2 {
733 return None;
734 }
735
736 let parse_part = |p: &str| -> (usize, usize) {
737 let s = p.trim_start_matches(['-', '+']);
738 let comps: Vec<&str> = s.split(',').collect();
739 let start = comps[0].parse::<usize>().unwrap_or(0);
740 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
741 (start, count)
742 };
743
744 let (old_start, old_count) = parse_part(subparts[0]);
745 let (new_start, new_count) = parse_part(subparts[1]);
746 Some((old_start, old_count, new_start, new_count))
747}
748
749fn apply_line_patch_inner(
750 repo_path: &Path,
751 file_path: &str,
752 hunk: &[DiffLine],
753 selected_line_idx_in_hunk: usize,
754 revert: bool,
755 target_has_modification: bool,
756 cached: bool,
757) -> Result<(), String> {
758 use std::io::Write;
759 use std::process::Stdio;
760
761 let path_check = std::path::Path::new(file_path);
762 if path_check.is_absolute()
763 || path_check.components().any(|c| c == std::path::Component::ParentDir)
764 {
765 return Err("Path escapes repository root".to_string());
766 }
767
768 if hunk.is_empty() {
769 return Err("Empty hunk".to_string());
770 }
771
772 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
773 Some(line) => line,
774 None => return Err("Invalid line index".to_string()),
775 };
776
777 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
778 return Err("Selected line is not a modification (must be + or -)".to_string());
779 }
780
781 let header_line = &hunk[0];
782 let (old_start, _old_count, new_start, _new_count) =
783 match parse_hunk_header(&header_line.content) {
784 Some(coords) => coords,
785 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
786 };
787
788 let mut patch_lines = Vec::new();
789 let mut new_old_count = 0;
790 let mut new_new_count = 0;
791
792 for (i, line) in hunk.iter().enumerate() {
793 if i == 0 {
794 continue;
795 }
796
797 if i == selected_line_idx_in_hunk {
798 if revert {
799 match line.kind {
800 DiffLineKind::Added => {
801 patch_lines.push(DiffLine {
802 kind: DiffLineKind::Removed,
803 content: line.content.clone(),
804 old_lineno: None,
805 new_lineno: None,
806 hunk_idx: None,
807 });
808 new_old_count += 1;
809 }
810 DiffLineKind::Removed => {
811 patch_lines.push(DiffLine {
812 kind: DiffLineKind::Added,
813 content: line.content.clone(),
814 old_lineno: None,
815 new_lineno: None,
816 hunk_idx: None,
817 });
818 new_new_count += 1;
819 }
820 _ => {}
821 }
822 } else {
823 match line.kind {
824 DiffLineKind::Added => {
825 patch_lines.push(DiffLine {
826 kind: DiffLineKind::Added,
827 content: line.content.clone(),
828 old_lineno: None,
829 new_lineno: None,
830 hunk_idx: None,
831 });
832 new_new_count += 1;
833 }
834 DiffLineKind::Removed => {
835 patch_lines.push(DiffLine {
836 kind: DiffLineKind::Removed,
837 content: line.content.clone(),
838 old_lineno: None,
839 new_lineno: None,
840 hunk_idx: None,
841 });
842 new_old_count += 1;
843 }
844 _ => {}
845 }
846 }
847 } else {
848 match line.kind {
849 DiffLineKind::Context => {
850 patch_lines.push(line.clone());
851 new_old_count += 1;
852 new_new_count += 1;
853 }
854 DiffLineKind::Added => {
855 if target_has_modification {
856 patch_lines.push(DiffLine {
857 kind: DiffLineKind::Context,
858 content: line.content.clone(),
859 old_lineno: None,
860 new_lineno: None,
861 hunk_idx: None,
862 });
863 new_old_count += 1;
864 new_new_count += 1;
865 } else {
866 }
868 }
869 DiffLineKind::Removed => {
870 if target_has_modification {
871 } else {
873 patch_lines.push(DiffLine {
874 kind: DiffLineKind::Context,
875 content: line.content.clone(),
876 old_lineno: None,
877 new_lineno: None,
878 hunk_idx: None,
879 });
880 new_old_count += 1;
881 new_new_count += 1;
882 }
883 }
884 _ => {}
885 }
886 }
887 }
888
889 let mut patch = String::new();
890 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
891 patch.push_str(&format!("--- a/{}\n", file_path));
892 patch.push_str(&format!("+++ b/{}\n", file_path));
893 patch.push_str(&format!(
894 "@@ -{},{} +{},{} @@\n",
895 old_start, new_old_count, new_start, new_new_count
896 ));
897
898 for line in patch_lines {
899 let prefix = match line.kind {
900 DiffLineKind::Added => "+",
901 DiffLineKind::Removed => "-",
902 DiffLineKind::Context => " ",
903 DiffLineKind::Header => "",
904 _ => "",
905 };
906 patch.push_str(prefix);
907 patch.push_str(&line.content);
908 patch.push('\n');
909 }
910
911 let mut args = vec!["apply"];
912 if cached {
913 args.push("--cached");
914 }
915 args.push("-");
916
917 let mut cmd = git_command();
918 let mut child = cmd
919 .args(&args)
920 .current_dir(repo_path)
921 .stdin(Stdio::piped())
922 .stdout(Stdio::piped())
923 .stderr(Stdio::piped())
924 .spawn()
925 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
926
927 if let Some(mut stdin) = child.stdin.take() {
928 stdin
929 .write_all(patch.as_bytes())
930 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
931 }
932
933 let output =
934 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
935
936 if !output.status.success() {
937 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
938 return Err(format!("git apply failed: {}", err_msg.trim()));
939 }
940
941 Ok(())
942}
943
944fn apply_hunk_patch(
945 repo_path: &Path,
946 file_path: &str,
947 hunk: &[DiffLine],
948 reverse: bool,
949 cached: bool,
950) -> Result<(), String> {
951 use std::io::Write;
952 use std::process::Stdio;
953
954 let path_check = std::path::Path::new(file_path);
955 if path_check.is_absolute()
956 || path_check.components().any(|c| c == std::path::Component::ParentDir)
957 {
958 return Err("Path escapes repository root".to_string());
959 }
960
961 let mut patch = String::new();
962 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
963 patch.push_str(&format!("--- a/{}\n", file_path));
964 patch.push_str(&format!("+++ b/{}\n", file_path));
965 for line in hunk {
966 let prefix = match line.kind {
967 DiffLineKind::Added => "+",
968 DiffLineKind::Removed => "-",
969 DiffLineKind::Context => " ",
970 DiffLineKind::Header => "",
971 _ => "",
972 };
973 patch.push_str(prefix);
974 patch.push_str(&line.content);
975 patch.push('\n');
976 }
977
978 let mut args = vec!["apply"];
979 if cached {
980 args.push("--cached");
981 }
982 if reverse {
983 args.push("--reverse");
984 }
985 args.push("-");
986
987 let mut cmd = git_command();
988 let mut child = cmd
989 .args(&args)
990 .current_dir(repo_path)
991 .stdin(Stdio::piped())
992 .stdout(Stdio::piped())
993 .stderr(Stdio::piped())
994 .spawn()
995 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
996
997 if let Some(mut stdin) = child.stdin.take() {
998 stdin
999 .write_all(patch.as_bytes())
1000 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
1001 }
1002
1003 let output =
1004 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
1005
1006 if !output.status.success() {
1007 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
1008 return Err(format!("git apply failed: {}", err_msg.trim()));
1009 }
1010
1011 Ok(())
1012}
1013
1014pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
1019 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1020
1021 if staged {
1022 unstage_file(repo_path, file_path)?;
1024 }
1025
1026 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
1028 status.contains(git2::Status::WT_NEW)
1029 } else {
1030 false
1031 };
1032
1033 if is_untracked {
1034 let full_path = repo_path.join(file_path);
1035 if full_path.exists() {
1036 if full_path.is_file() {
1037 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
1038 } else if full_path.is_dir() {
1039 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
1040 }
1041 }
1042 } else {
1043 let mut checkout_opts = git2::build::CheckoutBuilder::new();
1045 checkout_opts.path(Path::new(file_path));
1046 checkout_opts.force();
1047 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
1048 }
1049
1050 Ok(())
1051}
1052
1053pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
1056 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1057 let mut index = repo.index().map_err(|e| e.to_string())?;
1058 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
1059 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
1060
1061 let signature = repo
1062 .signature()
1063 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
1064
1065 let mut parents = Vec::new();
1067 let mut has_head = false;
1068 if let Ok(head) = repo.head() {
1069 if let Ok(parent_commit) = head.peel_to_commit() {
1070 has_head = true;
1071 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
1073 if parent_tree.id() == tree_id {
1074 return Err("No staged changes to commit".to_string());
1075 }
1076 parents.push(parent_commit);
1077 }
1078 }
1079
1080 if !has_head && index.is_empty() {
1081 return Err("No staged changes to commit (index is empty)".to_string());
1082 }
1083
1084 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1085
1086 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
1087 .map_err(|e| e.to_string())?;
1088
1089 Ok(())
1090}
1091
1092pub fn expand_tilde(s: &str) -> PathBuf {
1098 if s == "~" {
1099 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
1100 }
1101 if let Some(stripped) = s.strip_prefix("~/")
1102 && let Some(home) = dirs::home_dir()
1103 {
1104 return home.join(stripped);
1105 }
1106 PathBuf::from(s)
1107}
1108
1109pub fn discover_repo_root(start: &Path) -> Option<PathBuf> {
1116 let mut dir = if start.is_dir() { Some(start) } else { start.parent() };
1117 while let Some(current) = dir {
1118 if current.join(".git").exists() {
1119 return Some(current.to_path_buf());
1120 }
1121 dir = current.parent();
1122 }
1123 None
1124}
1125
1126pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1128 let repo = Repository::open(repo_path)?;
1129 repo.remote(name, url)?;
1130 Ok(())
1131}
1132
1133pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1135 let repo = Repository::open(repo_path)?;
1136 repo.remote_delete(name)?;
1137 Ok(())
1138}
1139
1140pub fn inspect_summary(item: &str) -> ItemStatus {
1142 let path = expand_tilde(item);
1143 if !path.is_dir() {
1144 return ItemStatus::Missing;
1145 }
1146 if !path.join(".git").exists() {
1147 return ItemStatus::Directory;
1148 }
1149 match Repository::open(&path) {
1150 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1151 Err(_) => ItemStatus::GitRepo(None),
1152 }
1153}
1154
1155pub fn inspect_detail(
1157 item: &str,
1158 commit_limit: usize,
1159 graph_max_commits: usize,
1160 enable_commit_signatures: bool,
1161) -> ItemDetail {
1162 let resolved = expand_tilde(item);
1163 if !resolved.is_dir() {
1164 return ItemDetail::Missing { resolved };
1165 }
1166 if !resolved.join(".git").exists() {
1167 return ItemDetail::Directory { resolved };
1168 }
1169 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1170 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1171 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1172 }
1173}
1174
1175fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1176 let mut sigs = std::collections::HashMap::new();
1177 let mut cmd = git_command();
1178 cmd.arg("log").arg("--all");
1179
1180 if limit > 0 {
1181 cmd.arg(format!("-n{}", limit));
1182 }
1183
1184 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1185
1186 if let Ok(out) = cmd.output() {
1187 if out.status.success() {
1188 let stdout_str = String::from_utf8_lossy(&out.stdout);
1189 for line in stdout_str.lines() {
1190 let parts: Vec<&str> = line.split_whitespace().collect();
1191 if parts.len() == 2 {
1192 sigs.insert(parts[0].to_string(), parts[1].to_string());
1193 } else if parts.len() == 1 {
1194 sigs.insert(parts[0].to_string(), "N".to_string());
1195 }
1196 }
1197 }
1198 }
1199 sigs
1200}
1201
1202#[derive(serde::Serialize, serde::Deserialize, Clone)]
1203struct CachedCommit {
1204 id: String,
1205 author: String,
1206 date: String,
1207 summary: String,
1208 message: String,
1209 time: i64,
1210}
1211
1212fn hash_path(path: &Path) -> String {
1213 use std::collections::hash_map::DefaultHasher;
1214 use std::hash::{Hash, Hasher};
1215 let mut hasher = DefaultHasher::new();
1216 path.hash(&mut hasher);
1217 format!("{:x}", hasher.finish())
1218}
1219
1220fn collect_commits(
1221 repo: &Repository,
1222 limit: usize,
1223 repo_path: &Path,
1224 enable_commit_signatures: bool,
1225) -> Result<Vec<CommitEntry>, git2::Error> {
1226 let mut walk = repo.revwalk()?;
1227 if walk.push_head().is_err() {
1228 return Ok(Vec::new());
1229 }
1230 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1231
1232 let mut commits = Vec::new();
1233 let oids: Vec<Result<git2::Oid, git2::Error>> =
1234 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1235
1236 let sig_map = if enable_commit_signatures {
1237 collect_signatures(repo_path, limit)
1238 } else {
1239 std::collections::HashMap::new()
1240 };
1241 let ref_map = get_cached_ref_map(repo, repo_path);
1242
1243 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1245 if let Some(ref dir) = cache_dir {
1246 let _ = std::fs::create_dir_all(dir);
1247 }
1248 let hash = hash_path(repo_path);
1249 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("v2_{}.json", hash)));
1250 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1251 .as_ref()
1252 .and_then(|f| std::fs::read_to_string(f).ok())
1253 .and_then(|s| serde_json::from_str(&s).ok())
1254 .unwrap_or_default();
1255
1256 let mut cache_updated = false;
1257
1258 for id in oids {
1259 let oid = id?;
1260 let oid_str = oid.to_string();
1261 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1262 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1263 let files = Vec::new();
1264
1265 if let Some(cached) = cache.get(&oid_str) {
1266 let when = format_relative_time(cached.time);
1267 commits.push(CommitEntry {
1268 id: cached.id.clone(),
1269 oid: oid_str,
1270 author: sanitize_text(&cached.author),
1271 when,
1272 date: cached.date.clone(),
1273 summary: sanitize_text(&cached.summary),
1274 message: sanitize_text(&cached.message),
1275 refs,
1276 files,
1277 signature_status: sig_status,
1278 });
1279 } else if let Ok(commit) = repo.find_commit(oid) {
1280 let short_id = format!("{:.7}", commit.id());
1281 let summary =
1282 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1283 let author = commit.author();
1284 let author_name = author.name().unwrap_or("?");
1285 let author_email = author.email().unwrap_or("?");
1286 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1287 let time_secs = commit.time().seconds();
1288 let when = format_relative_time(time_secs);
1289 let date = format_utc_date(time_secs);
1290 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1291
1292 let cached = CachedCommit {
1293 id: short_id.clone(),
1294 author: author_str.clone(),
1295 date: date.clone(),
1296 summary: summary.clone(),
1297 message: message.clone(),
1298 time: time_secs,
1299 };
1300 cache.insert(oid_str.clone(), cached);
1301 cache_updated = true;
1302
1303 commits.push(CommitEntry {
1304 id: short_id,
1305 oid: oid_str,
1306 author: author_str,
1307 when,
1308 date,
1309 summary,
1310 message,
1311 refs,
1312 files,
1313 signature_status: sig_status,
1314 });
1315 }
1316 }
1317
1318 if cache_updated {
1319 if let Some(ref f) = cache_file {
1320 if let Ok(json) = serde_json::to_string(&cache) {
1321 let _ = std::fs::write(f, json);
1322 }
1323 }
1324 }
1325
1326 Ok(commits)
1327}
1328
1329pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1330 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1331 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1332 if walk.push_head().is_err() {
1333 return Ok(Vec::new());
1334 }
1335 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1336
1337 let mut revisions = Vec::new();
1338
1339 for oid_res in walk {
1340 let oid = oid_res.map_err(|e| e.to_string())?;
1341 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1342
1343 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1344 let mut modified = false;
1345
1346 if commit.parent_count() > 0 {
1347 for i in 0..commit.parent_count() {
1348 if let Ok(parent) = commit.parent(i) {
1349 if let Ok(parent_tree) = parent.tree() {
1350 let mut diff_opts = git2::DiffOptions::new();
1351 diff_opts.pathspec(file_path);
1352 if let Ok(diff) = repo.diff_tree_to_tree(
1353 Some(&parent_tree),
1354 Some(&commit_tree),
1355 Some(&mut diff_opts),
1356 ) {
1357 if diff.deltas().len() > 0 {
1358 modified = true;
1359 break;
1360 }
1361 }
1362 }
1363 }
1364 }
1365 } else {
1366 let mut diff_opts = git2::DiffOptions::new();
1367 diff_opts.pathspec(file_path);
1368 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1369 {
1370 if diff.deltas().len() > 0 {
1371 modified = true;
1372 }
1373 }
1374 }
1375
1376 if modified {
1377 let author = commit.author();
1378 let author_name = author.name().unwrap_or("?");
1379 let author_email = author.email().unwrap_or("?");
1380 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1381 let time_secs = commit.time().seconds();
1382 let when = format_relative_time(time_secs);
1383 let date = format_utc_date(time_secs);
1384 let summary =
1385 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1386
1387 revisions.push(FileRevision {
1388 commit_oid: oid.to_string(),
1389 author: author_str,
1390 date,
1391 when,
1392 summary,
1393 });
1394 }
1395 }
1396
1397 Ok(revisions)
1398}
1399
1400#[derive(Debug, Clone)]
1401pub struct BlameEntry {
1402 pub line_no: usize,
1403 pub commit_id: String,
1404 pub author: String,
1405 pub date: String,
1406}
1407
1408pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1409 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1410 let mut opts = git2::BlameOptions::new();
1411 let blame = repo
1412 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1413 .map_err(|e| e.to_string())?;
1414
1415 let mut entries = Vec::new();
1416 for i in 0..blame.len() {
1417 if let Some(hunk) = blame.get_index(i) {
1418 let commit_id = hunk.final_commit_id().to_string();
1419 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1420
1421 let mut author_name = "Unknown".to_string();
1422 let mut date_str = "unknown".to_string();
1423 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1424 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1425 date_str = format_utc_date(commit.time().seconds());
1426 }
1427
1428 let start = hunk.final_start_line();
1429 let count = hunk.lines_in_hunk();
1430 for line_idx in 0..count {
1431 entries.push(BlameEntry {
1432 line_no: start + line_idx,
1433 commit_id: short_id.to_string(),
1434 author: author_name.clone(),
1435 date: date_str.clone(),
1436 });
1437 }
1438 }
1439 }
1440
1441 entries.sort_by_key(|e| e.line_no);
1442 Ok(entries)
1443}
1444
1445fn collect_committer_stats(
1446 repo: &Repository,
1447 limit: usize,
1448) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1449 let mut walk = repo.revwalk()?;
1450 if walk.push_head().is_err() {
1451 return Ok((Vec::new(), false));
1452 }
1453 let mut counts = std::collections::HashMap::new();
1454 let mut count = 0;
1455 let mut limit_reached = false;
1456 for id in walk {
1457 let oid = id?;
1458 if let Ok(commit) = repo.find_commit(oid) {
1459 let author = commit.author();
1460 let name = author.name().unwrap_or("?").to_string();
1461 let email = author.email().unwrap_or("?").to_string();
1462 let key = (name, email);
1463 *counts.entry(key).or_insert(0) += 1;
1464 count += 1;
1465 if count >= limit {
1466 limit_reached = true;
1467 break;
1468 }
1469 }
1470 }
1471
1472 let mut stats: Vec<CommitterStat> = counts
1473 .into_iter()
1474 .map(|((name, email), count)| CommitterStat { name, email, count })
1475 .collect();
1476
1477 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1478
1479 Ok((stats, limit_reached))
1480}
1481
1482fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1486 let commit_tree = match commit.tree() {
1487 Ok(t) => t,
1488 Err(_) => return Vec::new(),
1489 };
1490 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1493
1494 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1495 Ok(d) => d,
1496 Err(_) => return Vec::new(),
1497 };
1498
1499 let mut files = Vec::new();
1500 for delta in diff.deltas() {
1501 if files.len() >= MAX_FILES_PER_SECTION {
1502 break;
1503 }
1504 let path = delta
1505 .new_file()
1506 .path()
1507 .or_else(|| delta.old_file().path())
1508 .map(|p| p.to_string_lossy().into_owned())
1509 .unwrap_or_else(|| "(unknown)".to_string());
1510
1511 let label: &'static str = match delta.status() {
1512 git2::Delta::Added => "N",
1513 git2::Delta::Deleted => "D",
1514 git2::Delta::Modified => "M",
1515 git2::Delta::Renamed => "R",
1516 git2::Delta::Typechange => "T",
1517 _ => "M",
1518 };
1519 files.push(FileEntry { path, label });
1520 }
1521 files
1522}
1523
1524pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1525 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1526 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1527 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1528 Ok(commit_changed_files(&repo, &commit))
1529}
1530fn match_pattern(path: &str, pattern: &str) -> bool {
1531 if let Some(suffix) = pattern.strip_prefix('*') {
1532 return path.ends_with(suffix);
1533 }
1534 if let Some(prefix) = pattern.strip_suffix('*') {
1535 return path.starts_with(prefix);
1536 }
1537 if pattern.contains('*') {
1538 let parts: Vec<&str> = pattern.split('*').collect();
1539 if parts.len() == 2 {
1540 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1541 }
1542 }
1543 path == pattern || path.ends_with(&format!("/{}", pattern))
1544}
1545
1546static LFS_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1547
1548fn is_lfs_installed() -> bool {
1549 *LFS_INSTALLED.get_or_init(|| {
1550 if let Ok(output) = git_command().arg("lfs").arg("--version").output() {
1551 output.status.success()
1552 } else {
1553 false
1554 }
1555 })
1556}
1557
1558fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1559 let mut tracked = std::collections::HashSet::new();
1560
1561 let lfs_dir = repo_path.join(".git").join("lfs");
1563 let gitattributes_path = repo_path.join(".gitattributes");
1564 let has_lfs_attributes = if gitattributes_path.exists() {
1565 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1566 content.contains("filter=lfs")
1567 || content.contains("diff=lfs")
1568 || content.contains("merge=lfs")
1569 } else {
1570 false
1571 }
1572 } else {
1573 false
1574 };
1575
1576 if !lfs_dir.exists() && !has_lfs_attributes {
1577 return (false, tracked);
1578 }
1579
1580 let installed = is_lfs_installed();
1581 if installed {
1582 if let Ok(output) =
1583 git_command().arg("lfs").arg("ls-files").arg("-n").current_dir(repo_path).output()
1584 {
1585 if output.status.success() {
1586 if let Ok(stdout) = String::from_utf8(output.stdout) {
1587 for line in stdout.lines() {
1588 let trimmed = line.trim();
1589 if !trimmed.is_empty() {
1590 tracked.insert(trimmed.to_string());
1591 }
1592 }
1593 }
1594 }
1595 }
1596 }
1597
1598 let gitattributes_path = repo_path.join(".gitattributes");
1599 if gitattributes_path.exists() {
1600 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1601 let mut patterns = Vec::new();
1602 for line in content.lines() {
1603 let line = line.trim();
1604 if line.is_empty() || line.starts_with('#') {
1605 continue;
1606 }
1607 if line.contains("filter=lfs")
1608 || line.contains("diff=lfs")
1609 || line.contains("merge=lfs")
1610 {
1611 if let Some(pattern) = line.split_whitespace().next() {
1612 patterns.push(pattern.to_string());
1613 }
1614 }
1615 }
1616
1617 if !patterns.is_empty() {
1618 if let Ok(repo) = Repository::open(repo_path) {
1619 if let Ok(index) = repo.index() {
1620 for entry in index.iter() {
1621 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1622 for pat in &patterns {
1623 if match_pattern(path_str, pat) {
1624 tracked.insert(path_str.to_string());
1625 break;
1626 }
1627 }
1628 }
1629 }
1630 }
1631 }
1632 }
1633 }
1634 }
1635
1636 (installed, tracked)
1637}
1638
1639fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1640 let lfs_path = repo_path.join(".git").join("lfs");
1641 if !lfs_path.exists() {
1642 return None;
1643 }
1644
1645 fn calculate_dir_size(dir: &Path) -> u64 {
1646 let mut size = 0;
1647 if let Ok(entries) = std::fs::read_dir(dir) {
1648 for entry in entries.flatten() {
1649 if let Ok(metadata) = entry.metadata() {
1650 if metadata.is_dir() {
1651 size += calculate_dir_size(&entry.path());
1652 } else {
1653 size += metadata.len();
1654 }
1655 }
1656 }
1657 }
1658 size
1659 }
1660
1661 Some(calculate_dir_size(&lfs_path))
1662}
1663
1664fn collect_info(
1667 path: &Path,
1668 commit_limit: usize,
1669 _graph_max_commits: usize,
1670 enable_commit_signatures: bool,
1671) -> Result<RepoInfo, git2::Error> {
1672 let repo = Repository::open(path)?;
1673 let mut summary = RepoSummary::default();
1674 if let Ok(head) = repo.head() {
1675 summary.branch = head.shorthand().ok().map(String::from);
1676 }
1677 populate_ahead_behind(&repo, &mut summary);
1678
1679 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1680 let (lfs_inst, lfs_f) = get_lfs_info(path);
1681 info.lfs_installed = lfs_inst;
1682 info.lfs_files = lfs_f;
1683 info.lfs_storage_size = get_lfs_storage_size(path);
1684
1685 let is_detached = repo.head_detached().unwrap_or(false);
1686 if is_detached {
1687 info.branch = Some("HEAD".to_string());
1688 if let Ok(head) = repo.head() {
1689 if let Ok(commit) = head.peel_to_commit() {
1690 let short_id = format!("{:.7}", commit.id());
1691 let summary_text =
1692 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1693 let author = commit.author();
1694 let author_str = sanitize_text(&format!(
1695 "{} <{}>",
1696 author.name().unwrap_or("?"),
1697 author.email().unwrap_or("?")
1698 ));
1699 let when = format_relative_time(commit.time().seconds());
1700 info.head =
1701 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1702 }
1703 }
1704 } else if let Ok(head) = repo.head() {
1705 info.branch = head.shorthand().ok().map(String::from);
1706
1707 if let Ok(commit) = head.peel_to_commit() {
1708 let short_id = format!("{:.7}", commit.id());
1709 let summary_text =
1710 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1711 let author = commit.author();
1712 let author_str = sanitize_text(&format!(
1713 "{} <{}>",
1714 author.name().unwrap_or("?"),
1715 author.email().unwrap_or("?")
1716 ));
1717 let when = format_relative_time(commit.time().seconds());
1718 info.head =
1719 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1720 }
1721
1722 if let Ok(head_name) = head.name() {
1723 info.upstream = upstream_short_name(&repo, head_name);
1724 }
1725 }
1726
1727 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1728 info.commits = commits;
1729 }
1730
1731 populate_summary_and_file_changes(&repo, &mut info);
1732
1733 if let Ok(remotes) = load_tab_remotes(path) {
1734 info.remotes = TabData::Loaded(remotes);
1735 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1736 }
1737
1738 Ok(info)
1739}
1740
1741pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1742 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1743 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1744 let mut entries = Vec::new();
1745 for (i, entry) in reflog.iter().enumerate() {
1746 let target_oid = format!("{}", entry.id_new());
1747 let selector = format!("HEAD@{{{}}}", i);
1748 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1749
1750 let (command, message) = if let Some(pos) = msg.find(':') {
1751 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1752 } else {
1753 (String::new(), msg)
1754 };
1755
1756 let sig = entry.committer();
1757 let when_secs = sig.when().seconds();
1758 let when = format_relative_time(when_secs);
1759 let date = format_utc_date(when_secs);
1760
1761 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1762 }
1763 Ok(entries)
1764}
1765
1766pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1767 let output = git_command()
1768 .arg("checkout")
1769 .arg(commit_oid)
1770 .current_dir(repo_path)
1771 .output()
1772 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1773
1774 if !output.status.success() {
1775 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1776 return Err(git2::Error::from_str(&err));
1777 }
1778 Ok(())
1779}
1780
1781pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1782 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1783 let mut files = Vec::new();
1784 if let Ok(index) = repo.index() {
1785 for entry in index.iter() {
1786 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1787 files.push(path_str.to_string());
1788 }
1789 }
1790 }
1791 Ok(files)
1792}
1793
1794#[derive(Debug, Clone, Default)]
1796pub struct GraphLoad {
1797 pub lines: Vec<GraphLine>,
1798 pub notice: Option<String>,
1802}
1803
1804pub fn load_tab_graph_stream(
1805 repo_path: &Path,
1806 graph_max_commits: usize,
1807 enable_commit_signatures: bool,
1808 repo_resolved_path: String,
1809 tab_idx: usize,
1810 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1811) -> Result<GraphLoad, String> {
1812 let mut graph_lines = Vec::new();
1813 let format_str = if enable_commit_signatures {
1816 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?"
1817 } else {
1818 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad"
1819 };
1820
1821 let mut args = vec![
1822 "log".to_string(),
1823 "--graph".to_string(),
1824 "--all".to_string(),
1825 "--date=relative".to_string(),
1826 ];
1827 if graph_max_commits > 0 {
1828 args.push(format!("--max-count={}", graph_max_commits));
1829 }
1830 args.push(format!("--pretty=format:{}", format_str));
1831 args.push("--color=never".to_string());
1832
1833 let mut child =
1834 git_command().args(&args).current_dir(repo_path).spawn().map_err(|e| e.to_string())?;
1835
1836 let stderr_reader = spawn_pipe_reader(&mut child);
1839 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1840 let reader = std::io::BufReader::new(stdout);
1841 use std::io::BufRead;
1842
1843 for (idx, line_res) in reader.lines().enumerate() {
1844 let line = line_res.map_err(|e| e.to_string())?;
1845 let parsed = parse_graph_line(&line);
1846 graph_lines.push(parsed);
1847
1848 if (idx + 1) % 200 == 0 {
1850 let _ = tx.send((
1851 repo_resolved_path.clone(),
1852 tab_idx,
1853 TabPayload::Graph(Ok(graph_lines.clone())),
1854 ));
1855 }
1856 }
1857
1858 let status = child.wait().map_err(|e| e.to_string())?;
1860 let stderr = stderr_reader.join().unwrap_or_default();
1861 let notice = summarize_stderr(&stderr);
1862 if !status.success() && graph_lines.is_empty() {
1863 return Err(notice.unwrap_or_else(|| "git log failed".to_string()));
1864 }
1865
1866 Ok(GraphLoad { lines: graph_lines, notice })
1867}
1868
1869pub fn load_tab_branches(
1870 repo_path: &Path,
1871) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1872 let repo = match Repository::open(repo_path) {
1873 Ok(r) => r,
1874 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1875 };
1876
1877 let mut local_branches = Vec::new();
1878 let is_detached = repo.head_detached().unwrap_or(false);
1879 if is_detached {
1880 let mut short_sha = String::new();
1881 let mut short_message = String::new();
1882 if let Ok(head) = repo.head() {
1883 if let Ok(target) = head.peel_to_commit() {
1884 let id = target.id();
1885 let id_str = id.to_string();
1886 short_sha = safe_sha_slice(&id_str, 7).to_string();
1887 if let Ok(Some(summary)) = target.summary() {
1888 short_message = summary.to_string();
1889 }
1890 }
1891 }
1892 local_branches.push(BranchInfo {
1893 name: "HEAD".to_string(),
1894 is_head: true,
1895 short_sha,
1896 short_message: sanitize_text(&short_message),
1897 });
1898 }
1899 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1900 for (branch, _) in branches.flatten() {
1901 if let Ok(Some(name)) = branch.name() {
1902 let is_head = branch.is_head();
1903 let mut short_sha = String::new();
1904 let mut short_message = String::new();
1905 if let Ok(target) = branch.get().peel_to_commit() {
1906 let id = target.id();
1907 let id_str = id.to_string();
1908 short_sha = safe_sha_slice(&id_str, 7).to_string();
1909 if let Ok(Some(summary)) = target.summary() {
1910 short_message = summary.to_string();
1911 }
1912 }
1913 local_branches.push(BranchInfo {
1914 name: sanitize_text(name),
1915 is_head,
1916 short_sha,
1917 short_message: sanitize_text(&short_message),
1918 });
1919 }
1920 }
1921 }
1922 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1923
1924 let mut remote_branches = Vec::new();
1925 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1926 for (branch, _) in branches.flatten() {
1927 if let Ok(Some(name)) = branch.name() {
1928 if !name.ends_with("/HEAD") {
1929 let is_head = branch.is_head();
1930 let mut short_sha = String::new();
1931 let mut short_message = String::new();
1932 if let Ok(target) = branch.get().peel_to_commit() {
1933 let id = target.id();
1934 let id_str = id.to_string();
1935 short_sha = safe_sha_slice(&id_str, 7).to_string();
1936 if let Ok(Some(summary)) = target.summary() {
1937 short_message = summary.to_string();
1938 }
1939 }
1940 remote_branches.push(BranchInfo {
1941 name: sanitize_text(name),
1942 is_head,
1943 short_sha,
1944 short_message: sanitize_text(&short_message),
1945 });
1946 }
1947 }
1948 }
1949 }
1950 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1951
1952 (Ok(local_branches), Ok(remote_branches))
1953}
1954
1955pub fn load_tab_tags(
1956 repo_path: &Path,
1957) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1958 let repo = match Repository::open(repo_path) {
1959 Ok(r) => r,
1960 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1961 };
1962
1963 let mut local_tags = Vec::new();
1964 if let Ok(tags) = repo.tag_names(None) {
1965 for tag_opt in tags.iter() {
1966 if let Ok(Some(tag)) = tag_opt {
1967 let mut short_sha = String::new();
1968 let mut short_message = String::new();
1969 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1970 if let Ok(target) = reference.peel_to_commit() {
1971 let id = target.id();
1972 let id_str = id.to_string();
1973 short_sha = safe_sha_slice(&id_str, 7).to_string();
1974 if let Ok(Some(summary)) = target.summary() {
1975 short_message = summary.to_string();
1976 }
1977 }
1978 }
1979 local_tags.push(BranchInfo {
1980 name: sanitize_text(tag),
1981 is_head: false,
1982 short_sha,
1983 short_message: sanitize_text(&short_message),
1984 });
1985 }
1986 }
1987 }
1988 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1989
1990 (Ok(local_tags), Ok(Vec::new()))
1991}
1992
1993pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1994 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1995 let mut remotes_list = Vec::new();
1996 if let Ok(remotes) = repo.remotes() {
1997 for name in remotes.iter() {
1998 let Ok(Some(name)) = name else { continue };
1999 if let Ok(remote) = repo.find_remote(name) {
2000 let push_url = remote.pushurl().ok().flatten().map(String::from);
2001 let mut refspecs = Vec::new();
2002 for r in remote.refspecs() {
2003 if let Ok(s) = r.str() {
2004 refspecs.push(s.to_string());
2005 }
2006 }
2007 remotes_list.push(RemoteInfo {
2008 name: name.to_string(),
2009 url: remote.url().unwrap_or("(no url)").to_string(),
2010 push_url,
2011 refspecs,
2012 });
2013 }
2014 }
2015 }
2016 Ok(remotes_list)
2017}
2018
2019pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
2020 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2021 let mut temp_stashes = Vec::new();
2022 let _ = repo.stash_foreach(|index, message, oid| {
2023 temp_stashes.push((index, message.to_string(), *oid));
2024 true
2025 });
2026
2027 let mut stashes = Vec::new();
2028 for (index, message, oid) in temp_stashes {
2029 let mut files = Vec::new();
2030 if let Ok(commit) = repo.find_commit(oid) {
2031 files = commit_changed_files(&repo, &commit);
2032 }
2033 stashes.push(StashInfo {
2034 index,
2035 message: sanitize_text(&message),
2036 commit_id: oid.to_string(),
2037 files,
2038 });
2039 }
2040 Ok(stashes)
2041}
2042
2043pub fn load_tab_overview(
2044 repo_path: &Path,
2045 _commit_limit: usize,
2046) -> Result<(Vec<CommitterStat>, bool), String> {
2047 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2048 let (stats, limit_reached) =
2049 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
2050 Ok((stats, limit_reached))
2051}
2052
2053pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
2054 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2055 let submodules = repo.submodules().map_err(|e| e.to_string())?;
2056 let mut list = Vec::new();
2057 for sub in submodules {
2058 let name = sub.name().unwrap_or("").to_string();
2059 let path = sub.path().to_path_buf();
2060 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
2061 let commit_id = sub.index_id().map(|id| id.to_string());
2062 let head_id = sub.head_id().map(|id| id.to_string());
2063
2064 let mut is_initialized = true;
2065 let mut is_dirty = false;
2066
2067 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
2068 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
2069 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
2070 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
2071 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
2072 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
2073 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
2074 }
2075
2076 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
2077 }
2078 Ok(list)
2079}
2080
2081pub fn submodule_remove(repo_path: &Path, name: &str, path: &Path) -> Result<(), String> {
2093 let plain_relative = |p: &Path| {
2094 !p.as_os_str().is_empty()
2095 && p.components().all(|c| matches!(c, std::path::Component::Normal(_)))
2096 };
2097 if !plain_relative(path) {
2098 return Err(format!("Refusing to remove submodule at unsafe path '{}'", path.display()));
2099 }
2100 if !plain_relative(Path::new(name)) {
2101 return Err(format!("Refusing to remove submodule with unsafe name '{}'", name));
2102 }
2103 let path_str = path.to_str().ok_or("Submodule path is not valid UTF-8")?;
2104
2105 let run = |args: &[&str]| -> Result<Vec<u8>, String> {
2106 let output = git_command()
2107 .arg("--literal-pathspecs")
2108 .args(args)
2109 .current_dir(repo_path)
2110 .output()
2111 .map_err(|e| e.to_string())?;
2112 if !output.status.success() {
2113 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
2114 }
2115 Ok(output.stdout)
2116 };
2117
2118 let staged = run(&["ls-files", "--stage", "-z", "--", path_str])?;
2121 let entries: Vec<&[u8]> = staged.split(|b| *b == 0).filter(|e| !e.is_empty()).collect();
2122 let is_gitlink = match entries.as_slice() {
2123 [entry] => {
2124 let entry = String::from_utf8_lossy(entry);
2125 entry.starts_with("160000 ")
2126 && entry.split_once('\t').is_some_and(|(_, p)| p == path_str)
2127 }
2128 _ => false,
2129 };
2130 if !is_gitlink {
2131 return Err(format!("'{}' is not a submodule in the index; nothing was removed", path_str));
2132 }
2133
2134 run(&["submodule", "deinit", "-f", "--", path_str])?;
2135 run(&["rm", "-f", "--", path_str])?;
2136
2137 let module_path = run(&["rev-parse", "--git-path", &format!("modules/{}", name)])?;
2140 let module_dir = repo_path.join(String::from_utf8_lossy(&module_path).trim());
2141 if module_dir.is_dir() {
2142 std::fs::remove_dir_all(&module_dir).map_err(|e| {
2143 format!(
2144 "Submodule removed, but its module directory {} could not be deleted: {}",
2145 module_dir.display(),
2146 e
2147 )
2148 })?;
2149 }
2150 Ok(())
2151}
2152
2153pub fn submodule_update(
2161 repo_path: &Path,
2162 path: Option<&Path>,
2163 timeout: Duration,
2164) -> Result<(), String> {
2165 let mut cmd = git_command();
2166 cmd.args(["--literal-pathspecs", "submodule", "update", "--init", "--recursive"])
2167 .current_dir(repo_path);
2168 if let Some(path) = path {
2169 cmd.arg("--").arg(path);
2170 }
2171 let output = run_git_with_timeout(cmd, timeout).map_err(|e| e.to_string())?;
2172 if !output.status.success() {
2173 return Err(String::from_utf8_lossy(&output.stderr).trim().replace('\n', " "));
2174 }
2175 Ok(())
2176}
2177
2178fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2183 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
2184 std::collections::HashMap::new();
2185
2186 if let Ok(refs) = repo.references() {
2187 for reference in refs.flatten() {
2188 let Ok(target) = reference.peel_to_commit() else {
2190 continue;
2191 };
2192 let oid = target.id();
2193
2194 let Ok(full_name) = reference.name() else {
2195 continue;
2196 };
2197
2198 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
2199 branch.to_string()
2200 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
2201 format!("tag:{}", tag)
2202 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
2203 if remote.ends_with("/HEAD") {
2205 continue;
2206 }
2207 format!("remote:{}", remote)
2208 } else {
2209 continue;
2210 };
2211
2212 map.entry(oid).or_default().push(label);
2213 }
2214 }
2215
2216 if repo.head_detached().unwrap_or(false) {
2217 if let Ok(head) = repo.head() {
2218 if let Ok(target) = head.peel_to_commit() {
2219 let oid = target.id();
2220 let label = "head:HEAD".to_string();
2221 let mut entries = map.remove(&oid).unwrap_or_default();
2222 entries.insert(0, label);
2223 map.insert(oid, entries);
2224 }
2225 }
2226 }
2227
2228 map
2229}
2230
2231#[allow(clippy::type_complexity)]
2232static REF_MAP_CACHE: std::sync::OnceLock<
2233 std::sync::Mutex<
2234 std::collections::HashMap<
2235 String,
2236 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
2237 >,
2238 >,
2239> = std::sync::OnceLock::new();
2240
2241fn get_cached_ref_map(
2242 repo: &Repository,
2243 repo_path: &Path,
2244) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2245 let cache_lock =
2246 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
2247 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
2248 let path_key = repo_path.to_string_lossy().to_string();
2249
2250 if let Some((map, loaded_at)) = cache.get(&path_key) {
2251 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
2252 return map.clone();
2253 }
2254 }
2255
2256 let map = build_ref_map(repo);
2257 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
2258 map
2259}
2260
2261pub fn invalidate_ref_map_cache(repo_path: &Path) {
2262 if let Some(cache_lock) = REF_MAP_CACHE.get() {
2263 if let Ok(mut cache) = cache_lock.lock() {
2264 cache.remove(&repo_path.to_string_lossy().to_string());
2265 }
2266 }
2267}
2268
2269const MAX_FILES_PER_SECTION: usize = 100;
2272
2273fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
2275 let mut opts = StatusOptions::new();
2276 opts.include_untracked(true)
2277 .renames_head_to_index(true)
2278 .recurse_untracked_dirs(true)
2279 .show(StatusShow::IndexAndWorkdir);
2280 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2281 return;
2282 };
2283 for entry in statuses.iter() {
2284 let path = entry.path().unwrap_or("(unknown)").to_string();
2285 let flags = entry.status();
2286
2287 if flags.is_conflicted() {
2289 info.summary.conflicted += 1;
2290 } else {
2291 if flags.is_wt_new() {
2292 info.summary.untracked += 1;
2293 }
2294 if flags.is_wt_modified()
2295 || flags.is_wt_deleted()
2296 || flags.is_wt_renamed()
2297 || flags.is_wt_typechange()
2298 {
2299 info.summary.modified += 1;
2300 }
2301 if flags.is_index_new()
2302 || flags.is_index_modified()
2303 || flags.is_index_deleted()
2304 || flags.is_index_renamed()
2305 || flags.is_index_typechange()
2306 {
2307 info.summary.staged += 1;
2308 }
2309 }
2310
2311 let is_dir = if flags.is_wt_new() && !flags.is_index_new() {
2315 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2316 path_buf.is_dir()
2317 } else {
2318 false
2319 };
2320 if is_dir {
2321 continue;
2322 }
2323
2324 if flags.is_conflicted() {
2325 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2326 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2327 }
2328 continue;
2329 }
2330
2331 if (flags.is_index_new()
2333 || flags.is_index_modified()
2334 || flags.is_index_deleted()
2335 || flags.is_index_renamed()
2336 || flags.is_index_typechange())
2337 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2338 {
2339 let label = if flags.is_index_new() {
2340 "N"
2341 } else if flags.is_index_deleted() {
2342 "D"
2343 } else if flags.is_index_renamed() {
2344 "R"
2345 } else if flags.is_index_typechange() {
2346 "T"
2347 } else {
2348 "M"
2349 };
2350 info.changes.staged.push(FileEntry { path: path.clone(), label });
2351 }
2352
2353 if flags.is_wt_new() {
2355 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2356 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2357 }
2358 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2359 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2360 }
2361 } else if (flags.is_wt_modified()
2362 || flags.is_wt_deleted()
2363 || flags.is_wt_renamed()
2364 || flags.is_wt_typechange())
2365 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2366 {
2367 let label = if flags.is_wt_deleted() {
2368 "D"
2369 } else if flags.is_wt_renamed() {
2370 "R"
2371 } else if flags.is_wt_typechange() {
2372 "T"
2373 } else {
2374 "M"
2375 };
2376 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2377 }
2378 }
2379}
2380
2381fn collect_summary(repo: &Repository) -> RepoSummary {
2385 let mut s = RepoSummary::default();
2386 let is_detached = repo.head_detached().unwrap_or(false);
2387 if is_detached {
2388 s.branch = Some("HEAD".to_string());
2389 if let Ok(head) = repo.head() {
2390 if let Ok(commit) = head.peel_to_commit() {
2391 s.last_commit_time = Some(commit.time().seconds());
2392 }
2393 }
2394 } else if let Ok(head) = repo.head() {
2395 s.branch = head.shorthand().ok().map(String::from);
2396 if let Ok(commit) = head.peel_to_commit() {
2397 s.last_commit_time = Some(commit.time().seconds());
2398 }
2399 }
2400 populate_worktree(repo, &mut s);
2401 populate_ahead_behind(repo, &mut s);
2402 s.state = match repo.state() {
2403 git2::RepositoryState::Clean => RepoState::Clean,
2404 git2::RepositoryState::Merge => RepoState::Merge,
2405 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2406 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2407 RepoState::CherryPick
2408 }
2409 git2::RepositoryState::Bisect => RepoState::Bisect,
2410 git2::RepositoryState::Rebase
2411 | git2::RepositoryState::RebaseInteractive
2412 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2413 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2414 RepoState::ApplyMailbox
2415 }
2416 };
2417 s
2418}
2419
2420fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2421 let mut opts = StatusOptions::new();
2422 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2423 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2424 return;
2425 };
2426 for entry in statuses.iter() {
2427 let flags = entry.status();
2428 if flags.is_conflicted() {
2429 s.conflicted += 1;
2430 continue;
2431 }
2432 if flags.is_wt_new() {
2433 s.untracked += 1;
2434 }
2435 if flags.is_wt_modified()
2436 || flags.is_wt_deleted()
2437 || flags.is_wt_renamed()
2438 || flags.is_wt_typechange()
2439 {
2440 s.modified += 1;
2441 }
2442 if flags.is_index_new()
2443 || flags.is_index_modified()
2444 || flags.is_index_deleted()
2445 || flags.is_index_renamed()
2446 || flags.is_index_typechange()
2447 {
2448 s.staged += 1;
2449 }
2450 }
2451}
2452
2453fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2457 let Ok(head) = repo.head() else { return };
2458 let Some(local_oid) = head.target() else {
2459 return;
2460 };
2461 let Ok(head_name) = head.name() else { return };
2462 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2463 return;
2464 };
2465 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2466 return;
2467 };
2468 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2469 return;
2470 };
2471 let Some(upstream_oid) = upstream_ref.target() else {
2472 return;
2473 };
2474 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2475 s.ahead = ahead;
2476 s.behind = behind;
2477 }
2478}
2479
2480fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2482 let buf = repo.branch_upstream_name(head_name).ok()?;
2483 let raw = std::str::from_utf8(&buf).ok()?;
2484 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2485}
2486
2487pub fn format_relative_time(secs: i64) -> String {
2489 if secs <= 0 {
2490 return "unknown".to_string();
2491 }
2492 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2493 let now = SystemTime::now();
2494 let Ok(elapsed) = now.duration_since(then) else {
2495 return "in the future".to_string();
2496 };
2497 let secs = elapsed.as_secs();
2498 let (n, unit) = if secs < 60 {
2499 (secs, "second")
2500 } else if secs < 3600 {
2501 (secs / 60, "minute")
2502 } else if secs < 86_400 {
2503 (secs / 3600, "hour")
2504 } else if secs < 86_400 * 30 {
2505 (secs / 86_400, "day")
2506 } else if secs < 86_400 * 365 {
2507 (secs / (86_400 * 30), "month")
2508 } else {
2509 (secs / (86_400 * 365), "year")
2510 };
2511 let plural = if n == 1 { "" } else { "s" };
2512 format!("{} {}{} ago", n, unit, plural)
2513}
2514
2515fn format_utc_date(secs: i64) -> String {
2517 if secs <= 0 {
2518 return "unknown".to_string();
2519 }
2520 let seconds_in_day = 86400;
2521 let day_number = secs / seconds_in_day;
2522 let time_of_day = secs % seconds_in_day;
2523
2524 let mut hour = time_of_day / 3600;
2525 let mut minute = (time_of_day % 3600) / 60;
2526 let mut second = time_of_day % 60;
2527 if hour < 0 {
2528 hour += 24;
2529 }
2530 if minute < 0 {
2531 minute += 60;
2532 }
2533 if second < 0 {
2534 second += 60;
2535 }
2536
2537 let z = day_number + 719468;
2539 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2540 let doe = (z - era * 146097) as u32;
2541 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2542 let y = (yoe as i32) + (era as i32) * 400;
2543 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2544 let mp = (5 * doy + 2) / 153;
2545 let d = doy - (153 * mp + 2) / 5 + 1;
2546 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2547 let y = y + if m <= 2 { 1 } else { 0 };
2548
2549 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2550}
2551
2552fn get_file_diff_inner(
2555 repo_path: &Path,
2556 commit_oid: &str,
2557 file_path: &str,
2558) -> Option<Vec<DiffLine>> {
2559 let repo = Repository::open(repo_path).ok()?;
2560 let oid = git2::Oid::from_str(commit_oid).ok()?;
2561 let commit = repo.find_commit(oid).ok()?;
2562
2563 let commit_tree = commit.tree().ok()?;
2564 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2566
2567 let mut opts = git2::DiffOptions::new();
2568 opts.pathspec(file_path);
2569
2570 let diff =
2571 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2572
2573 collect_diff_lines(&diff)
2574}
2575
2576fn get_worktree_diff_inner(
2581 repo_path: &Path,
2582 file_path: &str,
2583 staged: bool,
2584) -> Option<Vec<DiffLine>> {
2585 let repo = Repository::open(repo_path).ok()?;
2586 let mut opts = git2::DiffOptions::new();
2587 opts.pathspec(file_path);
2588 opts.include_untracked(true);
2589 opts.recurse_untracked_dirs(true);
2590 opts.show_untracked_content(true);
2591
2592 let diff = if staged {
2593 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2595 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2596 } else {
2597 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2599 };
2600
2601 collect_diff_lines(&diff)
2602}
2603
2604fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2606 let mut lines: Vec<DiffLine> = Vec::new();
2607 let mut current_hunk_idx = None;
2608 let mut hunk_count = 0;
2609 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2610 let kind = match line.origin() {
2611 '+' => DiffLineKind::Added,
2612 '-' => DiffLineKind::Removed,
2613 'H' => {
2614 current_hunk_idx = Some(hunk_count);
2615 hunk_count += 1;
2616 DiffLineKind::Header
2617 }
2618 ' ' => DiffLineKind::Context,
2619 _ => return true, };
2621 let content = String::from_utf8_lossy(line.content())
2622 .trim_end_matches('\n')
2623 .trim_end_matches('\r')
2624 .to_string();
2625 lines.push(DiffLine {
2626 kind,
2627 content,
2628 old_lineno: line.old_lineno(),
2629 new_lineno: line.new_lineno(),
2630 hunk_idx: current_hunk_idx,
2631 });
2632 true
2633 })
2634 .ok()?;
2635 Some(lines)
2636}
2637
2638fn parse_graph_line(line: &str) -> GraphLine {
2639 if line.contains("__TWIG_SEP__") {
2640 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2641 if parts.len() >= 5 {
2642 let graph_and_hash = parts[0];
2643 let decoration = parts[1].trim().to_string();
2644 let summary = parts[2].trim().to_string();
2645 let author = parts[3].trim().to_string();
2646 let date = parts[4].trim().to_string();
2647 let signature_status =
2648 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2649
2650 let char_count = graph_and_hash.chars().count();
2651 if char_count >= 40 {
2652 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2653 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2654 GraphLine {
2655 graph,
2656 commit: Some(GraphCommit {
2657 oid,
2658 decoration,
2659 summary,
2660 author,
2661 date,
2662 signature_status,
2663 }),
2664 }
2665 } else {
2666 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2667 }
2668 } else {
2669 GraphLine { graph: line.to_string(), commit: None }
2670 }
2671 } else {
2672 GraphLine { graph: line.to_string(), commit: None }
2673 }
2674}
2675
2676#[allow(dead_code)]
2677fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2678 let mut graph_lines = Vec::new();
2679 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2680
2681 let mut args = vec![
2682 "log".to_string(),
2683 "--graph".to_string(),
2684 "--all".to_string(),
2685 "--date=relative".to_string(),
2686 ];
2687 if graph_max_commits > 0 {
2688 args.push(format!("--max-count={}", graph_max_commits));
2689 }
2690 args.push(format!("--pretty=format:{}", format_str));
2691 args.push("--color=never".to_string());
2692
2693 let output = git_command().args(&args).current_dir(repo_path).output();
2694
2695 if let Ok(out) = output {
2696 if out.status.success() {
2697 let stdout_str = String::from_utf8_lossy(&out.stdout);
2698 for line in stdout_str.lines() {
2699 graph_lines.push(parse_graph_line(line));
2700 }
2701 }
2702 }
2703 graph_lines
2704}
2705
2706pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2707 let output = git_command()
2708 .arg("checkout")
2709 .arg(branch_name)
2710 .current_dir(repo_path)
2711 .output()
2712 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2713
2714 if !output.status.success() {
2715 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2716 return Err(git2::Error::from_str(&err));
2717 }
2718 Ok(())
2719}
2720
2721pub fn checkout_remote_branch(
2722 repo_path: &Path,
2723 remote_branch_name: &str,
2724) -> Result<String, git2::Error> {
2725 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2726 if parts.len() < 2 {
2727 return Err(git2::Error::from_str("Invalid remote branch name"));
2728 }
2729 let local_name = parts[1];
2730
2731 let output = git_command()
2732 .arg("checkout")
2733 .arg(local_name)
2734 .current_dir(repo_path)
2735 .output()
2736 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2737
2738 if output.status.success() {
2739 return Ok(format!("Switched to existing branch '{}'", local_name));
2740 }
2741
2742 let output = git_command()
2743 .arg("checkout")
2744 .arg("--track")
2745 .arg(remote_branch_name)
2746 .current_dir(repo_path)
2747 .output()
2748 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2749
2750 if !output.status.success() {
2751 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2752 return Err(git2::Error::from_str(&err));
2753 }
2754
2755 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2756}
2757
2758pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2760 let repo = Repository::open(repo_path)?;
2761 let head = repo.head()?;
2762 let target_commit = head.peel_to_commit()?;
2763 repo.branch(branch_name, &target_commit, false)?;
2764 Ok(())
2765}
2766
2767pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2769 let repo = Repository::open(repo_path)?;
2770 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2771 branch.delete()?;
2772 Ok(())
2773}
2774
2775pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2777 let repo = Repository::open(repo_path)?;
2778 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2779 branch.delete()?;
2780 Ok(())
2781}
2782
2783pub fn create_tag(
2785 repo_path: &Path,
2786 tag_name: &str,
2787 commit_oid_str: &str,
2788 message: Option<&str>,
2789 force: bool,
2790) -> Result<(), git2::Error> {
2791 let repo = Repository::open(repo_path)?;
2792 let oid = git2::Oid::from_str(commit_oid_str)?;
2793 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2794 if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2795 let tagger_sig = repo.signature();
2796 let default_sig;
2797 let tagger = match &tagger_sig {
2798 Ok(s) => s,
2799 Err(_) => {
2800 if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2801 default_sig = sig;
2802 &default_sig
2803 } else {
2804 return Err(git2::Error::from_str("Failed to create signature"));
2805 }
2806 }
2807 };
2808 repo.tag(tag_name, &target_object, tagger, msg, force)?;
2809 } else {
2810 repo.tag_lightweight(tag_name, &target_object, force)?;
2811 }
2812 Ok(())
2813}
2814
2815pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2817 if let Ok(repo) = Repository::open(repo_path) {
2818 let ref_name = format!("refs/tags/{}", tag_name);
2819 repo.find_reference(&ref_name).is_ok()
2820 } else {
2821 false
2822 }
2823}
2824
2825pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2827 let repo = Repository::open(repo_path)?;
2828 repo.tag_delete(tag_name)?;
2829 Ok(())
2830}
2831
2832pub fn delete_remote_tag(
2834 repo_path: &Path,
2835 remote_name: &str,
2836 tag_name: &str,
2837 timeout: Duration,
2838) -> Result<(), Box<dyn std::error::Error>> {
2839 let safe_remote = safe_ref(remote_name)?;
2840 let safe_tag = safe_ref(tag_name)?;
2841 let mut cmd = git_command();
2842 cmd.arg("push").arg(safe_remote).arg("--delete").arg(safe_tag).current_dir(repo_path);
2843 let output = run_git_with_timeout(cmd, timeout)?;
2844 if !output.status.success() {
2845 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2846 return Err(err.into());
2847 }
2848 Ok(())
2849}
2850
2851pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2852 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2853 let output = git_command()
2854 .arg("checkout")
2855 .arg(safe_tag)
2856 .current_dir(repo_path)
2857 .output()
2858 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2859
2860 if !output.status.success() {
2861 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2862 return Err(git2::Error::from_str(&err));
2863 }
2864 Ok(())
2865}
2866
2867pub fn get_remote_tags(
2869 repo_path: &Path,
2870 remote_name: &str,
2871 timeout: Duration,
2872) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2873 let safe_remote = safe_ref(remote_name)?;
2874 let mut cmd = git_command();
2875 cmd.arg("ls-remote").arg("--tags").arg(safe_remote).current_dir(repo_path);
2876 let output = run_git_with_timeout(cmd, timeout)?;
2877
2878 if !output.status.success() {
2879 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2880 return Err(err.into());
2881 }
2882
2883 let stdout = String::from_utf8_lossy(&output.stdout);
2884 let repo = git2::Repository::open(repo_path)?;
2885 let mut tags_map = std::collections::HashMap::new();
2886
2887 for line in stdout.lines() {
2888 let parts: Vec<&str> = line.split_whitespace().collect();
2889 if parts.len() >= 2 {
2890 let sha = parts[0];
2891 let ref_name = parts[1];
2892 if ref_name.starts_with("refs/tags/") {
2893 let is_peeled = ref_name.ends_with("^{}");
2894 let clean_ref = if is_peeled {
2895 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2896 } else {
2897 ref_name
2898 };
2899 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2900 let short_sha = safe_sha_slice(sha, 7);
2901
2902 let mut short_message = String::new();
2904 if let Ok(oid) = git2::Oid::from_str(sha) {
2905 if let Ok(commit) = repo.find_commit(oid) {
2906 if let Ok(Some(summary)) = commit.summary() {
2907 short_message = summary.to_string();
2908 }
2909 }
2910 }
2911 if short_message.is_empty() {
2912 short_message = "(not fetched)".to_string();
2913 }
2914
2915 let sanitized_tag = sanitize_text(tag_name);
2916 let sanitized_msg = sanitize_text(&short_message);
2917
2918 if is_peeled {
2919 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2920 } else {
2921 tags_map
2922 .entry(sanitized_tag)
2923 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2924 }
2925 }
2926 }
2927 }
2928
2929 let mut tags = Vec::new();
2930 for (name, (short_sha, short_message)) in tags_map {
2931 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2932 }
2933 tags.sort_by(|a, b| b.name.cmp(&a.name));
2934 Ok(tags)
2935}
2936
2937pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2938 let mut s = String::new();
2939 for tag in tags {
2940 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2941 }
2942 s
2943}
2944
2945pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2946 let mut tags = Vec::new();
2947 for line in s.lines() {
2948 let parts: Vec<&str> = line.split('|').collect();
2949 if parts.len() >= 3 {
2950 tags.push(BranchInfo {
2951 name: parts[0].to_string(),
2952 is_head: false,
2953 short_sha: parts[1].to_string(),
2954 short_message: parts[2].to_string(),
2955 });
2956 }
2957 }
2958 tags
2959}
2960
2961pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2962 let mut repo = Repository::open(repo_path)?;
2963 repo.stash_drop(index)?;
2964 Ok(())
2965}
2966
2967pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2968 let stash_ref = format!("stash@{{{}}}", index);
2969 let output = git_command()
2970 .arg("stash")
2971 .arg("apply")
2972 .arg(&stash_ref)
2973 .current_dir(repo_path)
2974 .output()
2975 .map_err(|e| e.to_string())?;
2976
2977 if !output.status.success() {
2978 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2979 return Err(err_msg);
2980 }
2981 Ok(())
2982}
2983
2984pub fn save_stash(
2985 repo_path: &Path,
2986 message: &str,
2987 include_untracked: bool,
2988 keep_index: bool,
2989) -> Result<(), String> {
2990 let mut cmd = git_command();
2991 cmd.arg("stash").arg("push");
2992
2993 if include_untracked {
2994 cmd.arg("--include-untracked");
2995 }
2996 if keep_index {
2997 cmd.arg("--keep-index");
2998 }
2999
3000 if !message.is_empty() {
3001 cmd.arg("-m").arg(message);
3002 }
3003
3004 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
3005
3006 if !output.status.success() {
3007 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3008 return Err(err_msg);
3009 }
3010 Ok(())
3011}
3012
3013pub fn get_latest_change_time(item: &str) -> u64 {
3014 let path = expand_tilde(item);
3015 if !path.exists() {
3016 return 0;
3017 }
3018
3019 if path.join(".git").exists() {
3020 if let Ok(repo) = Repository::open(&path) {
3021 if let Ok(head) = repo.head() {
3022 if let Ok(commit) = head.peel_to_commit() {
3023 return commit.time().seconds() as u64;
3024 }
3025 }
3026 }
3027 }
3028
3029 if let Ok(meta) = std::fs::metadata(&path) {
3030 if let Ok(modified) = meta.modified() {
3031 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
3032 return duration.as_secs();
3033 }
3034 }
3035 }
3036 0
3037}
3038
3039pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
3040 if let Ok(repo) = Repository::open(repo_path) {
3041 if let Ok(head) = repo.head() {
3042 if let Ok(commit) = head.peel_to_commit() {
3043 if let Ok(msg) = commit.message() {
3044 return Some(msg.to_string());
3045 }
3046 }
3047 }
3048 }
3049 None
3050}
3051
3052pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
3053 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3054 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
3055 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
3056
3057 let mut index = repo.index().map_err(|e| e.to_string())?;
3058 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
3059 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
3060
3061 let signature = repo
3062 .signature()
3063 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
3064
3065 head_commit
3066 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
3067 .map_err(|e| e.to_string())?;
3068
3069 Ok(())
3070}
3071
3072pub fn is_merging(repo_path: &Path) -> bool {
3077 repo_path.join(".git/MERGE_HEAD").exists()
3078}
3079
3080pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
3083 let full_path = repo_path.join(file_path);
3084 let content = match std::fs::read_to_string(&full_path) {
3085 Ok(s) => s,
3086 Err(_) => return Vec::new(),
3087 };
3088
3089 let mut lines = Vec::new();
3090 let mut in_ours = false;
3091 let mut in_theirs = false;
3092
3093 for line in content.lines() {
3094 if line.starts_with("<<<<<<<") {
3095 in_ours = true;
3096 in_theirs = false;
3097 lines.push(DiffLine {
3098 kind: DiffLineKind::ConflictSeparator,
3099 content: line.to_string(),
3100 old_lineno: None,
3101 new_lineno: None,
3102 hunk_idx: None,
3103 });
3104 } else if line.starts_with("=======") {
3105 in_ours = false;
3106 in_theirs = true;
3107 lines.push(DiffLine {
3108 kind: DiffLineKind::ConflictSeparator,
3109 content: line.to_string(),
3110 old_lineno: None,
3111 new_lineno: None,
3112 hunk_idx: None,
3113 });
3114 } else if line.starts_with(">>>>>>>") {
3115 in_ours = false;
3116 in_theirs = false;
3117 lines.push(DiffLine {
3118 kind: DiffLineKind::ConflictSeparator,
3119 content: line.to_string(),
3120 old_lineno: None,
3121 new_lineno: None,
3122 hunk_idx: None,
3123 });
3124 } else if in_ours {
3125 lines.push(DiffLine {
3126 kind: DiffLineKind::ConflictOurs,
3127 content: line.to_string(),
3128 old_lineno: None,
3129 new_lineno: None,
3130 hunk_idx: None,
3131 });
3132 } else if in_theirs {
3133 lines.push(DiffLine {
3134 kind: DiffLineKind::ConflictTheirs,
3135 content: line.to_string(),
3136 old_lineno: None,
3137 new_lineno: None,
3138 hunk_idx: None,
3139 });
3140 } else {
3141 lines.push(DiffLine {
3142 kind: DiffLineKind::Context,
3143 content: line.to_string(),
3144 old_lineno: None,
3145 new_lineno: None,
3146 hunk_idx: None,
3147 });
3148 }
3149 }
3150 lines
3151}
3152
3153pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
3156 let output1 = git_command()
3157 .args(["checkout", "--ours", file_path])
3158 .current_dir(repo_path)
3159 .output()
3160 .map_err(|e| e.to_string())?;
3161 if !output1.status.success() {
3162 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3163 }
3164 stage_file(repo_path, file_path)?;
3165 Ok(())
3166}
3167
3168pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
3171 let output1 = git_command()
3172 .args(["checkout", "--theirs", file_path])
3173 .current_dir(repo_path)
3174 .output()
3175 .map_err(|e| e.to_string())?;
3176 if !output1.status.success() {
3177 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3178 }
3179 stage_file(repo_path, file_path)?;
3180 Ok(())
3181}
3182
3183pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
3185 stage_file(repo_path, file_path)
3186}
3187
3188pub fn resolve_conflict_hunk(
3192 repo_path: &Path,
3193 file_path: &str,
3194 hunk_idx: usize,
3195 accept_ours: bool,
3196) -> Result<(), String> {
3197 let full_path = repo_path.join(file_path);
3198 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3199
3200 let mut new_lines = Vec::new();
3201 let mut lines_iter = content.lines().peekable();
3202 let mut current_hunk_idx = 0;
3203
3204 while let Some(line) = lines_iter.next() {
3205 if line.starts_with("<<<<<<<") {
3206 let mut ours_block = Vec::new();
3207 let mut theirs_block = Vec::new();
3208
3209 let mut found_separator = false;
3211 while let Some(&next_line) = lines_iter.peek() {
3212 if next_line.starts_with("=======") {
3213 lines_iter.next(); found_separator = true;
3215 break;
3216 }
3217 if let Some(line) = lines_iter.next() {
3218 ours_block.push(line.to_string());
3219 }
3220 }
3221
3222 let mut found_end = false;
3224 let mut end_line_marker = ">>>>>>>".to_string();
3225 while let Some(&next_line) = lines_iter.peek() {
3226 if next_line.starts_with(">>>>>>>") {
3227 if let Some(marker) = lines_iter.next() {
3228 end_line_marker = marker.to_string(); }
3230 found_end = true;
3231 break;
3232 }
3233 if let Some(line) = lines_iter.next() {
3234 theirs_block.push(line.to_string());
3235 }
3236 }
3237
3238 if current_hunk_idx == hunk_idx {
3239 if accept_ours {
3240 new_lines.extend(ours_block);
3241 } else {
3242 new_lines.extend(theirs_block);
3243 }
3244 } else {
3245 new_lines.push(line.to_string());
3246 new_lines.extend(ours_block);
3247 if found_separator {
3248 new_lines.push("=======".to_string());
3249 }
3250 new_lines.extend(theirs_block);
3251 if found_end {
3252 new_lines.push(end_line_marker);
3253 }
3254 }
3255
3256 current_hunk_idx += 1;
3257 } else {
3258 new_lines.push(line.to_string());
3259 }
3260 }
3261
3262 let mut new_content = new_lines.join("\n");
3263 if content.ends_with('\n') && !new_content.ends_with('\n') {
3264 new_content.push('\n');
3265 }
3266 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3267
3268 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3270 let has_conflict_markers = updated_content
3271 .lines()
3272 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3273
3274 if !has_conflict_markers {
3275 stage_file(repo_path, file_path)?;
3276 }
3277
3278 Ok(())
3279}
3280
3281pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3283 let output = git_command()
3284 .args(["merge", "--abort"])
3285 .current_dir(repo_path)
3286 .output()
3287 .map_err(|e| e.to_string())?;
3288 if !output.status.success() {
3289 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3290 }
3291 Ok(())
3292}
3293
3294pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3296 let output = git_command()
3297 .args(["merge", "--continue"])
3298 .env("GIT_EDITOR", "true")
3299 .current_dir(repo_path)
3300 .output()
3301 .map_err(|e| e.to_string())?;
3302 if !output.status.success() {
3303 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3304 }
3305 Ok(())
3306}
3307
3308pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3310 let repo = Repository::open(repo_path).ok()?;
3311 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3312 let _upstream = branch.upstream().ok()?;
3313 let local_ref = branch.get().name().ok()?;
3314 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3315 remote_buf.as_str().ok().map(|s| s.to_string())
3316}
3317
3318pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3320 get_branch_upstream_remote(repo_path, branch_name).is_some()
3321}
3322
3323pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3325 let repo = Repository::open(repo_path).ok()?;
3326 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3327 if branch.upstream().is_ok() {
3328 if let Ok(local_ref) = branch.get().name() {
3329 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3330 if let Ok(name) = remote_buf.as_str() {
3331 return Some((name.to_string(), false));
3332 }
3333 }
3334 }
3335 }
3336 let remotes = repo.remotes().ok()?;
3337 let first_remote = remotes.iter().next()?.ok()??.to_string();
3338 Some((first_remote, true))
3339}
3340
3341pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3343 if let Ok(repo) = Repository::open(repo_path) {
3344 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3345 if let Ok(commit) = repo.find_commit(oid) {
3346 return commit.parent_count() == 0;
3347 }
3348 }
3349 }
3350 false
3351}
3352
3353pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3354 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3355 let mut worktrees_list = Vec::new();
3356 if let Ok(worktree_names) = repo.worktrees() {
3357 for name in worktree_names.iter() {
3358 let Ok(Some(wt_name)) = name else { continue };
3359 if let Ok(wt) = repo.find_worktree(wt_name) {
3360 let path = wt.path().to_path_buf();
3361 let mut branch = None;
3362 if let Ok(wt_repo) = Repository::open(&path) {
3363 if let Ok(head) = wt_repo.head() {
3364 branch = head.shorthand().map(String::from).ok();
3365 }
3366 }
3367
3368 let mut is_locked = false;
3369 let mut lock_reason = None;
3370 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3371 is_locked = true;
3372 if let Some(reason) = reason_opt {
3373 if !reason.is_empty() {
3374 lock_reason = Some(reason);
3375 }
3376 }
3377 }
3378
3379 worktrees_list.push(WorktreeInfo {
3380 name: wt_name.to_string(),
3381 path,
3382 branch,
3383 is_locked,
3384 lock_reason,
3385 });
3386 }
3387 }
3388 }
3389 Ok(worktrees_list)
3390}
3391
3392pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<bool, String> {
3398 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3399 let exists = repo.revparse_single(&format!("{}^{{commit}}", branch)).is_ok();
3400 let on_one_remote = || {
3401 let Ok(remotes) = repo.remotes() else { return false };
3402 let on = remotes.iter().flatten().flatten().filter(|remote| {
3403 repo.find_branch(&format!("{}/{}", remote, branch), git2::BranchType::Remote).is_ok()
3404 });
3405 on.count() == 1
3406 };
3407 let create = !exists && !on_one_remote();
3408
3409 let mut cmd = git_command();
3410 cmd.arg("worktree").arg("add");
3411 if create {
3412 cmd.arg("-b").arg(branch);
3413 }
3414 cmd.arg("--").arg(wt_path);
3415 if !create {
3416 cmd.arg(branch);
3417 }
3418 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
3419
3420 if !output.status.success() {
3421 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3422 }
3423 Ok(create)
3424}
3425
3426pub fn worktree_lock(repo_path: &Path, worktree: &Path, reason: &str) -> Result<(), String> {
3435 let output = git_command()
3436 .arg("worktree")
3437 .arg("lock")
3438 .arg("--reason")
3439 .arg(reason)
3440 .arg(worktree)
3441 .current_dir(repo_path)
3442 .output()
3443 .map_err(|e| e.to_string())?;
3444
3445 if !output.status.success() {
3446 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3447 }
3448 Ok(())
3449}
3450
3451pub fn worktree_unlock(repo_path: &Path, worktree: &Path) -> Result<(), String> {
3452 let output = git_command()
3453 .arg("worktree")
3454 .arg("unlock")
3455 .arg(worktree)
3456 .current_dir(repo_path)
3457 .output()
3458 .map_err(|e| e.to_string())?;
3459
3460 if !output.status.success() {
3461 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3462 }
3463 Ok(())
3464}
3465
3466pub fn worktree_remove(repo_path: &Path, worktree: &Path, force: bool) -> Result<(), String> {
3472 let mut cmd = git_command();
3473 cmd.arg("worktree").arg("remove");
3474 if force {
3475 cmd.arg("--force").arg("--force");
3476 }
3477 let output = cmd.arg(worktree).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3478
3479 if !output.status.success() {
3480 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3481 }
3482 Ok(())
3483}
3484
3485pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3486 let output = git_command()
3487 .arg("worktree")
3488 .arg("prune")
3489 .current_dir(repo_path)
3490 .output()
3491 .map_err(|e| e.to_string())?;
3492
3493 if !output.status.success() {
3494 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3495 }
3496 Ok(())
3497}
3498
3499pub fn load_tab_forge_issues(
3500 repo_path: &Path,
3501 assigned_only: bool,
3502) -> Result<Vec<ForgeIssue>, String> {
3503 let mut cmd = tool_command("gh");
3504 cmd.arg("issue").arg("list");
3505 if assigned_only {
3506 cmd.arg("--assignee").arg("@me");
3507 }
3508 cmd.arg("--limit")
3509 .arg("50")
3510 .arg("--json")
3511 .arg("number,title,state,author,assignees,url")
3512 .current_dir(repo_path);
3513
3514 let output = match cmd.output() {
3515 Ok(out) => out,
3516 Err(e) => {
3517 return Err(format!(
3518 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3519 e
3520 ));
3521 }
3522 };
3523
3524 if !output.status.success() {
3525 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3526 return Err(format!(
3527 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3528 err_msg
3529 ));
3530 }
3531
3532 #[derive(serde::Deserialize)]
3533 struct GhAuthor {
3534 login: String,
3535 }
3536
3537 #[derive(serde::Deserialize)]
3538 struct GhAssignee {
3539 login: String,
3540 }
3541
3542 #[derive(serde::Deserialize)]
3543 struct GhIssue {
3544 number: u32,
3545 title: String,
3546 state: String,
3547 author: Option<GhAuthor>,
3548 assignees: Option<Vec<GhAssignee>>,
3549 url: String,
3550 }
3551
3552 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3553 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3554
3555 let issues = raw_issues
3556 .into_iter()
3557 .map(|item| {
3558 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3559 let assignees =
3560 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3561 ForgeIssue {
3562 number: item.number,
3563 title: item.title,
3564 state: item.state,
3565 author,
3566 assignees,
3567 url: item.url,
3568 }
3569 })
3570 .collect();
3571
3572 Ok(issues)
3573}
3574
3575#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3576pub struct ForgePR {
3577 pub number: u32,
3578 pub title: String,
3579 pub state: String,
3580 pub author: String,
3581 pub assignees: Vec<String>,
3582 pub url: String,
3583 pub head_ref: String,
3584 pub head_ref_oid: String,
3585 pub body: String,
3586 pub status_checks: Vec<CIStatusCheck>,
3587 pub reviews: Vec<PRReview>,
3588}
3589
3590#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3591pub struct ForgePRComment {
3592 pub path: String,
3593 pub line: Option<u32>,
3594 pub body: String,
3595 pub author: String,
3596 pub commit_id: String,
3597}
3598
3599#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3600pub struct CIStatusCheck {
3601 pub name: String,
3602 pub state: Option<String>,
3603 pub status: Option<String>,
3604 pub conclusion: Option<String>,
3605}
3606
3607#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3608pub struct PRReview {
3609 pub author: String,
3610 pub body: String,
3611 pub state: String,
3612}
3613
3614pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3615 let mut cmd = tool_command("gh");
3616 cmd.arg("pr")
3617 .arg("list")
3618 .arg("--limit")
3619 .arg("50")
3620 .arg("--json")
3621 .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3622 .current_dir(repo_path);
3623
3624 let output = match cmd.output() {
3625 Ok(out) => out,
3626 Err(e) => {
3627 return Err(format!(
3628 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3629 e
3630 ));
3631 }
3632 };
3633
3634 if !output.status.success() {
3635 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3636 return Err(format!(
3637 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3638 err_msg
3639 ));
3640 }
3641
3642 parse_forge_prs(&output.stdout)
3643}
3644
3645fn parse_forge_prs(json: &[u8]) -> Result<Vec<ForgePR>, String> {
3647 #[derive(serde::Deserialize)]
3648 struct GhAuthor {
3649 login: String,
3650 }
3651
3652 #[derive(serde::Deserialize)]
3653 struct GhAssignee {
3654 login: String,
3655 }
3656
3657 #[derive(serde::Deserialize)]
3658 struct GhReview {
3659 author: Option<GhAuthor>,
3660 body: String,
3661 state: String,
3662 }
3663
3664 #[derive(serde::Deserialize)]
3669 struct GhStatusCheck {
3670 name: Option<String>,
3671 context: Option<String>,
3672 state: Option<String>,
3673 status: Option<String>,
3674 conclusion: Option<String>,
3675 }
3676
3677 #[derive(serde::Deserialize)]
3678 struct GhPR {
3679 number: u32,
3680 title: String,
3681 state: String,
3682 author: Option<GhAuthor>,
3683 assignees: Option<Vec<GhAssignee>>,
3684 url: String,
3685 #[serde(rename = "headRefName")]
3686 head_ref_name: String,
3687 #[serde(rename = "headRefOid")]
3688 head_ref_oid: String,
3689 body: String,
3690 #[serde(rename = "statusCheckRollup")]
3691 status_check_rollup: Option<Vec<GhStatusCheck>>,
3692 reviews: Option<Vec<GhReview>>,
3693 }
3694
3695 let raw_prs: Vec<GhPR> = serde_json::from_slice(json)
3696 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3697
3698 let prs = raw_prs
3699 .into_iter()
3700 .map(|item| {
3701 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3702 let assignees =
3703 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3704 let status_checks = item
3705 .status_check_rollup
3706 .unwrap_or_default()
3707 .into_iter()
3708 .map(|c| {
3709 let non_empty = |v: Option<String>| v.filter(|s| !s.is_empty());
3711 CIStatusCheck {
3712 name: c.name.or(c.context).unwrap_or_else(|| "unnamed check".to_string()),
3713 state: non_empty(c.state),
3714 status: non_empty(c.status),
3715 conclusion: non_empty(c.conclusion),
3716 }
3717 })
3718 .collect();
3719 let reviews = item
3720 .reviews
3721 .unwrap_or_default()
3722 .into_iter()
3723 .map(|r| PRReview {
3724 author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3725 body: r.body,
3726 state: r.state,
3727 })
3728 .collect();
3729
3730 ForgePR {
3731 number: item.number,
3732 title: item.title,
3733 state: item.state,
3734 author,
3735 assignees,
3736 url: item.url,
3737 head_ref: item.head_ref_name,
3738 head_ref_oid: item.head_ref_oid,
3739 body: item.body,
3740 status_checks,
3741 reviews,
3742 }
3743 })
3744 .collect();
3745
3746 Ok(prs)
3747}
3748
3749pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3750 let mut cmd = tool_command("gh");
3753 cmd.arg("api")
3754 .arg(format!("repos/:owner/:repo/pulls/{}/comments?per_page=100", pr_number))
3755 .arg("--paginate")
3756 .current_dir(repo_path);
3757
3758 let output = match cmd.output() {
3759 Ok(out) => out,
3760 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3761 };
3762
3763 if !output.status.success() {
3764 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3765 return Err(format!("Failed to load PR comments: {}", err));
3766 }
3767
3768 parse_pr_comments(&output.stdout)
3769}
3770
3771fn parse_pr_comments(json: &[u8]) -> Result<Vec<ForgePRComment>, String> {
3774 #[derive(serde::Deserialize)]
3775 struct GhUser {
3776 login: String,
3777 }
3778
3779 #[derive(serde::Deserialize)]
3780 struct GhPRComment {
3781 path: String,
3782 line: Option<u32>,
3783 body: String,
3784 user: Option<GhUser>,
3785 #[serde(rename = "commit_id")]
3786 commit_id: String,
3787 }
3788
3789 let mut comments = Vec::new();
3790 for page in serde_json::Deserializer::from_slice(json).into_iter::<Vec<GhPRComment>>() {
3791 let page = page.map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3792 comments.extend(page.into_iter().map(|c| ForgePRComment {
3793 path: c.path,
3794 line: c.line,
3795 body: c.body,
3796 author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3797 commit_id: c.commit_id,
3798 }));
3799 }
3800 Ok(comments)
3801}
3802
3803fn pr_line_comment_args(
3809 pr_number: u32,
3810 commit_id: &str,
3811 path: &str,
3812 line: u32,
3813 body: &str,
3814) -> Vec<String> {
3815 vec![
3816 "api".to_string(),
3817 format!("repos/:owner/:repo/pulls/{}/comments", pr_number),
3818 "--method".to_string(),
3819 "POST".to_string(),
3820 "-f".to_string(),
3821 format!("body={}", body),
3822 "-f".to_string(),
3823 format!("commit_id={}", commit_id),
3824 "-f".to_string(),
3825 format!("path={}", path),
3826 "-F".to_string(),
3827 format!("line={}", line),
3828 "-f".to_string(),
3829 "side=RIGHT".to_string(),
3830 ]
3831}
3832
3833pub fn add_pr_line_comment(
3834 repo_path: &Path,
3835 pr_number: u32,
3836 commit_id: &str,
3837 path: &str,
3838 line: u32,
3839 body: &str,
3840) -> Result<(), String> {
3841 let mut cmd = tool_command("gh");
3842 cmd.args(pr_line_comment_args(pr_number, commit_id, path, line, body)).current_dir(repo_path);
3843
3844 let output = match cmd.output() {
3845 Ok(out) => out,
3846 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3847 };
3848
3849 if !output.status.success() {
3850 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3851 return Err(format!("Failed to add PR comment: {}", err));
3852 }
3853 Ok(())
3854}
3855
3856pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3857 let mut cmd = tool_command("gh");
3858 cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3859
3860 let output = cmd.output().map_err(|e| e.to_string())?;
3861 if !output.status.success() {
3862 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3863 return Err(format!("Failed to checkout PR branch: {}", err));
3864 }
3865 Ok(format!("Checked out branch for PR #{}", pr_number))
3866}
3867
3868pub fn resolve_and_checkout_issue_branch(
3881 repo_path: &Path,
3882 issue_number: u32,
3883) -> Result<String, String> {
3884 let linked = linked_issue_branches(repo_path, issue_number).unwrap_or_default();
3887 match linked.as_slice() {
3888 [] => checkout_issue_branch_by_name(repo_path, issue_number),
3889 [name] => checkout_linked_issue_branch(repo_path, issue_number, name),
3890 many => Err(format!(
3891 "Issue #{} has {} linked branches ({}); check one out from the Branches tab",
3892 issue_number,
3893 many.len(),
3894 many.join(", ")
3895 )),
3896 }
3897}
3898
3899fn linked_issue_branches(repo_path: &Path, issue_number: u32) -> Result<Vec<String>, String> {
3901 const QUERY: &str = "query($owner: String!, $repo: String!, $number: Int!) { \
3902 repository(owner: $owner, name: $repo) { issue(number: $number) { \
3903 linkedBranches(first: 10) { nodes { ref { name } } } } } }";
3904 let output = tool_command("gh")
3907 .args(["api", "graphql", "-f"])
3908 .arg(format!("query={}", QUERY))
3909 .args(["-F", "owner={owner}", "-F", "repo={repo}", "-F"])
3910 .arg(format!("number={}", issue_number))
3911 .current_dir(repo_path)
3912 .output()
3913 .map_err(|e| e.to_string())?;
3914 if !output.status.success() {
3915 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
3916 }
3917 parse_linked_branches(&output.stdout)
3918}
3919
3920fn parse_linked_branches(json: &[u8]) -> Result<Vec<String>, String> {
3923 #[derive(serde::Deserialize)]
3924 struct GhRef {
3925 name: String,
3926 }
3927 #[derive(serde::Deserialize)]
3928 struct GhLinkedBranch {
3929 #[serde(rename = "ref")]
3930 git_ref: Option<GhRef>,
3931 }
3932 #[derive(serde::Deserialize)]
3933 struct GhLinkedBranches {
3934 nodes: Vec<Option<GhLinkedBranch>>,
3935 }
3936 #[derive(serde::Deserialize)]
3937 struct GhIssue {
3938 #[serde(rename = "linkedBranches")]
3939 linked_branches: GhLinkedBranches,
3940 }
3941 #[derive(serde::Deserialize)]
3942 struct GhRepository {
3943 issue: Option<GhIssue>,
3944 }
3945 #[derive(serde::Deserialize)]
3946 struct GhData {
3947 repository: Option<GhRepository>,
3948 }
3949 #[derive(serde::Deserialize)]
3950 struct GhResponse {
3951 data: GhData,
3952 }
3953
3954 let response: GhResponse = serde_json::from_slice(json)
3955 .map_err(|e| format!("Failed to parse linked branches: {}", e))?;
3956 let mut names: Vec<String> = Vec::new();
3957 let links = response.data.repository.and_then(|r| r.issue).map(|i| i.linked_branches.nodes);
3958 for name in links.into_iter().flatten().flatten().filter_map(|n| n.git_ref).map(|r| r.name) {
3959 if !names.contains(&name) {
3960 names.push(name);
3961 }
3962 }
3963 Ok(names)
3964}
3965
3966pub fn branch_names_issue(branch: &str, issue_number: u32) -> bool {
3971 let number = issue_number.to_string();
3972 branch.match_indices(&number).any(|(at, _)| {
3973 let before = branch[..at].chars().next_back();
3974 let after = branch[at + number.len()..].chars().next();
3975 before.is_none_or(|c| matches!(c, '-' | '_' | '/' | '#'))
3976 && after.is_none_or(|c| matches!(c, '-' | '_' | '/'))
3977 })
3978}
3979
3980fn run_checkout(repo_path: &Path, args: &[&str]) -> Result<(), String> {
3982 let output = git_command()
3983 .arg("checkout")
3984 .args(args)
3985 .current_dir(repo_path)
3986 .output()
3987 .map_err(|e| e.to_string())?;
3988 if !output.status.success() {
3989 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
3990 }
3991 Ok(())
3992}
3993
3994fn checkout_linked_issue_branch(
3999 repo_path: &Path,
4000 issue_number: u32,
4001 branch: &str,
4002) -> Result<String, String> {
4003 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
4004 if repo.find_branch(branch, git2::BranchType::Local).is_ok() {
4005 run_checkout(repo_path, &[branch, "--"])?;
4006 return Ok(format!("Checked out '{}', linked to issue #{}", branch, issue_number));
4007 }
4008 let mut on_remotes = Vec::new();
4009 if let Ok(remotes) = repo.remotes() {
4010 for remote in remotes.iter().flatten().flatten() {
4011 let tracking = format!("{}/{}", remote, branch);
4012 if repo.find_branch(&tracking, git2::BranchType::Remote).is_ok() {
4013 on_remotes.push(tracking);
4014 }
4015 }
4016 }
4017 match on_remotes.as_slice() {
4018 [tracking] => {
4019 run_checkout(repo_path, &["--track", tracking])?;
4020 Ok(format!(
4021 "Checked out '{}' from '{}', linked to issue #{}",
4022 branch, tracking, issue_number
4023 ))
4024 }
4025 [] => Err(format!(
4026 "Issue #{} is linked to branch '{}', which is not in this clone yet; fetch it first",
4027 issue_number, branch
4028 )),
4029 many => Err(format!(
4030 "Issue #{} is linked to branch '{}', found on several remotes ({}); check one out from the Branches tab",
4031 issue_number,
4032 branch,
4033 many.join(", ")
4034 )),
4035 }
4036}
4037
4038pub fn checkout_issue_branch_by_name(
4041 repo_path: &Path,
4042 issue_number: u32,
4043) -> Result<String, String> {
4044 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
4045 let mut local = Vec::new();
4046 let mut remote = Vec::new();
4047 for (branch, kind) in repo.branches(None).map_err(|e| e.to_string())?.flatten() {
4048 let Ok(Some(name)) = branch.name() else { continue };
4049 match kind {
4050 git2::BranchType::Local => {
4051 if branch_names_issue(name, issue_number) {
4052 local.push(name.to_string());
4053 }
4054 }
4055 git2::BranchType::Remote => {
4056 let Some((_, short)) = name.split_once('/') else { continue };
4058 if short != "HEAD" && branch_names_issue(short, issue_number) {
4059 remote.push(name.to_string());
4060 }
4061 }
4062 }
4063 }
4064 let ambiguous = |kind: &str, names: &[String]| {
4065 format!(
4066 "Several {} branches match issue #{} ({}); check one out from the Branches tab",
4067 kind,
4068 issue_number,
4069 names.join(", ")
4070 )
4071 };
4072
4073 match local.as_slice() {
4074 [name] => {
4075 run_checkout(repo_path, &[name, "--"])?;
4076 return Ok(format!("Checked out matched local branch '{}'", name));
4077 }
4078 [] => {}
4079 many => return Err(ambiguous("local", many)),
4080 }
4081 match remote.as_slice() {
4082 [name] => {
4083 run_checkout(repo_path, &["--track", name])?;
4084 return Ok(format!("Checked out matched remote branch '{}'", name));
4085 }
4086 [] => {}
4087 many => return Err(ambiguous("remote", many)),
4088 }
4089
4090 let new_branch_name = format!("issue-{}", issue_number);
4091 run_checkout(repo_path, &["-b", &new_branch_name])
4092 .map_err(|e| format!("Failed to create new branch: {}", e))?;
4093 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
4094}
4095
4096pub fn open_browser(url: &str) {
4097 #[cfg(target_os = "macos")]
4098 let _ = detached_command("open").arg(url).status();
4099 #[cfg(target_os = "windows")]
4100 let _ = detached_command("cmd").args(["/C", "start", url]).status();
4101 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
4102 let _ = detached_command("xdg-open").arg(url).status();
4103}
4104
4105#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
4106pub struct ForgeIssue {
4107 pub number: u32,
4108 pub title: String,
4109 pub state: String,
4110 pub author: String,
4111 pub assignees: Vec<String>,
4112 pub url: String,
4113}
4114
4115pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
4116 let remotes = load_tab_remotes(repo_path).ok()?;
4117 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
4118}
4119
4120pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
4121 let remotes = load_tab_remotes(repo_path).ok()?;
4122 remotes.into_iter().next().map(|r| r.url)
4123}
4124
4125#[cfg(test)]
4126#[allow(clippy::unwrap_used, clippy::panic)]
4127#[allow(clippy::disallowed_methods)]
4129mod tests {
4130 use super::*;
4131 use std::fs::File;
4132
4133 #[test]
4134 fn test_match_pattern() {
4135 assert!(match_pattern("test.psd", "*.psd"));
4136 assert!(match_pattern("assets/test.psd", "*.psd"));
4137 assert!(match_pattern("assets/img.png", "assets/*.png"));
4138 assert!(match_pattern("src/app.rs", "src/*"));
4139 assert!(match_pattern("test.txt", "test.txt"));
4140 assert!(!match_pattern("src/app.rs", "*.psd"));
4141 }
4142
4143 #[test]
4144 fn test_run_git_with_timeout_kills_a_hanging_child() {
4145 let mut cmd = if cfg!(target_os = "windows") {
4147 let mut c = std::process::Command::new("cmd");
4148 c.arg("/c").arg("timeout").arg("/t").arg("30").arg("/nobreak");
4149 c
4150 } else {
4151 let mut c = std::process::Command::new("sleep");
4152 c.arg("30");
4153 c
4154 };
4155 cmd.stdin(std::process::Stdio::null());
4156
4157 let started = Instant::now();
4158 let res = run_git_with_timeout(cmd, Duration::from_millis(300));
4159 let elapsed = started.elapsed();
4160
4161 assert!(res.is_err(), "expected a timeout error, got {:?}", res);
4162 assert!(elapsed < Duration::from_secs(10), "child was not killed promptly: {:?}", elapsed);
4163 }
4164
4165 #[test]
4166 fn test_get_lfs_info() {
4167 let mut temp_path = std::env::temp_dir();
4168 temp_path.push(format!(
4169 "twig_test_lfs_{}",
4170 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4171 ));
4172 std::fs::create_dir_all(&temp_path).unwrap();
4173
4174 let _repo = Repository::init(&temp_path).unwrap();
4176 let lfs_dir = temp_path.join(".git").join("lfs");
4177 std::fs::create_dir_all(&lfs_dir).unwrap();
4178
4179 let (installed, tracked) = get_lfs_info(&temp_path);
4181
4182 assert!(tracked.is_empty());
4184
4185 let gitattributes_path = temp_path.join(".gitattributes");
4187 std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
4188
4189 let (installed2, _tracked2) = get_lfs_info(&temp_path);
4190 assert_eq!(installed, installed2);
4191
4192 let _ = std::fs::remove_dir_all(&temp_path);
4194 }
4195
4196 #[test]
4197 fn test_comprehensive_gitwig_core_coverage() {
4198 let mut temp_path = std::env::temp_dir();
4199 temp_path.push(format!(
4200 "twig_test_cov_{}",
4201 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4202 ));
4203 std::fs::create_dir_all(&temp_path).unwrap();
4204
4205 let repo = Repository::init(&temp_path).unwrap();
4207
4208 let mut config = repo.config().unwrap();
4210 config.set_str("user.name", "Test User").unwrap();
4211 config.set_str("user.email", "test@example.com").unwrap();
4212
4213 let file_path = temp_path.join("test.txt");
4215 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4216 stage_file(&temp_path, "test.txt").unwrap();
4217 commit_changes(&temp_path, "first commit").unwrap();
4218
4219 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
4220 let head_oid = head_commit.id().to_string();
4221
4222 let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
4224 let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
4225
4226 let _ = stage_all_changes(&temp_path);
4228 let _ = unstage_all_changes(&temp_path);
4229
4230 std::fs::write(&file_path, "modified content").unwrap();
4231 let _ = discard_all_changes(&temp_path);
4232
4233 let _lfs_size = get_lfs_storage_size(&temp_path);
4235
4236 let _reflog = load_tab_reflog(&temp_path);
4238 let _tags = load_tab_tags(&temp_path);
4239 let _stashes = load_tab_stashes(&temp_path);
4240 let _submodules = load_tab_submodules(&temp_path);
4241 let _worktrees = load_tab_worktrees(&temp_path);
4242
4243 let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
4245 let _ = remote_delete(&temp_path, "upstream");
4246
4247 let wt_path = temp_path.join("wt-path");
4249 let _ = worktree_add(&temp_path, "new-branch", &wt_path);
4250 let _ = worktree_remove(&temp_path, &wt_path, true);
4251
4252 let _ = delete_tag(&temp_path, "v1.0.0");
4254
4255 let _ = apply_stash(&temp_path, 0);
4257 let _ = delete_stash(&temp_path, 0);
4258
4259 let _ = abort_merge(&temp_path);
4261 let _ = continue_merge(&temp_path);
4262
4263 let _ = is_merging(&temp_path);
4265 let _ = get_conflict_markers_diff(&temp_path, "test.txt");
4266 let _ = resolve_ours(&temp_path, "test.txt");
4267 let _ = resolve_theirs(&temp_path, "test.txt");
4268 let _ = mark_resolved(&temp_path, "test.txt");
4269
4270 let _target = get_branch_push_target(&temp_path, "master");
4272 let _target2 = get_branch_push_target(&temp_path, "main");
4273
4274 let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
4276 let _summary = inspect_summary(temp_path.to_str().unwrap());
4277
4278 let _ = std::fs::remove_dir_all(&temp_path);
4280 }
4281
4282 #[test]
4283 fn test_more_gitwig_core_coverage() {
4284 let mut temp_path = std::env::temp_dir();
4285 temp_path.push(format!(
4286 "twig_test_cov2_{}",
4287 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4288 ));
4289 std::fs::create_dir_all(&temp_path).unwrap();
4290
4291 let repo = Repository::init(&temp_path).unwrap();
4293
4294 let mut config = repo.config().unwrap();
4296 config.set_str("user.name", "Test User").unwrap();
4297 config.set_str("user.email", "test@example.com").unwrap();
4298
4299 let file_path = temp_path.join("test.txt");
4301 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4302 stage_file(&temp_path, "test.txt").unwrap();
4303 commit_changes(&temp_path, "first commit").unwrap();
4304
4305 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
4306 let head_oid = head_commit.id().to_string();
4307
4308 let _ = checkout_commit(&temp_path, &head_oid);
4310 let _ = checkout_local_branch(&temp_path, "master");
4311 let _ = checkout_remote_branch(&temp_path, "origin/master");
4312 let _ = checkout_tag(&temp_path, "v1.0.0");
4313
4314 let _ = create_branch(&temp_path, "new-branch-2");
4316 let _ = delete_local_branch(&temp_path, "new-branch-2");
4317 let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
4318 let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
4319 let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0", Duration::from_secs(5));
4320 let _ = get_remote_tags(&temp_path, "origin", Duration::from_secs(5));
4321
4322 let tag_info = vec![BranchInfo {
4324 name: "v1.0.0".to_string(),
4325 is_head: false,
4326 short_sha: "abc1234".to_string(),
4327 short_message: "tag msg".to_string(),
4328 }];
4329 let serialized = serialize_tags(&tag_info);
4330 let deserialized = deserialize_tags(&serialized);
4331 assert_eq!(deserialized.len(), 1);
4332 assert_eq!(deserialized[0].name, "v1.0.0");
4333
4334 let _ = get_file_history(&temp_path, "test.txt");
4336 let _ = get_file_blame(&temp_path, "test.txt");
4337 let _ = get_commit_files(&temp_path, &head_oid);
4338
4339 let _ = load_tab_files(&temp_path);
4341 let _ = load_tab_branches(&temp_path);
4342 let _ = load_tab_remotes(&temp_path);
4343 let _ = load_tab_overview(&temp_path, 10);
4344
4345 let (tx, _rx) = std::sync::mpsc::channel();
4347 let _ = load_tab_graph_stream(
4348 &temp_path,
4349 10,
4350 true,
4351 temp_path.to_str().unwrap().to_string(),
4352 0,
4353 tx,
4354 );
4355
4356 invalidate_ref_map_cache(&temp_path);
4358
4359 let _ = load_tab_forge_issues(&temp_path, false);
4361 let _ = load_tab_forge_issues(&temp_path, true);
4362 let _ = load_tab_forge_prs(&temp_path);
4363 let _ = load_pr_comments(&temp_path, 1);
4364 let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
4365
4366 let _ = std::fs::remove_dir_all(&temp_path);
4368 }
4369
4370 #[test]
4371 fn test_create_tag_annotated_and_force_update() {
4372 let mut temp_path = std::env::temp_dir();
4373 temp_path.push(format!(
4374 "twig_test_tag_force_{}",
4375 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4376 ));
4377 std::fs::create_dir_all(&temp_path).unwrap();
4378
4379 let repo = Repository::init(&temp_path).unwrap();
4380 let mut config = repo.config().unwrap();
4381 config.set_str("user.name", "Test User").unwrap();
4382 config.set_str("user.email", "test@example.com").unwrap();
4383
4384 let file_path = temp_path.join("test.txt");
4385 std::fs::write(&file_path, "commit 1\n").unwrap();
4386 stage_file(&temp_path, "test.txt").unwrap();
4387 commit_changes(&temp_path, "commit 1").unwrap();
4388
4389 let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
4390 let oid1 = head_commit1.id().to_string();
4391
4392 assert!(!tag_exists(&temp_path, "v1.0.1"));
4393 assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
4394 assert!(tag_exists(&temp_path, "v1.0.1"));
4395
4396 std::fs::write(&file_path, "commit 2\n").unwrap();
4398 stage_file(&temp_path, "test.txt").unwrap();
4399 commit_changes(&temp_path, "commit 2").unwrap();
4400
4401 let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
4402 let oid2 = head_commit2.id().to_string();
4403
4404 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
4405
4406 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
4408
4409 let _ = std::fs::remove_dir_all(&temp_path);
4410 }
4411
4412 use std::io::Write;
4413
4414 #[test]
4415 fn test_commit_amend() {
4416 let mut temp_path = std::env::temp_dir();
4417 temp_path.push(format!(
4418 "twig_test_amend_{}",
4419 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4420 ));
4421 std::fs::create_dir_all(&temp_path).unwrap();
4422
4423 let repo = Repository::init(&temp_path).unwrap();
4425
4426 let mut config = repo.config().unwrap();
4428 config.set_str("user.name", "Test User").unwrap();
4429 config.set_str("user.email", "test@example.com").unwrap();
4430
4431 let file_path = temp_path.join("test.txt");
4433 let mut file = File::create(&file_path).unwrap();
4434 writeln!(file, "initial content").unwrap();
4435
4436 stage_file(&temp_path, "test.txt").unwrap();
4438 commit_changes(&temp_path, "initial commit").unwrap();
4439
4440 let msg = get_last_commit_message(&temp_path).unwrap();
4442 assert_eq!(msg, "initial commit");
4443
4444 commit_amend(&temp_path, "amended commit").unwrap();
4446
4447 let amended_msg = get_last_commit_message(&temp_path).unwrap();
4449 assert_eq!(amended_msg, "amended commit");
4450
4451 let _ = std::fs::remove_dir_all(&temp_path);
4453 }
4454
4455 #[test]
4456 fn test_commit_signatures_collection() {
4457 let mut temp_path = std::env::temp_dir();
4458 temp_path.push(format!(
4459 "twig_test_sig_{}",
4460 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4461 ));
4462 std::fs::create_dir_all(&temp_path).unwrap();
4463
4464 let repo = Repository::init(&temp_path).unwrap();
4466
4467 let mut config = repo.config().unwrap();
4469 config.set_str("user.name", "Test User").unwrap();
4470 config.set_str("user.email", "test@example.com").unwrap();
4471
4472 let file_path = temp_path.join("test.txt");
4474 let mut file = File::create(&file_path).unwrap();
4475 writeln!(file, "initial content").unwrap();
4476
4477 stage_file(&temp_path, "test.txt").unwrap();
4479 commit_changes(&temp_path, "initial commit").unwrap();
4480
4481 let sigs = collect_signatures(&temp_path, 0);
4483 assert_eq!(sigs.len(), 1);
4484 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4485 let sig_status = sigs.get(&head_oid).unwrap();
4486 assert_eq!(sig_status, "N");
4487
4488 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4490 assert_eq!(commits.len(), 1);
4491 assert_eq!(commits[0].signature_status, "N");
4492 assert!(commits[0].files.is_empty());
4493
4494 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4496 assert_eq!(files.len(), 1);
4497 assert_eq!(files[0].path, "test.txt");
4498 assert_eq!(files[0].label, "N");
4499
4500 let graph = collect_graph_lines(&temp_path, 1000);
4502 assert_eq!(graph.len(), 1);
4503 assert!(graph[0].commit.is_some());
4504 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4505
4506 let _ = std::fs::remove_dir_all(&temp_path);
4508 }
4509
4510 fn run_git(dir: &Path, args: &[&str]) {
4511 let out = git_command()
4512 .args(["-c", "user.name=Gitwig Test", "-c", "user.email=test@gitwig.invalid"])
4513 .args(["-c", "commit.gpgsign=false"])
4514 .args(args)
4515 .current_dir(dir)
4516 .output()
4517 .unwrap();
4518 assert!(out.status.success(), "git {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
4519 }
4520
4521 fn unique_temp_dir(tag: &str) -> PathBuf {
4526 use std::sync::atomic::{AtomicUsize, Ordering};
4527 static NEXT: AtomicUsize = AtomicUsize::new(0);
4528 let dir = std::env::temp_dir().join(format!(
4529 "twig_test_{}_{}_{}_{}",
4530 tag,
4531 std::process::id(),
4532 NEXT.fetch_add(1, Ordering::Relaxed),
4533 SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
4534 ));
4535 std::fs::create_dir_all(&dir).unwrap();
4536 dir
4537 }
4538
4539 fn repo_with_branches(root: &Path, branches: &[&str]) -> PathBuf {
4541 let main = root.join("main");
4542 std::fs::create_dir_all(&main).unwrap();
4543 run_git(&main, &["init", "-q"]);
4544 run_git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]);
4545 for branch in branches {
4546 run_git(&main, &["branch", branch]);
4547 }
4548 main
4549 }
4550
4551 fn worktree_at<'a>(worktrees: &'a [WorktreeInfo], suffix: &str) -> &'a WorktreeInfo {
4552 worktrees
4553 .iter()
4554 .find(|wt| wt.path.ends_with(suffix))
4555 .unwrap_or_else(|| panic!("no worktree at {} in {:?}", suffix, worktrees))
4556 }
4557
4558 #[test]
4563 fn test_worktree_helpers_act_on_the_checkout_path_not_the_admin_name() {
4564 let root = unique_temp_dir("wt_admin_name");
4565 let main = repo_with_branches(&root, &["a", "b"]);
4566 let (x_foo, x_renamed, z_foo) =
4567 (root.join("x/foo"), root.join("x/renamed"), root.join("z/foo"));
4568 run_git(&main, &["worktree", "add", "-q", &x_foo.to_string_lossy(), "a"]);
4569 run_git(
4570 &main,
4571 &["worktree", "move", &x_foo.to_string_lossy(), &x_renamed.to_string_lossy()],
4572 );
4573 run_git(&main, &["worktree", "add", "-q", &z_foo.to_string_lossy(), "b"]);
4574 std::fs::write(z_foo.join("precious.txt"), "uncommitted work").unwrap();
4575
4576 let worktrees = load_tab_worktrees(&main).unwrap();
4577 let renamed = worktree_at(&worktrees, "x/renamed").clone();
4578 assert_eq!(renamed.name, "foo", "the moved worktree keeps its admin name");
4579
4580 worktree_lock(&main, &renamed.path, "testing").unwrap();
4581 assert!(worktree_at(&load_tab_worktrees(&main).unwrap(), "x/renamed").is_locked);
4582 worktree_unlock(&main, &renamed.path).unwrap();
4583 assert!(!worktree_at(&load_tab_worktrees(&main).unwrap(), "x/renamed").is_locked);
4584
4585 worktree_remove(&main, &renamed.path, false).unwrap();
4586 assert!(!x_renamed.exists(), "the selected worktree is removed");
4587 assert!(z_foo.join("precious.txt").exists(), "the other worktree is untouched");
4588 let _ = std::fs::remove_dir_all(&root);
4589 }
4590
4591 #[test]
4595 fn test_worktree_add_checks_out_existing_refs_and_creates_new_branches() {
4596 let root = unique_temp_dir("wt_add");
4597 let main = repo_with_branches(&root, &["existing"]);
4598 run_git(&main, &["tag", "v1"]);
4599 let branch_of = |wt: &Path| {
4600 let out =
4601 git_command().args(["rev-parse", "--abbrev-ref", "HEAD"]).current_dir(wt).output();
4602 String::from_utf8_lossy(&out.unwrap().stdout).trim().to_string()
4603 };
4604
4605 assert!(!worktree_add(&main, "existing", &root.join("a")).unwrap());
4606 assert_eq!(branch_of(&root.join("a")), "existing");
4607 assert!(worktree_add(&main, "feature/new", &root.join("b")).unwrap(), "created");
4608 assert_eq!(branch_of(&root.join("b")), "feature/new");
4609 assert!(!worktree_add(&main, "v1", &root.join("c")).unwrap());
4610 assert_eq!(branch_of(&root.join("c")), "HEAD", "a tag is checked out detached");
4611 assert!(worktree_add(&main, "dash", &root.join("-dash")).unwrap());
4613 let current = branch_of(&main);
4615 assert!(worktree_add(&main, ¤t, &root.join("d")).is_err());
4616
4617 let clone = root.join("clone");
4619 run_git(&root, &["clone", "-q", &main.to_string_lossy(), &clone.to_string_lossy()]);
4620 assert!(!worktree_add(&clone, "existing", &root.join("e")).unwrap());
4621 assert_eq!(branch_of(&root.join("e")), "existing");
4622 let upstream = git_command()
4623 .args(["rev-parse", "--abbrev-ref", "existing@{upstream}"])
4624 .current_dir(root.join("e"))
4625 .output()
4626 .unwrap();
4627 assert_eq!(String::from_utf8_lossy(&upstream.stdout).trim(), "origin/existing");
4628 let _ = std::fs::remove_dir_all(&root);
4629 }
4630
4631 #[test]
4632 fn test_worktree_remove_keeps_uncommitted_or_locked_work_unless_forced() {
4633 let root = unique_temp_dir("wt_remove_force");
4634 let main = repo_with_branches(&root, &["dirty", "locked"]);
4635 let (dirty, locked) = (root.join("dirty"), root.join("locked"));
4636 run_git(&main, &["worktree", "add", "-q", &dirty.to_string_lossy(), "dirty"]);
4637 run_git(&main, &["worktree", "add", "-q", &locked.to_string_lossy(), "locked"]);
4638 std::fs::write(dirty.join("wip.txt"), "untracked work").unwrap();
4639 run_git(&main, &["worktree", "lock", &locked.to_string_lossy()]);
4640
4641 assert!(worktree_remove(&main, &dirty, false).is_err());
4642 assert!(dirty.join("wip.txt").exists(), "a refused remove deletes nothing");
4643 assert!(worktree_remove(&main, &locked, false).is_err());
4644 assert!(locked.exists());
4645
4646 worktree_remove(&main, &dirty, true).unwrap();
4647 worktree_remove(&main, &locked, true).unwrap();
4648 assert!(!dirty.exists() && !locked.exists(), "force removes both");
4649 assert!(load_tab_worktrees(&main).unwrap().is_empty());
4650 let _ = std::fs::remove_dir_all(&root);
4651 }
4652
4653 #[test]
4657 fn test_submodule_remove_deletes_the_submodule_path_not_a_same_named_file() {
4658 let root = unique_temp_dir("submodule_remove");
4659 let lib = root.join("lib");
4660 std::fs::create_dir_all(&lib).unwrap();
4661 run_git(&lib, &["init", "-q"]);
4662 run_git(&lib, &["commit", "-q", "--allow-empty", "-m", "lib"]);
4663 let main = repo_with_branches(&root, &[]);
4664 std::fs::write(main.join("notes"), "tracked notes\n").unwrap();
4665 run_git(&main, &["add", "notes"]);
4666 run_git(&main, &["commit", "-q", "-m", "notes"]);
4667 std::fs::write(main.join("notes"), "tracked notes\nlocal edit\n").unwrap();
4668 run_git(
4669 &main,
4670 &["submodule", "add", "-q", "--name", "notes", &lib.to_string_lossy(), "vendor/n"],
4671 );
4672 run_git(&main, &["commit", "-q", "-m", "add submodule"]);
4673 assert!(main.join(".git/modules/notes").is_dir());
4674
4675 assert!(submodule_remove(&main, "notes", Path::new("notes")).is_err());
4677 assert!(submodule_remove(&main, "notes", Path::new("../lib")).is_err());
4678 assert!(submodule_remove(&main, "../notes", Path::new("vendor/n")).is_err());
4679 assert!(main.join("vendor/n").is_dir());
4680
4681 submodule_remove(&main, "notes", Path::new("vendor/n")).unwrap();
4682 assert_eq!(
4683 std::fs::read_to_string(main.join("notes")).unwrap(),
4684 "tracked notes\nlocal edit\n",
4685 "the same-named file keeps its local edit"
4686 );
4687 assert!(!main.join("vendor/n").exists());
4688 assert!(!main.join(".git/modules/notes").exists());
4689 assert!(!std::fs::read_to_string(main.join(".gitmodules")).unwrap().contains("vendor/n"));
4690 let status = git_command().args(["status", "--porcelain"]).current_dir(&main).output();
4691 let status = status.unwrap();
4692 assert!(status.status.success(), "the repository is still usable");
4693 let status = String::from_utf8_lossy(&status.stdout);
4695 assert!(status.lines().any(|l| l == "D vendor/n"), "{}", status);
4696 assert!(status.lines().any(|l| l == " M notes"), "{}", status);
4697 let listed = load_tab_submodules(&main).unwrap();
4700 let removed = listed.iter().find(|s| s.path == Path::new("vendor/n")).unwrap();
4701 assert!(removed.commit_id.is_none() && removed.head_id.is_some(), "{:?}", removed);
4702 let _ = std::fs::remove_dir_all(&root);
4703 }
4704
4705 #[test]
4709 fn test_submodule_update_initialises_and_returns_to_the_recorded_commit() {
4710 let root = unique_temp_dir("submodule_update");
4711 let lib = root.join("lib");
4712 std::fs::create_dir_all(&lib).unwrap();
4713 run_git(&lib, &["init", "-q"]);
4714 std::fs::write(lib.join("f"), "1\n").unwrap();
4715 run_git(&lib, &["add", "f"]);
4716 run_git(&lib, &["commit", "-q", "-m", "one"]);
4717 let main = repo_with_branches(&root, &[]);
4718 let lib_url = lib.to_string_lossy().to_string();
4719 run_git(&main, &["submodule", "add", "-q", &lib_url, "vendor/a"]);
4720 run_git(&main, &["submodule", "add", "-q", &lib_url, "vendor/b"]);
4721 run_git(&main, &["commit", "-q", "-m", "add submodules"]);
4722 run_git(&main, &["submodule", "deinit", "-q", "-f", "--all"]);
4723 let listed = |p: &str| {
4724 let subs = load_tab_submodules(&main).unwrap();
4725 subs.into_iter().find(|s| s.path == Path::new(p)).unwrap()
4726 };
4727 assert!(!listed("vendor/a").is_initialized && !listed("vendor/b").is_initialized);
4728
4729 let timeout = Duration::from_secs(60);
4730 submodule_update(&main, Some(Path::new("vendor/a")), timeout).unwrap();
4731 assert!(listed("vendor/a").is_initialized);
4732 assert!(!listed("vendor/b").is_initialized, "only the selected submodule");
4733 let sub_a = main.join("vendor/a");
4734 assert_eq!(std::fs::read_to_string(sub_a.join("f")).unwrap(), "1\n");
4735 submodule_update(&main, None, timeout).unwrap();
4736 assert!(listed("vendor/b").is_initialized, "no path updates every submodule");
4737
4738 std::fs::write(lib.join("f"), "2\n").unwrap();
4740 run_git(&lib, &["commit", "-q", "-am", "two"]);
4741 run_git(&sub_a, &["fetch", "-q", "origin", "HEAD"]);
4742 run_git(&sub_a, &["checkout", "-q", "FETCH_HEAD"]);
4743 assert!(listed("vendor/a").is_dirty);
4744 submodule_update(&main, Some(Path::new("vendor/a")), timeout).unwrap();
4745 assert!(!listed("vendor/a").is_dirty, "back on the recorded commit");
4746 assert_eq!(std::fs::read_to_string(sub_a.join("f")).unwrap(), "1\n");
4747
4748 run_git(&sub_a, &["checkout", "-q", "FETCH_HEAD"]);
4750 std::fs::write(sub_a.join("f"), "local\n").unwrap();
4751 assert!(submodule_update(&main, Some(Path::new("vendor/a")), timeout).is_err());
4752 assert_eq!(std::fs::read_to_string(sub_a.join("f")).unwrap(), "local\n");
4753
4754 assert!(submodule_update(&main, Some(Path::new("nope")), timeout).is_err());
4755 let _ = std::fs::remove_dir_all(&root);
4756 }
4757
4758 #[test]
4761 fn test_pr_line_comment_args_send_text_fields_verbatim() {
4762 let args = pr_line_comment_args(42, "abc123", "src/{owner}.rs", 7, "@alice 123");
4763 let field =
4764 |flag: &str, value: &str| args.windows(2).any(|w| w[0] == flag && w[1] == value);
4765 assert!(field("-f", "body=@alice 123"), "{:?}", args);
4766 assert!(field("-f", "path=src/{owner}.rs"), "{:?}", args);
4767 assert!(field("-f", "commit_id=abc123"), "{:?}", args);
4768 assert!(field("-f", "side=RIGHT"), "{:?}", args);
4769 assert!(field("-F", "line=7"), "line is sent as a number: {:?}", args);
4770 assert_eq!(args.iter().filter(|a| *a == "-F").count(), 1, "{:?}", args);
4771 assert_eq!(args[1], "repos/:owner/:repo/pulls/42/comments");
4772 }
4773
4774 #[test]
4778 fn test_parse_forge_prs_accepts_commit_statuses_and_running_checks() {
4779 let json = br#"[{"number":42,"title":"Add retry","state":"OPEN",
4780 "author":{"login":"alice"},"assignees":[],"url":"https://github.com/o/r/pull/42",
4781 "headRefName":"retry","headRefOid":"abc","body":"",
4782 "statusCheckRollup":[
4783 {"__typename":"CheckRun","name":"build","workflowName":"CI","status":"COMPLETED",
4784 "conclusion":"SUCCESS","startedAt":"2026-01-01T00:00:00Z","completedAt":"2026-01-01T00:01:00Z","detailsUrl":"https://x"},
4785 {"__typename":"CheckRun","name":"lint","workflowName":"CI","status":"IN_PROGRESS",
4786 "conclusion":"","startedAt":"2026-01-01T00:00:00Z","completedAt":"0001-01-01T00:00:00Z","detailsUrl":"https://x"},
4787 {"__typename":"StatusContext","context":"ci/circleci","state":"FAILURE",
4788 "targetUrl":"https://circleci.com/x","startedAt":"2026-01-01T00:00:00Z"}],
4789 "reviews":[{"author":null,"body":"LGTM","state":"APPROVED"}]}]"#;
4790 let prs = parse_forge_prs(json).unwrap();
4791 let checks = &prs[0].status_checks;
4792 assert_eq!(checks.len(), 3);
4793 assert_eq!(checks[0].conclusion.as_deref(), Some("SUCCESS"));
4794 assert_eq!((checks[1].name.as_str(), checks[1].conclusion.as_deref()), ("lint", None));
4795 assert_eq!(checks[1].status.as_deref(), Some("IN_PROGRESS"));
4796 assert_eq!(checks[2].name, "ci/circleci");
4797 assert_eq!(checks[2].state.as_deref(), Some("FAILURE"));
4798 assert_eq!(prs[0].reviews[0].author, "none");
4799 }
4800
4801 #[test]
4804 fn test_parse_pr_comments_reads_every_page() {
4805 let comment = |n: u32| {
4806 format!(
4807 r#"{{"path":"a.rs","line":{},"body":"c{}","user":{{"login":"bob"}},"commit_id":"x"}}"#,
4808 n, n
4809 )
4810 };
4811 let page = |range: std::ops::Range<u32>| {
4812 format!("[{}]", range.map(comment).collect::<Vec<_>>().join(","))
4813 };
4814 let json = format!("{}\n{}", page(0..100), page(100..130));
4815 let comments = parse_pr_comments(json.as_bytes()).unwrap();
4816 assert_eq!(comments.len(), 130);
4817 assert_eq!(comments[129].body, "c129");
4818 assert!(parse_pr_comments(b"[]").unwrap().is_empty());
4819 let deleted_user = br#"[{"path":"a","line":null,"body":"b","user":null,"commit_id":"x"}]"#;
4820 assert_eq!(parse_pr_comments(deleted_user).unwrap()[0].author, "none");
4821 assert!(parse_pr_comments(b"[{").is_err());
4822 }
4823
4824 #[test]
4825 fn test_parse_linked_branches() {
4826 let json = br#"{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[
4827 {"ref":{"name":"12-login-fails"}},{"ref":null},{"ref":{"name":"12-login-fails"}},
4828 {"ref":{"name":"alt-12"}}]}}}}}"#;
4829 assert_eq!(parse_linked_branches(json).unwrap(), vec!["12-login-fails", "alt-12"]);
4830 let none = br#"{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}"#;
4831 assert!(parse_linked_branches(none).unwrap().is_empty());
4832 let no_issue = br#"{"data":{"repository":{"issue":null}}}"#;
4833 assert!(parse_linked_branches(no_issue).unwrap().is_empty());
4834 assert!(parse_linked_branches(b"not json").is_err());
4835 }
4836
4837 #[test]
4838 fn test_branch_names_issue_matches_whole_tokens_only() {
4839 for (branch, issue) in [
4840 ("fix-1-typo", 1),
4841 ("issue-12-login", 12),
4842 ("12-login", 12),
4843 ("feature/12", 12),
4844 ("gh#12", 12),
4845 ("issue_12", 12),
4846 ("v1.2-release-2", 2),
4847 ] {
4848 assert!(branch_names_issue(branch, issue), "{} names #{}", branch, issue);
4849 }
4850 for (branch, issue) in [
4851 ("v1.2-release", 1),
4852 ("v1.2-release", 2),
4853 ("issue-12-login", 1),
4854 ("issue-12-login", 2),
4855 ("issue-123", 12),
4856 ("issue12", 12),
4857 ("main", 1),
4858 ] {
4859 assert!(!branch_names_issue(branch, issue), "{} does not name #{}", branch, issue);
4860 }
4861 }
4862
4863 fn current_branch(repo: &Path) -> String {
4864 let out = git_command().args(["branch", "--show-current"]).current_dir(repo).output();
4865 String::from_utf8_lossy(&out.unwrap().stdout).trim().to_string()
4866 }
4867
4868 #[test]
4869 fn test_checkout_issue_branch_by_name_picks_the_one_branch_naming_the_issue() {
4870 let root = unique_temp_dir("issue_branch");
4871 let main = repo_with_branches(
4872 &root,
4873 &["fix-1-typo", "v1.2-release", "issue-12-login", "7-crash", "fix/7"],
4874 );
4875 let start = current_branch(&main);
4876
4877 assert_eq!(
4879 checkout_issue_branch_by_name(&main, 2).unwrap(),
4880 "Created and switched to new branch 'issue-2'"
4881 );
4882 assert_eq!(current_branch(&main), "issue-2");
4883 checkout_issue_branch_by_name(&main, 12).unwrap();
4884 assert_eq!(current_branch(&main), "issue-12-login");
4885 checkout_issue_branch_by_name(&main, 1).unwrap();
4886 assert_eq!(current_branch(&main), "fix-1-typo");
4887
4888 let err = checkout_issue_branch_by_name(&main, 7).unwrap_err();
4890 assert!(err.contains("7-crash") && err.contains("fix/7"), "{}", err);
4891 assert_eq!(current_branch(&main), "fix-1-typo");
4892
4893 run_git(&main, &["checkout", "-q", &start]);
4895 run_git(&main, &["branch", "fix-9-remote"]);
4896 let clone = root.join("clone");
4897 run_git(&root, &["clone", "-q", &main.to_string_lossy(), &clone.to_string_lossy()]);
4898 assert_eq!(
4899 checkout_issue_branch_by_name(&clone, 9).unwrap(),
4900 "Checked out matched remote branch 'origin/fix-9-remote'"
4901 );
4902 assert_eq!(current_branch(&clone), "fix-9-remote");
4903 let _ = std::fs::remove_dir_all(&root);
4904 }
4905
4906 #[test]
4909 fn test_checkout_linked_issue_branch_never_restores_a_same_named_path() {
4910 let root = unique_temp_dir("linked_branch");
4911 let main = repo_with_branches(&root, &[]);
4912 std::fs::create_dir_all(main.join("docs")).unwrap();
4913 std::fs::write(main.join("docs/guide.md"), "v1\n").unwrap();
4914 run_git(&main, &["add", "docs"]);
4915 run_git(&main, &["commit", "-q", "-m", "docs"]);
4916 run_git(&main, &["branch", "linked-5"]);
4917 std::fs::write(main.join("docs/guide.md"), "v1\nunsaved edit\n").unwrap();
4918
4919 let err = checkout_linked_issue_branch(&main, 5, "docs").unwrap_err();
4920 assert!(err.contains("not in this clone"), "{}", err);
4921 assert_eq!(
4922 std::fs::read_to_string(main.join("docs/guide.md")).unwrap(),
4923 "v1\nunsaved edit\n"
4924 );
4925 checkout_linked_issue_branch(&main, 5, "linked-5").unwrap();
4926 assert_eq!(current_branch(&main), "linked-5");
4927 let _ = std::fs::remove_dir_all(&root);
4928 }
4929
4930 fn make_repo_with_unverifiable_signature() -> PathBuf {
4935 use std::io::Write;
4936 use std::process::Stdio;
4937 let temp_path = unique_temp_dir("nogpg");
4939 let repo = Repository::init(&temp_path).unwrap();
4940 let mut config = repo.config().unwrap();
4941 config.set_str("user.name", "Test User").unwrap();
4942 config.set_str("user.email", "test@example.com").unwrap();
4943 config.set_str("gpg.program", "/nonexistent/gitwig-test-no-gpg").unwrap();
4944
4945 let mut file = File::create(temp_path.join("test.txt")).unwrap();
4946 writeln!(file, "initial content").unwrap();
4947 stage_file(&temp_path, "test.txt").unwrap();
4948 commit_changes(&temp_path, "signed commit").unwrap();
4949
4950 let raw = git_command()
4952 .args(["cat-file", "-p", "HEAD"])
4953 .current_dir(&temp_path)
4954 .output()
4955 .unwrap();
4956 let mut signed = String::new();
4957 for line in String::from_utf8_lossy(&raw.stdout).lines() {
4958 signed.push_str(line);
4959 signed.push('\n');
4960 if line.starts_with("committer ") {
4961 signed.push_str("gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n");
4962 }
4963 }
4964 let mut hash_cmd = git_command();
4965 hash_cmd.args(["hash-object", "-t", "commit", "-w", "--stdin"]).current_dir(&temp_path);
4966 hash_cmd.stdin(Stdio::piped());
4967 let mut child = hash_cmd.spawn().unwrap();
4968 child.stdin.take().unwrap().write_all(signed.as_bytes()).unwrap();
4969 let out = child.wait_with_output().unwrap();
4970 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
4971 let new_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
4972 let head_ref = String::from_utf8_lossy(
4973 &git_command()
4974 .args(["symbolic-ref", "HEAD"])
4975 .current_dir(&temp_path)
4976 .output()
4977 .unwrap()
4978 .stdout,
4979 )
4980 .trim()
4981 .to_string();
4982 let upd = git_command()
4983 .args(["update-ref", &head_ref, &new_oid])
4984 .current_dir(&temp_path)
4985 .output()
4986 .unwrap();
4987 assert!(upd.status.success());
4988 temp_path
4989 }
4990
4991 #[test]
4992 fn graph_survives_missing_signature_program_and_reports_it() {
4993 let temp_path = make_repo_with_unverifiable_signature();
4994 let (tx, rx) = std::sync::mpsc::channel();
4995
4996 let load = load_tab_graph_stream(&temp_path, 100, true, String::new(), 2, tx)
4997 .expect("a missing gpg must not fail the graph");
4998 assert_eq!(load.lines.len(), 1);
4999 assert!(load.lines[0].commit.is_some());
5000 let notice = load.notice.expect("git's stderr must surface as a notice");
5001 assert!(
5002 notice.contains("/nonexistent/gitwig-test-no-gpg"),
5003 "notice must name the program git could not start: {notice}"
5004 );
5005 assert!(!notice.starts_with("error:"), "prefix should be stripped: {notice}");
5006 assert!(rx.try_recv().is_err(), "no batch is streamed for a one-commit graph");
5007
5008 let _ = std::fs::remove_dir_all(&temp_path);
5009 }
5010
5011 #[test]
5012 fn graph_skips_signature_lookup_when_disabled() {
5013 let temp_path = make_repo_with_unverifiable_signature();
5014 let (tx, _rx) = std::sync::mpsc::channel();
5015
5016 let load = load_tab_graph_stream(&temp_path, 100, false, String::new(), 2, tx).unwrap();
5017 assert_eq!(load.lines.len(), 1);
5018 assert_eq!(load.lines[0].commit.as_ref().unwrap().signature_status, "N");
5019 assert!(load.notice.is_none(), "no %G? means git never touches gpg");
5020
5021 let _ = std::fs::remove_dir_all(&temp_path);
5022 }
5023
5024 #[test]
5025 fn test_ref_map_cache_behavior() {
5026 let temp_dir = std::env::temp_dir();
5027 let repo_path = temp_dir.join("test_ref_map_repo");
5028 let _ = std::fs::remove_dir_all(&repo_path);
5029 std::fs::create_dir_all(&repo_path).unwrap();
5030
5031 let repo = Repository::init(&repo_path).unwrap();
5032
5033 let map1 = get_cached_ref_map(&repo, &repo_path);
5035
5036 let map2 = get_cached_ref_map(&repo, &repo_path);
5038 assert_eq!(map1.len(), map2.len());
5039
5040 invalidate_ref_map_cache(&repo_path);
5042
5043 let _ = std::fs::remove_dir_all(&repo_path);
5045 }
5046
5047 #[test]
5048 fn test_get_latest_change_time() {
5049 let mut temp_path = std::env::temp_dir();
5050 temp_path.push(format!(
5051 "twig_test_time_{}",
5052 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5053 ));
5054 std::fs::create_dir_all(&temp_path).unwrap();
5055
5056 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
5057 assert!(change_time > 0);
5058
5059 let _ = std::fs::remove_dir_all(&temp_path);
5060 }
5061
5062 #[test]
5063 fn test_committer_stats() {
5064 let mut temp_path = std::env::temp_dir();
5065 temp_path.push(format!(
5066 "twig_test_stats_{}",
5067 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5068 ));
5069 std::fs::create_dir_all(&temp_path).unwrap();
5070
5071 let repo = Repository::init(&temp_path).unwrap();
5073
5074 let mut config = repo.config().unwrap();
5076 config.set_str("user.name", "Test User").unwrap();
5077 config.set_str("user.email", "test@example.com").unwrap();
5078
5079 let file_path = temp_path.join("test.txt");
5081 let mut file = File::create(&file_path).unwrap();
5082 writeln!(file, "initial content").unwrap();
5083
5084 stage_file(&temp_path, "test.txt").unwrap();
5086 commit_changes(&temp_path, "initial commit").unwrap();
5087
5088 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
5090 assert_eq!(stats.len(), 1);
5091 assert_eq!(stats[0].name, "Test User");
5092 assert_eq!(stats[0].email, "test@example.com");
5093 assert_eq!(stats[0].count, 1);
5094 assert!(!limit_reached);
5095
5096 let _ = std::fs::remove_dir_all(&temp_path);
5098 }
5099
5100 #[test]
5101 fn test_untracked_files_in_unstaged() {
5102 let mut temp_path = std::env::temp_dir();
5103 temp_path.push(format!(
5104 "twig_test_untracked_{}",
5105 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5106 ));
5107 std::fs::create_dir_all(&temp_path).unwrap();
5108
5109 let _repo = Repository::init(&temp_path).unwrap();
5111
5112 let file_path = temp_path.join("untracked.txt");
5114 let mut file = File::create(&file_path).unwrap();
5115 writeln!(file, "hello untracked").unwrap();
5116
5117 let untracked_dir = temp_path.join("untracked_dir");
5119 std::fs::create_dir_all(&untracked_dir).unwrap();
5120 let nested_file_path = untracked_dir.join("nested.txt");
5121 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
5122
5123 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5125 match detail {
5126 ItemDetail::Repo { info, .. } => {
5127 let unstaged_paths: Vec<String> =
5129 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
5130 let untracked_paths: Vec<String> =
5131 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
5132
5133 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
5135 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
5136 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
5137 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
5138
5139 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
5141 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
5142 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
5143 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
5144 }
5145 _ => panic!("Expected ItemDetail::Repo"),
5146 }
5147
5148 let _ = std::fs::remove_dir_all(&temp_path);
5150 }
5151
5152 #[test]
5153 fn test_stage_new_and_deleted_files() {
5154 let mut temp_path = std::env::temp_dir();
5155 temp_path.push(format!(
5156 "twig_test_new_del_{}",
5157 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5158 ));
5159 std::fs::create_dir_all(&temp_path).unwrap();
5160
5161 let repo = Repository::init(&temp_path).unwrap();
5163
5164 let mut config = repo.config().unwrap();
5166 config.set_str("user.name", "Test User").unwrap();
5167 config.set_str("user.email", "test@example.com").unwrap();
5168
5169 let init_file = temp_path.join("init.txt");
5171 std::fs::write(&init_file, "initial").unwrap();
5172 stage_file(&temp_path, "init.txt").unwrap();
5173 commit_changes(&temp_path, "initial commit").unwrap();
5174
5175 let untracked_file = temp_path.join("untracked.txt");
5177 std::fs::write(&untracked_file, "new file content").unwrap();
5178
5179 stage_file(&temp_path, "untracked.txt").unwrap();
5181
5182 std::fs::remove_file(&init_file).unwrap();
5184
5185 stage_file(&temp_path, "init.txt").unwrap();
5187
5188 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5190 match detail {
5191 ItemDetail::Repo { info, .. } => {
5192 assert_eq!(info.changes.staged.len(), 2);
5194 let paths: Vec<String> =
5195 info.changes.staged.iter().map(|f| f.path.clone()).collect();
5196 assert!(paths.contains(&"untracked.txt".to_string()));
5197 assert!(paths.contains(&"init.txt".to_string()));
5198 }
5199 _ => panic!("Expected ItemDetail::Repo"),
5200 }
5201
5202 let _ = std::fs::remove_dir_all(&temp_path);
5204 }
5205
5206 #[test]
5207 fn test_discard_file_changes_all_cases() {
5208 let mut temp_path = std::env::temp_dir();
5209 temp_path.push(format!(
5210 "twig_test_discard_all_{}",
5211 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5212 ));
5213 std::fs::create_dir_all(&temp_path).unwrap();
5214
5215 let repo = Repository::init(&temp_path).unwrap();
5217
5218 let mut config = repo.config().unwrap();
5220 config.set_str("user.name", "Test User").unwrap();
5221 config.set_str("user.email", "test@example.com").unwrap();
5222
5223 let file_tracked = temp_path.join("tracked.txt");
5225 std::fs::write(&file_tracked, "original content\n").unwrap();
5226 stage_file(&temp_path, "tracked.txt").unwrap();
5227 commit_changes(&temp_path, "initial commit").unwrap();
5228
5229 let file_untracked = temp_path.join("untracked.txt");
5231 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
5232 assert!(file_untracked.exists());
5233 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
5234 assert!(!file_untracked.exists());
5235
5236 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
5238 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
5239 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5240
5241 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
5243 stage_file(&temp_path, "tracked.txt").unwrap();
5244 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5246 match detail {
5247 ItemDetail::Repo { info, .. } => {
5248 assert!(!info.changes.staged.is_empty());
5249 }
5250 _ => panic!("Expected ItemDetail::Repo"),
5251 }
5252 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
5253 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5254 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5256 match detail {
5257 ItemDetail::Repo { info, .. } => {
5258 assert!(info.changes.staged.is_empty());
5259 assert!(info.changes.unstaged.is_empty());
5260 }
5261 _ => panic!("Expected ItemDetail::Repo"),
5262 }
5263
5264 std::fs::remove_file(&file_tracked).unwrap();
5266 assert!(!file_tracked.exists());
5267 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
5268 assert!(file_tracked.exists());
5269 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5270
5271 let _ = std::fs::remove_dir_all(&temp_path);
5273 }
5274
5275 #[test]
5276 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5277 fn test_stage_unstage_by_hunk() {
5278 let mut temp_path = std::env::temp_dir();
5279 temp_path.push(format!(
5280 "twig_test_hunk_{}",
5281 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5282 ));
5283 std::fs::create_dir_all(&temp_path).unwrap();
5284
5285 let repo = Repository::init(&temp_path).unwrap();
5287
5288 let mut config = repo.config().unwrap();
5290 config.set_str("user.name", "Test User").unwrap();
5291 config.set_str("user.email", "test@example.com").unwrap();
5292
5293 let file_path = temp_path.join("multihunk.txt");
5295 let mut file = File::create(&file_path).unwrap();
5296 for i in 1..=20 {
5297 writeln!(file, "Line {}", i).unwrap();
5298 }
5299 drop(file);
5300
5301 stage_file(&temp_path, "multihunk.txt").unwrap();
5303 commit_changes(&temp_path, "initial commit").unwrap();
5304
5305 let mut file = File::create(&file_path).unwrap();
5307 for i in 1..=20 {
5308 if i == 2 || i == 18 {
5309 writeln!(file, "Line {} modified", i).unwrap();
5310 } else {
5311 writeln!(file, "Line {}", i).unwrap();
5312 }
5313 }
5314 drop(file);
5315
5316 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
5318 let mut hunk_ranges = Vec::new();
5320 let mut current_start = None;
5321 for (i, line) in diff_lines.iter().enumerate() {
5322 if line.kind == DiffLineKind::Header {
5323 if let Some(start) = current_start {
5324 hunk_ranges.push(start..i);
5325 }
5326 current_start = Some(i);
5327 }
5328 }
5329 if let Some(start) = current_start {
5330 hunk_ranges.push(start..diff_lines.len());
5331 }
5332
5333 assert_eq!(hunk_ranges.len(), 2);
5335
5336 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
5338 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
5339
5340 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
5342 let staged_content: String =
5343 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
5344 assert!(staged_content.contains("Line 18 modified"));
5345 assert!(!staged_content.contains("Line 2 modified"));
5346
5347 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
5349 let unstaged_content: String =
5350 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
5351 assert!(unstaged_content.contains("Line 2 modified"));
5352 assert!(!unstaged_content.contains("Line 18 modified"));
5353
5354 let staged_hunk_ranges = {
5356 let mut ranges = Vec::new();
5357 let mut current_start = None;
5358 for (i, line) in staged_diff.iter().enumerate() {
5359 if line.kind == DiffLineKind::Header {
5360 if let Some(start) = current_start {
5361 ranges.push(start..i);
5362 }
5363 current_start = Some(i);
5364 }
5365 }
5366 if let Some(start) = current_start {
5367 ranges.push(start..staged_diff.len());
5368 }
5369 ranges
5370 };
5371 assert_eq!(staged_hunk_ranges.len(), 1);
5372 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
5373 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
5374
5375 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
5377 assert!(staged_diff_after.is_empty());
5378
5379 let _ = std::fs::remove_dir_all(&temp_path);
5381 }
5382
5383 #[test]
5384 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5385 fn test_discard_hunk() {
5386 let mut temp_path = std::env::temp_dir();
5387 temp_path.push(format!(
5388 "twig_test_discard_h_{}",
5389 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5390 ));
5391 std::fs::create_dir_all(&temp_path).unwrap();
5392
5393 let repo = Repository::init(&temp_path).unwrap();
5395
5396 let mut config = repo.config().unwrap();
5398 config.set_str("user.name", "Test User").unwrap();
5399 config.set_str("user.email", "test@example.com").unwrap();
5400
5401 let file_path = temp_path.join("discardhunk.txt");
5403 let mut file = File::create(&file_path).unwrap();
5404 for i in 1..=20 {
5405 writeln!(file, "Line {}", i).unwrap();
5406 }
5407 drop(file);
5408
5409 stage_file(&temp_path, "discardhunk.txt").unwrap();
5411 commit_changes(&temp_path, "initial commit").unwrap();
5412
5413 let mut file = File::create(&file_path).unwrap();
5415 for i in 1..=20 {
5416 if i == 2 || i == 18 {
5417 writeln!(file, "Line {} modified", i).unwrap();
5418 } else {
5419 writeln!(file, "Line {}", i).unwrap();
5420 }
5421 }
5422 drop(file);
5423
5424 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
5426 let mut hunk_ranges = Vec::new();
5428 let mut current_start = None;
5429 for (i, line) in diff_lines.iter().enumerate() {
5430 if line.kind == DiffLineKind::Header {
5431 if let Some(start) = current_start {
5432 hunk_ranges.push(start..i);
5433 }
5434 current_start = Some(i);
5435 }
5436 }
5437 if let Some(start) = current_start {
5438 hunk_ranges.push(start..diff_lines.len());
5439 }
5440
5441 assert_eq!(hunk_ranges.len(), 2);
5443
5444 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
5446 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
5447
5448 let contents = std::fs::read_to_string(&file_path).unwrap();
5450 assert!(contents.contains("Line 2 modified"));
5451 assert!(contents.contains("Line 18\n"));
5452 assert!(!contents.contains("Line 18 modified"));
5453
5454 let _ = std::fs::remove_dir_all(&temp_path);
5456 }
5457
5458 #[test]
5459 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5460 fn test_stage_unstage_discard_line() {
5461 let mut temp_path = std::env::temp_dir();
5462 temp_path.push(format!(
5463 "twig_test_line_{}",
5464 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5465 ));
5466 std::fs::create_dir_all(&temp_path).unwrap();
5467
5468 let repo = Repository::init(&temp_path).unwrap();
5469 let mut config = repo.config().unwrap();
5470 config.set_str("user.name", "Test User").unwrap();
5471 config.set_str("user.email", "test@example.com").unwrap();
5472
5473 let file_path = temp_path.join("line_test.txt");
5475 let mut file = File::create(&file_path).unwrap();
5476 writeln!(file, "line A").unwrap();
5477 writeln!(file, "line B").unwrap();
5478 writeln!(file, "line C").unwrap();
5479 drop(file);
5480
5481 stage_file(&temp_path, "line_test.txt").unwrap();
5482 commit_changes(&temp_path, "initial").unwrap();
5483
5484 let mut file = File::create(&file_path).unwrap();
5486 writeln!(file, "line A modified").unwrap();
5487 writeln!(file, "line B").unwrap();
5488 writeln!(file, "line C modified").unwrap();
5489 drop(file);
5490
5491 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5492 let mut hunk_ranges = Vec::new();
5493 let mut current_start = None;
5494 for (i, line) in diff_lines.iter().enumerate() {
5495 if line.kind == DiffLineKind::Header {
5496 if let Some(start) = current_start {
5497 hunk_ranges.push(start..i);
5498 }
5499 current_start = Some(i);
5500 }
5501 }
5502 if let Some(start) = current_start {
5503 hunk_ranges.push(start..diff_lines.len());
5504 }
5505
5506 assert_eq!(hunk_ranges.len(), 1);
5507 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
5508
5509 assert_eq!(hunk0[2].content, "line A modified");
5510 assert_eq!(hunk0[5].content, "line C modified");
5511
5512 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
5514
5515 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
5517 assert!(
5518 staged_diff
5519 .iter()
5520 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
5521 );
5522 assert!(
5523 !staged_diff
5524 .iter()
5525 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
5526 );
5527
5528 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5530 assert!(
5531 !unstaged_diff
5532 .iter()
5533 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
5534 );
5535 assert!(
5536 unstaged_diff
5537 .iter()
5538 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
5539 );
5540
5541 assert_eq!(staged_diff[2].content, "line A modified");
5543 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
5544
5545 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
5547
5548 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5550 assert_eq!(unstaged_diff2[5].content, "line C modified");
5551 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
5552
5553 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5554 let remove_idx = unstaged_diff3
5555 .iter()
5556 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
5557 .unwrap();
5558 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
5559
5560 let contents = std::fs::read_to_string(&file_path).unwrap();
5562 assert!(contents.contains("line A modified"));
5563 assert!(contents.contains("line B"));
5564 assert!(contents.contains("line C\n"));
5565 assert!(!contents.contains("line C modified"));
5566
5567 let _ = std::fs::remove_dir_all(&temp_path);
5569 }
5570
5571 #[test]
5572 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5573 fn test_stage_unstage_discard_all_changes() {
5574 let mut temp_path = std::env::temp_dir();
5575 temp_path.push(format!(
5576 "twig_test_all_{}",
5577 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5578 ));
5579 std::fs::create_dir_all(&temp_path).unwrap();
5580
5581 let repo = Repository::init(&temp_path).unwrap();
5583
5584 let mut config = repo.config().unwrap();
5586 config.set_str("user.name", "Test User").unwrap();
5587 config.set_str("user.email", "test@example.com").unwrap();
5588
5589 let file_path = temp_path.join("tracked.txt");
5591 std::fs::write(&file_path, "original content\n").unwrap();
5592 stage_file(&temp_path, "tracked.txt").unwrap();
5593 commit_changes(&temp_path, "initial").unwrap();
5594
5595 std::fs::write(&file_path, "modified content\n").unwrap();
5597 let untracked_path = temp_path.join("untracked.txt");
5598 std::fs::write(&untracked_path, "untracked content\n").unwrap();
5599
5600 let status = repo.statuses(None).unwrap();
5602 assert_eq!(status.len(), 2);
5603
5604 stage_all_changes(&temp_path).unwrap();
5606
5607 let status = repo.statuses(None).unwrap();
5609 for entry in status.iter() {
5610 assert!(
5611 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
5612 );
5613 }
5614
5615 unstage_all_changes(&temp_path).unwrap();
5617
5618 let status = repo.statuses(None).unwrap();
5620 for entry in status.iter() {
5621 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
5622 }
5623
5624 discard_all_changes(&temp_path).unwrap();
5626
5627 let status = repo.statuses(None).unwrap();
5629 assert_eq!(status.len(), 0);
5630
5631 let contents = std::fs::read_to_string(&file_path).unwrap();
5633 assert_eq!(contents, "original content\n");
5634 assert!(!untracked_path.exists());
5635
5636 let _ = std::fs::remove_dir_all(&temp_path);
5638 }
5639
5640 #[test]
5641 fn test_merge_conflicts_flow() {
5642 let mut temp_path = std::env::temp_dir();
5643 temp_path.push(format!(
5644 "twig_test_conflict_{}",
5645 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5646 ));
5647 std::fs::create_dir_all(&temp_path).unwrap();
5648
5649 let repo = Repository::init(&temp_path).unwrap();
5651
5652 let mut config = repo.config().unwrap();
5654 config.set_str("user.name", "Test User").unwrap();
5655 config.set_str("user.email", "test@example.com").unwrap();
5656
5657 let file_path = temp_path.join("conflict.txt");
5659 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
5660 stage_file(&temp_path, "conflict.txt").unwrap();
5661 commit_changes(&temp_path, "initial commit").unwrap();
5662
5663 let output = git_command()
5665 .args(["symbolic-ref", "--short", "HEAD"])
5666 .current_dir(&temp_path)
5667 .output()
5668 .unwrap();
5669 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5670
5671 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5673
5674 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
5675 stage_file(&temp_path, "conflict.txt").unwrap();
5676 commit_changes(&temp_path, "feature commit").unwrap();
5677
5678 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5680
5681 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
5682 stage_file(&temp_path, "conflict.txt").unwrap();
5683 commit_changes(&temp_path, "main commit").unwrap();
5684
5685 assert!(!is_merging(&temp_path));
5687 let merge_output =
5688 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5689
5690 assert!(!merge_output.status.success());
5691 assert!(is_merging(&temp_path));
5692
5693 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
5695 assert!(!diff.is_empty());
5696 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
5697 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
5698 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
5699 assert!(has_separator);
5700 assert!(has_ours);
5701 assert!(has_theirs);
5702
5703 abort_merge(&temp_path).unwrap();
5705 assert!(!is_merging(&temp_path));
5706
5707 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5709 assert!(is_merging(&temp_path));
5710
5711 resolve_ours(&temp_path, "conflict.txt").unwrap();
5713 let contents = std::fs::read_to_string(&file_path).unwrap();
5714 assert!(contents.contains("line 2 on main"));
5715 assert!(!contents.contains("<<<<<<<"));
5716
5717 continue_merge(&temp_path).unwrap();
5719 assert!(!is_merging(&temp_path));
5720
5721 git_command().args(["reset", "--hard", "HEAD~1"]).current_dir(&temp_path).output().unwrap();
5723
5724 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5725 assert!(is_merging(&temp_path));
5726
5727 resolve_theirs(&temp_path, "conflict.txt").unwrap();
5728 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
5729 assert!(contents_theirs.contains("line 2 on feature"));
5730 assert!(!contents_theirs.contains("<<<<<<<"));
5731
5732 continue_merge(&temp_path).unwrap();
5733 assert!(!is_merging(&temp_path));
5734
5735 let _ = std::fs::remove_dir_all(&temp_path);
5737 }
5738
5739 #[test]
5740 fn test_resolve_conflict_hunk() {
5741 let mut temp_path = std::env::temp_dir();
5742 temp_path.push(format!(
5743 "twig_test_hunk_conflict_{}",
5744 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5745 ));
5746 std::fs::create_dir_all(&temp_path).unwrap();
5747
5748 let repo = Repository::init(&temp_path).unwrap();
5750
5751 let mut config = repo.config().unwrap();
5753 config.set_str("user.name", "Test User").unwrap();
5754 config.set_str("user.email", "test@example.com").unwrap();
5755
5756 let file_path = temp_path.join("conflict.txt");
5758 let initial_lines = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\n";
5759 std::fs::write(&file_path, initial_lines).unwrap();
5760 stage_file(&temp_path, "conflict.txt").unwrap();
5761 commit_changes(&temp_path, "initial commit").unwrap();
5762
5763 let output = git_command()
5765 .args(["symbolic-ref", "--short", "HEAD"])
5766 .current_dir(&temp_path)
5767 .output()
5768 .unwrap();
5769 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5770
5771 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5773 let feature_lines = "line 1\nline 2 on feature\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11 on feature\nline 12\n";
5774 std::fs::write(&file_path, feature_lines).unwrap();
5775 stage_file(&temp_path, "conflict.txt").unwrap();
5776 commit_changes(&temp_path, "feature commit").unwrap();
5777
5778 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5780 let main_lines = "line 1\nline 2 on main\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11 on main\nline 12\n";
5781 std::fs::write(&file_path, main_lines).unwrap();
5782 stage_file(&temp_path, "conflict.txt").unwrap();
5783 commit_changes(&temp_path, "main commit").unwrap();
5784
5785 let merge_output =
5787 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5788 assert!(!merge_output.status.success());
5789 assert!(is_merging(&temp_path));
5790
5791 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
5793 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
5794 assert!(contents_after_first.contains("line 2 on main"));
5796 assert!(!contents_after_first.contains("line 2 on feature"));
5797 assert!(contents_after_first.contains("<<<<<<<"));
5799 assert!(contents_after_first.contains("line 11 on main"));
5800 assert!(contents_after_first.contains("line 11 on feature"));
5801
5802 assert!(is_merging(&temp_path));
5804
5805 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
5810 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
5811 assert!(contents_after_second.contains("line 2 on main"));
5813 assert!(contents_after_second.contains("line 11 on feature"));
5814 assert!(!contents_after_second.contains("<<<<<<<"));
5815
5816 let status = repo.statuses(None).unwrap();
5818 assert_eq!(status.len(), 1);
5819 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
5820
5821 continue_merge(&temp_path).unwrap();
5823 assert!(!is_merging(&temp_path));
5824
5825 let _ = std::fs::remove_dir_all(&temp_path);
5827 }
5828
5829 #[test]
5830 fn test_branch_and_commit_helpers() {
5831 let mut temp_path = std::env::temp_dir();
5832 temp_path.push(format!(
5833 "twig_test_helpers_{}",
5834 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5835 ));
5836 let _ = std::fs::remove_dir_all(&temp_path);
5837 std::fs::create_dir_all(&temp_path).unwrap();
5838
5839 let repo = Repository::init(&temp_path).unwrap();
5841
5842 let mut config = repo.config().unwrap();
5844 config.set_str("user.name", "Test User").unwrap();
5845 config.set_str("user.email", "test@example.com").unwrap();
5846
5847 let file_path = temp_path.join("test.txt");
5849 std::fs::write(&file_path, "root content").unwrap();
5850 stage_file(&temp_path, "test.txt").unwrap();
5851 commit_changes(&temp_path, "root commit").unwrap();
5852
5853 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5854 assert!(is_root_commit(&temp_path, &head_oid));
5855
5856 std::fs::write(&file_path, "second content").unwrap();
5858 stage_file(&temp_path, "test.txt").unwrap();
5859 commit_changes(&temp_path, "second commit").unwrap();
5860
5861 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
5862 assert!(!is_root_commit(&temp_path, &new_head_oid));
5863
5864 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
5869 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
5870 "master"
5871 } else {
5872 "main"
5873 };
5874 let target = get_branch_push_target(&temp_path, active_branch);
5875 assert!(target.is_some());
5876 let (remote_name, set_upstream) = target.unwrap();
5877 assert_eq!(remote_name, "origin");
5878 assert!(set_upstream);
5879
5880 let mut git_config = repo.config().unwrap();
5882 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5883 git_config
5884 .set_str(
5885 &format!("branch.{}.merge", active_branch),
5886 &format!("refs/heads/{}", active_branch),
5887 )
5888 .unwrap();
5889
5890 let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5892 repo.reference(
5893 &format!("refs/remotes/origin/{}", active_branch),
5894 commit_obj.id(),
5895 true,
5896 "create mock remote ref",
5897 )
5898 .unwrap();
5899
5900 assert!(has_upstream_remote(&temp_path, active_branch));
5902 assert_eq!(
5903 get_branch_upstream_remote(&temp_path, active_branch),
5904 Some("origin".to_string())
5905 );
5906
5907 let target_tracking = get_branch_push_target(&temp_path, active_branch);
5909 assert!(target_tracking.is_some());
5910 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5911 assert_eq!(remote_name_tr, "origin");
5912 assert!(!set_upstream_tr);
5913
5914 let _ = std::fs::remove_dir_all(&temp_path);
5916 }
5917
5918 #[test]
5919 fn test_core_helpers_comprehensive() {
5920 assert_eq!(format_relative_time(0), "unknown");
5922 assert_eq!(format_relative_time(-100), "unknown");
5923
5924 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5925 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5926 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5927 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5928 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5929 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5930 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5931
5932 assert_eq!(format_utc_date(0), "unknown");
5934 assert_eq!(format_utc_date(-1), "unknown");
5935 let date_str = format_utc_date(1700000000);
5936 assert!(date_str.contains("2023-11-14"));
5937
5938 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5940 let tilde_path = expand_tilde("~/path");
5941 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5942
5943 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5945 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5946 assert_eq!(sanitize_text("M4 — UAS"), "M4 — UAS");
5947 assert_eq!(
5948 sanitize_text("feat: support utf-8 — – … 🚀 日本語"),
5949 "feat: support utf-8 — – … 🚀 日本語"
5950 );
5951
5952 assert_eq!(safe_sha_slice("12345", 10), "12345");
5954 assert_eq!(safe_sha_slice("12345", 3), "123");
5955
5956 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
5958 assert!(safe_ref("-invalid").is_err());
5959 assert!(safe_ref(" ").is_err());
5960
5961 let mut status = RepoSummary::default();
5963 assert!(status.is_clean());
5964 assert!(status.is_synced());
5965 assert!(status.unchanged());
5966
5967 status.modified = 1;
5968 assert!(!status.is_clean());
5969 assert!(!status.unchanged());
5970
5971 status.modified = 0;
5972 status.ahead = 1;
5973 assert!(!status.is_synced());
5974 assert!(!status.unchanged());
5975
5976 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5978 assert!(data_not_loaded.is_not_loaded());
5979 assert!(!data_not_loaded.is_loading());
5980 assert!(!data_not_loaded.is_loaded());
5981 assert_eq!(data_not_loaded.len(), 0);
5982 assert!(data_not_loaded.is_empty());
5983 assert!(data_not_loaded.as_ref().is_none());
5984 assert!(data_not_loaded.first().is_none());
5985 assert!(data_not_loaded.get(0).is_none());
5986 assert_eq!(data_not_loaded.iter().count(), 0);
5987 assert_eq!(data_not_loaded.as_slice().len(), 0);
5988
5989 let data_loading: TabData<Vec<i32>> = TabData::Loading;
5990 assert!(!data_loading.is_not_loaded());
5991 assert!(data_loading.is_loading());
5992
5993 let data_loaded = TabData::Loaded(vec![10, 20]);
5994 assert!(!data_loaded.is_not_loaded());
5995 assert!(!data_loaded.is_loading());
5996 assert!(data_loaded.is_loaded());
5997 assert_eq!(data_loaded.len(), 2);
5998 assert!(!data_loaded.is_empty());
5999 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
6000 assert_eq!(data_loaded.first().unwrap(), &10);
6001 assert_eq!(data_loaded.get(1).unwrap(), &20);
6002 assert_eq!(data_loaded.iter().count(), 2);
6003 assert_eq!(data_loaded.as_slice(), &[10, 20]);
6004 }
6005
6006 #[test]
6007 fn test_comprehensive_gitwig_core_coverage_booster() {
6008 let mut temp_path = std::env::temp_dir();
6010 temp_path.push(format!(
6011 "twig_test_boost_{}",
6012 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
6013 ));
6014 std::fs::create_dir_all(&temp_path).unwrap();
6015
6016 let repo = Repository::init(&temp_path).unwrap();
6017
6018 {
6020 let mut config = repo.config().unwrap();
6021 config.set_str("user.name", "User A").unwrap();
6022 config.set_str("user.email", "a@example.com").unwrap();
6023 std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
6024 stage_file(&temp_path, "file1.txt").unwrap();
6025 commit_changes(&temp_path, "commit A").unwrap();
6026 }
6027
6028 {
6030 let mut config = repo.config().unwrap();
6031 config.set_str("user.name", "User B").unwrap();
6032 config.set_str("user.email", "b@example.com").unwrap();
6033 std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
6034 stage_file(&temp_path, "file2.txt").unwrap();
6035 commit_changes(&temp_path, "commit B").unwrap();
6036 }
6037
6038 let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
6040 assert_eq!(stats.len(), 2);
6041 assert_eq!(stats[0].count, 1);
6042 assert_eq!(stats[1].count, 1);
6043
6044 std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
6047 stage_file(&temp_path, "file1.txt").unwrap();
6048 commit_changes(&temp_path, "deleted file1").unwrap();
6049 let head_oid = repo.head().unwrap().target().unwrap().to_string();
6050 let files = get_commit_files(&temp_path, &head_oid).unwrap();
6051 assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
6052
6053 let path_tilde = expand_tilde("~");
6055 assert!(path_tilde.is_absolute());
6056
6057 let lfs_dir = temp_path.join(".git").join("lfs");
6059 std::fs::create_dir_all(&lfs_dir).unwrap();
6060 let nested_dir = lfs_dir.join("objects").join("xx");
6061 std::fs::create_dir_all(&nested_dir).unwrap();
6062 std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
6063 let lfs_size = get_lfs_storage_size(&temp_path);
6064 assert_eq!(lfs_size, Some(5));
6065
6066 let mut sub = repo
6068 .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
6069 .unwrap();
6070 let _ = sub.init(false);
6071 let subs = load_tab_submodules(&temp_path).unwrap();
6072 assert!(!subs.is_empty());
6073
6074 let wt_path = temp_path.join("wt-booster");
6076 let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
6077 let _ = worktree_lock(&temp_path, &wt_path, "lock reason");
6078 let _ = worktree_unlock(&temp_path, &wt_path);
6079 let _ = worktree_prune(&temp_path);
6080 let _ = std::fs::remove_dir_all(&wt_path);
6081
6082 open_browser("");
6084
6085 let nonexistent = Path::new("/nonexistent_directory_twig_test");
6087 let _ = stage_file(nonexistent, "test.txt");
6088 let _ = unstage_file(nonexistent, "test.txt");
6089 let _ = discard_all_changes(nonexistent);
6090 let _ = discard_file_changes(nonexistent, "test.txt", false);
6091 let _ = commit_changes(nonexistent, "msg");
6092 let _ = remote_add(nonexistent, "origin", "url");
6093 let _ = get_file_history(nonexistent, "test.txt");
6094 let _ = get_file_blame(nonexistent, "test.txt");
6095 let _ = get_commit_files(nonexistent, "sha");
6096 let _ = load_tab_reflog(nonexistent);
6097 let _ = load_tab_files(nonexistent);
6098 let _ = load_tab_branches(nonexistent);
6099 let _ = load_tab_tags(nonexistent);
6100 let _ = load_tab_remotes(nonexistent);
6101 let _ = load_tab_stashes(nonexistent);
6102 let _ = load_tab_overview(nonexistent, 10);
6103 let _ = load_tab_submodules(nonexistent);
6104 let _ = checkout_remote_branch(nonexistent, "origin/branch");
6105 let _ = checkout_local_branch(nonexistent, "branch");
6106 let _ = checkout_tag(nonexistent, "tag");
6107 let _ = delete_local_branch(nonexistent, "branch");
6108 let _ = delete_remote_branch(nonexistent, "origin/branch");
6109 let _ = create_tag(nonexistent, "tag", "sha", None, false);
6110 let _ = delete_remote_tag(nonexistent, "origin", "tag", Duration::from_secs(5));
6111 let _ = get_remote_tags(nonexistent, "origin", Duration::from_secs(5));
6112 let _ = apply_stash(nonexistent, 0);
6113 let _ = delete_stash(nonexistent, 0);
6114 let _ = save_stash(nonexistent, "msg", false, false);
6115 let _ = commit_amend(nonexistent, "msg");
6116 let _ = resolve_ours(nonexistent, "file");
6117 let _ = resolve_theirs(nonexistent, "file");
6118 let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
6119 let _ = abort_merge(nonexistent);
6120 let _ = continue_merge(nonexistent);
6121 let _ = get_branch_upstream_remote(nonexistent, "branch");
6122 let _ = load_tab_worktrees(nonexistent);
6123 let _ = worktree_add(nonexistent, "branch", Path::new("path"));
6124 let _ = worktree_lock(nonexistent, Path::new("path"), "reason");
6125 let _ = worktree_unlock(nonexistent, Path::new("path"));
6126 let _ = worktree_remove(nonexistent, Path::new("path"), true);
6127 let _ = submodule_remove(nonexistent, "name", Path::new("path"));
6128 let _ = worktree_prune(nonexistent);
6129 let _ = load_tab_forge_issues(nonexistent, true);
6130 let _ = load_tab_forge_prs(nonexistent);
6131 let _ = load_pr_comments(nonexistent, 1);
6132 let _ = checkout_pr_branch(nonexistent, 1);
6133 let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
6134 let _ = get_remote_url(nonexistent, "origin");
6135 let _ = get_default_remote_url(nonexistent);
6136 let _ = get_latest_change_time("/nonexistent");
6137
6138 let dummy_hunk = vec![
6139 DiffLine {
6140 kind: DiffLineKind::Header,
6141 content: "@@ -1,1 +1,1 @@".to_string(),
6142 old_lineno: None,
6143 new_lineno: None,
6144 hunk_idx: None,
6145 },
6146 DiffLine {
6147 kind: DiffLineKind::Added,
6148 content: "line".to_string(),
6149 old_lineno: None,
6150 new_lineno: None,
6151 hunk_idx: None,
6152 },
6153 ];
6154 let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
6155 let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
6156 let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
6157 let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
6158 let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
6159 let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
6160
6161 let _ = std::fs::remove_dir_all(&temp_path);
6163 }
6164}