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