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
200#[derive(Debug, Clone)]
201pub enum TabPayload {
202 Files(Result<Vec<String>, String>),
203 Graph(Result<Vec<GraphLine>, String>),
204 Branches {
205 local: Result<Vec<BranchInfo>, String>,
206 remote: Result<Vec<BranchInfo>, String>,
207 },
208 Tags {
209 local: Result<Vec<BranchInfo>, String>,
210 remote: Result<Vec<BranchInfo>, String>,
211 },
212 Remotes(Result<Vec<RemoteInfo>, String>),
213 Stashes(Result<Vec<StashInfo>, String>),
214 Overview(Result<(Vec<CommitterStat>, bool), String>),
215 Worktrees(Result<Vec<WorktreeInfo>, String>),
216 Submodules(Result<Vec<SubmoduleInfo>, String>),
217 Reflog(Result<Vec<ReflogEntry>, String>),
218 Notice(String),
221 ForgeIssues(Result<Vec<ForgeIssue>, String>),
222 ForgePRs(Result<Vec<ForgePR>, String>),
223 PRComments(Result<Vec<ForgePRComment>, String>),
224}
225
226#[derive(Debug, Default, Clone)]
227pub struct RepoInfo {
228 pub branch: Option<String>,
229 pub head: Option<HeadInfo>,
230 pub remotes: TabData<Vec<RemoteInfo>>,
231 pub upstream: Option<String>,
233 pub summary: RepoSummary,
234 pub changes: WorktreeChanges,
236 pub commits: Vec<CommitEntry>,
238 pub graph_lines: TabData<Vec<GraphLine>>,
240 pub local_branches: TabData<Vec<BranchInfo>>,
242 pub remote_branches: TabData<Vec<BranchInfo>>,
244 pub local_tags: TabData<Vec<BranchInfo>>,
246 pub remote_tags: TabData<Vec<BranchInfo>>,
248 pub remote_tags_loaded: bool,
250 pub remote_tags_attempted: bool,
252 pub files: TabData<Vec<String>>,
254 pub stashes: TabData<Vec<StashInfo>>,
256 pub worktrees: TabData<Vec<WorktreeInfo>>,
258 pub submodules: TabData<Vec<SubmoduleInfo>>,
260 pub reflog: TabData<Vec<ReflogEntry>>,
262 pub forge_issues: TabData<Vec<ForgeIssue>>,
263 pub forge_prs: TabData<Vec<ForgePR>>,
264 pub committer_stats: TabData<Vec<CommitterStat>>,
266 pub committer_stats_limit_reached: bool,
268 pub tab_loaded_at: [Option<std::time::Instant>; 12],
270 pub tab_loading: [bool; 12],
272 pub lfs_files: std::collections::HashSet<String>,
274 pub lfs_installed: bool,
276 pub lfs_storage_size: Option<u64>,
278}
279
280#[derive(Debug, Clone)]
281pub struct HeadInfo {
282 pub short_id: String,
283 pub summary: String,
284 pub author: String,
285 pub when: String,
286}
287
288#[derive(Debug, Clone)]
289pub struct ReflogEntry {
290 pub index: usize,
291 pub target_oid: String,
292 pub selector: String,
293 pub command: String,
294 pub message: String,
295 pub when: String,
296 pub date: String,
297}
298
299#[derive(Debug, Clone)]
300pub struct RemoteInfo {
301 pub name: String,
302 pub url: String,
303 pub push_url: Option<String>,
304 pub refspecs: Vec<String>,
305}
306
307#[derive(Debug, Clone)]
308pub struct CommitEntry {
309 pub id: String,
311 pub oid: String,
313 pub author: String,
314 pub when: String,
315 pub date: String,
316 pub summary: String,
317 pub message: String,
318 pub refs: Vec<String>,
321 pub files: Vec<FileEntry>,
323 pub signature_status: String,
325}
326
327#[derive(Debug, Clone)]
328pub struct GraphLine {
329 pub graph: String,
330 pub commit: Option<GraphCommit>,
331}
332
333#[derive(Debug, Clone)]
334pub struct GraphCommit {
335 pub oid: String,
336 pub decoration: String,
337 pub summary: String,
338 pub author: String,
339 pub date: String,
340 pub signature_status: String,
342}
343
344#[derive(Debug, Clone)]
346pub struct FileEntry {
347 pub path: String,
349 pub label: &'static str,
351}
352
353#[derive(Debug, Default, Clone)]
356pub struct WorktreeChanges {
357 pub staged: Vec<FileEntry>,
358 pub unstaged: Vec<FileEntry>,
359 pub untracked: Vec<FileEntry>,
360 pub conflicted: Vec<FileEntry>,
361}
362
363#[derive(Debug, Clone, PartialEq)]
367pub enum DiffLineKind {
368 Header,
370 Added,
372 Removed,
374 Context,
376 ConflictOurs,
378 ConflictTheirs,
380 ConflictSeparator,
382}
383
384#[derive(Debug, Clone)]
386pub struct DiffLine {
387 pub kind: DiffLineKind,
388 pub content: String,
390 pub old_lineno: Option<u32>,
391 pub new_lineno: Option<u32>,
392 pub hunk_idx: Option<usize>,
393}
394
395pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
399 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
400}
401
402pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
409 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
410}
411
412pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
415 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
416 let mut index = repo.index().map_err(|e| e.to_string())?;
417 let full_path = repo_path.join(file_path);
418 if full_path.exists() {
419 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
420 } else {
421 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
422 }
423 index.write().map_err(|e| e.to_string())?;
424 Ok(())
425}
426
427pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
432 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
433 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
435 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
436 .map_err(|e| e.to_string())?;
437 } else {
438 let mut index = repo.index().map_err(|e| e.to_string())?;
440 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
441 index.write().map_err(|e| e.to_string())?;
442 }
443 Ok(())
444}
445
446pub fn sanitize_text(s: &str) -> String {
447 let mut result = String::with_capacity(s.len());
448 let mut chars = s.chars().peekable();
449 while let Some(c) = chars.next() {
450 if c == '\x1B' {
451 if let Some(&'[') = chars.peek() {
453 chars.next();
454 for seq_c in chars.by_ref() {
455 if seq_c.is_ascii_alphabetic() {
457 break;
458 }
459 }
460 } else {
461 for seq_c in chars.by_ref() {
463 if seq_c == ' ' || seq_c.is_ascii_alphabetic() {
464 break;
465 }
466 }
467 }
468 } else if (c < ' ' || c == '\x7F') && c != '\n' && c != '\r' && c != '\t' {
469 result.push(' ');
471 } else {
472 result.push(c);
473 }
474 }
475 result
476}
477
478pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
479 let mut end = len.min(sha.len());
480 while end > 0 && !sha.is_char_boundary(end) {
481 end -= 1;
482 }
483 &sha[..end]
484}
485
486const GIT_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
488
489fn run_git_with_timeout(
496 mut cmd: std::process::Command,
497 timeout: Duration,
498) -> Result<std::process::Output, Box<dyn std::error::Error>> {
499 if timeout.is_zero() {
500 return Ok(cmd.output()?);
501 }
502
503 cmd.stdin(std::process::Stdio::null())
504 .stdout(std::process::Stdio::piped())
505 .stderr(std::process::Stdio::piped());
506
507 let mut child = cmd.spawn()?;
508
509 let mut out_pipe = child.stdout.take();
510 let mut err_pipe = child.stderr.take();
511 let out_reader = std::thread::spawn(move || {
512 let mut buf = Vec::new();
513 if let Some(p) = out_pipe.as_mut() {
514 let _ = std::io::Read::read_to_end(p, &mut buf);
515 }
516 buf
517 });
518 let err_reader = std::thread::spawn(move || {
519 let mut buf = Vec::new();
520 if let Some(p) = err_pipe.as_mut() {
521 let _ = std::io::Read::read_to_end(p, &mut buf);
522 }
523 buf
524 });
525
526 let started = Instant::now();
527 let status = loop {
528 match child.try_wait() {
529 Ok(Some(status)) => break Some(status),
530 Ok(None) => {
531 if started.elapsed() >= timeout {
532 let _ = child.kill();
533 let _ = child.wait();
534 break None;
535 }
536 std::thread::sleep(GIT_TIMEOUT_POLL_INTERVAL);
537 }
538 Err(e) => {
539 let _ = child.kill();
540 let _ = child.wait();
541 return Err(e.into());
542 }
543 }
544 };
545
546 let stdout = out_reader.join().unwrap_or_default();
547 let stderr = err_reader.join().unwrap_or_default();
548
549 match status {
550 Some(status) => Ok(std::process::Output { status, stdout, stderr }),
551 None => {
552 Err(format!("git did not respond within {}s and was cancelled", timeout.as_secs())
553 .into())
554 }
555 }
556}
557
558pub fn safe_ref(r: &str) -> Result<&str, String> {
559 let trimmed = r.trim();
560 if trimmed.starts_with('-') {
561 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
562 }
563 if trimmed.is_empty() {
564 return Err("Ref name cannot be empty".to_string());
565 }
566 Ok(trimmed)
567}
568
569pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
571 let output = git_command()
572 .arg("add")
573 .arg("-A")
574 .current_dir(repo_path)
575 .output()
576 .map_err(|e| e.to_string())?;
577
578 if output.status.success() {
579 Ok(())
580 } else {
581 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
582 }
583}
584
585pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
587 let output =
588 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
589
590 if output.status.success() {
591 Ok(())
592 } else {
593 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
594 }
595}
596
597pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
599 let _ = git_command().arg("reset").current_dir(repo_path).output();
601
602 let checkout_out = git_command()
604 .arg("checkout")
605 .arg("--")
606 .arg(".")
607 .current_dir(repo_path)
608 .output()
609 .map_err(|e| e.to_string())?;
610
611 if !checkout_out.status.success() {
612 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
613 }
614
615 let clean_out = git_command()
617 .arg("clean")
618 .arg("-fd")
619 .current_dir(repo_path)
620 .output()
621 .map_err(|e| e.to_string())?;
622
623 if !clean_out.status.success() {
624 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
625 }
626
627 Ok(())
628}
629
630pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
632 apply_hunk_patch(repo_path, file_path, hunk, false, true)
633}
634
635pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
637 apply_hunk_patch(repo_path, file_path, hunk, true, true)
638}
639
640pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
642 apply_hunk_patch(repo_path, file_path, hunk, true, false)
643}
644
645pub fn stage_line(
647 repo_path: &Path,
648 file_path: &str,
649 hunk: &[DiffLine],
650 selected_line_idx: usize,
651) -> Result<(), String> {
652 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
653}
654
655pub fn unstage_line(
657 repo_path: &Path,
658 file_path: &str,
659 hunk: &[DiffLine],
660 selected_line_idx: usize,
661) -> Result<(), String> {
662 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
663}
664
665pub fn discard_line(
667 repo_path: &Path,
668 file_path: &str,
669 hunk: &[DiffLine],
670 selected_line_idx: usize,
671) -> Result<(), String> {
672 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
673}
674
675fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
676 if !header.starts_with("@@") {
677 return None;
678 }
679 let parts: Vec<&str> = header.split("@@").collect();
680 if parts.len() < 3 {
681 return None;
682 }
683 let meta = parts[1].trim();
684 let subparts: Vec<&str> = meta.split_whitespace().collect();
685 if subparts.len() < 2 {
686 return None;
687 }
688
689 let parse_part = |p: &str| -> (usize, usize) {
690 let s = p.trim_start_matches(['-', '+']);
691 let comps: Vec<&str> = s.split(',').collect();
692 let start = comps[0].parse::<usize>().unwrap_or(0);
693 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
694 (start, count)
695 };
696
697 let (old_start, old_count) = parse_part(subparts[0]);
698 let (new_start, new_count) = parse_part(subparts[1]);
699 Some((old_start, old_count, new_start, new_count))
700}
701
702fn apply_line_patch_inner(
703 repo_path: &Path,
704 file_path: &str,
705 hunk: &[DiffLine],
706 selected_line_idx_in_hunk: usize,
707 revert: bool,
708 target_has_modification: bool,
709 cached: bool,
710) -> Result<(), String> {
711 use std::io::Write;
712 use std::process::Stdio;
713
714 let path_check = std::path::Path::new(file_path);
715 if path_check.is_absolute()
716 || path_check.components().any(|c| c == std::path::Component::ParentDir)
717 {
718 return Err("Path escapes repository root".to_string());
719 }
720
721 if hunk.is_empty() {
722 return Err("Empty hunk".to_string());
723 }
724
725 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
726 Some(line) => line,
727 None => return Err("Invalid line index".to_string()),
728 };
729
730 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
731 return Err("Selected line is not a modification (must be + or -)".to_string());
732 }
733
734 let header_line = &hunk[0];
735 let (old_start, _old_count, new_start, _new_count) =
736 match parse_hunk_header(&header_line.content) {
737 Some(coords) => coords,
738 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
739 };
740
741 let mut patch_lines = Vec::new();
742 let mut new_old_count = 0;
743 let mut new_new_count = 0;
744
745 for (i, line) in hunk.iter().enumerate() {
746 if i == 0 {
747 continue;
748 }
749
750 if i == selected_line_idx_in_hunk {
751 if revert {
752 match line.kind {
753 DiffLineKind::Added => {
754 patch_lines.push(DiffLine {
755 kind: DiffLineKind::Removed,
756 content: line.content.clone(),
757 old_lineno: None,
758 new_lineno: None,
759 hunk_idx: None,
760 });
761 new_old_count += 1;
762 }
763 DiffLineKind::Removed => {
764 patch_lines.push(DiffLine {
765 kind: DiffLineKind::Added,
766 content: line.content.clone(),
767 old_lineno: None,
768 new_lineno: None,
769 hunk_idx: None,
770 });
771 new_new_count += 1;
772 }
773 _ => {}
774 }
775 } else {
776 match line.kind {
777 DiffLineKind::Added => {
778 patch_lines.push(DiffLine {
779 kind: DiffLineKind::Added,
780 content: line.content.clone(),
781 old_lineno: None,
782 new_lineno: None,
783 hunk_idx: None,
784 });
785 new_new_count += 1;
786 }
787 DiffLineKind::Removed => {
788 patch_lines.push(DiffLine {
789 kind: DiffLineKind::Removed,
790 content: line.content.clone(),
791 old_lineno: None,
792 new_lineno: None,
793 hunk_idx: None,
794 });
795 new_old_count += 1;
796 }
797 _ => {}
798 }
799 }
800 } else {
801 match line.kind {
802 DiffLineKind::Context => {
803 patch_lines.push(line.clone());
804 new_old_count += 1;
805 new_new_count += 1;
806 }
807 DiffLineKind::Added => {
808 if target_has_modification {
809 patch_lines.push(DiffLine {
810 kind: DiffLineKind::Context,
811 content: line.content.clone(),
812 old_lineno: None,
813 new_lineno: None,
814 hunk_idx: None,
815 });
816 new_old_count += 1;
817 new_new_count += 1;
818 } else {
819 }
821 }
822 DiffLineKind::Removed => {
823 if target_has_modification {
824 } else {
826 patch_lines.push(DiffLine {
827 kind: DiffLineKind::Context,
828 content: line.content.clone(),
829 old_lineno: None,
830 new_lineno: None,
831 hunk_idx: None,
832 });
833 new_old_count += 1;
834 new_new_count += 1;
835 }
836 }
837 _ => {}
838 }
839 }
840 }
841
842 let mut patch = String::new();
843 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
844 patch.push_str(&format!("--- a/{}\n", file_path));
845 patch.push_str(&format!("+++ b/{}\n", file_path));
846 patch.push_str(&format!(
847 "@@ -{},{} +{},{} @@\n",
848 old_start, new_old_count, new_start, new_new_count
849 ));
850
851 for line in patch_lines {
852 let prefix = match line.kind {
853 DiffLineKind::Added => "+",
854 DiffLineKind::Removed => "-",
855 DiffLineKind::Context => " ",
856 DiffLineKind::Header => "",
857 _ => "",
858 };
859 patch.push_str(prefix);
860 patch.push_str(&line.content);
861 patch.push('\n');
862 }
863
864 let mut args = vec!["apply"];
865 if cached {
866 args.push("--cached");
867 }
868 args.push("-");
869
870 let mut cmd = git_command();
871 let mut child = cmd
872 .args(&args)
873 .current_dir(repo_path)
874 .stdin(Stdio::piped())
875 .stdout(Stdio::piped())
876 .stderr(Stdio::piped())
877 .spawn()
878 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
879
880 if let Some(mut stdin) = child.stdin.take() {
881 stdin
882 .write_all(patch.as_bytes())
883 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
884 }
885
886 let output =
887 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
888
889 if !output.status.success() {
890 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
891 return Err(format!("git apply failed: {}", err_msg.trim()));
892 }
893
894 Ok(())
895}
896
897fn apply_hunk_patch(
898 repo_path: &Path,
899 file_path: &str,
900 hunk: &[DiffLine],
901 reverse: bool,
902 cached: bool,
903) -> Result<(), String> {
904 use std::io::Write;
905 use std::process::Stdio;
906
907 let path_check = std::path::Path::new(file_path);
908 if path_check.is_absolute()
909 || path_check.components().any(|c| c == std::path::Component::ParentDir)
910 {
911 return Err("Path escapes repository root".to_string());
912 }
913
914 let mut patch = String::new();
915 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
916 patch.push_str(&format!("--- a/{}\n", file_path));
917 patch.push_str(&format!("+++ b/{}\n", file_path));
918 for line in hunk {
919 let prefix = match line.kind {
920 DiffLineKind::Added => "+",
921 DiffLineKind::Removed => "-",
922 DiffLineKind::Context => " ",
923 DiffLineKind::Header => "",
924 _ => "",
925 };
926 patch.push_str(prefix);
927 patch.push_str(&line.content);
928 patch.push('\n');
929 }
930
931 let mut args = vec!["apply"];
932 if cached {
933 args.push("--cached");
934 }
935 if reverse {
936 args.push("--reverse");
937 }
938 args.push("-");
939
940 let mut cmd = git_command();
941 let mut child = cmd
942 .args(&args)
943 .current_dir(repo_path)
944 .stdin(Stdio::piped())
945 .stdout(Stdio::piped())
946 .stderr(Stdio::piped())
947 .spawn()
948 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
949
950 if let Some(mut stdin) = child.stdin.take() {
951 stdin
952 .write_all(patch.as_bytes())
953 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
954 }
955
956 let output =
957 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
958
959 if !output.status.success() {
960 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
961 return Err(format!("git apply failed: {}", err_msg.trim()));
962 }
963
964 Ok(())
965}
966
967pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
972 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
973
974 if staged {
975 unstage_file(repo_path, file_path)?;
977 }
978
979 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
981 status.contains(git2::Status::WT_NEW)
982 } else {
983 false
984 };
985
986 if is_untracked {
987 let full_path = repo_path.join(file_path);
988 if full_path.exists() {
989 if full_path.is_file() {
990 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
991 } else if full_path.is_dir() {
992 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
993 }
994 }
995 } else {
996 let mut checkout_opts = git2::build::CheckoutBuilder::new();
998 checkout_opts.path(Path::new(file_path));
999 checkout_opts.force();
1000 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
1001 }
1002
1003 Ok(())
1004}
1005
1006pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
1009 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1010 let mut index = repo.index().map_err(|e| e.to_string())?;
1011 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
1012 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
1013
1014 let signature = repo
1015 .signature()
1016 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
1017
1018 let mut parents = Vec::new();
1020 let mut has_head = false;
1021 if let Ok(head) = repo.head() {
1022 if let Ok(parent_commit) = head.peel_to_commit() {
1023 has_head = true;
1024 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
1026 if parent_tree.id() == tree_id {
1027 return Err("No staged changes to commit".to_string());
1028 }
1029 parents.push(parent_commit);
1030 }
1031 }
1032
1033 if !has_head && index.is_empty() {
1034 return Err("No staged changes to commit (index is empty)".to_string());
1035 }
1036
1037 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1038
1039 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
1040 .map_err(|e| e.to_string())?;
1041
1042 Ok(())
1043}
1044
1045pub fn expand_tilde(s: &str) -> PathBuf {
1051 if s == "~" {
1052 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
1053 }
1054 if let Some(stripped) = s.strip_prefix("~/")
1055 && let Some(home) = dirs::home_dir()
1056 {
1057 return home.join(stripped);
1058 }
1059 PathBuf::from(s)
1060}
1061
1062pub fn discover_repo_root(start: &Path) -> Option<PathBuf> {
1069 let mut dir = if start.is_dir() { Some(start) } else { start.parent() };
1070 while let Some(current) = dir {
1071 if current.join(".git").exists() {
1072 return Some(current.to_path_buf());
1073 }
1074 dir = current.parent();
1075 }
1076 None
1077}
1078
1079pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1081 let repo = Repository::open(repo_path)?;
1082 repo.remote(name, url)?;
1083 Ok(())
1084}
1085
1086pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1088 let repo = Repository::open(repo_path)?;
1089 repo.remote_delete(name)?;
1090 Ok(())
1091}
1092
1093pub fn inspect_summary(item: &str) -> ItemStatus {
1095 let path = expand_tilde(item);
1096 if !path.is_dir() {
1097 return ItemStatus::Missing;
1098 }
1099 if !path.join(".git").exists() {
1100 return ItemStatus::Directory;
1101 }
1102 match Repository::open(&path) {
1103 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1104 Err(_) => ItemStatus::GitRepo(None),
1105 }
1106}
1107
1108pub fn inspect_detail(
1110 item: &str,
1111 commit_limit: usize,
1112 graph_max_commits: usize,
1113 enable_commit_signatures: bool,
1114) -> ItemDetail {
1115 let resolved = expand_tilde(item);
1116 if !resolved.is_dir() {
1117 return ItemDetail::Missing { resolved };
1118 }
1119 if !resolved.join(".git").exists() {
1120 return ItemDetail::Directory { resolved };
1121 }
1122 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1123 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1124 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1125 }
1126}
1127
1128fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1129 let mut sigs = std::collections::HashMap::new();
1130 let mut cmd = git_command();
1131 cmd.arg("log").arg("--all");
1132
1133 if limit > 0 {
1134 cmd.arg(format!("-n{}", limit));
1135 }
1136
1137 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1138
1139 if let Ok(out) = cmd.output() {
1140 if out.status.success() {
1141 let stdout_str = String::from_utf8_lossy(&out.stdout);
1142 for line in stdout_str.lines() {
1143 let parts: Vec<&str> = line.split_whitespace().collect();
1144 if parts.len() == 2 {
1145 sigs.insert(parts[0].to_string(), parts[1].to_string());
1146 } else if parts.len() == 1 {
1147 sigs.insert(parts[0].to_string(), "N".to_string());
1148 }
1149 }
1150 }
1151 }
1152 sigs
1153}
1154
1155#[derive(serde::Serialize, serde::Deserialize, Clone)]
1156struct CachedCommit {
1157 id: String,
1158 author: String,
1159 date: String,
1160 summary: String,
1161 message: String,
1162 time: i64,
1163}
1164
1165fn hash_path(path: &Path) -> String {
1166 use std::collections::hash_map::DefaultHasher;
1167 use std::hash::{Hash, Hasher};
1168 let mut hasher = DefaultHasher::new();
1169 path.hash(&mut hasher);
1170 format!("{:x}", hasher.finish())
1171}
1172
1173fn collect_commits(
1174 repo: &Repository,
1175 limit: usize,
1176 repo_path: &Path,
1177 enable_commit_signatures: bool,
1178) -> Result<Vec<CommitEntry>, git2::Error> {
1179 let mut walk = repo.revwalk()?;
1180 if walk.push_head().is_err() {
1181 return Ok(Vec::new());
1182 }
1183 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1184
1185 let mut commits = Vec::new();
1186 let oids: Vec<Result<git2::Oid, git2::Error>> =
1187 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1188
1189 let sig_map = if enable_commit_signatures {
1190 collect_signatures(repo_path, limit)
1191 } else {
1192 std::collections::HashMap::new()
1193 };
1194 let ref_map = get_cached_ref_map(repo, repo_path);
1195
1196 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1198 if let Some(ref dir) = cache_dir {
1199 let _ = std::fs::create_dir_all(dir);
1200 }
1201 let hash = hash_path(repo_path);
1202 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("v2_{}.json", hash)));
1203 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1204 .as_ref()
1205 .and_then(|f| std::fs::read_to_string(f).ok())
1206 .and_then(|s| serde_json::from_str(&s).ok())
1207 .unwrap_or_default();
1208
1209 let mut cache_updated = false;
1210
1211 for id in oids {
1212 let oid = id?;
1213 let oid_str = oid.to_string();
1214 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1215 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1216 let files = Vec::new();
1217
1218 if let Some(cached) = cache.get(&oid_str) {
1219 let when = format_relative_time(cached.time);
1220 commits.push(CommitEntry {
1221 id: cached.id.clone(),
1222 oid: oid_str,
1223 author: sanitize_text(&cached.author),
1224 when,
1225 date: cached.date.clone(),
1226 summary: sanitize_text(&cached.summary),
1227 message: sanitize_text(&cached.message),
1228 refs,
1229 files,
1230 signature_status: sig_status,
1231 });
1232 } else if let Ok(commit) = repo.find_commit(oid) {
1233 let short_id = format!("{:.7}", commit.id());
1234 let summary =
1235 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1236 let author = commit.author();
1237 let author_name = author.name().unwrap_or("?");
1238 let author_email = author.email().unwrap_or("?");
1239 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1240 let time_secs = commit.time().seconds();
1241 let when = format_relative_time(time_secs);
1242 let date = format_utc_date(time_secs);
1243 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1244
1245 let cached = CachedCommit {
1246 id: short_id.clone(),
1247 author: author_str.clone(),
1248 date: date.clone(),
1249 summary: summary.clone(),
1250 message: message.clone(),
1251 time: time_secs,
1252 };
1253 cache.insert(oid_str.clone(), cached);
1254 cache_updated = true;
1255
1256 commits.push(CommitEntry {
1257 id: short_id,
1258 oid: oid_str,
1259 author: author_str,
1260 when,
1261 date,
1262 summary,
1263 message,
1264 refs,
1265 files,
1266 signature_status: sig_status,
1267 });
1268 }
1269 }
1270
1271 if cache_updated {
1272 if let Some(ref f) = cache_file {
1273 if let Ok(json) = serde_json::to_string(&cache) {
1274 let _ = std::fs::write(f, json);
1275 }
1276 }
1277 }
1278
1279 Ok(commits)
1280}
1281
1282pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1283 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1284 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1285 if walk.push_head().is_err() {
1286 return Ok(Vec::new());
1287 }
1288 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1289
1290 let mut revisions = Vec::new();
1291
1292 for oid_res in walk {
1293 let oid = oid_res.map_err(|e| e.to_string())?;
1294 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1295
1296 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1297 let mut modified = false;
1298
1299 if commit.parent_count() > 0 {
1300 for i in 0..commit.parent_count() {
1301 if let Ok(parent) = commit.parent(i) {
1302 if let Ok(parent_tree) = parent.tree() {
1303 let mut diff_opts = git2::DiffOptions::new();
1304 diff_opts.pathspec(file_path);
1305 if let Ok(diff) = repo.diff_tree_to_tree(
1306 Some(&parent_tree),
1307 Some(&commit_tree),
1308 Some(&mut diff_opts),
1309 ) {
1310 if diff.deltas().len() > 0 {
1311 modified = true;
1312 break;
1313 }
1314 }
1315 }
1316 }
1317 }
1318 } else {
1319 let mut diff_opts = git2::DiffOptions::new();
1320 diff_opts.pathspec(file_path);
1321 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1322 {
1323 if diff.deltas().len() > 0 {
1324 modified = true;
1325 }
1326 }
1327 }
1328
1329 if modified {
1330 let author = commit.author();
1331 let author_name = author.name().unwrap_or("?");
1332 let author_email = author.email().unwrap_or("?");
1333 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1334 let time_secs = commit.time().seconds();
1335 let when = format_relative_time(time_secs);
1336 let date = format_utc_date(time_secs);
1337 let summary =
1338 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1339
1340 revisions.push(FileRevision {
1341 commit_oid: oid.to_string(),
1342 author: author_str,
1343 date,
1344 when,
1345 summary,
1346 });
1347 }
1348 }
1349
1350 Ok(revisions)
1351}
1352
1353#[derive(Debug, Clone)]
1354pub struct BlameEntry {
1355 pub line_no: usize,
1356 pub commit_id: String,
1357 pub author: String,
1358 pub date: String,
1359}
1360
1361pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1362 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1363 let mut opts = git2::BlameOptions::new();
1364 let blame = repo
1365 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1366 .map_err(|e| e.to_string())?;
1367
1368 let mut entries = Vec::new();
1369 for i in 0..blame.len() {
1370 if let Some(hunk) = blame.get_index(i) {
1371 let commit_id = hunk.final_commit_id().to_string();
1372 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1373
1374 let mut author_name = "Unknown".to_string();
1375 let mut date_str = "unknown".to_string();
1376 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1377 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1378 date_str = format_utc_date(commit.time().seconds());
1379 }
1380
1381 let start = hunk.final_start_line();
1382 let count = hunk.lines_in_hunk();
1383 for line_idx in 0..count {
1384 entries.push(BlameEntry {
1385 line_no: start + line_idx,
1386 commit_id: short_id.to_string(),
1387 author: author_name.clone(),
1388 date: date_str.clone(),
1389 });
1390 }
1391 }
1392 }
1393
1394 entries.sort_by_key(|e| e.line_no);
1395 Ok(entries)
1396}
1397
1398fn collect_committer_stats(
1399 repo: &Repository,
1400 limit: usize,
1401) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1402 let mut walk = repo.revwalk()?;
1403 if walk.push_head().is_err() {
1404 return Ok((Vec::new(), false));
1405 }
1406 let mut counts = std::collections::HashMap::new();
1407 let mut count = 0;
1408 let mut limit_reached = false;
1409 for id in walk {
1410 let oid = id?;
1411 if let Ok(commit) = repo.find_commit(oid) {
1412 let author = commit.author();
1413 let name = author.name().unwrap_or("?").to_string();
1414 let email = author.email().unwrap_or("?").to_string();
1415 let key = (name, email);
1416 *counts.entry(key).or_insert(0) += 1;
1417 count += 1;
1418 if count >= limit {
1419 limit_reached = true;
1420 break;
1421 }
1422 }
1423 }
1424
1425 let mut stats: Vec<CommitterStat> = counts
1426 .into_iter()
1427 .map(|((name, email), count)| CommitterStat { name, email, count })
1428 .collect();
1429
1430 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1431
1432 Ok((stats, limit_reached))
1433}
1434
1435fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1439 let commit_tree = match commit.tree() {
1440 Ok(t) => t,
1441 Err(_) => return Vec::new(),
1442 };
1443 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1446
1447 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1448 Ok(d) => d,
1449 Err(_) => return Vec::new(),
1450 };
1451
1452 let mut files = Vec::new();
1453 for delta in diff.deltas() {
1454 if files.len() >= MAX_FILES_PER_SECTION {
1455 break;
1456 }
1457 let path = delta
1458 .new_file()
1459 .path()
1460 .or_else(|| delta.old_file().path())
1461 .map(|p| p.to_string_lossy().into_owned())
1462 .unwrap_or_else(|| "(unknown)".to_string());
1463
1464 let label: &'static str = match delta.status() {
1465 git2::Delta::Added => "N",
1466 git2::Delta::Deleted => "D",
1467 git2::Delta::Modified => "M",
1468 git2::Delta::Renamed => "R",
1469 git2::Delta::Typechange => "T",
1470 _ => "M",
1471 };
1472 files.push(FileEntry { path, label });
1473 }
1474 files
1475}
1476
1477pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1478 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1479 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1480 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1481 Ok(commit_changed_files(&repo, &commit))
1482}
1483fn match_pattern(path: &str, pattern: &str) -> bool {
1484 if let Some(suffix) = pattern.strip_prefix('*') {
1485 return path.ends_with(suffix);
1486 }
1487 if let Some(prefix) = pattern.strip_suffix('*') {
1488 return path.starts_with(prefix);
1489 }
1490 if pattern.contains('*') {
1491 let parts: Vec<&str> = pattern.split('*').collect();
1492 if parts.len() == 2 {
1493 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1494 }
1495 }
1496 path == pattern || path.ends_with(&format!("/{}", pattern))
1497}
1498
1499static LFS_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1500
1501fn is_lfs_installed() -> bool {
1502 *LFS_INSTALLED.get_or_init(|| {
1503 if let Ok(output) = git_command().arg("lfs").arg("--version").output() {
1504 output.status.success()
1505 } else {
1506 false
1507 }
1508 })
1509}
1510
1511fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1512 let mut tracked = std::collections::HashSet::new();
1513
1514 let lfs_dir = repo_path.join(".git").join("lfs");
1516 let gitattributes_path = repo_path.join(".gitattributes");
1517 let has_lfs_attributes = if gitattributes_path.exists() {
1518 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1519 content.contains("filter=lfs")
1520 || content.contains("diff=lfs")
1521 || content.contains("merge=lfs")
1522 } else {
1523 false
1524 }
1525 } else {
1526 false
1527 };
1528
1529 if !lfs_dir.exists() && !has_lfs_attributes {
1530 return (false, tracked);
1531 }
1532
1533 let installed = is_lfs_installed();
1534 if installed {
1535 if let Ok(output) =
1536 git_command().arg("lfs").arg("ls-files").arg("-n").current_dir(repo_path).output()
1537 {
1538 if output.status.success() {
1539 if let Ok(stdout) = String::from_utf8(output.stdout) {
1540 for line in stdout.lines() {
1541 let trimmed = line.trim();
1542 if !trimmed.is_empty() {
1543 tracked.insert(trimmed.to_string());
1544 }
1545 }
1546 }
1547 }
1548 }
1549 }
1550
1551 let gitattributes_path = repo_path.join(".gitattributes");
1552 if gitattributes_path.exists() {
1553 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1554 let mut patterns = Vec::new();
1555 for line in content.lines() {
1556 let line = line.trim();
1557 if line.is_empty() || line.starts_with('#') {
1558 continue;
1559 }
1560 if line.contains("filter=lfs")
1561 || line.contains("diff=lfs")
1562 || line.contains("merge=lfs")
1563 {
1564 if let Some(pattern) = line.split_whitespace().next() {
1565 patterns.push(pattern.to_string());
1566 }
1567 }
1568 }
1569
1570 if !patterns.is_empty() {
1571 if let Ok(repo) = Repository::open(repo_path) {
1572 if let Ok(index) = repo.index() {
1573 for entry in index.iter() {
1574 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1575 for pat in &patterns {
1576 if match_pattern(path_str, pat) {
1577 tracked.insert(path_str.to_string());
1578 break;
1579 }
1580 }
1581 }
1582 }
1583 }
1584 }
1585 }
1586 }
1587 }
1588
1589 (installed, tracked)
1590}
1591
1592fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1593 let lfs_path = repo_path.join(".git").join("lfs");
1594 if !lfs_path.exists() {
1595 return None;
1596 }
1597
1598 fn calculate_dir_size(dir: &Path) -> u64 {
1599 let mut size = 0;
1600 if let Ok(entries) = std::fs::read_dir(dir) {
1601 for entry in entries.flatten() {
1602 if let Ok(metadata) = entry.metadata() {
1603 if metadata.is_dir() {
1604 size += calculate_dir_size(&entry.path());
1605 } else {
1606 size += metadata.len();
1607 }
1608 }
1609 }
1610 }
1611 size
1612 }
1613
1614 Some(calculate_dir_size(&lfs_path))
1615}
1616
1617fn collect_info(
1620 path: &Path,
1621 commit_limit: usize,
1622 _graph_max_commits: usize,
1623 enable_commit_signatures: bool,
1624) -> Result<RepoInfo, git2::Error> {
1625 let repo = Repository::open(path)?;
1626 let mut summary = RepoSummary::default();
1627 if let Ok(head) = repo.head() {
1628 summary.branch = head.shorthand().ok().map(String::from);
1629 }
1630 populate_ahead_behind(&repo, &mut summary);
1631
1632 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1633 let (lfs_inst, lfs_f) = get_lfs_info(path);
1634 info.lfs_installed = lfs_inst;
1635 info.lfs_files = lfs_f;
1636 info.lfs_storage_size = get_lfs_storage_size(path);
1637
1638 let is_detached = repo.head_detached().unwrap_or(false);
1639 if is_detached {
1640 info.branch = Some("HEAD".to_string());
1641 if let Ok(head) = repo.head() {
1642 if let Ok(commit) = head.peel_to_commit() {
1643 let short_id = format!("{:.7}", commit.id());
1644 let summary_text =
1645 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1646 let author = commit.author();
1647 let author_str = sanitize_text(&format!(
1648 "{} <{}>",
1649 author.name().unwrap_or("?"),
1650 author.email().unwrap_or("?")
1651 ));
1652 let when = format_relative_time(commit.time().seconds());
1653 info.head =
1654 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1655 }
1656 }
1657 } else if let Ok(head) = repo.head() {
1658 info.branch = head.shorthand().ok().map(String::from);
1659
1660 if let Ok(commit) = head.peel_to_commit() {
1661 let short_id = format!("{:.7}", commit.id());
1662 let summary_text =
1663 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1664 let author = commit.author();
1665 let author_str = sanitize_text(&format!(
1666 "{} <{}>",
1667 author.name().unwrap_or("?"),
1668 author.email().unwrap_or("?")
1669 ));
1670 let when = format_relative_time(commit.time().seconds());
1671 info.head =
1672 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1673 }
1674
1675 if let Ok(head_name) = head.name() {
1676 info.upstream = upstream_short_name(&repo, head_name);
1677 }
1678 }
1679
1680 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1681 info.commits = commits;
1682 }
1683
1684 populate_summary_and_file_changes(&repo, &mut info);
1685
1686 if let Ok(remotes) = load_tab_remotes(path) {
1687 info.remotes = TabData::Loaded(remotes);
1688 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1689 }
1690
1691 Ok(info)
1692}
1693
1694pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1695 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1696 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1697 let mut entries = Vec::new();
1698 for (i, entry) in reflog.iter().enumerate() {
1699 let target_oid = format!("{}", entry.id_new());
1700 let selector = format!("HEAD@{{{}}}", i);
1701 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1702
1703 let (command, message) = if let Some(pos) = msg.find(':') {
1704 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1705 } else {
1706 (String::new(), msg)
1707 };
1708
1709 let sig = entry.committer();
1710 let when_secs = sig.when().seconds();
1711 let when = format_relative_time(when_secs);
1712 let date = format_utc_date(when_secs);
1713
1714 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1715 }
1716 Ok(entries)
1717}
1718
1719pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1720 let output = git_command()
1721 .arg("checkout")
1722 .arg(commit_oid)
1723 .current_dir(repo_path)
1724 .output()
1725 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1726
1727 if !output.status.success() {
1728 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1729 return Err(git2::Error::from_str(&err));
1730 }
1731 Ok(())
1732}
1733
1734pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1735 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1736 let mut files = Vec::new();
1737 if let Ok(index) = repo.index() {
1738 for entry in index.iter() {
1739 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1740 files.push(path_str.to_string());
1741 }
1742 }
1743 }
1744 Ok(files)
1745}
1746
1747#[derive(Debug, Clone, Default)]
1749pub struct GraphLoad {
1750 pub lines: Vec<GraphLine>,
1751 pub notice: Option<String>,
1755}
1756
1757pub fn load_tab_graph_stream(
1758 repo_path: &Path,
1759 graph_max_commits: usize,
1760 enable_commit_signatures: bool,
1761 repo_resolved_path: String,
1762 tab_idx: usize,
1763 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1764) -> Result<GraphLoad, String> {
1765 let mut graph_lines = Vec::new();
1766 let format_str = if enable_commit_signatures {
1769 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?"
1770 } else {
1771 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad"
1772 };
1773
1774 let mut args = vec![
1775 "log".to_string(),
1776 "--graph".to_string(),
1777 "--all".to_string(),
1778 "--date=relative".to_string(),
1779 ];
1780 if graph_max_commits > 0 {
1781 args.push(format!("--max-count={}", graph_max_commits));
1782 }
1783 args.push(format!("--pretty=format:{}", format_str));
1784 args.push("--color=never".to_string());
1785
1786 let mut child =
1787 git_command().args(&args).current_dir(repo_path).spawn().map_err(|e| e.to_string())?;
1788
1789 let stderr_reader = spawn_pipe_reader(&mut child);
1792 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1793 let reader = std::io::BufReader::new(stdout);
1794 use std::io::BufRead;
1795
1796 for (idx, line_res) in reader.lines().enumerate() {
1797 let line = line_res.map_err(|e| e.to_string())?;
1798 let parsed = parse_graph_line(&line);
1799 graph_lines.push(parsed);
1800
1801 if (idx + 1) % 200 == 0 {
1803 let _ = tx.send((
1804 repo_resolved_path.clone(),
1805 tab_idx,
1806 TabPayload::Graph(Ok(graph_lines.clone())),
1807 ));
1808 }
1809 }
1810
1811 let status = child.wait().map_err(|e| e.to_string())?;
1813 let stderr = stderr_reader.join().unwrap_or_default();
1814 let notice = summarize_stderr(&stderr);
1815 if !status.success() && graph_lines.is_empty() {
1816 return Err(notice.unwrap_or_else(|| "git log failed".to_string()));
1817 }
1818
1819 Ok(GraphLoad { lines: graph_lines, notice })
1820}
1821
1822pub fn load_tab_branches(
1823 repo_path: &Path,
1824) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1825 let repo = match Repository::open(repo_path) {
1826 Ok(r) => r,
1827 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1828 };
1829
1830 let mut local_branches = Vec::new();
1831 let is_detached = repo.head_detached().unwrap_or(false);
1832 if is_detached {
1833 let mut short_sha = String::new();
1834 let mut short_message = String::new();
1835 if let Ok(head) = repo.head() {
1836 if let Ok(target) = head.peel_to_commit() {
1837 let id = target.id();
1838 let id_str = id.to_string();
1839 short_sha = safe_sha_slice(&id_str, 7).to_string();
1840 if let Ok(Some(summary)) = target.summary() {
1841 short_message = summary.to_string();
1842 }
1843 }
1844 }
1845 local_branches.push(BranchInfo {
1846 name: "HEAD".to_string(),
1847 is_head: true,
1848 short_sha,
1849 short_message: sanitize_text(&short_message),
1850 });
1851 }
1852 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1853 for (branch, _) in branches.flatten() {
1854 if let Ok(Some(name)) = branch.name() {
1855 let is_head = branch.is_head();
1856 let mut short_sha = String::new();
1857 let mut short_message = String::new();
1858 if let Ok(target) = branch.get().peel_to_commit() {
1859 let id = target.id();
1860 let id_str = id.to_string();
1861 short_sha = safe_sha_slice(&id_str, 7).to_string();
1862 if let Ok(Some(summary)) = target.summary() {
1863 short_message = summary.to_string();
1864 }
1865 }
1866 local_branches.push(BranchInfo {
1867 name: sanitize_text(name),
1868 is_head,
1869 short_sha,
1870 short_message: sanitize_text(&short_message),
1871 });
1872 }
1873 }
1874 }
1875 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1876
1877 let mut remote_branches = Vec::new();
1878 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1879 for (branch, _) in branches.flatten() {
1880 if let Ok(Some(name)) = branch.name() {
1881 if !name.ends_with("/HEAD") {
1882 let is_head = branch.is_head();
1883 let mut short_sha = String::new();
1884 let mut short_message = String::new();
1885 if let Ok(target) = branch.get().peel_to_commit() {
1886 let id = target.id();
1887 let id_str = id.to_string();
1888 short_sha = safe_sha_slice(&id_str, 7).to_string();
1889 if let Ok(Some(summary)) = target.summary() {
1890 short_message = summary.to_string();
1891 }
1892 }
1893 remote_branches.push(BranchInfo {
1894 name: sanitize_text(name),
1895 is_head,
1896 short_sha,
1897 short_message: sanitize_text(&short_message),
1898 });
1899 }
1900 }
1901 }
1902 }
1903 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1904
1905 (Ok(local_branches), Ok(remote_branches))
1906}
1907
1908pub fn load_tab_tags(
1909 repo_path: &Path,
1910) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1911 let repo = match Repository::open(repo_path) {
1912 Ok(r) => r,
1913 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1914 };
1915
1916 let mut local_tags = Vec::new();
1917 if let Ok(tags) = repo.tag_names(None) {
1918 for tag_opt in tags.iter() {
1919 if let Ok(Some(tag)) = tag_opt {
1920 let mut short_sha = String::new();
1921 let mut short_message = String::new();
1922 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1923 if let Ok(target) = reference.peel_to_commit() {
1924 let id = target.id();
1925 let id_str = id.to_string();
1926 short_sha = safe_sha_slice(&id_str, 7).to_string();
1927 if let Ok(Some(summary)) = target.summary() {
1928 short_message = summary.to_string();
1929 }
1930 }
1931 }
1932 local_tags.push(BranchInfo {
1933 name: sanitize_text(tag),
1934 is_head: false,
1935 short_sha,
1936 short_message: sanitize_text(&short_message),
1937 });
1938 }
1939 }
1940 }
1941 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1942
1943 (Ok(local_tags), Ok(Vec::new()))
1944}
1945
1946pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1947 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1948 let mut remotes_list = Vec::new();
1949 if let Ok(remotes) = repo.remotes() {
1950 for name in remotes.iter() {
1951 let Ok(Some(name)) = name else { continue };
1952 if let Ok(remote) = repo.find_remote(name) {
1953 let push_url = remote.pushurl().ok().flatten().map(String::from);
1954 let mut refspecs = Vec::new();
1955 for r in remote.refspecs() {
1956 if let Ok(s) = r.str() {
1957 refspecs.push(s.to_string());
1958 }
1959 }
1960 remotes_list.push(RemoteInfo {
1961 name: name.to_string(),
1962 url: remote.url().unwrap_or("(no url)").to_string(),
1963 push_url,
1964 refspecs,
1965 });
1966 }
1967 }
1968 }
1969 Ok(remotes_list)
1970}
1971
1972pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1973 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1974 let mut temp_stashes = Vec::new();
1975 let _ = repo.stash_foreach(|index, message, oid| {
1976 temp_stashes.push((index, message.to_string(), *oid));
1977 true
1978 });
1979
1980 let mut stashes = Vec::new();
1981 for (index, message, oid) in temp_stashes {
1982 let mut files = Vec::new();
1983 if let Ok(commit) = repo.find_commit(oid) {
1984 files = commit_changed_files(&repo, &commit);
1985 }
1986 stashes.push(StashInfo {
1987 index,
1988 message: sanitize_text(&message),
1989 commit_id: oid.to_string(),
1990 files,
1991 });
1992 }
1993 Ok(stashes)
1994}
1995
1996pub fn load_tab_overview(
1997 repo_path: &Path,
1998 _commit_limit: usize,
1999) -> Result<(Vec<CommitterStat>, bool), String> {
2000 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2001 let (stats, limit_reached) =
2002 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
2003 Ok((stats, limit_reached))
2004}
2005
2006pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
2007 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2008 let submodules = repo.submodules().map_err(|e| e.to_string())?;
2009 let mut list = Vec::new();
2010 for sub in submodules {
2011 let name = sub.name().unwrap_or("").to_string();
2012 let path = sub.path().to_path_buf();
2013 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
2014 let commit_id = sub.index_id().map(|id| id.to_string());
2015 let head_id = sub.head_id().map(|id| id.to_string());
2016
2017 let mut is_initialized = true;
2018 let mut is_dirty = false;
2019
2020 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
2021 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
2022 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
2023 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
2024 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
2025 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
2026 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
2027 }
2028
2029 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
2030 }
2031 Ok(list)
2032}
2033
2034fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2039 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
2040 std::collections::HashMap::new();
2041
2042 if let Ok(refs) = repo.references() {
2043 for reference in refs.flatten() {
2044 let Ok(target) = reference.peel_to_commit() else {
2046 continue;
2047 };
2048 let oid = target.id();
2049
2050 let Ok(full_name) = reference.name() else {
2051 continue;
2052 };
2053
2054 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
2055 branch.to_string()
2056 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
2057 format!("tag:{}", tag)
2058 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
2059 if remote.ends_with("/HEAD") {
2061 continue;
2062 }
2063 format!("remote:{}", remote)
2064 } else {
2065 continue;
2066 };
2067
2068 map.entry(oid).or_default().push(label);
2069 }
2070 }
2071
2072 if repo.head_detached().unwrap_or(false) {
2073 if let Ok(head) = repo.head() {
2074 if let Ok(target) = head.peel_to_commit() {
2075 let oid = target.id();
2076 let label = "head:HEAD".to_string();
2077 let mut entries = map.remove(&oid).unwrap_or_default();
2078 entries.insert(0, label);
2079 map.insert(oid, entries);
2080 }
2081 }
2082 }
2083
2084 map
2085}
2086
2087#[allow(clippy::type_complexity)]
2088static REF_MAP_CACHE: std::sync::OnceLock<
2089 std::sync::Mutex<
2090 std::collections::HashMap<
2091 String,
2092 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
2093 >,
2094 >,
2095> = std::sync::OnceLock::new();
2096
2097fn get_cached_ref_map(
2098 repo: &Repository,
2099 repo_path: &Path,
2100) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2101 let cache_lock =
2102 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
2103 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
2104 let path_key = repo_path.to_string_lossy().to_string();
2105
2106 if let Some((map, loaded_at)) = cache.get(&path_key) {
2107 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
2108 return map.clone();
2109 }
2110 }
2111
2112 let map = build_ref_map(repo);
2113 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
2114 map
2115}
2116
2117pub fn invalidate_ref_map_cache(repo_path: &Path) {
2118 if let Some(cache_lock) = REF_MAP_CACHE.get() {
2119 if let Ok(mut cache) = cache_lock.lock() {
2120 cache.remove(&repo_path.to_string_lossy().to_string());
2121 }
2122 }
2123}
2124
2125const MAX_FILES_PER_SECTION: usize = 100;
2128
2129fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
2131 let mut opts = StatusOptions::new();
2132 opts.include_untracked(true)
2133 .renames_head_to_index(true)
2134 .recurse_untracked_dirs(true)
2135 .show(StatusShow::IndexAndWorkdir);
2136 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2137 return;
2138 };
2139 for entry in statuses.iter() {
2140 let path = entry.path().unwrap_or("(unknown)").to_string();
2141 let flags = entry.status();
2142
2143 if flags.is_conflicted() {
2145 info.summary.conflicted += 1;
2146 } else {
2147 if flags.is_wt_new() {
2148 info.summary.untracked += 1;
2149 }
2150 if flags.is_wt_modified()
2151 || flags.is_wt_deleted()
2152 || flags.is_wt_renamed()
2153 || flags.is_wt_typechange()
2154 {
2155 info.summary.modified += 1;
2156 }
2157 if flags.is_index_new()
2158 || flags.is_index_modified()
2159 || flags.is_index_deleted()
2160 || flags.is_index_renamed()
2161 || flags.is_index_typechange()
2162 {
2163 info.summary.staged += 1;
2164 }
2165 }
2166
2167 let is_dir = if flags.is_wt_new() && !flags.is_index_new() {
2171 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2172 path_buf.is_dir()
2173 } else {
2174 false
2175 };
2176 if is_dir {
2177 continue;
2178 }
2179
2180 if flags.is_conflicted() {
2181 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2182 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2183 }
2184 continue;
2185 }
2186
2187 if (flags.is_index_new()
2189 || flags.is_index_modified()
2190 || flags.is_index_deleted()
2191 || flags.is_index_renamed()
2192 || flags.is_index_typechange())
2193 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2194 {
2195 let label = if flags.is_index_new() {
2196 "N"
2197 } else if flags.is_index_deleted() {
2198 "D"
2199 } else if flags.is_index_renamed() {
2200 "R"
2201 } else if flags.is_index_typechange() {
2202 "T"
2203 } else {
2204 "M"
2205 };
2206 info.changes.staged.push(FileEntry { path: path.clone(), label });
2207 }
2208
2209 if flags.is_wt_new() {
2211 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2212 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2213 }
2214 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2215 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2216 }
2217 } else if (flags.is_wt_modified()
2218 || flags.is_wt_deleted()
2219 || flags.is_wt_renamed()
2220 || flags.is_wt_typechange())
2221 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2222 {
2223 let label = if flags.is_wt_deleted() {
2224 "D"
2225 } else if flags.is_wt_renamed() {
2226 "R"
2227 } else if flags.is_wt_typechange() {
2228 "T"
2229 } else {
2230 "M"
2231 };
2232 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2233 }
2234 }
2235}
2236
2237fn collect_summary(repo: &Repository) -> RepoSummary {
2241 let mut s = RepoSummary::default();
2242 let is_detached = repo.head_detached().unwrap_or(false);
2243 if is_detached {
2244 s.branch = Some("HEAD".to_string());
2245 if let Ok(head) = repo.head() {
2246 if let Ok(commit) = head.peel_to_commit() {
2247 s.last_commit_time = Some(commit.time().seconds());
2248 }
2249 }
2250 } else if let Ok(head) = repo.head() {
2251 s.branch = head.shorthand().ok().map(String::from);
2252 if let Ok(commit) = head.peel_to_commit() {
2253 s.last_commit_time = Some(commit.time().seconds());
2254 }
2255 }
2256 populate_worktree(repo, &mut s);
2257 populate_ahead_behind(repo, &mut s);
2258 s.state = match repo.state() {
2259 git2::RepositoryState::Clean => RepoState::Clean,
2260 git2::RepositoryState::Merge => RepoState::Merge,
2261 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2262 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2263 RepoState::CherryPick
2264 }
2265 git2::RepositoryState::Bisect => RepoState::Bisect,
2266 git2::RepositoryState::Rebase
2267 | git2::RepositoryState::RebaseInteractive
2268 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2269 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2270 RepoState::ApplyMailbox
2271 }
2272 };
2273 s
2274}
2275
2276fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2277 let mut opts = StatusOptions::new();
2278 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2279 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2280 return;
2281 };
2282 for entry in statuses.iter() {
2283 let flags = entry.status();
2284 if flags.is_conflicted() {
2285 s.conflicted += 1;
2286 continue;
2287 }
2288 if flags.is_wt_new() {
2289 s.untracked += 1;
2290 }
2291 if flags.is_wt_modified()
2292 || flags.is_wt_deleted()
2293 || flags.is_wt_renamed()
2294 || flags.is_wt_typechange()
2295 {
2296 s.modified += 1;
2297 }
2298 if flags.is_index_new()
2299 || flags.is_index_modified()
2300 || flags.is_index_deleted()
2301 || flags.is_index_renamed()
2302 || flags.is_index_typechange()
2303 {
2304 s.staged += 1;
2305 }
2306 }
2307}
2308
2309fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2313 let Ok(head) = repo.head() else { return };
2314 let Some(local_oid) = head.target() else {
2315 return;
2316 };
2317 let Ok(head_name) = head.name() else { return };
2318 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2319 return;
2320 };
2321 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2322 return;
2323 };
2324 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2325 return;
2326 };
2327 let Some(upstream_oid) = upstream_ref.target() else {
2328 return;
2329 };
2330 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2331 s.ahead = ahead;
2332 s.behind = behind;
2333 }
2334}
2335
2336fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2338 let buf = repo.branch_upstream_name(head_name).ok()?;
2339 let raw = std::str::from_utf8(&buf).ok()?;
2340 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2341}
2342
2343pub fn format_relative_time(secs: i64) -> String {
2345 if secs <= 0 {
2346 return "unknown".to_string();
2347 }
2348 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2349 let now = SystemTime::now();
2350 let Ok(elapsed) = now.duration_since(then) else {
2351 return "in the future".to_string();
2352 };
2353 let secs = elapsed.as_secs();
2354 let (n, unit) = if secs < 60 {
2355 (secs, "second")
2356 } else if secs < 3600 {
2357 (secs / 60, "minute")
2358 } else if secs < 86_400 {
2359 (secs / 3600, "hour")
2360 } else if secs < 86_400 * 30 {
2361 (secs / 86_400, "day")
2362 } else if secs < 86_400 * 365 {
2363 (secs / (86_400 * 30), "month")
2364 } else {
2365 (secs / (86_400 * 365), "year")
2366 };
2367 let plural = if n == 1 { "" } else { "s" };
2368 format!("{} {}{} ago", n, unit, plural)
2369}
2370
2371fn format_utc_date(secs: i64) -> String {
2373 if secs <= 0 {
2374 return "unknown".to_string();
2375 }
2376 let seconds_in_day = 86400;
2377 let day_number = secs / seconds_in_day;
2378 let time_of_day = secs % seconds_in_day;
2379
2380 let mut hour = time_of_day / 3600;
2381 let mut minute = (time_of_day % 3600) / 60;
2382 let mut second = time_of_day % 60;
2383 if hour < 0 {
2384 hour += 24;
2385 }
2386 if minute < 0 {
2387 minute += 60;
2388 }
2389 if second < 0 {
2390 second += 60;
2391 }
2392
2393 let z = day_number + 719468;
2395 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2396 let doe = (z - era * 146097) as u32;
2397 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2398 let y = (yoe as i32) + (era as i32) * 400;
2399 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2400 let mp = (5 * doy + 2) / 153;
2401 let d = doy - (153 * mp + 2) / 5 + 1;
2402 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2403 let y = y + if m <= 2 { 1 } else { 0 };
2404
2405 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2406}
2407
2408fn get_file_diff_inner(
2411 repo_path: &Path,
2412 commit_oid: &str,
2413 file_path: &str,
2414) -> Option<Vec<DiffLine>> {
2415 let repo = Repository::open(repo_path).ok()?;
2416 let oid = git2::Oid::from_str(commit_oid).ok()?;
2417 let commit = repo.find_commit(oid).ok()?;
2418
2419 let commit_tree = commit.tree().ok()?;
2420 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2422
2423 let mut opts = git2::DiffOptions::new();
2424 opts.pathspec(file_path);
2425
2426 let diff =
2427 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2428
2429 collect_diff_lines(&diff)
2430}
2431
2432fn get_worktree_diff_inner(
2437 repo_path: &Path,
2438 file_path: &str,
2439 staged: bool,
2440) -> Option<Vec<DiffLine>> {
2441 let repo = Repository::open(repo_path).ok()?;
2442 let mut opts = git2::DiffOptions::new();
2443 opts.pathspec(file_path);
2444 opts.include_untracked(true);
2445 opts.recurse_untracked_dirs(true);
2446 opts.show_untracked_content(true);
2447
2448 let diff = if staged {
2449 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2451 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2452 } else {
2453 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2455 };
2456
2457 collect_diff_lines(&diff)
2458}
2459
2460fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2462 let mut lines: Vec<DiffLine> = Vec::new();
2463 let mut current_hunk_idx = None;
2464 let mut hunk_count = 0;
2465 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2466 let kind = match line.origin() {
2467 '+' => DiffLineKind::Added,
2468 '-' => DiffLineKind::Removed,
2469 'H' => {
2470 current_hunk_idx = Some(hunk_count);
2471 hunk_count += 1;
2472 DiffLineKind::Header
2473 }
2474 ' ' => DiffLineKind::Context,
2475 _ => return true, };
2477 let content = String::from_utf8_lossy(line.content())
2478 .trim_end_matches('\n')
2479 .trim_end_matches('\r')
2480 .to_string();
2481 lines.push(DiffLine {
2482 kind,
2483 content,
2484 old_lineno: line.old_lineno(),
2485 new_lineno: line.new_lineno(),
2486 hunk_idx: current_hunk_idx,
2487 });
2488 true
2489 })
2490 .ok()?;
2491 Some(lines)
2492}
2493
2494fn parse_graph_line(line: &str) -> GraphLine {
2495 if line.contains("__TWIG_SEP__") {
2496 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2497 if parts.len() >= 5 {
2498 let graph_and_hash = parts[0];
2499 let decoration = parts[1].trim().to_string();
2500 let summary = parts[2].trim().to_string();
2501 let author = parts[3].trim().to_string();
2502 let date = parts[4].trim().to_string();
2503 let signature_status =
2504 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2505
2506 let char_count = graph_and_hash.chars().count();
2507 if char_count >= 40 {
2508 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2509 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2510 GraphLine {
2511 graph,
2512 commit: Some(GraphCommit {
2513 oid,
2514 decoration,
2515 summary,
2516 author,
2517 date,
2518 signature_status,
2519 }),
2520 }
2521 } else {
2522 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2523 }
2524 } else {
2525 GraphLine { graph: line.to_string(), commit: None }
2526 }
2527 } else {
2528 GraphLine { graph: line.to_string(), commit: None }
2529 }
2530}
2531
2532#[allow(dead_code)]
2533fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2534 let mut graph_lines = Vec::new();
2535 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2536
2537 let mut args = vec![
2538 "log".to_string(),
2539 "--graph".to_string(),
2540 "--all".to_string(),
2541 "--date=relative".to_string(),
2542 ];
2543 if graph_max_commits > 0 {
2544 args.push(format!("--max-count={}", graph_max_commits));
2545 }
2546 args.push(format!("--pretty=format:{}", format_str));
2547 args.push("--color=never".to_string());
2548
2549 let output = git_command().args(&args).current_dir(repo_path).output();
2550
2551 if let Ok(out) = output {
2552 if out.status.success() {
2553 let stdout_str = String::from_utf8_lossy(&out.stdout);
2554 for line in stdout_str.lines() {
2555 graph_lines.push(parse_graph_line(line));
2556 }
2557 }
2558 }
2559 graph_lines
2560}
2561
2562pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2563 let output = git_command()
2564 .arg("checkout")
2565 .arg(branch_name)
2566 .current_dir(repo_path)
2567 .output()
2568 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2569
2570 if !output.status.success() {
2571 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2572 return Err(git2::Error::from_str(&err));
2573 }
2574 Ok(())
2575}
2576
2577pub fn checkout_remote_branch(
2578 repo_path: &Path,
2579 remote_branch_name: &str,
2580) -> Result<String, git2::Error> {
2581 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2582 if parts.len() < 2 {
2583 return Err(git2::Error::from_str("Invalid remote branch name"));
2584 }
2585 let local_name = parts[1];
2586
2587 let output = git_command()
2588 .arg("checkout")
2589 .arg(local_name)
2590 .current_dir(repo_path)
2591 .output()
2592 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2593
2594 if output.status.success() {
2595 return Ok(format!("Switched to existing branch '{}'", local_name));
2596 }
2597
2598 let output = git_command()
2599 .arg("checkout")
2600 .arg("--track")
2601 .arg(remote_branch_name)
2602 .current_dir(repo_path)
2603 .output()
2604 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2605
2606 if !output.status.success() {
2607 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2608 return Err(git2::Error::from_str(&err));
2609 }
2610
2611 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2612}
2613
2614pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2616 let repo = Repository::open(repo_path)?;
2617 let head = repo.head()?;
2618 let target_commit = head.peel_to_commit()?;
2619 repo.branch(branch_name, &target_commit, false)?;
2620 Ok(())
2621}
2622
2623pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2625 let repo = Repository::open(repo_path)?;
2626 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2627 branch.delete()?;
2628 Ok(())
2629}
2630
2631pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2633 let repo = Repository::open(repo_path)?;
2634 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2635 branch.delete()?;
2636 Ok(())
2637}
2638
2639pub fn create_tag(
2641 repo_path: &Path,
2642 tag_name: &str,
2643 commit_oid_str: &str,
2644 message: Option<&str>,
2645 force: bool,
2646) -> Result<(), git2::Error> {
2647 let repo = Repository::open(repo_path)?;
2648 let oid = git2::Oid::from_str(commit_oid_str)?;
2649 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2650 if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2651 let tagger_sig = repo.signature();
2652 let default_sig;
2653 let tagger = match &tagger_sig {
2654 Ok(s) => s,
2655 Err(_) => {
2656 if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2657 default_sig = sig;
2658 &default_sig
2659 } else {
2660 return Err(git2::Error::from_str("Failed to create signature"));
2661 }
2662 }
2663 };
2664 repo.tag(tag_name, &target_object, tagger, msg, force)?;
2665 } else {
2666 repo.tag_lightweight(tag_name, &target_object, force)?;
2667 }
2668 Ok(())
2669}
2670
2671pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2673 if let Ok(repo) = Repository::open(repo_path) {
2674 let ref_name = format!("refs/tags/{}", tag_name);
2675 repo.find_reference(&ref_name).is_ok()
2676 } else {
2677 false
2678 }
2679}
2680
2681pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2683 let repo = Repository::open(repo_path)?;
2684 repo.tag_delete(tag_name)?;
2685 Ok(())
2686}
2687
2688pub fn delete_remote_tag(
2690 repo_path: &Path,
2691 remote_name: &str,
2692 tag_name: &str,
2693 timeout: Duration,
2694) -> Result<(), Box<dyn std::error::Error>> {
2695 let safe_remote = safe_ref(remote_name)?;
2696 let safe_tag = safe_ref(tag_name)?;
2697 let mut cmd = git_command();
2698 cmd.arg("push").arg(safe_remote).arg("--delete").arg(safe_tag).current_dir(repo_path);
2699 let output = run_git_with_timeout(cmd, timeout)?;
2700 if !output.status.success() {
2701 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2702 return Err(err.into());
2703 }
2704 Ok(())
2705}
2706
2707pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2708 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2709 let output = git_command()
2710 .arg("checkout")
2711 .arg(safe_tag)
2712 .current_dir(repo_path)
2713 .output()
2714 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2715
2716 if !output.status.success() {
2717 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2718 return Err(git2::Error::from_str(&err));
2719 }
2720 Ok(())
2721}
2722
2723pub fn get_remote_tags(
2725 repo_path: &Path,
2726 remote_name: &str,
2727 timeout: Duration,
2728) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2729 let safe_remote = safe_ref(remote_name)?;
2730 let mut cmd = git_command();
2731 cmd.arg("ls-remote").arg("--tags").arg(safe_remote).current_dir(repo_path);
2732 let output = run_git_with_timeout(cmd, timeout)?;
2733
2734 if !output.status.success() {
2735 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2736 return Err(err.into());
2737 }
2738
2739 let stdout = String::from_utf8_lossy(&output.stdout);
2740 let repo = git2::Repository::open(repo_path)?;
2741 let mut tags_map = std::collections::HashMap::new();
2742
2743 for line in stdout.lines() {
2744 let parts: Vec<&str> = line.split_whitespace().collect();
2745 if parts.len() >= 2 {
2746 let sha = parts[0];
2747 let ref_name = parts[1];
2748 if ref_name.starts_with("refs/tags/") {
2749 let is_peeled = ref_name.ends_with("^{}");
2750 let clean_ref = if is_peeled {
2751 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2752 } else {
2753 ref_name
2754 };
2755 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2756 let short_sha = safe_sha_slice(sha, 7);
2757
2758 let mut short_message = String::new();
2760 if let Ok(oid) = git2::Oid::from_str(sha) {
2761 if let Ok(commit) = repo.find_commit(oid) {
2762 if let Ok(Some(summary)) = commit.summary() {
2763 short_message = summary.to_string();
2764 }
2765 }
2766 }
2767 if short_message.is_empty() {
2768 short_message = "(not fetched)".to_string();
2769 }
2770
2771 let sanitized_tag = sanitize_text(tag_name);
2772 let sanitized_msg = sanitize_text(&short_message);
2773
2774 if is_peeled {
2775 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2776 } else {
2777 tags_map
2778 .entry(sanitized_tag)
2779 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2780 }
2781 }
2782 }
2783 }
2784
2785 let mut tags = Vec::new();
2786 for (name, (short_sha, short_message)) in tags_map {
2787 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2788 }
2789 tags.sort_by(|a, b| b.name.cmp(&a.name));
2790 Ok(tags)
2791}
2792
2793pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2794 let mut s = String::new();
2795 for tag in tags {
2796 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2797 }
2798 s
2799}
2800
2801pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2802 let mut tags = Vec::new();
2803 for line in s.lines() {
2804 let parts: Vec<&str> = line.split('|').collect();
2805 if parts.len() >= 3 {
2806 tags.push(BranchInfo {
2807 name: parts[0].to_string(),
2808 is_head: false,
2809 short_sha: parts[1].to_string(),
2810 short_message: parts[2].to_string(),
2811 });
2812 }
2813 }
2814 tags
2815}
2816
2817pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2818 let mut repo = Repository::open(repo_path)?;
2819 repo.stash_drop(index)?;
2820 Ok(())
2821}
2822
2823pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2824 let stash_ref = format!("stash@{{{}}}", index);
2825 let output = git_command()
2826 .arg("stash")
2827 .arg("apply")
2828 .arg(&stash_ref)
2829 .current_dir(repo_path)
2830 .output()
2831 .map_err(|e| e.to_string())?;
2832
2833 if !output.status.success() {
2834 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2835 return Err(err_msg);
2836 }
2837 Ok(())
2838}
2839
2840pub fn save_stash(
2841 repo_path: &Path,
2842 message: &str,
2843 include_untracked: bool,
2844 keep_index: bool,
2845) -> Result<(), String> {
2846 let mut cmd = git_command();
2847 cmd.arg("stash").arg("push");
2848
2849 if include_untracked {
2850 cmd.arg("--include-untracked");
2851 }
2852 if keep_index {
2853 cmd.arg("--keep-index");
2854 }
2855
2856 if !message.is_empty() {
2857 cmd.arg("-m").arg(message);
2858 }
2859
2860 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2861
2862 if !output.status.success() {
2863 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2864 return Err(err_msg);
2865 }
2866 Ok(())
2867}
2868
2869pub fn get_latest_change_time(item: &str) -> u64 {
2870 let path = expand_tilde(item);
2871 if !path.exists() {
2872 return 0;
2873 }
2874
2875 if path.join(".git").exists() {
2876 if let Ok(repo) = Repository::open(&path) {
2877 if let Ok(head) = repo.head() {
2878 if let Ok(commit) = head.peel_to_commit() {
2879 return commit.time().seconds() as u64;
2880 }
2881 }
2882 }
2883 }
2884
2885 if let Ok(meta) = std::fs::metadata(&path) {
2886 if let Ok(modified) = meta.modified() {
2887 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2888 return duration.as_secs();
2889 }
2890 }
2891 }
2892 0
2893}
2894
2895pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2896 if let Ok(repo) = Repository::open(repo_path) {
2897 if let Ok(head) = repo.head() {
2898 if let Ok(commit) = head.peel_to_commit() {
2899 if let Ok(msg) = commit.message() {
2900 return Some(msg.to_string());
2901 }
2902 }
2903 }
2904 }
2905 None
2906}
2907
2908pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2909 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2910 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2911 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2912
2913 let mut index = repo.index().map_err(|e| e.to_string())?;
2914 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2915 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2916
2917 let signature = repo
2918 .signature()
2919 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2920
2921 head_commit
2922 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2923 .map_err(|e| e.to_string())?;
2924
2925 Ok(())
2926}
2927
2928pub fn is_merging(repo_path: &Path) -> bool {
2933 repo_path.join(".git/MERGE_HEAD").exists()
2934}
2935
2936pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2939 let full_path = repo_path.join(file_path);
2940 let content = match std::fs::read_to_string(&full_path) {
2941 Ok(s) => s,
2942 Err(_) => return Vec::new(),
2943 };
2944
2945 let mut lines = Vec::new();
2946 let mut in_ours = false;
2947 let mut in_theirs = false;
2948
2949 for line in content.lines() {
2950 if line.starts_with("<<<<<<<") {
2951 in_ours = true;
2952 in_theirs = false;
2953 lines.push(DiffLine {
2954 kind: DiffLineKind::ConflictSeparator,
2955 content: line.to_string(),
2956 old_lineno: None,
2957 new_lineno: None,
2958 hunk_idx: None,
2959 });
2960 } else if line.starts_with("=======") {
2961 in_ours = false;
2962 in_theirs = true;
2963 lines.push(DiffLine {
2964 kind: DiffLineKind::ConflictSeparator,
2965 content: line.to_string(),
2966 old_lineno: None,
2967 new_lineno: None,
2968 hunk_idx: None,
2969 });
2970 } else if line.starts_with(">>>>>>>") {
2971 in_ours = false;
2972 in_theirs = false;
2973 lines.push(DiffLine {
2974 kind: DiffLineKind::ConflictSeparator,
2975 content: line.to_string(),
2976 old_lineno: None,
2977 new_lineno: None,
2978 hunk_idx: None,
2979 });
2980 } else if in_ours {
2981 lines.push(DiffLine {
2982 kind: DiffLineKind::ConflictOurs,
2983 content: line.to_string(),
2984 old_lineno: None,
2985 new_lineno: None,
2986 hunk_idx: None,
2987 });
2988 } else if in_theirs {
2989 lines.push(DiffLine {
2990 kind: DiffLineKind::ConflictTheirs,
2991 content: line.to_string(),
2992 old_lineno: None,
2993 new_lineno: None,
2994 hunk_idx: None,
2995 });
2996 } else {
2997 lines.push(DiffLine {
2998 kind: DiffLineKind::Context,
2999 content: line.to_string(),
3000 old_lineno: None,
3001 new_lineno: None,
3002 hunk_idx: None,
3003 });
3004 }
3005 }
3006 lines
3007}
3008
3009pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
3012 let output1 = git_command()
3013 .args(["checkout", "--ours", file_path])
3014 .current_dir(repo_path)
3015 .output()
3016 .map_err(|e| e.to_string())?;
3017 if !output1.status.success() {
3018 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3019 }
3020 stage_file(repo_path, file_path)?;
3021 Ok(())
3022}
3023
3024pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
3027 let output1 = git_command()
3028 .args(["checkout", "--theirs", file_path])
3029 .current_dir(repo_path)
3030 .output()
3031 .map_err(|e| e.to_string())?;
3032 if !output1.status.success() {
3033 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3034 }
3035 stage_file(repo_path, file_path)?;
3036 Ok(())
3037}
3038
3039pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
3041 stage_file(repo_path, file_path)
3042}
3043
3044pub fn resolve_conflict_hunk(
3048 repo_path: &Path,
3049 file_path: &str,
3050 hunk_idx: usize,
3051 accept_ours: bool,
3052) -> Result<(), String> {
3053 let full_path = repo_path.join(file_path);
3054 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3055
3056 let mut new_lines = Vec::new();
3057 let mut lines_iter = content.lines().peekable();
3058 let mut current_hunk_idx = 0;
3059
3060 while let Some(line) = lines_iter.next() {
3061 if line.starts_with("<<<<<<<") {
3062 let mut ours_block = Vec::new();
3063 let mut theirs_block = Vec::new();
3064
3065 let mut found_separator = false;
3067 while let Some(&next_line) = lines_iter.peek() {
3068 if next_line.starts_with("=======") {
3069 lines_iter.next(); found_separator = true;
3071 break;
3072 }
3073 if let Some(line) = lines_iter.next() {
3074 ours_block.push(line.to_string());
3075 }
3076 }
3077
3078 let mut found_end = false;
3080 let mut end_line_marker = ">>>>>>>".to_string();
3081 while let Some(&next_line) = lines_iter.peek() {
3082 if next_line.starts_with(">>>>>>>") {
3083 if let Some(marker) = lines_iter.next() {
3084 end_line_marker = marker.to_string(); }
3086 found_end = true;
3087 break;
3088 }
3089 if let Some(line) = lines_iter.next() {
3090 theirs_block.push(line.to_string());
3091 }
3092 }
3093
3094 if current_hunk_idx == hunk_idx {
3095 if accept_ours {
3096 new_lines.extend(ours_block);
3097 } else {
3098 new_lines.extend(theirs_block);
3099 }
3100 } else {
3101 new_lines.push(line.to_string());
3102 new_lines.extend(ours_block);
3103 if found_separator {
3104 new_lines.push("=======".to_string());
3105 }
3106 new_lines.extend(theirs_block);
3107 if found_end {
3108 new_lines.push(end_line_marker);
3109 }
3110 }
3111
3112 current_hunk_idx += 1;
3113 } else {
3114 new_lines.push(line.to_string());
3115 }
3116 }
3117
3118 let mut new_content = new_lines.join("\n");
3119 if content.ends_with('\n') && !new_content.ends_with('\n') {
3120 new_content.push('\n');
3121 }
3122 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3123
3124 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3126 let has_conflict_markers = updated_content
3127 .lines()
3128 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3129
3130 if !has_conflict_markers {
3131 stage_file(repo_path, file_path)?;
3132 }
3133
3134 Ok(())
3135}
3136
3137pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3139 let output = git_command()
3140 .args(["merge", "--abort"])
3141 .current_dir(repo_path)
3142 .output()
3143 .map_err(|e| e.to_string())?;
3144 if !output.status.success() {
3145 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3146 }
3147 Ok(())
3148}
3149
3150pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3152 let output = git_command()
3153 .args(["merge", "--continue"])
3154 .env("GIT_EDITOR", "true")
3155 .current_dir(repo_path)
3156 .output()
3157 .map_err(|e| e.to_string())?;
3158 if !output.status.success() {
3159 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3160 }
3161 Ok(())
3162}
3163
3164pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3166 let repo = Repository::open(repo_path).ok()?;
3167 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3168 let _upstream = branch.upstream().ok()?;
3169 let local_ref = branch.get().name().ok()?;
3170 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3171 remote_buf.as_str().ok().map(|s| s.to_string())
3172}
3173
3174pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3176 get_branch_upstream_remote(repo_path, branch_name).is_some()
3177}
3178
3179pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3181 let repo = Repository::open(repo_path).ok()?;
3182 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3183 if branch.upstream().is_ok() {
3184 if let Ok(local_ref) = branch.get().name() {
3185 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3186 if let Ok(name) = remote_buf.as_str() {
3187 return Some((name.to_string(), false));
3188 }
3189 }
3190 }
3191 }
3192 let remotes = repo.remotes().ok()?;
3193 let first_remote = remotes.iter().next()?.ok()??.to_string();
3194 Some((first_remote, true))
3195}
3196
3197pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3199 if let Ok(repo) = Repository::open(repo_path) {
3200 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3201 if let Ok(commit) = repo.find_commit(oid) {
3202 return commit.parent_count() == 0;
3203 }
3204 }
3205 }
3206 false
3207}
3208
3209pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3210 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3211 let mut worktrees_list = Vec::new();
3212 if let Ok(worktree_names) = repo.worktrees() {
3213 for name in worktree_names.iter() {
3214 let Ok(Some(wt_name)) = name else { continue };
3215 if let Ok(wt) = repo.find_worktree(wt_name) {
3216 let path = wt.path().to_path_buf();
3217 let mut branch = None;
3218 if let Ok(wt_repo) = Repository::open(&path) {
3219 if let Ok(head) = wt_repo.head() {
3220 branch = head.shorthand().map(String::from).ok();
3221 }
3222 }
3223
3224 let mut is_locked = false;
3225 let mut lock_reason = None;
3226 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3227 is_locked = true;
3228 if let Some(reason) = reason_opt {
3229 if !reason.is_empty() {
3230 lock_reason = Some(reason);
3231 }
3232 }
3233 }
3234
3235 worktrees_list.push(WorktreeInfo {
3236 name: wt_name.to_string(),
3237 path,
3238 branch,
3239 is_locked,
3240 lock_reason,
3241 });
3242 }
3243 }
3244 }
3245 Ok(worktrees_list)
3246}
3247
3248pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3249 let output = git_command()
3250 .arg("worktree")
3251 .arg("add")
3252 .arg(wt_path)
3253 .arg(branch)
3254 .current_dir(repo_path)
3255 .output()
3256 .map_err(|e| e.to_string())?;
3257
3258 if !output.status.success() {
3259 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3260 }
3261 Ok(())
3262}
3263
3264pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3265 let output = git_command()
3266 .arg("worktree")
3267 .arg("lock")
3268 .arg("--reason")
3269 .arg(reason)
3270 .arg(name)
3271 .current_dir(repo_path)
3272 .output()
3273 .map_err(|e| e.to_string())?;
3274
3275 if !output.status.success() {
3276 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3277 }
3278 Ok(())
3279}
3280
3281pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3282 let output = git_command()
3283 .arg("worktree")
3284 .arg("unlock")
3285 .arg(name)
3286 .current_dir(repo_path)
3287 .output()
3288 .map_err(|e| e.to_string())?;
3289
3290 if !output.status.success() {
3291 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3292 }
3293 Ok(())
3294}
3295
3296pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3297 let mut cmd = git_command();
3298 cmd.arg("worktree").arg("remove");
3299 if force {
3300 cmd.arg("--force");
3301 }
3302 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3303
3304 if !output.status.success() {
3305 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3306 }
3307 Ok(())
3308}
3309
3310pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3311 let output = git_command()
3312 .arg("worktree")
3313 .arg("prune")
3314 .current_dir(repo_path)
3315 .output()
3316 .map_err(|e| e.to_string())?;
3317
3318 if !output.status.success() {
3319 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3320 }
3321 Ok(())
3322}
3323
3324pub fn load_tab_forge_issues(
3325 repo_path: &Path,
3326 assigned_only: bool,
3327) -> Result<Vec<ForgeIssue>, String> {
3328 let mut cmd = tool_command("gh");
3329 cmd.arg("issue").arg("list");
3330 if assigned_only {
3331 cmd.arg("--assignee").arg("@me");
3332 }
3333 cmd.arg("--limit")
3334 .arg("50")
3335 .arg("--json")
3336 .arg("number,title,state,author,assignees,url")
3337 .current_dir(repo_path);
3338
3339 let output = match cmd.output() {
3340 Ok(out) => out,
3341 Err(e) => {
3342 return Err(format!(
3343 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3344 e
3345 ));
3346 }
3347 };
3348
3349 if !output.status.success() {
3350 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3351 return Err(format!(
3352 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3353 err_msg
3354 ));
3355 }
3356
3357 #[derive(serde::Deserialize)]
3358 struct GhAuthor {
3359 login: String,
3360 }
3361
3362 #[derive(serde::Deserialize)]
3363 struct GhAssignee {
3364 login: String,
3365 }
3366
3367 #[derive(serde::Deserialize)]
3368 struct GhIssue {
3369 number: u32,
3370 title: String,
3371 state: String,
3372 author: Option<GhAuthor>,
3373 assignees: Option<Vec<GhAssignee>>,
3374 url: String,
3375 }
3376
3377 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3378 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3379
3380 let issues = raw_issues
3381 .into_iter()
3382 .map(|item| {
3383 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3384 let assignees =
3385 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3386 ForgeIssue {
3387 number: item.number,
3388 title: item.title,
3389 state: item.state,
3390 author,
3391 assignees,
3392 url: item.url,
3393 }
3394 })
3395 .collect();
3396
3397 Ok(issues)
3398}
3399
3400#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3401pub struct ForgePR {
3402 pub number: u32,
3403 pub title: String,
3404 pub state: String,
3405 pub author: String,
3406 pub assignees: Vec<String>,
3407 pub url: String,
3408 pub head_ref: String,
3409 pub head_ref_oid: String,
3410 pub body: String,
3411 pub status_checks: Vec<CIStatusCheck>,
3412 pub reviews: Vec<PRReview>,
3413}
3414
3415#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3416pub struct ForgePRComment {
3417 pub path: String,
3418 pub line: Option<u32>,
3419 pub body: String,
3420 pub author: String,
3421 pub commit_id: String,
3422}
3423
3424#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3425pub struct CIStatusCheck {
3426 pub name: String,
3427 pub state: Option<String>,
3428 pub status: Option<String>,
3429 pub conclusion: Option<String>,
3430}
3431
3432#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3433pub struct PRReview {
3434 pub author: String,
3435 pub body: String,
3436 pub state: String,
3437}
3438
3439pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3440 let mut cmd = tool_command("gh");
3441 cmd.arg("pr")
3442 .arg("list")
3443 .arg("--limit")
3444 .arg("50")
3445 .arg("--json")
3446 .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3447 .current_dir(repo_path);
3448
3449 let output = match cmd.output() {
3450 Ok(out) => out,
3451 Err(e) => {
3452 return Err(format!(
3453 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3454 e
3455 ));
3456 }
3457 };
3458
3459 if !output.status.success() {
3460 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3461 return Err(format!(
3462 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3463 err_msg
3464 ));
3465 }
3466
3467 #[derive(serde::Deserialize)]
3468 struct GhAuthor {
3469 login: String,
3470 }
3471
3472 #[derive(serde::Deserialize)]
3473 struct GhAssignee {
3474 login: String,
3475 }
3476
3477 #[derive(serde::Deserialize)]
3478 struct GhReview {
3479 author: Option<GhAuthor>,
3480 body: String,
3481 state: String,
3482 }
3483
3484 #[derive(serde::Deserialize)]
3485 struct GhStatusCheck {
3486 name: String,
3487 state: Option<String>,
3488 status: Option<String>,
3489 conclusion: Option<String>,
3490 }
3491
3492 #[derive(serde::Deserialize)]
3493 struct GhPR {
3494 number: u32,
3495 title: String,
3496 state: String,
3497 author: Option<GhAuthor>,
3498 assignees: Option<Vec<GhAssignee>>,
3499 url: String,
3500 #[serde(rename = "headRefName")]
3501 head_ref_name: String,
3502 #[serde(rename = "headRefOid")]
3503 head_ref_oid: String,
3504 body: String,
3505 #[serde(rename = "statusCheckRollup")]
3506 status_check_rollup: Option<Vec<GhStatusCheck>>,
3507 reviews: Option<Vec<GhReview>>,
3508 }
3509
3510 let raw_prs: Vec<GhPR> = serde_json::from_slice(&output.stdout)
3511 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3512
3513 let prs = raw_prs
3514 .into_iter()
3515 .map(|item| {
3516 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3517 let assignees =
3518 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3519 let status_checks = item
3520 .status_check_rollup
3521 .unwrap_or_default()
3522 .into_iter()
3523 .map(|c| CIStatusCheck {
3524 name: c.name,
3525 state: c.state,
3526 status: c.status,
3527 conclusion: c.conclusion,
3528 })
3529 .collect();
3530 let reviews = item
3531 .reviews
3532 .unwrap_or_default()
3533 .into_iter()
3534 .map(|r| PRReview {
3535 author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3536 body: r.body,
3537 state: r.state,
3538 })
3539 .collect();
3540
3541 ForgePR {
3542 number: item.number,
3543 title: item.title,
3544 state: item.state,
3545 author,
3546 assignees,
3547 url: item.url,
3548 head_ref: item.head_ref_name,
3549 head_ref_oid: item.head_ref_oid,
3550 body: item.body,
3551 status_checks,
3552 reviews,
3553 }
3554 })
3555 .collect();
3556
3557 Ok(prs)
3558}
3559
3560pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3561 let mut cmd = tool_command("gh");
3562 cmd.arg("api")
3563 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3564 .current_dir(repo_path);
3565
3566 let output = match cmd.output() {
3567 Ok(out) => out,
3568 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3569 };
3570
3571 if !output.status.success() {
3572 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3573 return Err(format!("Failed to load PR comments: {}", err));
3574 }
3575
3576 #[derive(serde::Deserialize)]
3577 struct GhUser {
3578 login: String,
3579 }
3580
3581 #[derive(serde::Deserialize)]
3582 struct GhPRComment {
3583 path: String,
3584 line: Option<u32>,
3585 body: String,
3586 user: Option<GhUser>,
3587 #[serde(rename = "commit_id")]
3588 commit_id: String,
3589 }
3590
3591 let raw_comments: Vec<GhPRComment> = serde_json::from_slice(&output.stdout)
3592 .map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3593
3594 let comments = raw_comments
3595 .into_iter()
3596 .map(|c| ForgePRComment {
3597 path: c.path,
3598 line: c.line,
3599 body: c.body,
3600 author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3601 commit_id: c.commit_id,
3602 })
3603 .collect();
3604
3605 Ok(comments)
3606}
3607
3608pub fn add_pr_line_comment(
3609 repo_path: &Path,
3610 pr_number: u32,
3611 commit_id: &str,
3612 path: &str,
3613 line: u32,
3614 body: &str,
3615) -> Result<(), String> {
3616 let mut cmd = tool_command("gh");
3617 cmd.arg("api")
3618 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3619 .arg("--method")
3620 .arg("POST")
3621 .arg("-F")
3622 .arg(format!("body={}", body))
3623 .arg("-F")
3624 .arg(format!("commit_id={}", commit_id))
3625 .arg("-F")
3626 .arg(format!("path={}", path))
3627 .arg("-F")
3628 .arg(format!("line={}", line))
3629 .arg("-F")
3630 .arg("side=RIGHT")
3631 .current_dir(repo_path);
3632
3633 let output = match cmd.output() {
3634 Ok(out) => out,
3635 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3636 };
3637
3638 if !output.status.success() {
3639 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3640 return Err(format!("Failed to add PR comment: {}", err));
3641 }
3642 Ok(())
3643}
3644
3645pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3646 let mut cmd = tool_command("gh");
3647 cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3648
3649 let output = cmd.output().map_err(|e| e.to_string())?;
3650 if !output.status.success() {
3651 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3652 return Err(format!("Failed to checkout PR branch: {}", err));
3653 }
3654 Ok(format!("Checked out branch for PR #{}", pr_number))
3655}
3656
3657pub fn resolve_and_checkout_issue_branch(
3658 repo_path: &Path,
3659 issue_number: u32,
3660) -> Result<String, String> {
3661 let mut cmd = tool_command("gh");
3662 cmd.arg("issue")
3663 .arg("view")
3664 .arg(issue_number.to_string())
3665 .arg("--json")
3666 .arg("developmentBranch")
3667 .current_dir(repo_path);
3668
3669 if let Ok(output) = cmd.output() {
3670 if output.status.success() {
3671 #[derive(serde::Deserialize)]
3672 struct GhDevBranch {
3673 name: String,
3674 }
3675 #[derive(serde::Deserialize)]
3676 struct GhIssueView {
3677 #[serde(rename = "developmentBranch")]
3678 development_branch: Option<GhDevBranch>,
3679 }
3680 if let Ok(parsed) = serde_json::from_slice::<GhIssueView>(&output.stdout) {
3681 if let Some(dev_branch) = parsed.development_branch {
3682 if !dev_branch.name.is_empty() {
3683 let name = dev_branch.name;
3684 if checkout_local_branch(repo_path, &name).is_ok() {
3685 return Ok(format!("Checked out linked local branch '{}'", name));
3686 }
3687 if let Ok(msg) = checkout_remote_branch(repo_path, &name) {
3688 return Ok(format!("Checked out linked remote branch '{}'", msg));
3689 }
3690 let checkout_out = git_command()
3691 .arg("checkout")
3692 .arg("-b")
3693 .arg(&name)
3694 .current_dir(repo_path)
3695 .output();
3696 if let Ok(out) = checkout_out {
3697 if out.status.success() {
3698 return Ok(format!(
3699 "Created and switched to linked branch '{}'",
3700 name
3701 ));
3702 }
3703 }
3704 }
3705 }
3706 }
3707 }
3708 }
3709
3710 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3711 let target_pattern = format!("{}", issue_number);
3712 let mut found_local = None;
3713 let mut found_remote = None;
3714
3715 if let Ok(branches) = repo.branches(None) {
3716 for (branch, branch_type) in branches.flatten() {
3717 if let Ok(Some(name)) = branch.name() {
3718 if name.contains(&target_pattern) {
3719 match branch_type {
3720 git2::BranchType::Local => {
3721 found_local = Some(name.to_string());
3722 break;
3723 }
3724 git2::BranchType::Remote => {
3725 found_remote = Some(name.to_string());
3726 }
3727 }
3728 }
3729 }
3730 }
3731 }
3732
3733 if let Some(local_name) = found_local {
3734 checkout_local_branch(repo_path, &local_name).map_err(|e| e.to_string())?;
3735 return Ok(format!("Checked out matched local branch '{}'", local_name));
3736 }
3737
3738 if let Some(remote_name) = found_remote {
3739 let msg = checkout_remote_branch(repo_path, &remote_name).map_err(|e| e.to_string())?;
3740 return Ok(format!("Checked out matched remote branch: {}", msg));
3741 }
3742
3743 let new_branch_name = format!("issue-{}", issue_number);
3744 let checkout_out = git_command()
3745 .arg("checkout")
3746 .arg("-b")
3747 .arg(&new_branch_name)
3748 .current_dir(repo_path)
3749 .output()
3750 .map_err(|e| e.to_string())?;
3751
3752 if !checkout_out.status.success() {
3753 let err = String::from_utf8_lossy(&checkout_out.stderr).trim().to_string();
3754 return Err(format!("Failed to create new branch: {}", err));
3755 }
3756
3757 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
3758}
3759
3760pub fn open_browser(url: &str) {
3761 #[cfg(target_os = "macos")]
3762 let _ = detached_command("open").arg(url).status();
3763 #[cfg(target_os = "windows")]
3764 let _ = detached_command("cmd").args(["/C", "start", url]).status();
3765 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3766 let _ = detached_command("xdg-open").arg(url).status();
3767}
3768
3769#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3770pub struct ForgeIssue {
3771 pub number: u32,
3772 pub title: String,
3773 pub state: String,
3774 pub author: String,
3775 pub assignees: Vec<String>,
3776 pub url: String,
3777}
3778
3779pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
3780 let remotes = load_tab_remotes(repo_path).ok()?;
3781 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
3782}
3783
3784pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
3785 let remotes = load_tab_remotes(repo_path).ok()?;
3786 remotes.into_iter().next().map(|r| r.url)
3787}
3788
3789#[cfg(test)]
3790#[allow(clippy::unwrap_used, clippy::panic)]
3791#[allow(clippy::disallowed_methods)]
3793mod tests {
3794 use super::*;
3795 use std::fs::File;
3796
3797 #[test]
3798 fn test_match_pattern() {
3799 assert!(match_pattern("test.psd", "*.psd"));
3800 assert!(match_pattern("assets/test.psd", "*.psd"));
3801 assert!(match_pattern("assets/img.png", "assets/*.png"));
3802 assert!(match_pattern("src/app.rs", "src/*"));
3803 assert!(match_pattern("test.txt", "test.txt"));
3804 assert!(!match_pattern("src/app.rs", "*.psd"));
3805 }
3806
3807 #[test]
3808 fn test_run_git_with_timeout_kills_a_hanging_child() {
3809 let mut cmd = if cfg!(target_os = "windows") {
3811 let mut c = std::process::Command::new("cmd");
3812 c.arg("/c").arg("timeout").arg("/t").arg("30").arg("/nobreak");
3813 c
3814 } else {
3815 let mut c = std::process::Command::new("sleep");
3816 c.arg("30");
3817 c
3818 };
3819 cmd.stdin(std::process::Stdio::null());
3820
3821 let started = Instant::now();
3822 let res = run_git_with_timeout(cmd, Duration::from_millis(300));
3823 let elapsed = started.elapsed();
3824
3825 assert!(res.is_err(), "expected a timeout error, got {:?}", res);
3826 assert!(elapsed < Duration::from_secs(10), "child was not killed promptly: {:?}", elapsed);
3827 }
3828
3829 #[test]
3830 fn test_get_lfs_info() {
3831 let mut temp_path = std::env::temp_dir();
3832 temp_path.push(format!(
3833 "twig_test_lfs_{}",
3834 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3835 ));
3836 std::fs::create_dir_all(&temp_path).unwrap();
3837
3838 let _repo = Repository::init(&temp_path).unwrap();
3840 let lfs_dir = temp_path.join(".git").join("lfs");
3841 std::fs::create_dir_all(&lfs_dir).unwrap();
3842
3843 let (installed, tracked) = get_lfs_info(&temp_path);
3845
3846 assert!(tracked.is_empty());
3848
3849 let gitattributes_path = temp_path.join(".gitattributes");
3851 std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
3852
3853 let (installed2, _tracked2) = get_lfs_info(&temp_path);
3854 assert_eq!(installed, installed2);
3855
3856 let _ = std::fs::remove_dir_all(&temp_path);
3858 }
3859
3860 #[test]
3861 fn test_comprehensive_gitwig_core_coverage() {
3862 let mut temp_path = std::env::temp_dir();
3863 temp_path.push(format!(
3864 "twig_test_cov_{}",
3865 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3866 ));
3867 std::fs::create_dir_all(&temp_path).unwrap();
3868
3869 let repo = Repository::init(&temp_path).unwrap();
3871
3872 let mut config = repo.config().unwrap();
3874 config.set_str("user.name", "Test User").unwrap();
3875 config.set_str("user.email", "test@example.com").unwrap();
3876
3877 let file_path = temp_path.join("test.txt");
3879 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3880 stage_file(&temp_path, "test.txt").unwrap();
3881 commit_changes(&temp_path, "first commit").unwrap();
3882
3883 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3884 let head_oid = head_commit.id().to_string();
3885
3886 let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
3888 let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
3889
3890 let _ = stage_all_changes(&temp_path);
3892 let _ = unstage_all_changes(&temp_path);
3893
3894 std::fs::write(&file_path, "modified content").unwrap();
3895 let _ = discard_all_changes(&temp_path);
3896
3897 let _lfs_size = get_lfs_storage_size(&temp_path);
3899
3900 let _reflog = load_tab_reflog(&temp_path);
3902 let _tags = load_tab_tags(&temp_path);
3903 let _stashes = load_tab_stashes(&temp_path);
3904 let _submodules = load_tab_submodules(&temp_path);
3905 let _worktrees = load_tab_worktrees(&temp_path);
3906
3907 let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
3909 let _ = remote_delete(&temp_path, "upstream");
3910
3911 let wt_path = temp_path.join("wt-path");
3913 let _ = worktree_add(&temp_path, "new-branch", &wt_path);
3914 let _ = worktree_remove(&temp_path, "wt-path", true);
3915
3916 let _ = delete_tag(&temp_path, "v1.0.0");
3918
3919 let _ = apply_stash(&temp_path, 0);
3921 let _ = delete_stash(&temp_path, 0);
3922
3923 let _ = abort_merge(&temp_path);
3925 let _ = continue_merge(&temp_path);
3926
3927 let _ = is_merging(&temp_path);
3929 let _ = get_conflict_markers_diff(&temp_path, "test.txt");
3930 let _ = resolve_ours(&temp_path, "test.txt");
3931 let _ = resolve_theirs(&temp_path, "test.txt");
3932 let _ = mark_resolved(&temp_path, "test.txt");
3933
3934 let _target = get_branch_push_target(&temp_path, "master");
3936 let _target2 = get_branch_push_target(&temp_path, "main");
3937
3938 let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
3940 let _summary = inspect_summary(temp_path.to_str().unwrap());
3941
3942 let _ = std::fs::remove_dir_all(&temp_path);
3944 }
3945
3946 #[test]
3947 fn test_more_gitwig_core_coverage() {
3948 let mut temp_path = std::env::temp_dir();
3949 temp_path.push(format!(
3950 "twig_test_cov2_{}",
3951 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3952 ));
3953 std::fs::create_dir_all(&temp_path).unwrap();
3954
3955 let repo = Repository::init(&temp_path).unwrap();
3957
3958 let mut config = repo.config().unwrap();
3960 config.set_str("user.name", "Test User").unwrap();
3961 config.set_str("user.email", "test@example.com").unwrap();
3962
3963 let file_path = temp_path.join("test.txt");
3965 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3966 stage_file(&temp_path, "test.txt").unwrap();
3967 commit_changes(&temp_path, "first commit").unwrap();
3968
3969 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3970 let head_oid = head_commit.id().to_string();
3971
3972 let _ = checkout_commit(&temp_path, &head_oid);
3974 let _ = checkout_local_branch(&temp_path, "master");
3975 let _ = checkout_remote_branch(&temp_path, "origin/master");
3976 let _ = checkout_tag(&temp_path, "v1.0.0");
3977
3978 let _ = create_branch(&temp_path, "new-branch-2");
3980 let _ = delete_local_branch(&temp_path, "new-branch-2");
3981 let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
3982 let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
3983 let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0", Duration::from_secs(5));
3984 let _ = get_remote_tags(&temp_path, "origin", Duration::from_secs(5));
3985
3986 let tag_info = vec![BranchInfo {
3988 name: "v1.0.0".to_string(),
3989 is_head: false,
3990 short_sha: "abc1234".to_string(),
3991 short_message: "tag msg".to_string(),
3992 }];
3993 let serialized = serialize_tags(&tag_info);
3994 let deserialized = deserialize_tags(&serialized);
3995 assert_eq!(deserialized.len(), 1);
3996 assert_eq!(deserialized[0].name, "v1.0.0");
3997
3998 let _ = get_file_history(&temp_path, "test.txt");
4000 let _ = get_file_blame(&temp_path, "test.txt");
4001 let _ = get_commit_files(&temp_path, &head_oid);
4002
4003 let _ = load_tab_files(&temp_path);
4005 let _ = load_tab_branches(&temp_path);
4006 let _ = load_tab_remotes(&temp_path);
4007 let _ = load_tab_overview(&temp_path, 10);
4008
4009 let (tx, _rx) = std::sync::mpsc::channel();
4011 let _ = load_tab_graph_stream(
4012 &temp_path,
4013 10,
4014 true,
4015 temp_path.to_str().unwrap().to_string(),
4016 0,
4017 tx,
4018 );
4019
4020 invalidate_ref_map_cache(&temp_path);
4022
4023 let _ = load_tab_forge_issues(&temp_path, false);
4025 let _ = load_tab_forge_issues(&temp_path, true);
4026 let _ = load_tab_forge_prs(&temp_path);
4027 let _ = load_pr_comments(&temp_path, 1);
4028 let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
4029
4030 let _ = std::fs::remove_dir_all(&temp_path);
4032 }
4033
4034 #[test]
4035 fn test_create_tag_annotated_and_force_update() {
4036 let mut temp_path = std::env::temp_dir();
4037 temp_path.push(format!(
4038 "twig_test_tag_force_{}",
4039 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4040 ));
4041 std::fs::create_dir_all(&temp_path).unwrap();
4042
4043 let repo = Repository::init(&temp_path).unwrap();
4044 let mut config = repo.config().unwrap();
4045 config.set_str("user.name", "Test User").unwrap();
4046 config.set_str("user.email", "test@example.com").unwrap();
4047
4048 let file_path = temp_path.join("test.txt");
4049 std::fs::write(&file_path, "commit 1\n").unwrap();
4050 stage_file(&temp_path, "test.txt").unwrap();
4051 commit_changes(&temp_path, "commit 1").unwrap();
4052
4053 let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
4054 let oid1 = head_commit1.id().to_string();
4055
4056 assert!(!tag_exists(&temp_path, "v1.0.1"));
4057 assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
4058 assert!(tag_exists(&temp_path, "v1.0.1"));
4059
4060 std::fs::write(&file_path, "commit 2\n").unwrap();
4062 stage_file(&temp_path, "test.txt").unwrap();
4063 commit_changes(&temp_path, "commit 2").unwrap();
4064
4065 let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
4066 let oid2 = head_commit2.id().to_string();
4067
4068 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
4069
4070 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
4072
4073 let _ = std::fs::remove_dir_all(&temp_path);
4074 }
4075
4076 use std::io::Write;
4077
4078 #[test]
4079 fn test_commit_amend() {
4080 let mut temp_path = std::env::temp_dir();
4081 temp_path.push(format!(
4082 "twig_test_amend_{}",
4083 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4084 ));
4085 std::fs::create_dir_all(&temp_path).unwrap();
4086
4087 let repo = Repository::init(&temp_path).unwrap();
4089
4090 let mut config = repo.config().unwrap();
4092 config.set_str("user.name", "Test User").unwrap();
4093 config.set_str("user.email", "test@example.com").unwrap();
4094
4095 let file_path = temp_path.join("test.txt");
4097 let mut file = File::create(&file_path).unwrap();
4098 writeln!(file, "initial content").unwrap();
4099
4100 stage_file(&temp_path, "test.txt").unwrap();
4102 commit_changes(&temp_path, "initial commit").unwrap();
4103
4104 let msg = get_last_commit_message(&temp_path).unwrap();
4106 assert_eq!(msg, "initial commit");
4107
4108 commit_amend(&temp_path, "amended commit").unwrap();
4110
4111 let amended_msg = get_last_commit_message(&temp_path).unwrap();
4113 assert_eq!(amended_msg, "amended commit");
4114
4115 let _ = std::fs::remove_dir_all(&temp_path);
4117 }
4118
4119 #[test]
4120 fn test_commit_signatures_collection() {
4121 let mut temp_path = std::env::temp_dir();
4122 temp_path.push(format!(
4123 "twig_test_sig_{}",
4124 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4125 ));
4126 std::fs::create_dir_all(&temp_path).unwrap();
4127
4128 let repo = Repository::init(&temp_path).unwrap();
4130
4131 let mut config = repo.config().unwrap();
4133 config.set_str("user.name", "Test User").unwrap();
4134 config.set_str("user.email", "test@example.com").unwrap();
4135
4136 let file_path = temp_path.join("test.txt");
4138 let mut file = File::create(&file_path).unwrap();
4139 writeln!(file, "initial content").unwrap();
4140
4141 stage_file(&temp_path, "test.txt").unwrap();
4143 commit_changes(&temp_path, "initial commit").unwrap();
4144
4145 let sigs = collect_signatures(&temp_path, 0);
4147 assert_eq!(sigs.len(), 1);
4148 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4149 let sig_status = sigs.get(&head_oid).unwrap();
4150 assert_eq!(sig_status, "N");
4151
4152 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4154 assert_eq!(commits.len(), 1);
4155 assert_eq!(commits[0].signature_status, "N");
4156 assert!(commits[0].files.is_empty());
4157
4158 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4160 assert_eq!(files.len(), 1);
4161 assert_eq!(files[0].path, "test.txt");
4162 assert_eq!(files[0].label, "N");
4163
4164 let graph = collect_graph_lines(&temp_path, 1000);
4166 assert_eq!(graph.len(), 1);
4167 assert!(graph[0].commit.is_some());
4168 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4169
4170 let _ = std::fs::remove_dir_all(&temp_path);
4172 }
4173
4174 fn make_repo_with_unverifiable_signature() -> PathBuf {
4179 use std::io::Write;
4180 use std::process::Stdio;
4181 let mut temp_path = std::env::temp_dir();
4182 temp_path.push(format!(
4183 "twig_test_nogpg_{}",
4184 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4185 ));
4186 std::fs::create_dir_all(&temp_path).unwrap();
4187 let repo = Repository::init(&temp_path).unwrap();
4188 let mut config = repo.config().unwrap();
4189 config.set_str("user.name", "Test User").unwrap();
4190 config.set_str("user.email", "test@example.com").unwrap();
4191 config.set_str("gpg.program", "/nonexistent/gitwig-test-no-gpg").unwrap();
4192
4193 let mut file = File::create(temp_path.join("test.txt")).unwrap();
4194 writeln!(file, "initial content").unwrap();
4195 stage_file(&temp_path, "test.txt").unwrap();
4196 commit_changes(&temp_path, "signed commit").unwrap();
4197
4198 let raw = git_command()
4200 .args(["cat-file", "-p", "HEAD"])
4201 .current_dir(&temp_path)
4202 .output()
4203 .unwrap();
4204 let mut signed = String::new();
4205 for line in String::from_utf8_lossy(&raw.stdout).lines() {
4206 signed.push_str(line);
4207 signed.push('\n');
4208 if line.starts_with("committer ") {
4209 signed.push_str("gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n");
4210 }
4211 }
4212 let mut hash_cmd = git_command();
4213 hash_cmd.args(["hash-object", "-t", "commit", "-w", "--stdin"]).current_dir(&temp_path);
4214 hash_cmd.stdin(Stdio::piped());
4215 let mut child = hash_cmd.spawn().unwrap();
4216 child.stdin.take().unwrap().write_all(signed.as_bytes()).unwrap();
4217 let out = child.wait_with_output().unwrap();
4218 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
4219 let new_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
4220 let head_ref = String::from_utf8_lossy(
4221 &git_command()
4222 .args(["symbolic-ref", "HEAD"])
4223 .current_dir(&temp_path)
4224 .output()
4225 .unwrap()
4226 .stdout,
4227 )
4228 .trim()
4229 .to_string();
4230 let upd = git_command()
4231 .args(["update-ref", &head_ref, &new_oid])
4232 .current_dir(&temp_path)
4233 .output()
4234 .unwrap();
4235 assert!(upd.status.success());
4236 temp_path
4237 }
4238
4239 #[test]
4240 fn graph_survives_missing_signature_program_and_reports_it() {
4241 let temp_path = make_repo_with_unverifiable_signature();
4242 let (tx, rx) = std::sync::mpsc::channel();
4243
4244 let load = load_tab_graph_stream(&temp_path, 100, true, String::new(), 2, tx)
4245 .expect("a missing gpg must not fail the graph");
4246 assert_eq!(load.lines.len(), 1);
4247 assert!(load.lines[0].commit.is_some());
4248 let notice = load.notice.expect("git's stderr must surface as a notice");
4249 assert!(
4250 notice.contains("/nonexistent/gitwig-test-no-gpg"),
4251 "notice must name the program git could not start: {notice}"
4252 );
4253 assert!(!notice.starts_with("error:"), "prefix should be stripped: {notice}");
4254 assert!(rx.try_recv().is_err(), "no batch is streamed for a one-commit graph");
4255
4256 let _ = std::fs::remove_dir_all(&temp_path);
4257 }
4258
4259 #[test]
4260 fn graph_skips_signature_lookup_when_disabled() {
4261 let temp_path = make_repo_with_unverifiable_signature();
4262 let (tx, _rx) = std::sync::mpsc::channel();
4263
4264 let load = load_tab_graph_stream(&temp_path, 100, false, String::new(), 2, tx).unwrap();
4265 assert_eq!(load.lines.len(), 1);
4266 assert_eq!(load.lines[0].commit.as_ref().unwrap().signature_status, "N");
4267 assert!(load.notice.is_none(), "no %G? means git never touches gpg");
4268
4269 let _ = std::fs::remove_dir_all(&temp_path);
4270 }
4271
4272 #[test]
4273 fn test_ref_map_cache_behavior() {
4274 let temp_dir = std::env::temp_dir();
4275 let repo_path = temp_dir.join("test_ref_map_repo");
4276 let _ = std::fs::remove_dir_all(&repo_path);
4277 std::fs::create_dir_all(&repo_path).unwrap();
4278
4279 let repo = Repository::init(&repo_path).unwrap();
4280
4281 let map1 = get_cached_ref_map(&repo, &repo_path);
4283
4284 let map2 = get_cached_ref_map(&repo, &repo_path);
4286 assert_eq!(map1.len(), map2.len());
4287
4288 invalidate_ref_map_cache(&repo_path);
4290
4291 let _ = std::fs::remove_dir_all(&repo_path);
4293 }
4294
4295 #[test]
4296 fn test_get_latest_change_time() {
4297 let mut temp_path = std::env::temp_dir();
4298 temp_path.push(format!(
4299 "twig_test_time_{}",
4300 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4301 ));
4302 std::fs::create_dir_all(&temp_path).unwrap();
4303
4304 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
4305 assert!(change_time > 0);
4306
4307 let _ = std::fs::remove_dir_all(&temp_path);
4308 }
4309
4310 #[test]
4311 fn test_committer_stats() {
4312 let mut temp_path = std::env::temp_dir();
4313 temp_path.push(format!(
4314 "twig_test_stats_{}",
4315 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4316 ));
4317 std::fs::create_dir_all(&temp_path).unwrap();
4318
4319 let repo = Repository::init(&temp_path).unwrap();
4321
4322 let mut config = repo.config().unwrap();
4324 config.set_str("user.name", "Test User").unwrap();
4325 config.set_str("user.email", "test@example.com").unwrap();
4326
4327 let file_path = temp_path.join("test.txt");
4329 let mut file = File::create(&file_path).unwrap();
4330 writeln!(file, "initial content").unwrap();
4331
4332 stage_file(&temp_path, "test.txt").unwrap();
4334 commit_changes(&temp_path, "initial commit").unwrap();
4335
4336 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
4338 assert_eq!(stats.len(), 1);
4339 assert_eq!(stats[0].name, "Test User");
4340 assert_eq!(stats[0].email, "test@example.com");
4341 assert_eq!(stats[0].count, 1);
4342 assert!(!limit_reached);
4343
4344 let _ = std::fs::remove_dir_all(&temp_path);
4346 }
4347
4348 #[test]
4349 fn test_untracked_files_in_unstaged() {
4350 let mut temp_path = std::env::temp_dir();
4351 temp_path.push(format!(
4352 "twig_test_untracked_{}",
4353 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4354 ));
4355 std::fs::create_dir_all(&temp_path).unwrap();
4356
4357 let _repo = Repository::init(&temp_path).unwrap();
4359
4360 let file_path = temp_path.join("untracked.txt");
4362 let mut file = File::create(&file_path).unwrap();
4363 writeln!(file, "hello untracked").unwrap();
4364
4365 let untracked_dir = temp_path.join("untracked_dir");
4367 std::fs::create_dir_all(&untracked_dir).unwrap();
4368 let nested_file_path = untracked_dir.join("nested.txt");
4369 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
4370
4371 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4373 match detail {
4374 ItemDetail::Repo { info, .. } => {
4375 let unstaged_paths: Vec<String> =
4377 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
4378 let untracked_paths: Vec<String> =
4379 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
4380
4381 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
4383 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
4384 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
4385 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
4386
4387 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
4389 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
4390 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
4391 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
4392 }
4393 _ => panic!("Expected ItemDetail::Repo"),
4394 }
4395
4396 let _ = std::fs::remove_dir_all(&temp_path);
4398 }
4399
4400 #[test]
4401 fn test_stage_new_and_deleted_files() {
4402 let mut temp_path = std::env::temp_dir();
4403 temp_path.push(format!(
4404 "twig_test_new_del_{}",
4405 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4406 ));
4407 std::fs::create_dir_all(&temp_path).unwrap();
4408
4409 let repo = Repository::init(&temp_path).unwrap();
4411
4412 let mut config = repo.config().unwrap();
4414 config.set_str("user.name", "Test User").unwrap();
4415 config.set_str("user.email", "test@example.com").unwrap();
4416
4417 let init_file = temp_path.join("init.txt");
4419 std::fs::write(&init_file, "initial").unwrap();
4420 stage_file(&temp_path, "init.txt").unwrap();
4421 commit_changes(&temp_path, "initial commit").unwrap();
4422
4423 let untracked_file = temp_path.join("untracked.txt");
4425 std::fs::write(&untracked_file, "new file content").unwrap();
4426
4427 stage_file(&temp_path, "untracked.txt").unwrap();
4429
4430 std::fs::remove_file(&init_file).unwrap();
4432
4433 stage_file(&temp_path, "init.txt").unwrap();
4435
4436 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4438 match detail {
4439 ItemDetail::Repo { info, .. } => {
4440 assert_eq!(info.changes.staged.len(), 2);
4442 let paths: Vec<String> =
4443 info.changes.staged.iter().map(|f| f.path.clone()).collect();
4444 assert!(paths.contains(&"untracked.txt".to_string()));
4445 assert!(paths.contains(&"init.txt".to_string()));
4446 }
4447 _ => panic!("Expected ItemDetail::Repo"),
4448 }
4449
4450 let _ = std::fs::remove_dir_all(&temp_path);
4452 }
4453
4454 #[test]
4455 fn test_discard_file_changes_all_cases() {
4456 let mut temp_path = std::env::temp_dir();
4457 temp_path.push(format!(
4458 "twig_test_discard_all_{}",
4459 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4460 ));
4461 std::fs::create_dir_all(&temp_path).unwrap();
4462
4463 let repo = Repository::init(&temp_path).unwrap();
4465
4466 let mut config = repo.config().unwrap();
4468 config.set_str("user.name", "Test User").unwrap();
4469 config.set_str("user.email", "test@example.com").unwrap();
4470
4471 let file_tracked = temp_path.join("tracked.txt");
4473 std::fs::write(&file_tracked, "original content\n").unwrap();
4474 stage_file(&temp_path, "tracked.txt").unwrap();
4475 commit_changes(&temp_path, "initial commit").unwrap();
4476
4477 let file_untracked = temp_path.join("untracked.txt");
4479 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
4480 assert!(file_untracked.exists());
4481 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
4482 assert!(!file_untracked.exists());
4483
4484 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
4486 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4487 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4488
4489 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
4491 stage_file(&temp_path, "tracked.txt").unwrap();
4492 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4494 match detail {
4495 ItemDetail::Repo { info, .. } => {
4496 assert!(!info.changes.staged.is_empty());
4497 }
4498 _ => panic!("Expected ItemDetail::Repo"),
4499 }
4500 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
4501 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4502 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4504 match detail {
4505 ItemDetail::Repo { info, .. } => {
4506 assert!(info.changes.staged.is_empty());
4507 assert!(info.changes.unstaged.is_empty());
4508 }
4509 _ => panic!("Expected ItemDetail::Repo"),
4510 }
4511
4512 std::fs::remove_file(&file_tracked).unwrap();
4514 assert!(!file_tracked.exists());
4515 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4516 assert!(file_tracked.exists());
4517 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4518
4519 let _ = std::fs::remove_dir_all(&temp_path);
4521 }
4522
4523 #[test]
4524 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4525 fn test_stage_unstage_by_hunk() {
4526 let mut temp_path = std::env::temp_dir();
4527 temp_path.push(format!(
4528 "twig_test_hunk_{}",
4529 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4530 ));
4531 std::fs::create_dir_all(&temp_path).unwrap();
4532
4533 let repo = Repository::init(&temp_path).unwrap();
4535
4536 let mut config = repo.config().unwrap();
4538 config.set_str("user.name", "Test User").unwrap();
4539 config.set_str("user.email", "test@example.com").unwrap();
4540
4541 let file_path = temp_path.join("multihunk.txt");
4543 let mut file = File::create(&file_path).unwrap();
4544 for i in 1..=20 {
4545 writeln!(file, "Line {}", i).unwrap();
4546 }
4547 drop(file);
4548
4549 stage_file(&temp_path, "multihunk.txt").unwrap();
4551 commit_changes(&temp_path, "initial commit").unwrap();
4552
4553 let mut file = File::create(&file_path).unwrap();
4555 for i in 1..=20 {
4556 if i == 2 || i == 18 {
4557 writeln!(file, "Line {} modified", i).unwrap();
4558 } else {
4559 writeln!(file, "Line {}", i).unwrap();
4560 }
4561 }
4562 drop(file);
4563
4564 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4566 let mut hunk_ranges = Vec::new();
4568 let mut current_start = None;
4569 for (i, line) in diff_lines.iter().enumerate() {
4570 if line.kind == DiffLineKind::Header {
4571 if let Some(start) = current_start {
4572 hunk_ranges.push(start..i);
4573 }
4574 current_start = Some(i);
4575 }
4576 }
4577 if let Some(start) = current_start {
4578 hunk_ranges.push(start..diff_lines.len());
4579 }
4580
4581 assert_eq!(hunk_ranges.len(), 2);
4583
4584 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4586 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
4587
4588 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4590 let staged_content: String =
4591 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4592 assert!(staged_content.contains("Line 18 modified"));
4593 assert!(!staged_content.contains("Line 2 modified"));
4594
4595 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4597 let unstaged_content: String =
4598 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4599 assert!(unstaged_content.contains("Line 2 modified"));
4600 assert!(!unstaged_content.contains("Line 18 modified"));
4601
4602 let staged_hunk_ranges = {
4604 let mut ranges = Vec::new();
4605 let mut current_start = None;
4606 for (i, line) in staged_diff.iter().enumerate() {
4607 if line.kind == DiffLineKind::Header {
4608 if let Some(start) = current_start {
4609 ranges.push(start..i);
4610 }
4611 current_start = Some(i);
4612 }
4613 }
4614 if let Some(start) = current_start {
4615 ranges.push(start..staged_diff.len());
4616 }
4617 ranges
4618 };
4619 assert_eq!(staged_hunk_ranges.len(), 1);
4620 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
4621 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
4622
4623 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4625 assert!(staged_diff_after.is_empty());
4626
4627 let _ = std::fs::remove_dir_all(&temp_path);
4629 }
4630
4631 #[test]
4632 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4633 fn test_discard_hunk() {
4634 let mut temp_path = std::env::temp_dir();
4635 temp_path.push(format!(
4636 "twig_test_discard_h_{}",
4637 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4638 ));
4639 std::fs::create_dir_all(&temp_path).unwrap();
4640
4641 let repo = Repository::init(&temp_path).unwrap();
4643
4644 let mut config = repo.config().unwrap();
4646 config.set_str("user.name", "Test User").unwrap();
4647 config.set_str("user.email", "test@example.com").unwrap();
4648
4649 let file_path = temp_path.join("discardhunk.txt");
4651 let mut file = File::create(&file_path).unwrap();
4652 for i in 1..=20 {
4653 writeln!(file, "Line {}", i).unwrap();
4654 }
4655 drop(file);
4656
4657 stage_file(&temp_path, "discardhunk.txt").unwrap();
4659 commit_changes(&temp_path, "initial commit").unwrap();
4660
4661 let mut file = File::create(&file_path).unwrap();
4663 for i in 1..=20 {
4664 if i == 2 || i == 18 {
4665 writeln!(file, "Line {} modified", i).unwrap();
4666 } else {
4667 writeln!(file, "Line {}", i).unwrap();
4668 }
4669 }
4670 drop(file);
4671
4672 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
4674 let mut hunk_ranges = Vec::new();
4676 let mut current_start = None;
4677 for (i, line) in diff_lines.iter().enumerate() {
4678 if line.kind == DiffLineKind::Header {
4679 if let Some(start) = current_start {
4680 hunk_ranges.push(start..i);
4681 }
4682 current_start = Some(i);
4683 }
4684 }
4685 if let Some(start) = current_start {
4686 hunk_ranges.push(start..diff_lines.len());
4687 }
4688
4689 assert_eq!(hunk_ranges.len(), 2);
4691
4692 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4694 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
4695
4696 let contents = std::fs::read_to_string(&file_path).unwrap();
4698 assert!(contents.contains("Line 2 modified"));
4699 assert!(contents.contains("Line 18\n"));
4700 assert!(!contents.contains("Line 18 modified"));
4701
4702 let _ = std::fs::remove_dir_all(&temp_path);
4704 }
4705
4706 #[test]
4707 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4708 fn test_stage_unstage_discard_line() {
4709 let mut temp_path = std::env::temp_dir();
4710 temp_path.push(format!(
4711 "twig_test_line_{}",
4712 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4713 ));
4714 std::fs::create_dir_all(&temp_path).unwrap();
4715
4716 let repo = Repository::init(&temp_path).unwrap();
4717 let mut config = repo.config().unwrap();
4718 config.set_str("user.name", "Test User").unwrap();
4719 config.set_str("user.email", "test@example.com").unwrap();
4720
4721 let file_path = temp_path.join("line_test.txt");
4723 let mut file = File::create(&file_path).unwrap();
4724 writeln!(file, "line A").unwrap();
4725 writeln!(file, "line B").unwrap();
4726 writeln!(file, "line C").unwrap();
4727 drop(file);
4728
4729 stage_file(&temp_path, "line_test.txt").unwrap();
4730 commit_changes(&temp_path, "initial").unwrap();
4731
4732 let mut file = File::create(&file_path).unwrap();
4734 writeln!(file, "line A modified").unwrap();
4735 writeln!(file, "line B").unwrap();
4736 writeln!(file, "line C modified").unwrap();
4737 drop(file);
4738
4739 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4740 let mut hunk_ranges = Vec::new();
4741 let mut current_start = None;
4742 for (i, line) in diff_lines.iter().enumerate() {
4743 if line.kind == DiffLineKind::Header {
4744 if let Some(start) = current_start {
4745 hunk_ranges.push(start..i);
4746 }
4747 current_start = Some(i);
4748 }
4749 }
4750 if let Some(start) = current_start {
4751 hunk_ranges.push(start..diff_lines.len());
4752 }
4753
4754 assert_eq!(hunk_ranges.len(), 1);
4755 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
4756
4757 assert_eq!(hunk0[2].content, "line A modified");
4758 assert_eq!(hunk0[5].content, "line C modified");
4759
4760 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
4762
4763 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
4765 assert!(
4766 staged_diff
4767 .iter()
4768 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4769 );
4770 assert!(
4771 !staged_diff
4772 .iter()
4773 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4774 );
4775
4776 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4778 assert!(
4779 !unstaged_diff
4780 .iter()
4781 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4782 );
4783 assert!(
4784 unstaged_diff
4785 .iter()
4786 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4787 );
4788
4789 assert_eq!(staged_diff[2].content, "line A modified");
4791 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
4792
4793 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
4795
4796 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4798 assert_eq!(unstaged_diff2[5].content, "line C modified");
4799 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
4800
4801 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4802 let remove_idx = unstaged_diff3
4803 .iter()
4804 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
4805 .unwrap();
4806 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
4807
4808 let contents = std::fs::read_to_string(&file_path).unwrap();
4810 assert!(contents.contains("line A modified"));
4811 assert!(contents.contains("line B"));
4812 assert!(contents.contains("line C\n"));
4813 assert!(!contents.contains("line C modified"));
4814
4815 let _ = std::fs::remove_dir_all(&temp_path);
4817 }
4818
4819 #[test]
4820 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4821 fn test_stage_unstage_discard_all_changes() {
4822 let mut temp_path = std::env::temp_dir();
4823 temp_path.push(format!(
4824 "twig_test_all_{}",
4825 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4826 ));
4827 std::fs::create_dir_all(&temp_path).unwrap();
4828
4829 let repo = Repository::init(&temp_path).unwrap();
4831
4832 let mut config = repo.config().unwrap();
4834 config.set_str("user.name", "Test User").unwrap();
4835 config.set_str("user.email", "test@example.com").unwrap();
4836
4837 let file_path = temp_path.join("tracked.txt");
4839 std::fs::write(&file_path, "original content\n").unwrap();
4840 stage_file(&temp_path, "tracked.txt").unwrap();
4841 commit_changes(&temp_path, "initial").unwrap();
4842
4843 std::fs::write(&file_path, "modified content\n").unwrap();
4845 let untracked_path = temp_path.join("untracked.txt");
4846 std::fs::write(&untracked_path, "untracked content\n").unwrap();
4847
4848 let status = repo.statuses(None).unwrap();
4850 assert_eq!(status.len(), 2);
4851
4852 stage_all_changes(&temp_path).unwrap();
4854
4855 let status = repo.statuses(None).unwrap();
4857 for entry in status.iter() {
4858 assert!(
4859 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
4860 );
4861 }
4862
4863 unstage_all_changes(&temp_path).unwrap();
4865
4866 let status = repo.statuses(None).unwrap();
4868 for entry in status.iter() {
4869 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
4870 }
4871
4872 discard_all_changes(&temp_path).unwrap();
4874
4875 let status = repo.statuses(None).unwrap();
4877 assert_eq!(status.len(), 0);
4878
4879 let contents = std::fs::read_to_string(&file_path).unwrap();
4881 assert_eq!(contents, "original content\n");
4882 assert!(!untracked_path.exists());
4883
4884 let _ = std::fs::remove_dir_all(&temp_path);
4886 }
4887
4888 #[test]
4889 fn test_merge_conflicts_flow() {
4890 let mut temp_path = std::env::temp_dir();
4891 temp_path.push(format!(
4892 "twig_test_conflict_{}",
4893 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4894 ));
4895 std::fs::create_dir_all(&temp_path).unwrap();
4896
4897 let repo = Repository::init(&temp_path).unwrap();
4899
4900 let mut config = repo.config().unwrap();
4902 config.set_str("user.name", "Test User").unwrap();
4903 config.set_str("user.email", "test@example.com").unwrap();
4904
4905 let file_path = temp_path.join("conflict.txt");
4907 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4908 stage_file(&temp_path, "conflict.txt").unwrap();
4909 commit_changes(&temp_path, "initial commit").unwrap();
4910
4911 let output = git_command()
4913 .args(["symbolic-ref", "--short", "HEAD"])
4914 .current_dir(&temp_path)
4915 .output()
4916 .unwrap();
4917 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4918
4919 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
4921
4922 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
4923 stage_file(&temp_path, "conflict.txt").unwrap();
4924 commit_changes(&temp_path, "feature commit").unwrap();
4925
4926 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
4928
4929 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
4930 stage_file(&temp_path, "conflict.txt").unwrap();
4931 commit_changes(&temp_path, "main commit").unwrap();
4932
4933 assert!(!is_merging(&temp_path));
4935 let merge_output =
4936 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4937
4938 assert!(!merge_output.status.success());
4939 assert!(is_merging(&temp_path));
4940
4941 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
4943 assert!(!diff.is_empty());
4944 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
4945 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
4946 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
4947 assert!(has_separator);
4948 assert!(has_ours);
4949 assert!(has_theirs);
4950
4951 abort_merge(&temp_path).unwrap();
4953 assert!(!is_merging(&temp_path));
4954
4955 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4957 assert!(is_merging(&temp_path));
4958
4959 resolve_ours(&temp_path, "conflict.txt").unwrap();
4961 let contents = std::fs::read_to_string(&file_path).unwrap();
4962 assert!(contents.contains("line 2 on main"));
4963 assert!(!contents.contains("<<<<<<<"));
4964
4965 continue_merge(&temp_path).unwrap();
4967 assert!(!is_merging(&temp_path));
4968
4969 git_command().args(["reset", "--hard", "HEAD~1"]).current_dir(&temp_path).output().unwrap();
4971
4972 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4973 assert!(is_merging(&temp_path));
4974
4975 resolve_theirs(&temp_path, "conflict.txt").unwrap();
4976 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4977 assert!(contents_theirs.contains("line 2 on feature"));
4978 assert!(!contents_theirs.contains("<<<<<<<"));
4979
4980 continue_merge(&temp_path).unwrap();
4981 assert!(!is_merging(&temp_path));
4982
4983 let _ = std::fs::remove_dir_all(&temp_path);
4985 }
4986
4987 #[test]
4988 fn test_resolve_conflict_hunk() {
4989 let mut temp_path = std::env::temp_dir();
4990 temp_path.push(format!(
4991 "twig_test_hunk_conflict_{}",
4992 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4993 ));
4994 std::fs::create_dir_all(&temp_path).unwrap();
4995
4996 let repo = Repository::init(&temp_path).unwrap();
4998
4999 let mut config = repo.config().unwrap();
5001 config.set_str("user.name", "Test User").unwrap();
5002 config.set_str("user.email", "test@example.com").unwrap();
5003
5004 let file_path = temp_path.join("conflict.txt");
5006 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";
5007 std::fs::write(&file_path, initial_lines).unwrap();
5008 stage_file(&temp_path, "conflict.txt").unwrap();
5009 commit_changes(&temp_path, "initial commit").unwrap();
5010
5011 let output = git_command()
5013 .args(["symbolic-ref", "--short", "HEAD"])
5014 .current_dir(&temp_path)
5015 .output()
5016 .unwrap();
5017 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5018
5019 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5021 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";
5022 std::fs::write(&file_path, feature_lines).unwrap();
5023 stage_file(&temp_path, "conflict.txt").unwrap();
5024 commit_changes(&temp_path, "feature commit").unwrap();
5025
5026 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5028 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";
5029 std::fs::write(&file_path, main_lines).unwrap();
5030 stage_file(&temp_path, "conflict.txt").unwrap();
5031 commit_changes(&temp_path, "main commit").unwrap();
5032
5033 let merge_output =
5035 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5036 assert!(!merge_output.status.success());
5037 assert!(is_merging(&temp_path));
5038
5039 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
5041 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
5042 assert!(contents_after_first.contains("line 2 on main"));
5044 assert!(!contents_after_first.contains("line 2 on feature"));
5045 assert!(contents_after_first.contains("<<<<<<<"));
5047 assert!(contents_after_first.contains("line 11 on main"));
5048 assert!(contents_after_first.contains("line 11 on feature"));
5049
5050 assert!(is_merging(&temp_path));
5052
5053 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
5058 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
5059 assert!(contents_after_second.contains("line 2 on main"));
5061 assert!(contents_after_second.contains("line 11 on feature"));
5062 assert!(!contents_after_second.contains("<<<<<<<"));
5063
5064 let status = repo.statuses(None).unwrap();
5066 assert_eq!(status.len(), 1);
5067 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
5068
5069 continue_merge(&temp_path).unwrap();
5071 assert!(!is_merging(&temp_path));
5072
5073 let _ = std::fs::remove_dir_all(&temp_path);
5075 }
5076
5077 #[test]
5078 fn test_branch_and_commit_helpers() {
5079 let mut temp_path = std::env::temp_dir();
5080 temp_path.push(format!(
5081 "twig_test_helpers_{}",
5082 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5083 ));
5084 let _ = std::fs::remove_dir_all(&temp_path);
5085 std::fs::create_dir_all(&temp_path).unwrap();
5086
5087 let repo = Repository::init(&temp_path).unwrap();
5089
5090 let mut config = repo.config().unwrap();
5092 config.set_str("user.name", "Test User").unwrap();
5093 config.set_str("user.email", "test@example.com").unwrap();
5094
5095 let file_path = temp_path.join("test.txt");
5097 std::fs::write(&file_path, "root content").unwrap();
5098 stage_file(&temp_path, "test.txt").unwrap();
5099 commit_changes(&temp_path, "root commit").unwrap();
5100
5101 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5102 assert!(is_root_commit(&temp_path, &head_oid));
5103
5104 std::fs::write(&file_path, "second content").unwrap();
5106 stage_file(&temp_path, "test.txt").unwrap();
5107 commit_changes(&temp_path, "second commit").unwrap();
5108
5109 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
5110 assert!(!is_root_commit(&temp_path, &new_head_oid));
5111
5112 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
5117 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
5118 "master"
5119 } else {
5120 "main"
5121 };
5122 let target = get_branch_push_target(&temp_path, active_branch);
5123 assert!(target.is_some());
5124 let (remote_name, set_upstream) = target.unwrap();
5125 assert_eq!(remote_name, "origin");
5126 assert!(set_upstream);
5127
5128 let mut git_config = repo.config().unwrap();
5130 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5131 git_config
5132 .set_str(
5133 &format!("branch.{}.merge", active_branch),
5134 &format!("refs/heads/{}", active_branch),
5135 )
5136 .unwrap();
5137
5138 let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5140 repo.reference(
5141 &format!("refs/remotes/origin/{}", active_branch),
5142 commit_obj.id(),
5143 true,
5144 "create mock remote ref",
5145 )
5146 .unwrap();
5147
5148 assert!(has_upstream_remote(&temp_path, active_branch));
5150 assert_eq!(
5151 get_branch_upstream_remote(&temp_path, active_branch),
5152 Some("origin".to_string())
5153 );
5154
5155 let target_tracking = get_branch_push_target(&temp_path, active_branch);
5157 assert!(target_tracking.is_some());
5158 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5159 assert_eq!(remote_name_tr, "origin");
5160 assert!(!set_upstream_tr);
5161
5162 let _ = std::fs::remove_dir_all(&temp_path);
5164 }
5165
5166 #[test]
5167 fn test_core_helpers_comprehensive() {
5168 assert_eq!(format_relative_time(0), "unknown");
5170 assert_eq!(format_relative_time(-100), "unknown");
5171
5172 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5173 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5174 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5175 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5176 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5177 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5178 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5179
5180 assert_eq!(format_utc_date(0), "unknown");
5182 assert_eq!(format_utc_date(-1), "unknown");
5183 let date_str = format_utc_date(1700000000);
5184 assert!(date_str.contains("2023-11-14"));
5185
5186 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5188 let tilde_path = expand_tilde("~/path");
5189 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5190
5191 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5193 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5194 assert_eq!(sanitize_text("M4 — UAS"), "M4 — UAS");
5195 assert_eq!(
5196 sanitize_text("feat: support utf-8 — – … 🚀 日本語"),
5197 "feat: support utf-8 — – … 🚀 日本語"
5198 );
5199
5200 assert_eq!(safe_sha_slice("12345", 10), "12345");
5202 assert_eq!(safe_sha_slice("12345", 3), "123");
5203
5204 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
5206 assert!(safe_ref("-invalid").is_err());
5207 assert!(safe_ref(" ").is_err());
5208
5209 let mut status = RepoSummary::default();
5211 assert!(status.is_clean());
5212 assert!(status.is_synced());
5213 assert!(status.unchanged());
5214
5215 status.modified = 1;
5216 assert!(!status.is_clean());
5217 assert!(!status.unchanged());
5218
5219 status.modified = 0;
5220 status.ahead = 1;
5221 assert!(!status.is_synced());
5222 assert!(!status.unchanged());
5223
5224 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5226 assert!(data_not_loaded.is_not_loaded());
5227 assert!(!data_not_loaded.is_loading());
5228 assert!(!data_not_loaded.is_loaded());
5229 assert_eq!(data_not_loaded.len(), 0);
5230 assert!(data_not_loaded.is_empty());
5231 assert!(data_not_loaded.as_ref().is_none());
5232 assert!(data_not_loaded.first().is_none());
5233 assert!(data_not_loaded.get(0).is_none());
5234 assert_eq!(data_not_loaded.iter().count(), 0);
5235 assert_eq!(data_not_loaded.as_slice().len(), 0);
5236
5237 let data_loading: TabData<Vec<i32>> = TabData::Loading;
5238 assert!(!data_loading.is_not_loaded());
5239 assert!(data_loading.is_loading());
5240
5241 let data_loaded = TabData::Loaded(vec![10, 20]);
5242 assert!(!data_loaded.is_not_loaded());
5243 assert!(!data_loaded.is_loading());
5244 assert!(data_loaded.is_loaded());
5245 assert_eq!(data_loaded.len(), 2);
5246 assert!(!data_loaded.is_empty());
5247 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
5248 assert_eq!(data_loaded.first().unwrap(), &10);
5249 assert_eq!(data_loaded.get(1).unwrap(), &20);
5250 assert_eq!(data_loaded.iter().count(), 2);
5251 assert_eq!(data_loaded.as_slice(), &[10, 20]);
5252 }
5253
5254 #[test]
5255 fn test_comprehensive_gitwig_core_coverage_booster() {
5256 let mut temp_path = std::env::temp_dir();
5258 temp_path.push(format!(
5259 "twig_test_boost_{}",
5260 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5261 ));
5262 std::fs::create_dir_all(&temp_path).unwrap();
5263
5264 let repo = Repository::init(&temp_path).unwrap();
5265
5266 {
5268 let mut config = repo.config().unwrap();
5269 config.set_str("user.name", "User A").unwrap();
5270 config.set_str("user.email", "a@example.com").unwrap();
5271 std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
5272 stage_file(&temp_path, "file1.txt").unwrap();
5273 commit_changes(&temp_path, "commit A").unwrap();
5274 }
5275
5276 {
5278 let mut config = repo.config().unwrap();
5279 config.set_str("user.name", "User B").unwrap();
5280 config.set_str("user.email", "b@example.com").unwrap();
5281 std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
5282 stage_file(&temp_path, "file2.txt").unwrap();
5283 commit_changes(&temp_path, "commit B").unwrap();
5284 }
5285
5286 let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
5288 assert_eq!(stats.len(), 2);
5289 assert_eq!(stats[0].count, 1);
5290 assert_eq!(stats[1].count, 1);
5291
5292 std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
5295 stage_file(&temp_path, "file1.txt").unwrap();
5296 commit_changes(&temp_path, "deleted file1").unwrap();
5297 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5298 let files = get_commit_files(&temp_path, &head_oid).unwrap();
5299 assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
5300
5301 let path_tilde = expand_tilde("~");
5303 assert!(path_tilde.is_absolute());
5304
5305 let lfs_dir = temp_path.join(".git").join("lfs");
5307 std::fs::create_dir_all(&lfs_dir).unwrap();
5308 let nested_dir = lfs_dir.join("objects").join("xx");
5309 std::fs::create_dir_all(&nested_dir).unwrap();
5310 std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
5311 let lfs_size = get_lfs_storage_size(&temp_path);
5312 assert_eq!(lfs_size, Some(5));
5313
5314 let mut sub = repo
5316 .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
5317 .unwrap();
5318 let _ = sub.init(false);
5319 let subs = load_tab_submodules(&temp_path).unwrap();
5320 assert!(!subs.is_empty());
5321
5322 let wt_path = temp_path.join("wt-booster");
5324 let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
5325 let _ = worktree_lock(&temp_path, "wt-booster", "lock reason");
5326 let _ = worktree_unlock(&temp_path, "wt-booster");
5327 let _ = worktree_prune(&temp_path);
5328 let _ = std::fs::remove_dir_all(&wt_path);
5329
5330 open_browser("");
5332
5333 let nonexistent = Path::new("/nonexistent_directory_twig_test");
5335 let _ = stage_file(nonexistent, "test.txt");
5336 let _ = unstage_file(nonexistent, "test.txt");
5337 let _ = discard_all_changes(nonexistent);
5338 let _ = discard_file_changes(nonexistent, "test.txt", false);
5339 let _ = commit_changes(nonexistent, "msg");
5340 let _ = remote_add(nonexistent, "origin", "url");
5341 let _ = get_file_history(nonexistent, "test.txt");
5342 let _ = get_file_blame(nonexistent, "test.txt");
5343 let _ = get_commit_files(nonexistent, "sha");
5344 let _ = load_tab_reflog(nonexistent);
5345 let _ = load_tab_files(nonexistent);
5346 let _ = load_tab_branches(nonexistent);
5347 let _ = load_tab_tags(nonexistent);
5348 let _ = load_tab_remotes(nonexistent);
5349 let _ = load_tab_stashes(nonexistent);
5350 let _ = load_tab_overview(nonexistent, 10);
5351 let _ = load_tab_submodules(nonexistent);
5352 let _ = checkout_remote_branch(nonexistent, "origin/branch");
5353 let _ = checkout_local_branch(nonexistent, "branch");
5354 let _ = checkout_tag(nonexistent, "tag");
5355 let _ = delete_local_branch(nonexistent, "branch");
5356 let _ = delete_remote_branch(nonexistent, "origin/branch");
5357 let _ = create_tag(nonexistent, "tag", "sha", None, false);
5358 let _ = delete_remote_tag(nonexistent, "origin", "tag", Duration::from_secs(5));
5359 let _ = get_remote_tags(nonexistent, "origin", Duration::from_secs(5));
5360 let _ = apply_stash(nonexistent, 0);
5361 let _ = delete_stash(nonexistent, 0);
5362 let _ = save_stash(nonexistent, "msg", false, false);
5363 let _ = commit_amend(nonexistent, "msg");
5364 let _ = resolve_ours(nonexistent, "file");
5365 let _ = resolve_theirs(nonexistent, "file");
5366 let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
5367 let _ = abort_merge(nonexistent);
5368 let _ = continue_merge(nonexistent);
5369 let _ = get_branch_upstream_remote(nonexistent, "branch");
5370 let _ = load_tab_worktrees(nonexistent);
5371 let _ = worktree_add(nonexistent, "branch", Path::new("path"));
5372 let _ = worktree_lock(nonexistent, "name", "reason");
5373 let _ = worktree_unlock(nonexistent, "name");
5374 let _ = worktree_remove(nonexistent, "name", true);
5375 let _ = worktree_prune(nonexistent);
5376 let _ = load_tab_forge_issues(nonexistent, true);
5377 let _ = load_tab_forge_prs(nonexistent);
5378 let _ = load_pr_comments(nonexistent, 1);
5379 let _ = checkout_pr_branch(nonexistent, 1);
5380 let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
5381 let _ = get_remote_url(nonexistent, "origin");
5382 let _ = get_default_remote_url(nonexistent);
5383 let _ = get_latest_change_time("/nonexistent");
5384
5385 let dummy_hunk = vec![
5386 DiffLine {
5387 kind: DiffLineKind::Header,
5388 content: "@@ -1,1 +1,1 @@".to_string(),
5389 old_lineno: None,
5390 new_lineno: None,
5391 hunk_idx: None,
5392 },
5393 DiffLine {
5394 kind: DiffLineKind::Added,
5395 content: "line".to_string(),
5396 old_lineno: None,
5397 new_lineno: None,
5398 hunk_idx: None,
5399 },
5400 ];
5401 let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
5402 let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
5403 let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
5404 let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
5405 let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
5406 let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
5407
5408 let _ = std::fs::remove_dir_all(&temp_path);
5410 }
5411}