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 message: Option<&str>,
2576 force: bool,
2577) -> Result<(), git2::Error> {
2578 let repo = Repository::open(repo_path)?;
2579 let oid = git2::Oid::from_str(commit_oid_str)?;
2580 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2581 if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2582 let tagger_sig = repo.signature();
2583 let default_sig;
2584 let tagger = match &tagger_sig {
2585 Ok(s) => s,
2586 Err(_) => {
2587 if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2588 default_sig = sig;
2589 &default_sig
2590 } else {
2591 return Err(git2::Error::from_str("Failed to create signature"));
2592 }
2593 }
2594 };
2595 repo.tag(tag_name, &target_object, tagger, msg, force)?;
2596 } else {
2597 repo.tag_lightweight(tag_name, &target_object, force)?;
2598 }
2599 Ok(())
2600}
2601
2602pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2604 if let Ok(repo) = Repository::open(repo_path) {
2605 let ref_name = format!("refs/tags/{}", tag_name);
2606 repo.find_reference(&ref_name).is_ok()
2607 } else {
2608 false
2609 }
2610}
2611
2612pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2614 let repo = Repository::open(repo_path)?;
2615 repo.tag_delete(tag_name)?;
2616 Ok(())
2617}
2618
2619pub fn delete_remote_tag(
2621 repo_path: &Path,
2622 remote_name: &str,
2623 tag_name: &str,
2624) -> Result<(), Box<dyn std::error::Error>> {
2625 let safe_remote = safe_ref(remote_name)?;
2626 let safe_tag = safe_ref(tag_name)?;
2627 let output = git_command()
2628 .arg("push")
2629 .arg(safe_remote)
2630 .arg("--delete")
2631 .arg(safe_tag)
2632 .current_dir(repo_path)
2633 .output()?;
2634 if !output.status.success() {
2635 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2636 return Err(err.into());
2637 }
2638 Ok(())
2639}
2640
2641pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2642 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2643 let output = git_command()
2644 .arg("checkout")
2645 .arg(safe_tag)
2646 .current_dir(repo_path)
2647 .output()
2648 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2649
2650 if !output.status.success() {
2651 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2652 return Err(git2::Error::from_str(&err));
2653 }
2654 Ok(())
2655}
2656
2657pub fn get_remote_tags(
2659 repo_path: &Path,
2660 remote_name: &str,
2661) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2662 let safe_remote = safe_ref(remote_name)?;
2663 let output = git_command()
2664 .arg("ls-remote")
2665 .arg("--tags")
2666 .arg(safe_remote)
2667 .current_dir(repo_path)
2668 .output()?;
2669
2670 if !output.status.success() {
2671 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2672 return Err(err.into());
2673 }
2674
2675 let stdout = String::from_utf8_lossy(&output.stdout);
2676 let repo = git2::Repository::open(repo_path)?;
2677 let mut tags_map = std::collections::HashMap::new();
2678
2679 for line in stdout.lines() {
2680 let parts: Vec<&str> = line.split_whitespace().collect();
2681 if parts.len() >= 2 {
2682 let sha = parts[0];
2683 let ref_name = parts[1];
2684 if ref_name.starts_with("refs/tags/") {
2685 let is_peeled = ref_name.ends_with("^{}");
2686 let clean_ref = if is_peeled {
2687 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2688 } else {
2689 ref_name
2690 };
2691 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2692 let short_sha = safe_sha_slice(sha, 7);
2693
2694 let mut short_message = String::new();
2696 if let Ok(oid) = git2::Oid::from_str(sha) {
2697 if let Ok(commit) = repo.find_commit(oid) {
2698 if let Ok(Some(summary)) = commit.summary() {
2699 short_message = summary.to_string();
2700 }
2701 }
2702 }
2703 if short_message.is_empty() {
2704 short_message = "(not fetched)".to_string();
2705 }
2706
2707 let sanitized_tag = sanitize_text(tag_name);
2708 let sanitized_msg = sanitize_text(&short_message);
2709
2710 if is_peeled {
2711 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2712 } else {
2713 tags_map
2714 .entry(sanitized_tag)
2715 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2716 }
2717 }
2718 }
2719 }
2720
2721 let mut tags = Vec::new();
2722 for (name, (short_sha, short_message)) in tags_map {
2723 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2724 }
2725 tags.sort_by(|a, b| b.name.cmp(&a.name));
2726 Ok(tags)
2727}
2728
2729pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2730 let mut s = String::new();
2731 for tag in tags {
2732 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2733 }
2734 s
2735}
2736
2737pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2738 let mut tags = Vec::new();
2739 for line in s.lines() {
2740 let parts: Vec<&str> = line.split('|').collect();
2741 if parts.len() >= 3 {
2742 tags.push(BranchInfo {
2743 name: parts[0].to_string(),
2744 is_head: false,
2745 short_sha: parts[1].to_string(),
2746 short_message: parts[2].to_string(),
2747 });
2748 }
2749 }
2750 tags
2751}
2752
2753pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2754 let mut repo = Repository::open(repo_path)?;
2755 repo.stash_drop(index)?;
2756 Ok(())
2757}
2758
2759pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2760 let stash_ref = format!("stash@{{{}}}", index);
2761 let output = std::process::Command::new("git")
2762 .env("GIT_TERMINAL_PROMPT", "0")
2763 .env("GIT_SSH_COMMAND", ssh_command_val())
2764 .arg("stash")
2765 .arg("apply")
2766 .arg(&stash_ref)
2767 .current_dir(repo_path)
2768 .output()
2769 .map_err(|e| e.to_string())?;
2770
2771 if !output.status.success() {
2772 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2773 return Err(err_msg);
2774 }
2775 Ok(())
2776}
2777
2778pub fn save_stash(
2779 repo_path: &Path,
2780 message: &str,
2781 include_untracked: bool,
2782 keep_index: bool,
2783) -> Result<(), String> {
2784 let mut cmd = std::process::Command::new("git");
2785 cmd.env("GIT_TERMINAL_PROMPT", "0")
2786 .env("GIT_SSH_COMMAND", ssh_command_val())
2787 .arg("stash")
2788 .arg("push");
2789
2790 if include_untracked {
2791 cmd.arg("--include-untracked");
2792 }
2793 if keep_index {
2794 cmd.arg("--keep-index");
2795 }
2796
2797 if !message.is_empty() {
2798 cmd.arg("-m").arg(message);
2799 }
2800
2801 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2802
2803 if !output.status.success() {
2804 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2805 return Err(err_msg);
2806 }
2807 Ok(())
2808}
2809
2810pub fn get_latest_change_time(item: &str) -> u64 {
2811 let path = expand_tilde(item);
2812 if !path.exists() {
2813 return 0;
2814 }
2815
2816 if path.join(".git").exists() {
2817 if let Ok(repo) = Repository::open(&path) {
2818 if let Ok(head) = repo.head() {
2819 if let Ok(commit) = head.peel_to_commit() {
2820 return commit.time().seconds() as u64;
2821 }
2822 }
2823 }
2824 }
2825
2826 if let Ok(meta) = std::fs::metadata(&path) {
2827 if let Ok(modified) = meta.modified() {
2828 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2829 return duration.as_secs();
2830 }
2831 }
2832 }
2833 0
2834}
2835
2836pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2837 if let Ok(repo) = Repository::open(repo_path) {
2838 if let Ok(head) = repo.head() {
2839 if let Ok(commit) = head.peel_to_commit() {
2840 if let Ok(msg) = commit.message() {
2841 return Some(msg.to_string());
2842 }
2843 }
2844 }
2845 }
2846 None
2847}
2848
2849pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2850 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2851 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2852 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2853
2854 let mut index = repo.index().map_err(|e| e.to_string())?;
2855 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2856 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2857
2858 let signature = repo
2859 .signature()
2860 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2861
2862 head_commit
2863 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2864 .map_err(|e| e.to_string())?;
2865
2866 Ok(())
2867}
2868
2869pub fn is_merging(repo_path: &Path) -> bool {
2874 repo_path.join(".git/MERGE_HEAD").exists()
2875}
2876
2877pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2880 let full_path = repo_path.join(file_path);
2881 let content = match std::fs::read_to_string(&full_path) {
2882 Ok(s) => s,
2883 Err(_) => return Vec::new(),
2884 };
2885
2886 let mut lines = Vec::new();
2887 let mut in_ours = false;
2888 let mut in_theirs = false;
2889
2890 for line in content.lines() {
2891 if line.starts_with("<<<<<<<") {
2892 in_ours = true;
2893 in_theirs = false;
2894 lines.push(DiffLine {
2895 kind: DiffLineKind::ConflictSeparator,
2896 content: line.to_string(),
2897 old_lineno: None,
2898 new_lineno: None,
2899 hunk_idx: None,
2900 });
2901 } else if line.starts_with("=======") {
2902 in_ours = false;
2903 in_theirs = true;
2904 lines.push(DiffLine {
2905 kind: DiffLineKind::ConflictSeparator,
2906 content: line.to_string(),
2907 old_lineno: None,
2908 new_lineno: None,
2909 hunk_idx: None,
2910 });
2911 } else if line.starts_with(">>>>>>>") {
2912 in_ours = false;
2913 in_theirs = false;
2914 lines.push(DiffLine {
2915 kind: DiffLineKind::ConflictSeparator,
2916 content: line.to_string(),
2917 old_lineno: None,
2918 new_lineno: None,
2919 hunk_idx: None,
2920 });
2921 } else if in_ours {
2922 lines.push(DiffLine {
2923 kind: DiffLineKind::ConflictOurs,
2924 content: line.to_string(),
2925 old_lineno: None,
2926 new_lineno: None,
2927 hunk_idx: None,
2928 });
2929 } else if in_theirs {
2930 lines.push(DiffLine {
2931 kind: DiffLineKind::ConflictTheirs,
2932 content: line.to_string(),
2933 old_lineno: None,
2934 new_lineno: None,
2935 hunk_idx: None,
2936 });
2937 } else {
2938 lines.push(DiffLine {
2939 kind: DiffLineKind::Context,
2940 content: line.to_string(),
2941 old_lineno: None,
2942 new_lineno: None,
2943 hunk_idx: None,
2944 });
2945 }
2946 }
2947 lines
2948}
2949
2950pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2953 let output1 = std::process::Command::new("git")
2954 .env("GIT_TERMINAL_PROMPT", "0")
2955 .env("GIT_SSH_COMMAND", ssh_command_val())
2956 .args(["checkout", "--ours", file_path])
2957 .current_dir(repo_path)
2958 .output()
2959 .map_err(|e| e.to_string())?;
2960 if !output1.status.success() {
2961 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2962 }
2963 stage_file(repo_path, file_path)?;
2964 Ok(())
2965}
2966
2967pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2970 let output1 = std::process::Command::new("git")
2971 .env("GIT_TERMINAL_PROMPT", "0")
2972 .env("GIT_SSH_COMMAND", ssh_command_val())
2973 .args(["checkout", "--theirs", file_path])
2974 .current_dir(repo_path)
2975 .output()
2976 .map_err(|e| e.to_string())?;
2977 if !output1.status.success() {
2978 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2979 }
2980 stage_file(repo_path, file_path)?;
2981 Ok(())
2982}
2983
2984pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2986 stage_file(repo_path, file_path)
2987}
2988
2989pub fn resolve_conflict_hunk(
2993 repo_path: &Path,
2994 file_path: &str,
2995 hunk_idx: usize,
2996 accept_ours: bool,
2997) -> Result<(), String> {
2998 let full_path = repo_path.join(file_path);
2999 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3000
3001 let mut new_lines = Vec::new();
3002 let mut lines_iter = content.lines().peekable();
3003 let mut current_hunk_idx = 0;
3004
3005 while let Some(line) = lines_iter.next() {
3006 if line.starts_with("<<<<<<<") {
3007 let mut ours_block = Vec::new();
3008 let mut theirs_block = Vec::new();
3009
3010 let mut found_separator = false;
3012 while let Some(&next_line) = lines_iter.peek() {
3013 if next_line.starts_with("=======") {
3014 lines_iter.next(); found_separator = true;
3016 break;
3017 }
3018 if let Some(line) = lines_iter.next() {
3019 ours_block.push(line.to_string());
3020 }
3021 }
3022
3023 let mut found_end = false;
3025 let mut end_line_marker = ">>>>>>>".to_string();
3026 while let Some(&next_line) = lines_iter.peek() {
3027 if next_line.starts_with(">>>>>>>") {
3028 if let Some(marker) = lines_iter.next() {
3029 end_line_marker = marker.to_string(); }
3031 found_end = true;
3032 break;
3033 }
3034 if let Some(line) = lines_iter.next() {
3035 theirs_block.push(line.to_string());
3036 }
3037 }
3038
3039 if current_hunk_idx == hunk_idx {
3040 if accept_ours {
3041 new_lines.extend(ours_block);
3042 } else {
3043 new_lines.extend(theirs_block);
3044 }
3045 } else {
3046 new_lines.push(line.to_string());
3047 new_lines.extend(ours_block);
3048 if found_separator {
3049 new_lines.push("=======".to_string());
3050 }
3051 new_lines.extend(theirs_block);
3052 if found_end {
3053 new_lines.push(end_line_marker);
3054 }
3055 }
3056
3057 current_hunk_idx += 1;
3058 } else {
3059 new_lines.push(line.to_string());
3060 }
3061 }
3062
3063 let mut new_content = new_lines.join("\n");
3064 if content.ends_with('\n') && !new_content.ends_with('\n') {
3065 new_content.push('\n');
3066 }
3067 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3068
3069 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3071 let has_conflict_markers = updated_content
3072 .lines()
3073 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3074
3075 if !has_conflict_markers {
3076 stage_file(repo_path, file_path)?;
3077 }
3078
3079 Ok(())
3080}
3081
3082pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3084 let output = std::process::Command::new("git")
3085 .env("GIT_TERMINAL_PROMPT", "0")
3086 .env("GIT_SSH_COMMAND", ssh_command_val())
3087 .args(["merge", "--abort"])
3088 .current_dir(repo_path)
3089 .output()
3090 .map_err(|e| e.to_string())?;
3091 if !output.status.success() {
3092 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3093 }
3094 Ok(())
3095}
3096
3097pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3099 let output = std::process::Command::new("git")
3100 .env("GIT_TERMINAL_PROMPT", "0")
3101 .env("GIT_SSH_COMMAND", ssh_command_val())
3102 .args(["merge", "--continue"])
3103 .env("GIT_EDITOR", "true")
3104 .current_dir(repo_path)
3105 .output()
3106 .map_err(|e| e.to_string())?;
3107 if !output.status.success() {
3108 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3109 }
3110 Ok(())
3111}
3112
3113pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3115 let repo = Repository::open(repo_path).ok()?;
3116 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3117 let _upstream = branch.upstream().ok()?;
3118 let local_ref = branch.get().name().ok()?;
3119 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3120 remote_buf.as_str().ok().map(|s| s.to_string())
3121}
3122
3123pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3125 get_branch_upstream_remote(repo_path, branch_name).is_some()
3126}
3127
3128pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3130 let repo = Repository::open(repo_path).ok()?;
3131 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3132 if branch.upstream().is_ok() {
3133 if let Ok(local_ref) = branch.get().name() {
3134 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3135 if let Ok(name) = remote_buf.as_str() {
3136 return Some((name.to_string(), false));
3137 }
3138 }
3139 }
3140 }
3141 let remotes = repo.remotes().ok()?;
3142 let first_remote = remotes.iter().next()?.ok()??.to_string();
3143 Some((first_remote, true))
3144}
3145
3146pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3148 if let Ok(repo) = Repository::open(repo_path) {
3149 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3150 if let Ok(commit) = repo.find_commit(oid) {
3151 return commit.parent_count() == 0;
3152 }
3153 }
3154 }
3155 false
3156}
3157
3158pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3159 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3160 let mut worktrees_list = Vec::new();
3161 if let Ok(worktree_names) = repo.worktrees() {
3162 for name in worktree_names.iter() {
3163 let Ok(Some(wt_name)) = name else { continue };
3164 if let Ok(wt) = repo.find_worktree(wt_name) {
3165 let path = wt.path().to_path_buf();
3166 let mut branch = None;
3167 if let Ok(wt_repo) = Repository::open(&path) {
3168 if let Ok(head) = wt_repo.head() {
3169 branch = head.shorthand().map(String::from).ok();
3170 }
3171 }
3172
3173 let mut is_locked = false;
3174 let mut lock_reason = None;
3175 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3176 is_locked = true;
3177 if let Some(reason) = reason_opt {
3178 if !reason.is_empty() {
3179 lock_reason = Some(reason);
3180 }
3181 }
3182 }
3183
3184 worktrees_list.push(WorktreeInfo {
3185 name: wt_name.to_string(),
3186 path,
3187 branch,
3188 is_locked,
3189 lock_reason,
3190 });
3191 }
3192 }
3193 }
3194 Ok(worktrees_list)
3195}
3196
3197pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3198 let output = std::process::Command::new("git")
3199 .arg("worktree")
3200 .arg("add")
3201 .arg(wt_path)
3202 .arg(branch)
3203 .current_dir(repo_path)
3204 .output()
3205 .map_err(|e| e.to_string())?;
3206
3207 if !output.status.success() {
3208 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3209 }
3210 Ok(())
3211}
3212
3213pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3214 let output = std::process::Command::new("git")
3215 .arg("worktree")
3216 .arg("lock")
3217 .arg("--reason")
3218 .arg(reason)
3219 .arg(name)
3220 .current_dir(repo_path)
3221 .output()
3222 .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_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3231 let output = std::process::Command::new("git")
3232 .arg("worktree")
3233 .arg("unlock")
3234 .arg(name)
3235 .current_dir(repo_path)
3236 .output()
3237 .map_err(|e| e.to_string())?;
3238
3239 if !output.status.success() {
3240 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3241 }
3242 Ok(())
3243}
3244
3245pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3246 let mut cmd = std::process::Command::new("git");
3247 cmd.arg("worktree").arg("remove");
3248 if force {
3249 cmd.arg("--force");
3250 }
3251 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3252
3253 if !output.status.success() {
3254 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3255 }
3256 Ok(())
3257}
3258
3259pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3260 let output = std::process::Command::new("git")
3261 .arg("worktree")
3262 .arg("prune")
3263 .current_dir(repo_path)
3264 .output()
3265 .map_err(|e| e.to_string())?;
3266
3267 if !output.status.success() {
3268 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3269 }
3270 Ok(())
3271}
3272
3273pub fn load_tab_forge_issues(
3274 repo_path: &Path,
3275 assigned_only: bool,
3276) -> Result<Vec<ForgeIssue>, String> {
3277 let mut cmd = std::process::Command::new("gh");
3278 cmd.arg("issue").arg("list");
3279 if assigned_only {
3280 cmd.arg("--assignee").arg("@me");
3281 }
3282 cmd.arg("--limit")
3283 .arg("50")
3284 .arg("--json")
3285 .arg("number,title,state,author,assignees,url")
3286 .current_dir(repo_path);
3287
3288 let output = match cmd.output() {
3289 Ok(out) => out,
3290 Err(e) => {
3291 return Err(format!(
3292 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3293 e
3294 ));
3295 }
3296 };
3297
3298 if !output.status.success() {
3299 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3300 return Err(format!(
3301 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3302 err_msg
3303 ));
3304 }
3305
3306 #[derive(serde::Deserialize)]
3307 struct GhAuthor {
3308 login: String,
3309 }
3310
3311 #[derive(serde::Deserialize)]
3312 struct GhAssignee {
3313 login: String,
3314 }
3315
3316 #[derive(serde::Deserialize)]
3317 struct GhIssue {
3318 number: u32,
3319 title: String,
3320 state: String,
3321 author: Option<GhAuthor>,
3322 assignees: Option<Vec<GhAssignee>>,
3323 url: String,
3324 }
3325
3326 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3327 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3328
3329 let issues = raw_issues
3330 .into_iter()
3331 .map(|item| {
3332 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3333 let assignees =
3334 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3335 ForgeIssue {
3336 number: item.number,
3337 title: item.title,
3338 state: item.state,
3339 author,
3340 assignees,
3341 url: item.url,
3342 }
3343 })
3344 .collect();
3345
3346 Ok(issues)
3347}
3348
3349#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3350pub struct ForgePR {
3351 pub number: u32,
3352 pub title: String,
3353 pub state: String,
3354 pub author: String,
3355 pub assignees: Vec<String>,
3356 pub url: String,
3357 pub head_ref: String,
3358 pub head_ref_oid: String,
3359 pub body: String,
3360 pub status_checks: Vec<CIStatusCheck>,
3361 pub reviews: Vec<PRReview>,
3362}
3363
3364#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3365pub struct ForgePRComment {
3366 pub path: String,
3367 pub line: Option<u32>,
3368 pub body: String,
3369 pub author: String,
3370 pub commit_id: String,
3371}
3372
3373#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3374pub struct CIStatusCheck {
3375 pub name: String,
3376 pub state: Option<String>,
3377 pub status: Option<String>,
3378 pub conclusion: Option<String>,
3379}
3380
3381#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3382pub struct PRReview {
3383 pub author: String,
3384 pub body: String,
3385 pub state: String,
3386}
3387
3388pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3389 let mut cmd = std::process::Command::new("gh");
3390 cmd.arg("pr")
3391 .arg("list")
3392 .arg("--limit")
3393 .arg("50")
3394 .arg("--json")
3395 .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3396 .current_dir(repo_path);
3397
3398 let output = match cmd.output() {
3399 Ok(out) => out,
3400 Err(e) => {
3401 return Err(format!(
3402 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3403 e
3404 ));
3405 }
3406 };
3407
3408 if !output.status.success() {
3409 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3410 return Err(format!(
3411 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3412 err_msg
3413 ));
3414 }
3415
3416 #[derive(serde::Deserialize)]
3417 struct GhAuthor {
3418 login: String,
3419 }
3420
3421 #[derive(serde::Deserialize)]
3422 struct GhAssignee {
3423 login: String,
3424 }
3425
3426 #[derive(serde::Deserialize)]
3427 struct GhReview {
3428 author: Option<GhAuthor>,
3429 body: String,
3430 state: String,
3431 }
3432
3433 #[derive(serde::Deserialize)]
3434 struct GhStatusCheck {
3435 name: String,
3436 state: Option<String>,
3437 status: Option<String>,
3438 conclusion: Option<String>,
3439 }
3440
3441 #[derive(serde::Deserialize)]
3442 struct GhPR {
3443 number: u32,
3444 title: String,
3445 state: String,
3446 author: Option<GhAuthor>,
3447 assignees: Option<Vec<GhAssignee>>,
3448 url: String,
3449 #[serde(rename = "headRefName")]
3450 head_ref_name: String,
3451 #[serde(rename = "headRefOid")]
3452 head_ref_oid: String,
3453 body: String,
3454 #[serde(rename = "statusCheckRollup")]
3455 status_check_rollup: Option<Vec<GhStatusCheck>>,
3456 reviews: Option<Vec<GhReview>>,
3457 }
3458
3459 let raw_prs: Vec<GhPR> = serde_json::from_slice(&output.stdout)
3460 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3461
3462 let prs = raw_prs
3463 .into_iter()
3464 .map(|item| {
3465 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3466 let assignees =
3467 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3468 let status_checks = item
3469 .status_check_rollup
3470 .unwrap_or_default()
3471 .into_iter()
3472 .map(|c| CIStatusCheck {
3473 name: c.name,
3474 state: c.state,
3475 status: c.status,
3476 conclusion: c.conclusion,
3477 })
3478 .collect();
3479 let reviews = item
3480 .reviews
3481 .unwrap_or_default()
3482 .into_iter()
3483 .map(|r| PRReview {
3484 author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3485 body: r.body,
3486 state: r.state,
3487 })
3488 .collect();
3489
3490 ForgePR {
3491 number: item.number,
3492 title: item.title,
3493 state: item.state,
3494 author,
3495 assignees,
3496 url: item.url,
3497 head_ref: item.head_ref_name,
3498 head_ref_oid: item.head_ref_oid,
3499 body: item.body,
3500 status_checks,
3501 reviews,
3502 }
3503 })
3504 .collect();
3505
3506 Ok(prs)
3507}
3508
3509pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3510 let mut cmd = std::process::Command::new("gh");
3511 cmd.arg("api")
3512 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3513 .current_dir(repo_path);
3514
3515 let output = match cmd.output() {
3516 Ok(out) => out,
3517 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3518 };
3519
3520 if !output.status.success() {
3521 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3522 return Err(format!("Failed to load PR comments: {}", err));
3523 }
3524
3525 #[derive(serde::Deserialize)]
3526 struct GhUser {
3527 login: String,
3528 }
3529
3530 #[derive(serde::Deserialize)]
3531 struct GhPRComment {
3532 path: String,
3533 line: Option<u32>,
3534 body: String,
3535 user: Option<GhUser>,
3536 #[serde(rename = "commit_id")]
3537 commit_id: String,
3538 }
3539
3540 let raw_comments: Vec<GhPRComment> = serde_json::from_slice(&output.stdout)
3541 .map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3542
3543 let comments = raw_comments
3544 .into_iter()
3545 .map(|c| ForgePRComment {
3546 path: c.path,
3547 line: c.line,
3548 body: c.body,
3549 author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3550 commit_id: c.commit_id,
3551 })
3552 .collect();
3553
3554 Ok(comments)
3555}
3556
3557pub fn add_pr_line_comment(
3558 repo_path: &Path,
3559 pr_number: u32,
3560 commit_id: &str,
3561 path: &str,
3562 line: u32,
3563 body: &str,
3564) -> Result<(), String> {
3565 let mut cmd = std::process::Command::new("gh");
3566 cmd.arg("api")
3567 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3568 .arg("--method")
3569 .arg("POST")
3570 .arg("-F")
3571 .arg(format!("body={}", body))
3572 .arg("-F")
3573 .arg(format!("commit_id={}", commit_id))
3574 .arg("-F")
3575 .arg(format!("path={}", path))
3576 .arg("-F")
3577 .arg(format!("line={}", line))
3578 .arg("-F")
3579 .arg("side=RIGHT")
3580 .current_dir(repo_path);
3581
3582 let output = match cmd.output() {
3583 Ok(out) => out,
3584 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3585 };
3586
3587 if !output.status.success() {
3588 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3589 return Err(format!("Failed to add PR comment: {}", err));
3590 }
3591 Ok(())
3592}
3593
3594pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3595 let mut cmd = std::process::Command::new("gh");
3596 cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3597
3598 let output = cmd.output().map_err(|e| e.to_string())?;
3599 if !output.status.success() {
3600 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3601 return Err(format!("Failed to checkout PR branch: {}", err));
3602 }
3603 Ok(format!("Checked out branch for PR #{}", pr_number))
3604}
3605
3606pub fn resolve_and_checkout_issue_branch(
3607 repo_path: &Path,
3608 issue_number: u32,
3609) -> Result<String, String> {
3610 let mut cmd = std::process::Command::new("gh");
3611 cmd.arg("issue")
3612 .arg("view")
3613 .arg(issue_number.to_string())
3614 .arg("--json")
3615 .arg("developmentBranch")
3616 .current_dir(repo_path);
3617
3618 if let Ok(output) = cmd.output() {
3619 if output.status.success() {
3620 #[derive(serde::Deserialize)]
3621 struct GhDevBranch {
3622 name: String,
3623 }
3624 #[derive(serde::Deserialize)]
3625 struct GhIssueView {
3626 #[serde(rename = "developmentBranch")]
3627 development_branch: Option<GhDevBranch>,
3628 }
3629 if let Ok(parsed) = serde_json::from_slice::<GhIssueView>(&output.stdout) {
3630 if let Some(dev_branch) = parsed.development_branch {
3631 if !dev_branch.name.is_empty() {
3632 let name = dev_branch.name;
3633 if checkout_local_branch(repo_path, &name).is_ok() {
3634 return Ok(format!("Checked out linked local branch '{}'", name));
3635 }
3636 if let Ok(msg) = checkout_remote_branch(repo_path, &name) {
3637 return Ok(format!("Checked out linked remote branch '{}'", msg));
3638 }
3639 let checkout_out = std::process::Command::new("git")
3640 .arg("checkout")
3641 .arg("-b")
3642 .arg(&name)
3643 .current_dir(repo_path)
3644 .output();
3645 if let Ok(out) = checkout_out {
3646 if out.status.success() {
3647 return Ok(format!(
3648 "Created and switched to linked branch '{}'",
3649 name
3650 ));
3651 }
3652 }
3653 }
3654 }
3655 }
3656 }
3657 }
3658
3659 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3660 let target_pattern = format!("{}", issue_number);
3661 let mut found_local = None;
3662 let mut found_remote = None;
3663
3664 if let Ok(branches) = repo.branches(None) {
3665 for (branch, branch_type) in branches.flatten() {
3666 if let Ok(Some(name)) = branch.name() {
3667 if name.contains(&target_pattern) {
3668 match branch_type {
3669 git2::BranchType::Local => {
3670 found_local = Some(name.to_string());
3671 break;
3672 }
3673 git2::BranchType::Remote => {
3674 found_remote = Some(name.to_string());
3675 }
3676 }
3677 }
3678 }
3679 }
3680 }
3681
3682 if let Some(local_name) = found_local {
3683 checkout_local_branch(repo_path, &local_name).map_err(|e| e.to_string())?;
3684 return Ok(format!("Checked out matched local branch '{}'", local_name));
3685 }
3686
3687 if let Some(remote_name) = found_remote {
3688 let msg = checkout_remote_branch(repo_path, &remote_name).map_err(|e| e.to_string())?;
3689 return Ok(format!("Checked out matched remote branch: {}", msg));
3690 }
3691
3692 let new_branch_name = format!("issue-{}", issue_number);
3693 let checkout_out = std::process::Command::new("git")
3694 .arg("checkout")
3695 .arg("-b")
3696 .arg(&new_branch_name)
3697 .current_dir(repo_path)
3698 .output()
3699 .map_err(|e| e.to_string())?;
3700
3701 if !checkout_out.status.success() {
3702 let err = String::from_utf8_lossy(&checkout_out.stderr).trim().to_string();
3703 return Err(format!("Failed to create new branch: {}", err));
3704 }
3705
3706 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
3707}
3708
3709pub fn open_browser(url: &str) {
3710 #[cfg(target_os = "macos")]
3711 let _ = std::process::Command::new("open").arg(url).status();
3712 #[cfg(target_os = "windows")]
3713 let _ = std::process::Command::new("cmd").args(["/C", "start", url]).status();
3714 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3715 let _ = std::process::Command::new("xdg-open").arg(url).status();
3716}
3717
3718#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3719pub struct ForgeIssue {
3720 pub number: u32,
3721 pub title: String,
3722 pub state: String,
3723 pub author: String,
3724 pub assignees: Vec<String>,
3725 pub url: String,
3726}
3727
3728pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
3729 let remotes = load_tab_remotes(repo_path).ok()?;
3730 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
3731}
3732
3733pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
3734 let remotes = load_tab_remotes(repo_path).ok()?;
3735 remotes.into_iter().next().map(|r| r.url)
3736}
3737
3738#[cfg(test)]
3739#[allow(clippy::unwrap_used, clippy::panic)]
3740mod tests {
3741 use super::*;
3742 use std::fs::File;
3743
3744 #[test]
3745 fn test_match_pattern() {
3746 assert!(match_pattern("test.psd", "*.psd"));
3747 assert!(match_pattern("assets/test.psd", "*.psd"));
3748 assert!(match_pattern("assets/img.png", "assets/*.png"));
3749 assert!(match_pattern("src/app.rs", "src/*"));
3750 assert!(match_pattern("test.txt", "test.txt"));
3751 assert!(!match_pattern("src/app.rs", "*.psd"));
3752 }
3753
3754 #[test]
3755 fn test_get_lfs_info() {
3756 let mut temp_path = std::env::temp_dir();
3757 temp_path.push(format!(
3758 "twig_test_lfs_{}",
3759 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3760 ));
3761 std::fs::create_dir_all(&temp_path).unwrap();
3762
3763 let _repo = Repository::init(&temp_path).unwrap();
3765 let lfs_dir = temp_path.join(".git").join("lfs");
3766 std::fs::create_dir_all(&lfs_dir).unwrap();
3767
3768 let (installed, tracked) = get_lfs_info(&temp_path);
3770
3771 assert!(tracked.is_empty());
3773
3774 let gitattributes_path = temp_path.join(".gitattributes");
3776 std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
3777
3778 let (installed2, _tracked2) = get_lfs_info(&temp_path);
3779 assert_eq!(installed, installed2);
3780
3781 let _ = std::fs::remove_dir_all(&temp_path);
3783 }
3784
3785 #[test]
3786 fn test_comprehensive_gitwig_core_coverage() {
3787 let mut temp_path = std::env::temp_dir();
3788 temp_path.push(format!(
3789 "twig_test_cov_{}",
3790 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3791 ));
3792 std::fs::create_dir_all(&temp_path).unwrap();
3793
3794 let repo = Repository::init(&temp_path).unwrap();
3796
3797 let mut config = repo.config().unwrap();
3799 config.set_str("user.name", "Test User").unwrap();
3800 config.set_str("user.email", "test@example.com").unwrap();
3801
3802 let file_path = temp_path.join("test.txt");
3804 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3805 stage_file(&temp_path, "test.txt").unwrap();
3806 commit_changes(&temp_path, "first commit").unwrap();
3807
3808 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3809 let head_oid = head_commit.id().to_string();
3810
3811 let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
3813 let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
3814
3815 let _ = stage_all_changes(&temp_path);
3817 let _ = unstage_all_changes(&temp_path);
3818
3819 std::fs::write(&file_path, "modified content").unwrap();
3820 let _ = discard_all_changes(&temp_path);
3821
3822 let _lfs_size = get_lfs_storage_size(&temp_path);
3824
3825 let _reflog = load_tab_reflog(&temp_path);
3827 let _tags = load_tab_tags(&temp_path);
3828 let _stashes = load_tab_stashes(&temp_path);
3829 let _submodules = load_tab_submodules(&temp_path);
3830 let _worktrees = load_tab_worktrees(&temp_path);
3831
3832 let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
3834 let _ = remote_delete(&temp_path, "upstream");
3835
3836 let wt_path = temp_path.join("wt-path");
3838 let _ = worktree_add(&temp_path, "new-branch", &wt_path);
3839 let _ = worktree_remove(&temp_path, "wt-path", true);
3840
3841 let _ = delete_tag(&temp_path, "v1.0.0");
3843
3844 let _ = apply_stash(&temp_path, 0);
3846 let _ = delete_stash(&temp_path, 0);
3847
3848 let _ = abort_merge(&temp_path);
3850 let _ = continue_merge(&temp_path);
3851
3852 let _ = is_merging(&temp_path);
3854 let _ = get_conflict_markers_diff(&temp_path, "test.txt");
3855 let _ = resolve_ours(&temp_path, "test.txt");
3856 let _ = resolve_theirs(&temp_path, "test.txt");
3857 let _ = mark_resolved(&temp_path, "test.txt");
3858
3859 let _target = get_branch_push_target(&temp_path, "master");
3861 let _target2 = get_branch_push_target(&temp_path, "main");
3862
3863 let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
3865 let _summary = inspect_summary(temp_path.to_str().unwrap());
3866
3867 let _ = std::fs::remove_dir_all(&temp_path);
3869 }
3870
3871 #[test]
3872 fn test_more_gitwig_core_coverage() {
3873 let mut temp_path = std::env::temp_dir();
3874 temp_path.push(format!(
3875 "twig_test_cov2_{}",
3876 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3877 ));
3878 std::fs::create_dir_all(&temp_path).unwrap();
3879
3880 let repo = Repository::init(&temp_path).unwrap();
3882
3883 let mut config = repo.config().unwrap();
3885 config.set_str("user.name", "Test User").unwrap();
3886 config.set_str("user.email", "test@example.com").unwrap();
3887
3888 let file_path = temp_path.join("test.txt");
3890 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3891 stage_file(&temp_path, "test.txt").unwrap();
3892 commit_changes(&temp_path, "first commit").unwrap();
3893
3894 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3895 let head_oid = head_commit.id().to_string();
3896
3897 let _ = checkout_commit(&temp_path, &head_oid);
3899 let _ = checkout_local_branch(&temp_path, "master");
3900 let _ = checkout_remote_branch(&temp_path, "origin/master");
3901 let _ = checkout_tag(&temp_path, "v1.0.0");
3902
3903 let _ = create_branch(&temp_path, "new-branch-2");
3905 let _ = delete_local_branch(&temp_path, "new-branch-2");
3906 let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
3907 let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
3908 let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0");
3909 let _ = get_remote_tags(&temp_path, "origin");
3910
3911 let tag_info = vec![BranchInfo {
3913 name: "v1.0.0".to_string(),
3914 is_head: false,
3915 short_sha: "abc1234".to_string(),
3916 short_message: "tag msg".to_string(),
3917 }];
3918 let serialized = serialize_tags(&tag_info);
3919 let deserialized = deserialize_tags(&serialized);
3920 assert_eq!(deserialized.len(), 1);
3921 assert_eq!(deserialized[0].name, "v1.0.0");
3922
3923 let _ = get_file_history(&temp_path, "test.txt");
3925 let _ = get_file_blame(&temp_path, "test.txt");
3926 let _ = get_commit_files(&temp_path, &head_oid);
3927
3928 let _ = load_tab_files(&temp_path);
3930 let _ = load_tab_branches(&temp_path);
3931 let _ = load_tab_remotes(&temp_path);
3932 let _ = load_tab_overview(&temp_path, 10);
3933
3934 let (tx, _rx) = std::sync::mpsc::channel();
3936 let _ =
3937 load_tab_graph_stream(&temp_path, 10, temp_path.to_str().unwrap().to_string(), 0, tx);
3938
3939 invalidate_ref_map_cache(&temp_path);
3941
3942 let _ = load_tab_forge_issues(&temp_path, false);
3944 let _ = load_tab_forge_issues(&temp_path, true);
3945 let _ = load_tab_forge_prs(&temp_path);
3946 let _ = load_pr_comments(&temp_path, 1);
3947 let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
3948
3949 let _ = std::fs::remove_dir_all(&temp_path);
3951 }
3952
3953 #[test]
3954 fn test_create_tag_annotated_and_force_update() {
3955 let mut temp_path = std::env::temp_dir();
3956 temp_path.push(format!(
3957 "twig_test_tag_force_{}",
3958 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3959 ));
3960 std::fs::create_dir_all(&temp_path).unwrap();
3961
3962 let repo = Repository::init(&temp_path).unwrap();
3963 let mut config = repo.config().unwrap();
3964 config.set_str("user.name", "Test User").unwrap();
3965 config.set_str("user.email", "test@example.com").unwrap();
3966
3967 let file_path = temp_path.join("test.txt");
3968 std::fs::write(&file_path, "commit 1\n").unwrap();
3969 stage_file(&temp_path, "test.txt").unwrap();
3970 commit_changes(&temp_path, "commit 1").unwrap();
3971
3972 let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
3973 let oid1 = head_commit1.id().to_string();
3974
3975 assert!(!tag_exists(&temp_path, "v1.0.1"));
3976 assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
3977 assert!(tag_exists(&temp_path, "v1.0.1"));
3978
3979 std::fs::write(&file_path, "commit 2\n").unwrap();
3981 stage_file(&temp_path, "test.txt").unwrap();
3982 commit_changes(&temp_path, "commit 2").unwrap();
3983
3984 let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
3985 let oid2 = head_commit2.id().to_string();
3986
3987 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
3988
3989 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
3991
3992 let _ = std::fs::remove_dir_all(&temp_path);
3993 }
3994
3995 use std::io::Write;
3996
3997 #[test]
3998 fn test_commit_amend() {
3999 let mut temp_path = std::env::temp_dir();
4000 temp_path.push(format!(
4001 "twig_test_amend_{}",
4002 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4003 ));
4004 std::fs::create_dir_all(&temp_path).unwrap();
4005
4006 let repo = Repository::init(&temp_path).unwrap();
4008
4009 let mut config = repo.config().unwrap();
4011 config.set_str("user.name", "Test User").unwrap();
4012 config.set_str("user.email", "test@example.com").unwrap();
4013
4014 let file_path = temp_path.join("test.txt");
4016 let mut file = File::create(&file_path).unwrap();
4017 writeln!(file, "initial content").unwrap();
4018
4019 stage_file(&temp_path, "test.txt").unwrap();
4021 commit_changes(&temp_path, "initial commit").unwrap();
4022
4023 let msg = get_last_commit_message(&temp_path).unwrap();
4025 assert_eq!(msg, "initial commit");
4026
4027 commit_amend(&temp_path, "amended commit").unwrap();
4029
4030 let amended_msg = get_last_commit_message(&temp_path).unwrap();
4032 assert_eq!(amended_msg, "amended commit");
4033
4034 let _ = std::fs::remove_dir_all(&temp_path);
4036 }
4037
4038 #[test]
4039 fn test_commit_signatures_collection() {
4040 let mut temp_path = std::env::temp_dir();
4041 temp_path.push(format!(
4042 "twig_test_sig_{}",
4043 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4044 ));
4045 std::fs::create_dir_all(&temp_path).unwrap();
4046
4047 let repo = Repository::init(&temp_path).unwrap();
4049
4050 let mut config = repo.config().unwrap();
4052 config.set_str("user.name", "Test User").unwrap();
4053 config.set_str("user.email", "test@example.com").unwrap();
4054
4055 let file_path = temp_path.join("test.txt");
4057 let mut file = File::create(&file_path).unwrap();
4058 writeln!(file, "initial content").unwrap();
4059
4060 stage_file(&temp_path, "test.txt").unwrap();
4062 commit_changes(&temp_path, "initial commit").unwrap();
4063
4064 let sigs = collect_signatures(&temp_path, 0);
4066 assert_eq!(sigs.len(), 1);
4067 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4068 let sig_status = sigs.get(&head_oid).unwrap();
4069 assert_eq!(sig_status, "N");
4070
4071 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4073 assert_eq!(commits.len(), 1);
4074 assert_eq!(commits[0].signature_status, "N");
4075 assert!(commits[0].files.is_empty());
4076
4077 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4079 assert_eq!(files.len(), 1);
4080 assert_eq!(files[0].path, "test.txt");
4081 assert_eq!(files[0].label, "N");
4082
4083 let graph = collect_graph_lines(&temp_path, 1000);
4085 assert_eq!(graph.len(), 1);
4086 assert!(graph[0].commit.is_some());
4087 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4088
4089 let _ = std::fs::remove_dir_all(&temp_path);
4091 }
4092
4093 #[test]
4094 fn test_ref_map_cache_behavior() {
4095 let temp_dir = std::env::temp_dir();
4096 let repo_path = temp_dir.join("test_ref_map_repo");
4097 let _ = std::fs::remove_dir_all(&repo_path);
4098 std::fs::create_dir_all(&repo_path).unwrap();
4099
4100 let repo = Repository::init(&repo_path).unwrap();
4101
4102 let map1 = get_cached_ref_map(&repo, &repo_path);
4104
4105 let map2 = get_cached_ref_map(&repo, &repo_path);
4107 assert_eq!(map1.len(), map2.len());
4108
4109 invalidate_ref_map_cache(&repo_path);
4111
4112 let _ = std::fs::remove_dir_all(&repo_path);
4114 }
4115
4116 #[test]
4117 fn test_get_latest_change_time() {
4118 let mut temp_path = std::env::temp_dir();
4119 temp_path.push(format!(
4120 "twig_test_time_{}",
4121 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4122 ));
4123 std::fs::create_dir_all(&temp_path).unwrap();
4124
4125 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
4126 assert!(change_time > 0);
4127
4128 let _ = std::fs::remove_dir_all(&temp_path);
4129 }
4130
4131 #[test]
4132 fn test_committer_stats() {
4133 let mut temp_path = std::env::temp_dir();
4134 temp_path.push(format!(
4135 "twig_test_stats_{}",
4136 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4137 ));
4138 std::fs::create_dir_all(&temp_path).unwrap();
4139
4140 let repo = Repository::init(&temp_path).unwrap();
4142
4143 let mut config = repo.config().unwrap();
4145 config.set_str("user.name", "Test User").unwrap();
4146 config.set_str("user.email", "test@example.com").unwrap();
4147
4148 let file_path = temp_path.join("test.txt");
4150 let mut file = File::create(&file_path).unwrap();
4151 writeln!(file, "initial content").unwrap();
4152
4153 stage_file(&temp_path, "test.txt").unwrap();
4155 commit_changes(&temp_path, "initial commit").unwrap();
4156
4157 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
4159 assert_eq!(stats.len(), 1);
4160 assert_eq!(stats[0].name, "Test User");
4161 assert_eq!(stats[0].email, "test@example.com");
4162 assert_eq!(stats[0].count, 1);
4163 assert!(!limit_reached);
4164
4165 let _ = std::fs::remove_dir_all(&temp_path);
4167 }
4168
4169 #[test]
4170 fn test_untracked_files_in_unstaged() {
4171 let mut temp_path = std::env::temp_dir();
4172 temp_path.push(format!(
4173 "twig_test_untracked_{}",
4174 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4175 ));
4176 std::fs::create_dir_all(&temp_path).unwrap();
4177
4178 let _repo = Repository::init(&temp_path).unwrap();
4180
4181 let file_path = temp_path.join("untracked.txt");
4183 let mut file = File::create(&file_path).unwrap();
4184 writeln!(file, "hello untracked").unwrap();
4185
4186 let untracked_dir = temp_path.join("untracked_dir");
4188 std::fs::create_dir_all(&untracked_dir).unwrap();
4189 let nested_file_path = untracked_dir.join("nested.txt");
4190 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
4191
4192 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4194 match detail {
4195 ItemDetail::Repo { info, .. } => {
4196 let unstaged_paths: Vec<String> =
4198 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
4199 let untracked_paths: Vec<String> =
4200 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
4201
4202 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
4204 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
4205 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
4206 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
4207
4208 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
4210 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
4211 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
4212 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
4213 }
4214 _ => panic!("Expected ItemDetail::Repo"),
4215 }
4216
4217 let _ = std::fs::remove_dir_all(&temp_path);
4219 }
4220
4221 #[test]
4222 fn test_stage_new_and_deleted_files() {
4223 let mut temp_path = std::env::temp_dir();
4224 temp_path.push(format!(
4225 "twig_test_new_del_{}",
4226 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4227 ));
4228 std::fs::create_dir_all(&temp_path).unwrap();
4229
4230 let repo = Repository::init(&temp_path).unwrap();
4232
4233 let mut config = repo.config().unwrap();
4235 config.set_str("user.name", "Test User").unwrap();
4236 config.set_str("user.email", "test@example.com").unwrap();
4237
4238 let init_file = temp_path.join("init.txt");
4240 std::fs::write(&init_file, "initial").unwrap();
4241 stage_file(&temp_path, "init.txt").unwrap();
4242 commit_changes(&temp_path, "initial commit").unwrap();
4243
4244 let untracked_file = temp_path.join("untracked.txt");
4246 std::fs::write(&untracked_file, "new file content").unwrap();
4247
4248 stage_file(&temp_path, "untracked.txt").unwrap();
4250
4251 std::fs::remove_file(&init_file).unwrap();
4253
4254 stage_file(&temp_path, "init.txt").unwrap();
4256
4257 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4259 match detail {
4260 ItemDetail::Repo { info, .. } => {
4261 assert_eq!(info.changes.staged.len(), 2);
4263 let paths: Vec<String> =
4264 info.changes.staged.iter().map(|f| f.path.clone()).collect();
4265 assert!(paths.contains(&"untracked.txt".to_string()));
4266 assert!(paths.contains(&"init.txt".to_string()));
4267 }
4268 _ => panic!("Expected ItemDetail::Repo"),
4269 }
4270
4271 let _ = std::fs::remove_dir_all(&temp_path);
4273 }
4274
4275 #[test]
4276 fn test_discard_file_changes_all_cases() {
4277 let mut temp_path = std::env::temp_dir();
4278 temp_path.push(format!(
4279 "twig_test_discard_all_{}",
4280 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4281 ));
4282 std::fs::create_dir_all(&temp_path).unwrap();
4283
4284 let repo = Repository::init(&temp_path).unwrap();
4286
4287 let mut config = repo.config().unwrap();
4289 config.set_str("user.name", "Test User").unwrap();
4290 config.set_str("user.email", "test@example.com").unwrap();
4291
4292 let file_tracked = temp_path.join("tracked.txt");
4294 std::fs::write(&file_tracked, "original content\n").unwrap();
4295 stage_file(&temp_path, "tracked.txt").unwrap();
4296 commit_changes(&temp_path, "initial commit").unwrap();
4297
4298 let file_untracked = temp_path.join("untracked.txt");
4300 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
4301 assert!(file_untracked.exists());
4302 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
4303 assert!(!file_untracked.exists());
4304
4305 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
4307 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4308 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4309
4310 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
4312 stage_file(&temp_path, "tracked.txt").unwrap();
4313 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4315 match detail {
4316 ItemDetail::Repo { info, .. } => {
4317 assert!(!info.changes.staged.is_empty());
4318 }
4319 _ => panic!("Expected ItemDetail::Repo"),
4320 }
4321 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
4322 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4323 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4325 match detail {
4326 ItemDetail::Repo { info, .. } => {
4327 assert!(info.changes.staged.is_empty());
4328 assert!(info.changes.unstaged.is_empty());
4329 }
4330 _ => panic!("Expected ItemDetail::Repo"),
4331 }
4332
4333 std::fs::remove_file(&file_tracked).unwrap();
4335 assert!(!file_tracked.exists());
4336 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4337 assert!(file_tracked.exists());
4338 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4339
4340 let _ = std::fs::remove_dir_all(&temp_path);
4342 }
4343
4344 #[test]
4345 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4346 fn test_stage_unstage_by_hunk() {
4347 let mut temp_path = std::env::temp_dir();
4348 temp_path.push(format!(
4349 "twig_test_hunk_{}",
4350 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4351 ));
4352 std::fs::create_dir_all(&temp_path).unwrap();
4353
4354 let repo = Repository::init(&temp_path).unwrap();
4356
4357 let mut config = repo.config().unwrap();
4359 config.set_str("user.name", "Test User").unwrap();
4360 config.set_str("user.email", "test@example.com").unwrap();
4361
4362 let file_path = temp_path.join("multihunk.txt");
4364 let mut file = File::create(&file_path).unwrap();
4365 for i in 1..=20 {
4366 writeln!(file, "Line {}", i).unwrap();
4367 }
4368 drop(file);
4369
4370 stage_file(&temp_path, "multihunk.txt").unwrap();
4372 commit_changes(&temp_path, "initial commit").unwrap();
4373
4374 let mut file = File::create(&file_path).unwrap();
4376 for i in 1..=20 {
4377 if i == 2 || i == 18 {
4378 writeln!(file, "Line {} modified", i).unwrap();
4379 } else {
4380 writeln!(file, "Line {}", i).unwrap();
4381 }
4382 }
4383 drop(file);
4384
4385 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4387 let mut hunk_ranges = Vec::new();
4389 let mut current_start = None;
4390 for (i, line) in diff_lines.iter().enumerate() {
4391 if line.kind == DiffLineKind::Header {
4392 if let Some(start) = current_start {
4393 hunk_ranges.push(start..i);
4394 }
4395 current_start = Some(i);
4396 }
4397 }
4398 if let Some(start) = current_start {
4399 hunk_ranges.push(start..diff_lines.len());
4400 }
4401
4402 assert_eq!(hunk_ranges.len(), 2);
4404
4405 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4407 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
4408
4409 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4411 let staged_content: String =
4412 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4413 assert!(staged_content.contains("Line 18 modified"));
4414 assert!(!staged_content.contains("Line 2 modified"));
4415
4416 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4418 let unstaged_content: String =
4419 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4420 assert!(unstaged_content.contains("Line 2 modified"));
4421 assert!(!unstaged_content.contains("Line 18 modified"));
4422
4423 let staged_hunk_ranges = {
4425 let mut ranges = Vec::new();
4426 let mut current_start = None;
4427 for (i, line) in staged_diff.iter().enumerate() {
4428 if line.kind == DiffLineKind::Header {
4429 if let Some(start) = current_start {
4430 ranges.push(start..i);
4431 }
4432 current_start = Some(i);
4433 }
4434 }
4435 if let Some(start) = current_start {
4436 ranges.push(start..staged_diff.len());
4437 }
4438 ranges
4439 };
4440 assert_eq!(staged_hunk_ranges.len(), 1);
4441 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
4442 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
4443
4444 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4446 assert!(staged_diff_after.is_empty());
4447
4448 let _ = std::fs::remove_dir_all(&temp_path);
4450 }
4451
4452 #[test]
4453 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4454 fn test_discard_hunk() {
4455 let mut temp_path = std::env::temp_dir();
4456 temp_path.push(format!(
4457 "twig_test_discard_h_{}",
4458 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4459 ));
4460 std::fs::create_dir_all(&temp_path).unwrap();
4461
4462 let repo = Repository::init(&temp_path).unwrap();
4464
4465 let mut config = repo.config().unwrap();
4467 config.set_str("user.name", "Test User").unwrap();
4468 config.set_str("user.email", "test@example.com").unwrap();
4469
4470 let file_path = temp_path.join("discardhunk.txt");
4472 let mut file = File::create(&file_path).unwrap();
4473 for i in 1..=20 {
4474 writeln!(file, "Line {}", i).unwrap();
4475 }
4476 drop(file);
4477
4478 stage_file(&temp_path, "discardhunk.txt").unwrap();
4480 commit_changes(&temp_path, "initial commit").unwrap();
4481
4482 let mut file = File::create(&file_path).unwrap();
4484 for i in 1..=20 {
4485 if i == 2 || i == 18 {
4486 writeln!(file, "Line {} modified", i).unwrap();
4487 } else {
4488 writeln!(file, "Line {}", i).unwrap();
4489 }
4490 }
4491 drop(file);
4492
4493 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
4495 let mut hunk_ranges = Vec::new();
4497 let mut current_start = None;
4498 for (i, line) in diff_lines.iter().enumerate() {
4499 if line.kind == DiffLineKind::Header {
4500 if let Some(start) = current_start {
4501 hunk_ranges.push(start..i);
4502 }
4503 current_start = Some(i);
4504 }
4505 }
4506 if let Some(start) = current_start {
4507 hunk_ranges.push(start..diff_lines.len());
4508 }
4509
4510 assert_eq!(hunk_ranges.len(), 2);
4512
4513 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4515 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
4516
4517 let contents = std::fs::read_to_string(&file_path).unwrap();
4519 assert!(contents.contains("Line 2 modified"));
4520 assert!(contents.contains("Line 18\n"));
4521 assert!(!contents.contains("Line 18 modified"));
4522
4523 let _ = std::fs::remove_dir_all(&temp_path);
4525 }
4526
4527 #[test]
4528 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4529 fn test_stage_unstage_discard_line() {
4530 let mut temp_path = std::env::temp_dir();
4531 temp_path.push(format!(
4532 "twig_test_line_{}",
4533 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4534 ));
4535 std::fs::create_dir_all(&temp_path).unwrap();
4536
4537 let repo = Repository::init(&temp_path).unwrap();
4538 let mut config = repo.config().unwrap();
4539 config.set_str("user.name", "Test User").unwrap();
4540 config.set_str("user.email", "test@example.com").unwrap();
4541
4542 let file_path = temp_path.join("line_test.txt");
4544 let mut file = File::create(&file_path).unwrap();
4545 writeln!(file, "line A").unwrap();
4546 writeln!(file, "line B").unwrap();
4547 writeln!(file, "line C").unwrap();
4548 drop(file);
4549
4550 stage_file(&temp_path, "line_test.txt").unwrap();
4551 commit_changes(&temp_path, "initial").unwrap();
4552
4553 let mut file = File::create(&file_path).unwrap();
4555 writeln!(file, "line A modified").unwrap();
4556 writeln!(file, "line B").unwrap();
4557 writeln!(file, "line C modified").unwrap();
4558 drop(file);
4559
4560 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4561 let mut hunk_ranges = Vec::new();
4562 let mut current_start = None;
4563 for (i, line) in diff_lines.iter().enumerate() {
4564 if line.kind == DiffLineKind::Header {
4565 if let Some(start) = current_start {
4566 hunk_ranges.push(start..i);
4567 }
4568 current_start = Some(i);
4569 }
4570 }
4571 if let Some(start) = current_start {
4572 hunk_ranges.push(start..diff_lines.len());
4573 }
4574
4575 assert_eq!(hunk_ranges.len(), 1);
4576 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
4577
4578 assert_eq!(hunk0[2].content, "line A modified");
4579 assert_eq!(hunk0[5].content, "line C modified");
4580
4581 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
4583
4584 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
4586 assert!(
4587 staged_diff
4588 .iter()
4589 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4590 );
4591 assert!(
4592 !staged_diff
4593 .iter()
4594 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4595 );
4596
4597 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4599 assert!(
4600 !unstaged_diff
4601 .iter()
4602 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4603 );
4604 assert!(
4605 unstaged_diff
4606 .iter()
4607 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4608 );
4609
4610 assert_eq!(staged_diff[2].content, "line A modified");
4612 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
4613
4614 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
4616
4617 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4619 assert_eq!(unstaged_diff2[5].content, "line C modified");
4620 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
4621
4622 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4623 let remove_idx = unstaged_diff3
4624 .iter()
4625 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
4626 .unwrap();
4627 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
4628
4629 let contents = std::fs::read_to_string(&file_path).unwrap();
4631 assert!(contents.contains("line A modified"));
4632 assert!(contents.contains("line B"));
4633 assert!(contents.contains("line C\n"));
4634 assert!(!contents.contains("line C modified"));
4635
4636 let _ = std::fs::remove_dir_all(&temp_path);
4638 }
4639
4640 #[test]
4641 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4642 fn test_stage_unstage_discard_all_changes() {
4643 let mut temp_path = std::env::temp_dir();
4644 temp_path.push(format!(
4645 "twig_test_all_{}",
4646 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4647 ));
4648 std::fs::create_dir_all(&temp_path).unwrap();
4649
4650 let repo = Repository::init(&temp_path).unwrap();
4652
4653 let mut config = repo.config().unwrap();
4655 config.set_str("user.name", "Test User").unwrap();
4656 config.set_str("user.email", "test@example.com").unwrap();
4657
4658 let file_path = temp_path.join("tracked.txt");
4660 std::fs::write(&file_path, "original content\n").unwrap();
4661 stage_file(&temp_path, "tracked.txt").unwrap();
4662 commit_changes(&temp_path, "initial").unwrap();
4663
4664 std::fs::write(&file_path, "modified content\n").unwrap();
4666 let untracked_path = temp_path.join("untracked.txt");
4667 std::fs::write(&untracked_path, "untracked content\n").unwrap();
4668
4669 let status = repo.statuses(None).unwrap();
4671 assert_eq!(status.len(), 2);
4672
4673 stage_all_changes(&temp_path).unwrap();
4675
4676 let status = repo.statuses(None).unwrap();
4678 for entry in status.iter() {
4679 assert!(
4680 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
4681 );
4682 }
4683
4684 unstage_all_changes(&temp_path).unwrap();
4686
4687 let status = repo.statuses(None).unwrap();
4689 for entry in status.iter() {
4690 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
4691 }
4692
4693 discard_all_changes(&temp_path).unwrap();
4695
4696 let status = repo.statuses(None).unwrap();
4698 assert_eq!(status.len(), 0);
4699
4700 let contents = std::fs::read_to_string(&file_path).unwrap();
4702 assert_eq!(contents, "original content\n");
4703 assert!(!untracked_path.exists());
4704
4705 let _ = std::fs::remove_dir_all(&temp_path);
4707 }
4708
4709 #[test]
4710 fn test_merge_conflicts_flow() {
4711 let mut temp_path = std::env::temp_dir();
4712 temp_path.push(format!(
4713 "twig_test_conflict_{}",
4714 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4715 ));
4716 std::fs::create_dir_all(&temp_path).unwrap();
4717
4718 let repo = Repository::init(&temp_path).unwrap();
4720
4721 let mut config = repo.config().unwrap();
4723 config.set_str("user.name", "Test User").unwrap();
4724 config.set_str("user.email", "test@example.com").unwrap();
4725
4726 let file_path = temp_path.join("conflict.txt");
4728 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4729 stage_file(&temp_path, "conflict.txt").unwrap();
4730 commit_changes(&temp_path, "initial commit").unwrap();
4731
4732 let output = std::process::Command::new("git")
4734 .env("GIT_TERMINAL_PROMPT", "0")
4735 .env("GIT_SSH_COMMAND", ssh_command_val())
4736 .args(["symbolic-ref", "--short", "HEAD"])
4737 .current_dir(&temp_path)
4738 .output()
4739 .unwrap();
4740 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4741
4742 std::process::Command::new("git")
4744 .env("GIT_TERMINAL_PROMPT", "0")
4745 .env("GIT_SSH_COMMAND", ssh_command_val())
4746 .args(["checkout", "-b", "feature"])
4747 .current_dir(&temp_path)
4748 .output()
4749 .unwrap();
4750
4751 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
4752 stage_file(&temp_path, "conflict.txt").unwrap();
4753 commit_changes(&temp_path, "feature commit").unwrap();
4754
4755 std::process::Command::new("git")
4757 .env("GIT_TERMINAL_PROMPT", "0")
4758 .env("GIT_SSH_COMMAND", ssh_command_val())
4759 .args(["checkout", &main_branch])
4760 .current_dir(&temp_path)
4761 .output()
4762 .unwrap();
4763
4764 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
4765 stage_file(&temp_path, "conflict.txt").unwrap();
4766 commit_changes(&temp_path, "main commit").unwrap();
4767
4768 assert!(!is_merging(&temp_path));
4770 let merge_output = std::process::Command::new("git")
4771 .env("GIT_TERMINAL_PROMPT", "0")
4772 .env("GIT_SSH_COMMAND", ssh_command_val())
4773 .args(["merge", "feature"])
4774 .current_dir(&temp_path)
4775 .output()
4776 .unwrap();
4777
4778 assert!(!merge_output.status.success());
4779 assert!(is_merging(&temp_path));
4780
4781 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
4783 assert!(!diff.is_empty());
4784 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
4785 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
4786 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
4787 assert!(has_separator);
4788 assert!(has_ours);
4789 assert!(has_theirs);
4790
4791 abort_merge(&temp_path).unwrap();
4793 assert!(!is_merging(&temp_path));
4794
4795 std::process::Command::new("git")
4797 .env("GIT_TERMINAL_PROMPT", "0")
4798 .env("GIT_SSH_COMMAND", ssh_command_val())
4799 .args(["merge", "feature"])
4800 .current_dir(&temp_path)
4801 .output()
4802 .unwrap();
4803 assert!(is_merging(&temp_path));
4804
4805 resolve_ours(&temp_path, "conflict.txt").unwrap();
4807 let contents = std::fs::read_to_string(&file_path).unwrap();
4808 assert!(contents.contains("line 2 on main"));
4809 assert!(!contents.contains("<<<<<<<"));
4810
4811 continue_merge(&temp_path).unwrap();
4813 assert!(!is_merging(&temp_path));
4814
4815 std::process::Command::new("git")
4817 .env("GIT_TERMINAL_PROMPT", "0")
4818 .env("GIT_SSH_COMMAND", ssh_command_val())
4819 .args(["reset", "--hard", "HEAD~1"])
4820 .current_dir(&temp_path)
4821 .output()
4822 .unwrap();
4823
4824 std::process::Command::new("git")
4825 .env("GIT_TERMINAL_PROMPT", "0")
4826 .env("GIT_SSH_COMMAND", ssh_command_val())
4827 .args(["merge", "feature"])
4828 .current_dir(&temp_path)
4829 .output()
4830 .unwrap();
4831 assert!(is_merging(&temp_path));
4832
4833 resolve_theirs(&temp_path, "conflict.txt").unwrap();
4834 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4835 assert!(contents_theirs.contains("line 2 on feature"));
4836 assert!(!contents_theirs.contains("<<<<<<<"));
4837
4838 continue_merge(&temp_path).unwrap();
4839 assert!(!is_merging(&temp_path));
4840
4841 let _ = std::fs::remove_dir_all(&temp_path);
4843 }
4844
4845 #[test]
4846 fn test_resolve_conflict_hunk() {
4847 let mut temp_path = std::env::temp_dir();
4848 temp_path.push(format!(
4849 "twig_test_hunk_conflict_{}",
4850 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4851 ));
4852 std::fs::create_dir_all(&temp_path).unwrap();
4853
4854 let repo = Repository::init(&temp_path).unwrap();
4856
4857 let mut config = repo.config().unwrap();
4859 config.set_str("user.name", "Test User").unwrap();
4860 config.set_str("user.email", "test@example.com").unwrap();
4861
4862 let file_path = temp_path.join("conflict.txt");
4864 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";
4865 std::fs::write(&file_path, initial_lines).unwrap();
4866 stage_file(&temp_path, "conflict.txt").unwrap();
4867 commit_changes(&temp_path, "initial commit").unwrap();
4868
4869 let output = std::process::Command::new("git")
4871 .env("GIT_TERMINAL_PROMPT", "0")
4872 .env("GIT_SSH_COMMAND", ssh_command_val())
4873 .args(["symbolic-ref", "--short", "HEAD"])
4874 .current_dir(&temp_path)
4875 .output()
4876 .unwrap();
4877 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4878
4879 std::process::Command::new("git")
4881 .env("GIT_TERMINAL_PROMPT", "0")
4882 .env("GIT_SSH_COMMAND", ssh_command_val())
4883 .args(["checkout", "-b", "feature"])
4884 .current_dir(&temp_path)
4885 .output()
4886 .unwrap();
4887 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";
4888 std::fs::write(&file_path, feature_lines).unwrap();
4889 stage_file(&temp_path, "conflict.txt").unwrap();
4890 commit_changes(&temp_path, "feature commit").unwrap();
4891
4892 std::process::Command::new("git")
4894 .env("GIT_TERMINAL_PROMPT", "0")
4895 .env("GIT_SSH_COMMAND", ssh_command_val())
4896 .args(["checkout", &main_branch])
4897 .current_dir(&temp_path)
4898 .output()
4899 .unwrap();
4900 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";
4901 std::fs::write(&file_path, main_lines).unwrap();
4902 stage_file(&temp_path, "conflict.txt").unwrap();
4903 commit_changes(&temp_path, "main commit").unwrap();
4904
4905 let merge_output = std::process::Command::new("git")
4907 .env("GIT_TERMINAL_PROMPT", "0")
4908 .env("GIT_SSH_COMMAND", ssh_command_val())
4909 .args(["merge", "feature"])
4910 .current_dir(&temp_path)
4911 .output()
4912 .unwrap();
4913 assert!(!merge_output.status.success());
4914 assert!(is_merging(&temp_path));
4915
4916 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
4918 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
4919 assert!(contents_after_first.contains("line 2 on main"));
4921 assert!(!contents_after_first.contains("line 2 on feature"));
4922 assert!(contents_after_first.contains("<<<<<<<"));
4924 assert!(contents_after_first.contains("line 11 on main"));
4925 assert!(contents_after_first.contains("line 11 on feature"));
4926
4927 assert!(is_merging(&temp_path));
4929
4930 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
4935 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
4936 assert!(contents_after_second.contains("line 2 on main"));
4938 assert!(contents_after_second.contains("line 11 on feature"));
4939 assert!(!contents_after_second.contains("<<<<<<<"));
4940
4941 let status = repo.statuses(None).unwrap();
4943 assert_eq!(status.len(), 1);
4944 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
4945
4946 continue_merge(&temp_path).unwrap();
4948 assert!(!is_merging(&temp_path));
4949
4950 let _ = std::fs::remove_dir_all(&temp_path);
4952 }
4953
4954 #[test]
4955 fn test_branch_and_commit_helpers() {
4956 let mut temp_path = std::env::temp_dir();
4957 temp_path.push(format!(
4958 "twig_test_helpers_{}",
4959 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4960 ));
4961 let _ = std::fs::remove_dir_all(&temp_path);
4962 std::fs::create_dir_all(&temp_path).unwrap();
4963
4964 let repo = Repository::init(&temp_path).unwrap();
4966
4967 let mut config = repo.config().unwrap();
4969 config.set_str("user.name", "Test User").unwrap();
4970 config.set_str("user.email", "test@example.com").unwrap();
4971
4972 let file_path = temp_path.join("test.txt");
4974 std::fs::write(&file_path, "root content").unwrap();
4975 stage_file(&temp_path, "test.txt").unwrap();
4976 commit_changes(&temp_path, "root commit").unwrap();
4977
4978 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4979 assert!(is_root_commit(&temp_path, &head_oid));
4980
4981 std::fs::write(&file_path, "second content").unwrap();
4983 stage_file(&temp_path, "test.txt").unwrap();
4984 commit_changes(&temp_path, "second commit").unwrap();
4985
4986 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
4987 assert!(!is_root_commit(&temp_path, &new_head_oid));
4988
4989 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
4994 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
4995 "master"
4996 } else {
4997 "main"
4998 };
4999 let target = get_branch_push_target(&temp_path, active_branch);
5000 assert!(target.is_some());
5001 let (remote_name, set_upstream) = target.unwrap();
5002 assert_eq!(remote_name, "origin");
5003 assert!(set_upstream);
5004
5005 let mut git_config = repo.config().unwrap();
5007 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5008 git_config
5009 .set_str(
5010 &format!("branch.{}.merge", active_branch),
5011 &format!("refs/heads/{}", active_branch),
5012 )
5013 .unwrap();
5014
5015 let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5017 repo.reference(
5018 &format!("refs/remotes/origin/{}", active_branch),
5019 commit_obj.id(),
5020 true,
5021 "create mock remote ref",
5022 )
5023 .unwrap();
5024
5025 assert!(has_upstream_remote(&temp_path, active_branch));
5027 assert_eq!(
5028 get_branch_upstream_remote(&temp_path, active_branch),
5029 Some("origin".to_string())
5030 );
5031
5032 let target_tracking = get_branch_push_target(&temp_path, active_branch);
5034 assert!(target_tracking.is_some());
5035 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5036 assert_eq!(remote_name_tr, "origin");
5037 assert!(!set_upstream_tr);
5038
5039 let _ = std::fs::remove_dir_all(&temp_path);
5041 }
5042
5043 #[test]
5044 fn test_core_helpers_comprehensive() {
5045 assert_eq!(format_relative_time(0), "unknown");
5047 assert_eq!(format_relative_time(-100), "unknown");
5048
5049 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5050 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5051 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5052 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5053 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5054 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5055 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5056
5057 assert_eq!(format_utc_date(0), "unknown");
5059 assert_eq!(format_utc_date(-1), "unknown");
5060 let date_str = format_utc_date(1700000000);
5061 assert!(date_str.contains("2023-11-14"));
5062
5063 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5065 let tilde_path = expand_tilde("~/path");
5066 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5067
5068 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5070 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5071
5072 assert_eq!(safe_sha_slice("12345", 10), "12345");
5074 assert_eq!(safe_sha_slice("12345", 3), "123");
5075
5076 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
5078 assert!(safe_ref("-invalid").is_err());
5079 assert!(safe_ref(" ").is_err());
5080
5081 let mut status = RepoSummary::default();
5083 assert!(status.is_clean());
5084 assert!(status.is_synced());
5085 assert!(status.unchanged());
5086
5087 status.modified = 1;
5088 assert!(!status.is_clean());
5089 assert!(!status.unchanged());
5090
5091 status.modified = 0;
5092 status.ahead = 1;
5093 assert!(!status.is_synced());
5094 assert!(!status.unchanged());
5095
5096 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5098 assert!(data_not_loaded.is_not_loaded());
5099 assert!(!data_not_loaded.is_loading());
5100 assert!(!data_not_loaded.is_loaded());
5101 assert_eq!(data_not_loaded.len(), 0);
5102 assert!(data_not_loaded.is_empty());
5103 assert!(data_not_loaded.as_ref().is_none());
5104 assert!(data_not_loaded.first().is_none());
5105 assert!(data_not_loaded.get(0).is_none());
5106 assert_eq!(data_not_loaded.iter().count(), 0);
5107 assert_eq!(data_not_loaded.as_slice().len(), 0);
5108
5109 let data_loading: TabData<Vec<i32>> = TabData::Loading;
5110 assert!(!data_loading.is_not_loaded());
5111 assert!(data_loading.is_loading());
5112
5113 let data_loaded = TabData::Loaded(vec![10, 20]);
5114 assert!(!data_loaded.is_not_loaded());
5115 assert!(!data_loaded.is_loading());
5116 assert!(data_loaded.is_loaded());
5117 assert_eq!(data_loaded.len(), 2);
5118 assert!(!data_loaded.is_empty());
5119 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
5120 assert_eq!(data_loaded.first().unwrap(), &10);
5121 assert_eq!(data_loaded.get(1).unwrap(), &20);
5122 assert_eq!(data_loaded.iter().count(), 2);
5123 assert_eq!(data_loaded.as_slice(), &[10, 20]);
5124 }
5125
5126 #[test]
5127 fn test_comprehensive_gitwig_core_coverage_booster() {
5128 let mut temp_path = std::env::temp_dir();
5130 temp_path.push(format!(
5131 "twig_test_boost_{}",
5132 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5133 ));
5134 std::fs::create_dir_all(&temp_path).unwrap();
5135
5136 let repo = Repository::init(&temp_path).unwrap();
5137
5138 {
5140 let mut config = repo.config().unwrap();
5141 config.set_str("user.name", "User A").unwrap();
5142 config.set_str("user.email", "a@example.com").unwrap();
5143 std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
5144 stage_file(&temp_path, "file1.txt").unwrap();
5145 commit_changes(&temp_path, "commit A").unwrap();
5146 }
5147
5148 {
5150 let mut config = repo.config().unwrap();
5151 config.set_str("user.name", "User B").unwrap();
5152 config.set_str("user.email", "b@example.com").unwrap();
5153 std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
5154 stage_file(&temp_path, "file2.txt").unwrap();
5155 commit_changes(&temp_path, "commit B").unwrap();
5156 }
5157
5158 let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
5160 assert_eq!(stats.len(), 2);
5161 assert_eq!(stats[0].count, 1);
5162 assert_eq!(stats[1].count, 1);
5163
5164 std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
5167 stage_file(&temp_path, "file1.txt").unwrap();
5168 commit_changes(&temp_path, "deleted file1").unwrap();
5169 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5170 let files = get_commit_files(&temp_path, &head_oid).unwrap();
5171 assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
5172
5173 let path_tilde = expand_tilde("~");
5175 assert!(path_tilde.is_absolute());
5176
5177 let lfs_dir = temp_path.join(".git").join("lfs");
5179 std::fs::create_dir_all(&lfs_dir).unwrap();
5180 let nested_dir = lfs_dir.join("objects").join("xx");
5181 std::fs::create_dir_all(&nested_dir).unwrap();
5182 std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
5183 let lfs_size = get_lfs_storage_size(&temp_path);
5184 assert_eq!(lfs_size, Some(5));
5185
5186 let mut sub = repo
5188 .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
5189 .unwrap();
5190 let _ = sub.init(false);
5191 let subs = load_tab_submodules(&temp_path).unwrap();
5192 assert!(!subs.is_empty());
5193
5194 let wt_path = temp_path.join("wt-booster");
5196 let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
5197 let _ = worktree_lock(&temp_path, "wt-booster", "lock reason");
5198 let _ = worktree_unlock(&temp_path, "wt-booster");
5199 let _ = worktree_prune(&temp_path);
5200 let _ = std::fs::remove_dir_all(&wt_path);
5201
5202 open_browser("");
5204
5205 let nonexistent = Path::new("/nonexistent_directory_twig_test");
5207 let _ = stage_file(nonexistent, "test.txt");
5208 let _ = unstage_file(nonexistent, "test.txt");
5209 let _ = discard_all_changes(nonexistent);
5210 let _ = discard_file_changes(nonexistent, "test.txt", false);
5211 let _ = commit_changes(nonexistent, "msg");
5212 let _ = remote_add(nonexistent, "origin", "url");
5213 let _ = get_file_history(nonexistent, "test.txt");
5214 let _ = get_file_blame(nonexistent, "test.txt");
5215 let _ = get_commit_files(nonexistent, "sha");
5216 let _ = load_tab_reflog(nonexistent);
5217 let _ = load_tab_files(nonexistent);
5218 let _ = load_tab_branches(nonexistent);
5219 let _ = load_tab_tags(nonexistent);
5220 let _ = load_tab_remotes(nonexistent);
5221 let _ = load_tab_stashes(nonexistent);
5222 let _ = load_tab_overview(nonexistent, 10);
5223 let _ = load_tab_submodules(nonexistent);
5224 let _ = checkout_remote_branch(nonexistent, "origin/branch");
5225 let _ = checkout_local_branch(nonexistent, "branch");
5226 let _ = checkout_tag(nonexistent, "tag");
5227 let _ = delete_local_branch(nonexistent, "branch");
5228 let _ = delete_remote_branch(nonexistent, "origin/branch");
5229 let _ = create_tag(nonexistent, "tag", "sha", None, false);
5230 let _ = delete_remote_tag(nonexistent, "origin", "tag");
5231 let _ = get_remote_tags(nonexistent, "origin");
5232 let _ = apply_stash(nonexistent, 0);
5233 let _ = delete_stash(nonexistent, 0);
5234 let _ = save_stash(nonexistent, "msg", false, false);
5235 let _ = commit_amend(nonexistent, "msg");
5236 let _ = resolve_ours(nonexistent, "file");
5237 let _ = resolve_theirs(nonexistent, "file");
5238 let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
5239 let _ = abort_merge(nonexistent);
5240 let _ = continue_merge(nonexistent);
5241 let _ = get_branch_upstream_remote(nonexistent, "branch");
5242 let _ = load_tab_worktrees(nonexistent);
5243 let _ = worktree_add(nonexistent, "branch", Path::new("path"));
5244 let _ = worktree_lock(nonexistent, "name", "reason");
5245 let _ = worktree_unlock(nonexistent, "name");
5246 let _ = worktree_remove(nonexistent, "name", true);
5247 let _ = worktree_prune(nonexistent);
5248 let _ = load_tab_forge_issues(nonexistent, true);
5249 let _ = load_tab_forge_prs(nonexistent);
5250 let _ = load_pr_comments(nonexistent, 1);
5251 let _ = checkout_pr_branch(nonexistent, 1);
5252 let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
5253 let _ = get_remote_url(nonexistent, "origin");
5254 let _ = get_default_remote_url(nonexistent);
5255 let _ = get_latest_change_time("/nonexistent");
5256
5257 let dummy_hunk = vec![
5258 DiffLine {
5259 kind: DiffLineKind::Header,
5260 content: "@@ -1,1 +1,1 @@".to_string(),
5261 old_lineno: None,
5262 new_lineno: None,
5263 hunk_idx: None,
5264 },
5265 DiffLine {
5266 kind: DiffLineKind::Added,
5267 content: "line".to_string(),
5268 old_lineno: None,
5269 new_lineno: None,
5270 hunk_idx: None,
5271 },
5272 ];
5273 let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
5274 let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
5275 let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
5276 let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
5277 let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
5278 let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
5279
5280 let _ = std::fs::remove_dir_all(&temp_path);
5282 }
5283}