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