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 Missing,
31 Directory,
32 GitRepo(Option<RepoSummary>),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum RepoState {
37 #[default]
38 Clean,
39 Merge,
40 Revert,
41 CherryPick,
42 Bisect,
43 Rebase,
44 ApplyMailbox,
45}
46
47#[derive(Debug, Default, Clone)]
50pub struct RepoSummary {
51 pub branch: Option<String>,
54 pub staged: usize,
55 pub modified: usize,
56 pub untracked: usize,
57 pub conflicted: usize,
58 pub ahead: usize,
59 pub behind: usize,
60 pub state: RepoState,
61 pub last_commit_time: Option<i64>,
62}
63
64impl RepoSummary {
65 pub fn is_clean(&self) -> bool {
66 self.staged + self.modified + self.untracked + self.conflicted == 0
67 }
68 pub fn is_synced(&self) -> bool {
69 self.ahead + self.behind == 0
70 }
71 pub fn unchanged(&self) -> bool {
72 self.is_clean() && self.is_synced()
73 }
74}
75
76#[derive(Debug, Clone)]
79pub enum ItemDetail {
80 Missing { resolved: PathBuf },
81 Directory { resolved: PathBuf },
82 Repo { resolved: PathBuf, info: Box<RepoInfo> },
83 Error { resolved: PathBuf, message: String },
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct BranchInfo {
88 pub name: String,
89 pub is_head: bool,
90 pub short_sha: String,
91 pub short_message: String,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct FileRevision {
96 pub commit_oid: String,
97 pub author: String,
98 pub date: String,
99 pub when: String,
100 pub summary: String,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct StashInfo {
105 pub index: usize,
106 pub message: String,
107 pub commit_id: String,
108 pub files: Vec<FileEntry>,
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct CommitterStat {
113 pub name: String,
114 pub email: String,
115 pub count: usize,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub enum TabData<T> {
120 #[default]
121 NotLoaded,
122 Loading,
123 Loaded(T),
124 Error(String),
125}
126
127impl<T> TabData<T> {
128 pub fn is_not_loaded(&self) -> bool {
129 matches!(self, TabData::NotLoaded)
130 }
131 pub fn is_loading(&self) -> bool {
132 matches!(self, TabData::Loading)
133 }
134 #[allow(dead_code)]
135 pub fn is_loaded(&self) -> bool {
136 matches!(self, TabData::Loaded(_))
137 }
138 pub fn as_ref(&self) -> Option<&T> {
139 match self {
140 TabData::Loaded(val) => Some(val),
141 _ => None,
142 }
143 }
144}
145
146impl<T> TabData<Vec<T>> {
147 pub fn len(&self) -> usize {
148 self.as_ref().map(|v| v.len()).unwrap_or(0)
149 }
150 pub fn is_empty(&self) -> bool {
151 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
152 }
153 pub fn first(&self) -> Option<&T> {
154 self.as_ref().and_then(|v| v.first())
155 }
156 pub fn get(&self, index: usize) -> Option<&T> {
157 self.as_ref().and_then(|v| v.get(index))
158 }
159 pub fn iter(&self) -> std::slice::Iter<'_, T> {
160 match self {
161 TabData::Loaded(v) => v.iter(),
162 _ => [].iter(),
163 }
164 }
165 pub fn as_slice(&self) -> &[T] {
166 match self {
167 TabData::Loaded(v) => v.as_slice(),
168 _ => &[],
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct WorktreeInfo {
175 pub name: String,
176 pub path: PathBuf,
177 pub branch: Option<String>,
178 pub is_locked: bool,
179 pub lock_reason: Option<String>,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct SubmoduleInfo {
184 pub name: String,
185 pub path: PathBuf,
186 pub url: String,
187 pub commit_id: Option<String>,
188 pub head_id: Option<String>,
189 pub is_initialized: bool,
190 pub is_dirty: bool,
191}
192
193#[derive(Debug, Clone)]
194pub enum TabPayload {
195 Files(Result<Vec<String>, String>),
196 Graph(Result<Vec<GraphLine>, String>),
197 Branches { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
198 Tags { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
199 Remotes(Result<Vec<RemoteInfo>, String>),
200 Stashes(Result<Vec<StashInfo>, String>),
201 Overview(Result<(Vec<CommitterStat>, bool), String>),
202 Worktrees(Result<Vec<WorktreeInfo>, String>),
203 Submodules(Result<Vec<SubmoduleInfo>, String>),
204 Reflog(Result<Vec<ReflogEntry>, String>),
205 ForgeIssues(Result<Vec<ForgeIssue>, String>),
206}
207
208#[derive(Debug, Default, Clone)]
209pub struct RepoInfo {
210 pub branch: Option<String>,
211 pub head: Option<HeadInfo>,
212 pub remotes: TabData<Vec<RemoteInfo>>,
213 pub upstream: Option<String>,
215 pub summary: RepoSummary,
216 pub changes: WorktreeChanges,
218 pub commits: Vec<CommitEntry>,
220 pub graph_lines: TabData<Vec<GraphLine>>,
222 pub local_branches: TabData<Vec<BranchInfo>>,
224 pub remote_branches: TabData<Vec<BranchInfo>>,
226 pub local_tags: TabData<Vec<BranchInfo>>,
228 pub remote_tags: TabData<Vec<BranchInfo>>,
230 pub remote_tags_loaded: bool,
232 pub remote_tags_attempted: bool,
234 pub files: TabData<Vec<String>>,
236 pub stashes: TabData<Vec<StashInfo>>,
238 pub worktrees: TabData<Vec<WorktreeInfo>>,
240 pub submodules: TabData<Vec<SubmoduleInfo>>,
242 pub reflog: TabData<Vec<ReflogEntry>>,
244 pub forge_issues: TabData<Vec<ForgeIssue>>,
245 pub committer_stats: TabData<Vec<CommitterStat>>,
247 pub committer_stats_limit_reached: bool,
249 pub tab_loaded_at: [Option<std::time::Instant>; 11],
251 pub tab_loading: [bool; 11],
253 pub lfs_files: std::collections::HashSet<String>,
255 pub lfs_installed: bool,
257 pub lfs_storage_size: Option<u64>,
259}
260
261#[derive(Debug, Clone)]
262pub struct HeadInfo {
263 pub short_id: String,
264 pub summary: String,
265 pub author: String,
266 pub when: String,
267}
268
269#[derive(Debug, Clone)]
270pub struct ReflogEntry {
271 pub index: usize,
272 pub target_oid: String,
273 pub selector: String,
274 pub command: String,
275 pub message: String,
276 pub when: String,
277 pub date: String,
278}
279
280#[derive(Debug, Clone)]
281pub struct RemoteInfo {
282 pub name: String,
283 pub url: String,
284 pub push_url: Option<String>,
285 pub refspecs: Vec<String>,
286}
287
288#[derive(Debug, Clone)]
289pub struct CommitEntry {
290 pub id: String,
292 pub oid: String,
294 pub author: String,
295 pub when: String,
296 pub date: String,
297 pub summary: String,
298 pub message: String,
299 pub refs: Vec<String>,
302 pub files: Vec<FileEntry>,
304 pub signature_status: String,
306}
307
308#[derive(Debug, Clone)]
309pub struct GraphLine {
310 pub graph: String,
311 pub commit: Option<GraphCommit>,
312}
313
314#[derive(Debug, Clone)]
315pub struct GraphCommit {
316 pub oid: String,
317 pub decoration: String,
318 pub summary: String,
319 pub author: String,
320 pub date: String,
321 pub signature_status: String,
323}
324
325#[derive(Debug, Clone)]
327pub struct FileEntry {
328 pub path: String,
330 pub label: &'static str,
332}
333
334#[derive(Debug, Default, Clone)]
337pub struct WorktreeChanges {
338 pub staged: Vec<FileEntry>,
339 pub unstaged: Vec<FileEntry>,
340 pub untracked: Vec<FileEntry>,
341 pub conflicted: Vec<FileEntry>,
342}
343
344#[derive(Debug, Clone, PartialEq)]
348pub enum DiffLineKind {
349 Header,
351 Added,
353 Removed,
355 Context,
357 ConflictOurs,
359 ConflictTheirs,
361 ConflictSeparator,
363}
364
365#[derive(Debug, Clone)]
367pub struct DiffLine {
368 pub kind: DiffLineKind,
369 pub content: String,
371 pub old_lineno: Option<u32>,
372 pub new_lineno: Option<u32>,
373 pub hunk_idx: Option<usize>,
374}
375
376pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
380 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
381}
382
383pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
390 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
391}
392
393pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
396 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
397 let mut index = repo.index().map_err(|e| e.to_string())?;
398 let full_path = repo_path.join(file_path);
399 if full_path.exists() {
400 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
401 } else {
402 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
403 }
404 index.write().map_err(|e| e.to_string())?;
405 Ok(())
406}
407
408pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
413 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
414 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
416 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
417 .map_err(|e| e.to_string())?;
418 } else {
419 let mut index = repo.index().map_err(|e| e.to_string())?;
421 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
422 index.write().map_err(|e| e.to_string())?;
423 }
424 Ok(())
425}
426
427pub fn sanitize_text(s: &str) -> String {
428 let mut result = String::with_capacity(s.len());
429 let bytes = s.as_bytes();
430 let mut i = 0;
431 while i < bytes.len() {
432 if bytes[i] == 0x1B {
433 i += 1;
435 if i < bytes.len() && bytes[i] == b'[' {
436 i += 1;
437 while i < bytes.len() {
438 let c = bytes[i];
439 i += 1;
440 if c.is_ascii_alphabetic() {
442 break;
443 }
444 }
445 } else {
446 while i < bytes.len() {
448 let c = bytes[i];
449 i += 1;
450 if c == b' ' || c.is_ascii_alphabetic() {
451 break;
452 }
453 }
454 }
455 } else {
456 let c = bytes[i];
457 if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
459 result.push(' ');
460 } else {
461 result.push(c as char);
462 }
463 i += 1;
464 }
465 }
466 result
467}
468
469pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
470 let mut end = len.min(sha.len());
471 while end > 0 && !sha.is_char_boundary(end) {
472 end -= 1;
473 }
474 &sha[..end]
475}
476
477fn ssh_command_val() -> &'static str {
478 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
479 "ssh -o StrictHostKeyChecking=yes"
480 } else {
481 "ssh -o StrictHostKeyChecking=accept-new"
482 }
483}
484
485fn git_command() -> std::process::Command {
486 let mut cmd = std::process::Command::new("git");
487 cmd.env("GIT_TERMINAL_PROMPT", "0");
488 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
489 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
490 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
491 cmd
492}
493
494pub fn safe_ref(r: &str) -> Result<&str, String> {
495 let trimmed = r.trim();
496 if trimmed.starts_with('-') {
497 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
498 }
499 if trimmed.is_empty() {
500 return Err("Ref name cannot be empty".to_string());
501 }
502 Ok(trimmed)
503}
504
505pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
507 let output = git_command()
508 .arg("add")
509 .arg("-A")
510 .current_dir(repo_path)
511 .output()
512 .map_err(|e| e.to_string())?;
513
514 if output.status.success() {
515 Ok(())
516 } else {
517 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
518 }
519}
520
521pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
523 let output =
524 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
525
526 if output.status.success() {
527 Ok(())
528 } else {
529 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
530 }
531}
532
533pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
535 let _ = git_command().arg("reset").current_dir(repo_path).output();
537
538 let checkout_out = git_command()
540 .arg("checkout")
541 .arg("--")
542 .arg(".")
543 .current_dir(repo_path)
544 .output()
545 .map_err(|e| e.to_string())?;
546
547 if !checkout_out.status.success() {
548 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
549 }
550
551 let clean_out = git_command()
553 .arg("clean")
554 .arg("-fd")
555 .current_dir(repo_path)
556 .output()
557 .map_err(|e| e.to_string())?;
558
559 if !clean_out.status.success() {
560 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
561 }
562
563 Ok(())
564}
565
566pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
568 apply_hunk_patch(repo_path, file_path, hunk, false, true)
569}
570
571pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
573 apply_hunk_patch(repo_path, file_path, hunk, true, true)
574}
575
576pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
578 apply_hunk_patch(repo_path, file_path, hunk, true, false)
579}
580
581pub fn stage_line(
583 repo_path: &Path,
584 file_path: &str,
585 hunk: &[DiffLine],
586 selected_line_idx: usize,
587) -> Result<(), String> {
588 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
589}
590
591pub fn unstage_line(
593 repo_path: &Path,
594 file_path: &str,
595 hunk: &[DiffLine],
596 selected_line_idx: usize,
597) -> Result<(), String> {
598 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
599}
600
601pub fn discard_line(
603 repo_path: &Path,
604 file_path: &str,
605 hunk: &[DiffLine],
606 selected_line_idx: usize,
607) -> Result<(), String> {
608 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
609}
610
611fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
612 if !header.starts_with("@@") {
613 return None;
614 }
615 let parts: Vec<&str> = header.split("@@").collect();
616 if parts.len() < 3 {
617 return None;
618 }
619 let meta = parts[1].trim();
620 let subparts: Vec<&str> = meta.split_whitespace().collect();
621 if subparts.len() < 2 {
622 return None;
623 }
624
625 let parse_part = |p: &str| -> (usize, usize) {
626 let s = p.trim_start_matches(['-', '+']);
627 let comps: Vec<&str> = s.split(',').collect();
628 let start = comps[0].parse::<usize>().unwrap_or(0);
629 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
630 (start, count)
631 };
632
633 let (old_start, old_count) = parse_part(subparts[0]);
634 let (new_start, new_count) = parse_part(subparts[1]);
635 Some((old_start, old_count, new_start, new_count))
636}
637
638fn apply_line_patch_inner(
639 repo_path: &Path,
640 file_path: &str,
641 hunk: &[DiffLine],
642 selected_line_idx_in_hunk: usize,
643 revert: bool,
644 target_has_modification: bool,
645 cached: bool,
646) -> Result<(), String> {
647 use std::io::Write;
648 use std::process::{Command, Stdio};
649
650 let path_check = std::path::Path::new(file_path);
651 if path_check.is_absolute()
652 || path_check.components().any(|c| c == std::path::Component::ParentDir)
653 {
654 return Err("Path escapes repository root".to_string());
655 }
656
657 if hunk.is_empty() {
658 return Err("Empty hunk".to_string());
659 }
660
661 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
662 Some(line) => line,
663 None => return Err("Invalid line index".to_string()),
664 };
665
666 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
667 return Err("Selected line is not a modification (must be + or -)".to_string());
668 }
669
670 let header_line = &hunk[0];
671 let (old_start, _old_count, new_start, _new_count) =
672 match parse_hunk_header(&header_line.content) {
673 Some(coords) => coords,
674 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
675 };
676
677 let mut patch_lines = Vec::new();
678 let mut new_old_count = 0;
679 let mut new_new_count = 0;
680
681 for (i, line) in hunk.iter().enumerate() {
682 if i == 0 {
683 continue;
684 }
685
686 if i == selected_line_idx_in_hunk {
687 if revert {
688 match line.kind {
689 DiffLineKind::Added => {
690 patch_lines.push(DiffLine {
691 kind: DiffLineKind::Removed,
692 content: line.content.clone(),
693 old_lineno: None,
694 new_lineno: None,
695 hunk_idx: None,
696 });
697 new_old_count += 1;
698 }
699 DiffLineKind::Removed => {
700 patch_lines.push(DiffLine {
701 kind: DiffLineKind::Added,
702 content: line.content.clone(),
703 old_lineno: None,
704 new_lineno: None,
705 hunk_idx: None,
706 });
707 new_new_count += 1;
708 }
709 _ => {}
710 }
711 } else {
712 match line.kind {
713 DiffLineKind::Added => {
714 patch_lines.push(DiffLine {
715 kind: DiffLineKind::Added,
716 content: line.content.clone(),
717 old_lineno: None,
718 new_lineno: None,
719 hunk_idx: None,
720 });
721 new_new_count += 1;
722 }
723 DiffLineKind::Removed => {
724 patch_lines.push(DiffLine {
725 kind: DiffLineKind::Removed,
726 content: line.content.clone(),
727 old_lineno: None,
728 new_lineno: None,
729 hunk_idx: None,
730 });
731 new_old_count += 1;
732 }
733 _ => {}
734 }
735 }
736 } else {
737 match line.kind {
738 DiffLineKind::Context => {
739 patch_lines.push(line.clone());
740 new_old_count += 1;
741 new_new_count += 1;
742 }
743 DiffLineKind::Added => {
744 if target_has_modification {
745 patch_lines.push(DiffLine {
746 kind: DiffLineKind::Context,
747 content: line.content.clone(),
748 old_lineno: None,
749 new_lineno: None,
750 hunk_idx: None,
751 });
752 new_old_count += 1;
753 new_new_count += 1;
754 } else {
755 }
757 }
758 DiffLineKind::Removed => {
759 if target_has_modification {
760 } else {
762 patch_lines.push(DiffLine {
763 kind: DiffLineKind::Context,
764 content: line.content.clone(),
765 old_lineno: None,
766 new_lineno: None,
767 hunk_idx: None,
768 });
769 new_old_count += 1;
770 new_new_count += 1;
771 }
772 }
773 _ => {}
774 }
775 }
776 }
777
778 let mut patch = String::new();
779 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
780 patch.push_str(&format!("--- a/{}\n", file_path));
781 patch.push_str(&format!("+++ b/{}\n", file_path));
782 patch.push_str(&format!(
783 "@@ -{},{} +{},{} @@\n",
784 old_start, new_old_count, new_start, new_new_count
785 ));
786
787 for line in patch_lines {
788 let prefix = match line.kind {
789 DiffLineKind::Added => "+",
790 DiffLineKind::Removed => "-",
791 DiffLineKind::Context => " ",
792 DiffLineKind::Header => "",
793 _ => "",
794 };
795 patch.push_str(prefix);
796 patch.push_str(&line.content);
797 patch.push('\n');
798 }
799
800 let mut args = vec!["apply"];
801 if cached {
802 args.push("--cached");
803 }
804 args.push("-");
805
806 let mut cmd = Command::new("git");
807 let mut child = cmd
808 .env("GIT_TERMINAL_PROMPT", "0")
809 .env("GIT_SSH_COMMAND", ssh_command_val())
810 .args(&args)
811 .current_dir(repo_path)
812 .stdin(Stdio::piped())
813 .stdout(Stdio::piped())
814 .stderr(Stdio::piped())
815 .spawn()
816 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
817
818 if let Some(mut stdin) = child.stdin.take() {
819 stdin
820 .write_all(patch.as_bytes())
821 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
822 }
823
824 let output =
825 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
826
827 if !output.status.success() {
828 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
829 return Err(format!("git apply failed: {}", err_msg.trim()));
830 }
831
832 Ok(())
833}
834
835fn apply_hunk_patch(
836 repo_path: &Path,
837 file_path: &str,
838 hunk: &[DiffLine],
839 reverse: bool,
840 cached: bool,
841) -> Result<(), String> {
842 use std::io::Write;
843 use std::process::{Command, Stdio};
844
845 let path_check = std::path::Path::new(file_path);
846 if path_check.is_absolute()
847 || path_check.components().any(|c| c == std::path::Component::ParentDir)
848 {
849 return Err("Path escapes repository root".to_string());
850 }
851
852 let mut patch = String::new();
853 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
854 patch.push_str(&format!("--- a/{}\n", file_path));
855 patch.push_str(&format!("+++ b/{}\n", file_path));
856 for line in hunk {
857 let prefix = match line.kind {
858 DiffLineKind::Added => "+",
859 DiffLineKind::Removed => "-",
860 DiffLineKind::Context => " ",
861 DiffLineKind::Header => "",
862 _ => "",
863 };
864 patch.push_str(prefix);
865 patch.push_str(&line.content);
866 patch.push('\n');
867 }
868
869 let mut args = vec!["apply"];
870 if cached {
871 args.push("--cached");
872 }
873 if reverse {
874 args.push("--reverse");
875 }
876 args.push("-");
877
878 let mut cmd = Command::new("git");
879 let mut child = cmd
880 .env("GIT_TERMINAL_PROMPT", "0")
881 .env("GIT_SSH_COMMAND", ssh_command_val())
882 .args(&args)
883 .current_dir(repo_path)
884 .stdin(Stdio::piped())
885 .stdout(Stdio::piped())
886 .stderr(Stdio::piped())
887 .spawn()
888 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
889
890 if let Some(mut stdin) = child.stdin.take() {
891 stdin
892 .write_all(patch.as_bytes())
893 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
894 }
895
896 let output =
897 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
898
899 if !output.status.success() {
900 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
901 return Err(format!("git apply failed: {}", err_msg.trim()));
902 }
903
904 Ok(())
905}
906
907pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
912 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
913
914 if staged {
915 unstage_file(repo_path, file_path)?;
917 }
918
919 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
921 status.contains(git2::Status::WT_NEW)
922 } else {
923 false
924 };
925
926 if is_untracked {
927 let full_path = repo_path.join(file_path);
928 if full_path.exists() {
929 if full_path.is_file() {
930 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
931 } else if full_path.is_dir() {
932 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
933 }
934 }
935 } else {
936 let mut checkout_opts = git2::build::CheckoutBuilder::new();
938 checkout_opts.path(Path::new(file_path));
939 checkout_opts.force();
940 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
941 }
942
943 Ok(())
944}
945
946pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
949 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
950 let mut index = repo.index().map_err(|e| e.to_string())?;
951 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
952 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
953
954 let signature = repo
955 .signature()
956 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
957
958 let mut parents = Vec::new();
960 let mut has_head = false;
961 if let Ok(head) = repo.head() {
962 if let Ok(parent_commit) = head.peel_to_commit() {
963 has_head = true;
964 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
966 if parent_tree.id() == tree_id {
967 return Err("No staged changes to commit".to_string());
968 }
969 parents.push(parent_commit);
970 }
971 }
972
973 if !has_head && index.is_empty() {
974 return Err("No staged changes to commit (index is empty)".to_string());
975 }
976
977 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
978
979 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
980 .map_err(|e| e.to_string())?;
981
982 Ok(())
983}
984
985pub fn expand_tilde(s: &str) -> PathBuf {
991 if s == "~" {
992 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
993 }
994 if let Some(stripped) = s.strip_prefix("~/")
995 && let Some(home) = dirs::home_dir()
996 {
997 return home.join(stripped);
998 }
999 PathBuf::from(s)
1000}
1001
1002pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1004 let repo = Repository::open(repo_path)?;
1005 repo.remote(name, url)?;
1006 Ok(())
1007}
1008
1009pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1011 let repo = Repository::open(repo_path)?;
1012 repo.remote_delete(name)?;
1013 Ok(())
1014}
1015
1016pub fn inspect_summary(item: &str) -> ItemStatus {
1018 let path = expand_tilde(item);
1019 if !path.is_dir() {
1020 return ItemStatus::Missing;
1021 }
1022 if !path.join(".git").exists() {
1023 return ItemStatus::Directory;
1024 }
1025 match Repository::open(&path) {
1026 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1027 Err(_) => ItemStatus::GitRepo(None),
1028 }
1029}
1030
1031pub fn inspect_detail(
1033 item: &str,
1034 commit_limit: usize,
1035 graph_max_commits: usize,
1036 enable_commit_signatures: bool,
1037) -> ItemDetail {
1038 let resolved = expand_tilde(item);
1039 if !resolved.is_dir() {
1040 return ItemDetail::Missing { resolved };
1041 }
1042 if !resolved.join(".git").exists() {
1043 return ItemDetail::Directory { resolved };
1044 }
1045 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1046 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1047 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1048 }
1049}
1050
1051fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1052 let mut sigs = std::collections::HashMap::new();
1053 let mut cmd = std::process::Command::new("git");
1054 cmd.env("GIT_TERMINAL_PROMPT", "0")
1055 .env("GIT_SSH_COMMAND", ssh_command_val())
1056 .arg("log")
1057 .arg("--all");
1058
1059 if limit > 0 {
1060 cmd.arg(format!("-n{}", limit));
1061 }
1062
1063 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1064
1065 if let Ok(out) = cmd.output() {
1066 if out.status.success() {
1067 let stdout_str = String::from_utf8_lossy(&out.stdout);
1068 for line in stdout_str.lines() {
1069 let parts: Vec<&str> = line.split_whitespace().collect();
1070 if parts.len() == 2 {
1071 sigs.insert(parts[0].to_string(), parts[1].to_string());
1072 } else if parts.len() == 1 {
1073 sigs.insert(parts[0].to_string(), "N".to_string());
1074 }
1075 }
1076 }
1077 }
1078 sigs
1079}
1080
1081#[derive(serde::Serialize, serde::Deserialize, Clone)]
1082struct CachedCommit {
1083 id: String,
1084 author: String,
1085 date: String,
1086 summary: String,
1087 message: String,
1088 time: i64,
1089}
1090
1091fn hash_path(path: &Path) -> String {
1092 use std::collections::hash_map::DefaultHasher;
1093 use std::hash::{Hash, Hasher};
1094 let mut hasher = DefaultHasher::new();
1095 path.hash(&mut hasher);
1096 format!("{:x}", hasher.finish())
1097}
1098
1099fn collect_commits(
1100 repo: &Repository,
1101 limit: usize,
1102 repo_path: &Path,
1103 enable_commit_signatures: bool,
1104) -> Result<Vec<CommitEntry>, git2::Error> {
1105 let mut walk = repo.revwalk()?;
1106 if walk.push_head().is_err() {
1107 return Ok(Vec::new());
1108 }
1109 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1110
1111 let mut commits = Vec::new();
1112 let oids: Vec<Result<git2::Oid, git2::Error>> =
1113 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1114
1115 let sig_map = if enable_commit_signatures {
1116 collect_signatures(repo_path, limit)
1117 } else {
1118 std::collections::HashMap::new()
1119 };
1120 let ref_map = get_cached_ref_map(repo, repo_path);
1121
1122 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1124 if let Some(ref dir) = cache_dir {
1125 let _ = std::fs::create_dir_all(dir);
1126 }
1127 let hash = hash_path(repo_path);
1128 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1129 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1130 .as_ref()
1131 .and_then(|f| std::fs::read_to_string(f).ok())
1132 .and_then(|s| serde_json::from_str(&s).ok())
1133 .unwrap_or_default();
1134
1135 let mut cache_updated = false;
1136
1137 for id in oids {
1138 let oid = id?;
1139 let oid_str = oid.to_string();
1140 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1141 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1142 let files = Vec::new();
1143
1144 if let Some(cached) = cache.get(&oid_str) {
1145 let when = format_relative_time(cached.time);
1146 commits.push(CommitEntry {
1147 id: cached.id.clone(),
1148 oid: oid_str,
1149 author: sanitize_text(&cached.author),
1150 when,
1151 date: cached.date.clone(),
1152 summary: sanitize_text(&cached.summary),
1153 message: sanitize_text(&cached.message),
1154 refs,
1155 files,
1156 signature_status: sig_status,
1157 });
1158 } else if let Ok(commit) = repo.find_commit(oid) {
1159 let short_id = format!("{:.7}", commit.id());
1160 let summary =
1161 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1162 let author = commit.author();
1163 let author_name = author.name().unwrap_or("?");
1164 let author_email = author.email().unwrap_or("?");
1165 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1166 let time_secs = commit.time().seconds();
1167 let when = format_relative_time(time_secs);
1168 let date = format_utc_date(time_secs);
1169 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1170
1171 let cached = CachedCommit {
1172 id: short_id.clone(),
1173 author: author_str.clone(),
1174 date: date.clone(),
1175 summary: summary.clone(),
1176 message: message.clone(),
1177 time: time_secs,
1178 };
1179 cache.insert(oid_str.clone(), cached);
1180 cache_updated = true;
1181
1182 commits.push(CommitEntry {
1183 id: short_id,
1184 oid: oid_str,
1185 author: author_str,
1186 when,
1187 date,
1188 summary,
1189 message,
1190 refs,
1191 files,
1192 signature_status: sig_status,
1193 });
1194 }
1195 }
1196
1197 if cache_updated {
1198 if let Some(ref f) = cache_file {
1199 if let Ok(json) = serde_json::to_string(&cache) {
1200 let _ = std::fs::write(f, json);
1201 }
1202 }
1203 }
1204
1205 Ok(commits)
1206}
1207
1208pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1209 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1210 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1211 if walk.push_head().is_err() {
1212 return Ok(Vec::new());
1213 }
1214 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1215
1216 let mut revisions = Vec::new();
1217
1218 for oid_res in walk {
1219 let oid = oid_res.map_err(|e| e.to_string())?;
1220 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1221
1222 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1223 let mut modified = false;
1224
1225 if commit.parent_count() > 0 {
1226 for i in 0..commit.parent_count() {
1227 if let Ok(parent) = commit.parent(i) {
1228 if let Ok(parent_tree) = parent.tree() {
1229 let mut diff_opts = git2::DiffOptions::new();
1230 diff_opts.pathspec(file_path);
1231 if let Ok(diff) = repo.diff_tree_to_tree(
1232 Some(&parent_tree),
1233 Some(&commit_tree),
1234 Some(&mut diff_opts),
1235 ) {
1236 if diff.deltas().len() > 0 {
1237 modified = true;
1238 break;
1239 }
1240 }
1241 }
1242 }
1243 }
1244 } else {
1245 let mut diff_opts = git2::DiffOptions::new();
1246 diff_opts.pathspec(file_path);
1247 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1248 {
1249 if diff.deltas().len() > 0 {
1250 modified = true;
1251 }
1252 }
1253 }
1254
1255 if modified {
1256 let author = commit.author();
1257 let author_name = author.name().unwrap_or("?");
1258 let author_email = author.email().unwrap_or("?");
1259 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1260 let time_secs = commit.time().seconds();
1261 let when = format_relative_time(time_secs);
1262 let date = format_utc_date(time_secs);
1263 let summary =
1264 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1265
1266 revisions.push(FileRevision {
1267 commit_oid: oid.to_string(),
1268 author: author_str,
1269 date,
1270 when,
1271 summary,
1272 });
1273 }
1274 }
1275
1276 Ok(revisions)
1277}
1278
1279#[derive(Debug, Clone)]
1280pub struct BlameEntry {
1281 pub line_no: usize,
1282 pub commit_id: String,
1283 pub author: String,
1284 pub date: String,
1285}
1286
1287pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1288 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1289 let mut opts = git2::BlameOptions::new();
1290 let blame = repo
1291 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1292 .map_err(|e| e.to_string())?;
1293
1294 let mut entries = Vec::new();
1295 for i in 0..blame.len() {
1296 if let Some(hunk) = blame.get_index(i) {
1297 let commit_id = hunk.final_commit_id().to_string();
1298 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1299
1300 let mut author_name = "Unknown".to_string();
1301 let mut date_str = "unknown".to_string();
1302 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1303 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1304 date_str = format_utc_date(commit.time().seconds());
1305 }
1306
1307 let start = hunk.final_start_line();
1308 let count = hunk.lines_in_hunk();
1309 for line_idx in 0..count {
1310 entries.push(BlameEntry {
1311 line_no: start + line_idx,
1312 commit_id: short_id.to_string(),
1313 author: author_name.clone(),
1314 date: date_str.clone(),
1315 });
1316 }
1317 }
1318 }
1319
1320 entries.sort_by_key(|e| e.line_no);
1321 Ok(entries)
1322}
1323
1324fn collect_committer_stats(
1325 repo: &Repository,
1326 limit: usize,
1327) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1328 let mut walk = repo.revwalk()?;
1329 if walk.push_head().is_err() {
1330 return Ok((Vec::new(), false));
1331 }
1332 let mut counts = std::collections::HashMap::new();
1333 let mut count = 0;
1334 let mut limit_reached = false;
1335 for id in walk {
1336 let oid = id?;
1337 if let Ok(commit) = repo.find_commit(oid) {
1338 let author = commit.author();
1339 let name = author.name().unwrap_or("?").to_string();
1340 let email = author.email().unwrap_or("?").to_string();
1341 let key = (name, email);
1342 *counts.entry(key).or_insert(0) += 1;
1343 count += 1;
1344 if count >= limit {
1345 limit_reached = true;
1346 break;
1347 }
1348 }
1349 }
1350
1351 let mut stats: Vec<CommitterStat> = counts
1352 .into_iter()
1353 .map(|((name, email), count)| CommitterStat { name, email, count })
1354 .collect();
1355
1356 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1357
1358 Ok((stats, limit_reached))
1359}
1360
1361fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1365 let commit_tree = match commit.tree() {
1366 Ok(t) => t,
1367 Err(_) => return Vec::new(),
1368 };
1369 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1372
1373 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1374 Ok(d) => d,
1375 Err(_) => return Vec::new(),
1376 };
1377
1378 let mut files = Vec::new();
1379 for delta in diff.deltas() {
1380 if files.len() >= MAX_FILES_PER_SECTION {
1381 break;
1382 }
1383 let path = delta
1384 .new_file()
1385 .path()
1386 .or_else(|| delta.old_file().path())
1387 .map(|p| p.to_string_lossy().into_owned())
1388 .unwrap_or_else(|| "(unknown)".to_string());
1389
1390 let label: &'static str = match delta.status() {
1391 git2::Delta::Added => "N",
1392 git2::Delta::Deleted => "D",
1393 git2::Delta::Modified => "M",
1394 git2::Delta::Renamed => "R",
1395 git2::Delta::Typechange => "T",
1396 _ => "M",
1397 };
1398 files.push(FileEntry { path, label });
1399 }
1400 files
1401}
1402
1403pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1404 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1405 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1406 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1407 Ok(commit_changed_files(&repo, &commit))
1408}
1409fn match_pattern(path: &str, pattern: &str) -> bool {
1410 if let Some(suffix) = pattern.strip_prefix('*') {
1411 return path.ends_with(suffix);
1412 }
1413 if let Some(prefix) = pattern.strip_suffix('*') {
1414 return path.starts_with(prefix);
1415 }
1416 if pattern.contains('*') {
1417 let parts: Vec<&str> = pattern.split('*').collect();
1418 if parts.len() == 2 {
1419 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1420 }
1421 }
1422 path == pattern || path.ends_with(&format!("/{}", pattern))
1423}
1424
1425fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1426 let mut tracked = std::collections::HashSet::new();
1427 let mut installed = false;
1428
1429 if let Ok(output) = std::process::Command::new("git").arg("lfs").arg("--version").output() {
1430 installed = output.status.success();
1431 }
1432
1433 if installed {
1434 if let Ok(output) = std::process::Command::new("git")
1435 .arg("lfs")
1436 .arg("ls-files")
1437 .arg("-n")
1438 .current_dir(repo_path)
1439 .output()
1440 {
1441 if output.status.success() {
1442 if let Ok(stdout) = String::from_utf8(output.stdout) {
1443 for line in stdout.lines() {
1444 let trimmed = line.trim();
1445 if !trimmed.is_empty() {
1446 tracked.insert(trimmed.to_string());
1447 }
1448 }
1449 }
1450 }
1451 }
1452 }
1453
1454 let gitattributes_path = repo_path.join(".gitattributes");
1455 if gitattributes_path.exists() {
1456 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1457 let mut patterns = Vec::new();
1458 for line in content.lines() {
1459 let line = line.trim();
1460 if line.is_empty() || line.starts_with('#') {
1461 continue;
1462 }
1463 if line.contains("filter=lfs")
1464 || line.contains("diff=lfs")
1465 || line.contains("merge=lfs")
1466 {
1467 if let Some(pattern) = line.split_whitespace().next() {
1468 patterns.push(pattern.to_string());
1469 }
1470 }
1471 }
1472
1473 if !patterns.is_empty() {
1474 if let Ok(repo) = Repository::open(repo_path) {
1475 if let Ok(index) = repo.index() {
1476 for entry in index.iter() {
1477 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1478 for pat in &patterns {
1479 if match_pattern(path_str, pat) {
1480 tracked.insert(path_str.to_string());
1481 break;
1482 }
1483 }
1484 }
1485 }
1486 }
1487 }
1488 }
1489 }
1490 }
1491
1492 (installed, tracked)
1493}
1494
1495fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1496 let lfs_path = repo_path.join(".git").join("lfs");
1497 if !lfs_path.exists() {
1498 return None;
1499 }
1500
1501 fn calculate_dir_size(dir: &Path) -> u64 {
1502 let mut size = 0;
1503 if let Ok(entries) = std::fs::read_dir(dir) {
1504 for entry in entries.flatten() {
1505 if let Ok(metadata) = entry.metadata() {
1506 if metadata.is_dir() {
1507 size += calculate_dir_size(&entry.path());
1508 } else {
1509 size += metadata.len();
1510 }
1511 }
1512 }
1513 }
1514 size
1515 }
1516
1517 Some(calculate_dir_size(&lfs_path))
1518}
1519
1520fn collect_info(
1523 path: &Path,
1524 commit_limit: usize,
1525 _graph_max_commits: usize,
1526 enable_commit_signatures: bool,
1527) -> Result<RepoInfo, git2::Error> {
1528 let repo = Repository::open(path)?;
1529 let mut summary = RepoSummary::default();
1530 if let Ok(head) = repo.head() {
1531 summary.branch = head.shorthand().ok().map(String::from);
1532 }
1533 populate_ahead_behind(&repo, &mut summary);
1534
1535 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1536 let (lfs_inst, lfs_f) = get_lfs_info(path);
1537 info.lfs_installed = lfs_inst;
1538 info.lfs_files = lfs_f;
1539 info.lfs_storage_size = get_lfs_storage_size(path);
1540
1541 if let Ok(head) = repo.head() {
1542 info.branch = head.shorthand().ok().map(String::from);
1543
1544 if let Ok(commit) = head.peel_to_commit() {
1545 let short_id = format!("{:.7}", commit.id());
1546 let summary_text =
1547 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1548 let author = commit.author();
1549 let author_str = sanitize_text(&format!(
1550 "{} <{}>",
1551 author.name().unwrap_or("?"),
1552 author.email().unwrap_or("?")
1553 ));
1554 let when = format_relative_time(commit.time().seconds());
1555 info.head =
1556 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1557 }
1558
1559 if let Ok(head_name) = head.name() {
1560 info.upstream = upstream_short_name(&repo, head_name);
1561 }
1562 }
1563
1564 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1565 info.commits = commits;
1566 }
1567
1568 populate_summary_and_file_changes(&repo, &mut info);
1569
1570 if let Ok(remotes) = load_tab_remotes(path) {
1571 info.remotes = TabData::Loaded(remotes);
1572 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1573 }
1574
1575 Ok(info)
1576}
1577
1578pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1579 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1580 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1581 let mut entries = Vec::new();
1582 for (i, entry) in reflog.iter().enumerate() {
1583 let target_oid = format!("{}", entry.id_new());
1584 let selector = format!("HEAD@{{{}}}", i);
1585 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1586
1587 let (command, message) = if let Some(pos) = msg.find(':') {
1588 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1589 } else {
1590 (String::new(), msg)
1591 };
1592
1593 let sig = entry.committer();
1594 let when_secs = sig.when().seconds();
1595 let when = format_relative_time(when_secs);
1596 let date = format_utc_date(when_secs);
1597
1598 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1599 }
1600 Ok(entries)
1601}
1602
1603pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1604 let output = std::process::Command::new("git")
1605 .env("GIT_TERMINAL_PROMPT", "0")
1606 .env("GIT_SSH_COMMAND", ssh_command_val())
1607 .arg("checkout")
1608 .arg(commit_oid)
1609 .current_dir(repo_path)
1610 .output()
1611 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1612
1613 if !output.status.success() {
1614 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1615 return Err(git2::Error::from_str(&err));
1616 }
1617 Ok(())
1618}
1619
1620pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1621 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1622 let mut files = Vec::new();
1623 if let Ok(index) = repo.index() {
1624 for entry in index.iter() {
1625 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1626 files.push(path_str.to_string());
1627 }
1628 }
1629 }
1630 Ok(files)
1631}
1632
1633pub fn load_tab_graph_stream(
1634 repo_path: &Path,
1635 graph_max_commits: usize,
1636 repo_resolved_path: String,
1637 tab_idx: usize,
1638 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1639) -> Result<Vec<GraphLine>, String> {
1640 let mut graph_lines = Vec::new();
1641 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1642
1643 let mut args = vec![
1644 "log".to_string(),
1645 "--graph".to_string(),
1646 "--all".to_string(),
1647 "--date=relative".to_string(),
1648 ];
1649 if graph_max_commits > 0 {
1650 args.push(format!("--max-count={}", graph_max_commits));
1651 }
1652 args.push(format!("--pretty=format:{}", format_str));
1653 args.push("--color=never".to_string());
1654
1655 let mut child = std::process::Command::new("git")
1656 .env("GIT_TERMINAL_PROMPT", "0")
1657 .env("GIT_SSH_COMMAND", ssh_command_val())
1658 .args(&args)
1659 .current_dir(repo_path)
1660 .stdout(std::process::Stdio::piped())
1661 .spawn()
1662 .map_err(|e| e.to_string())?;
1663
1664 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1665 let reader = std::io::BufReader::new(stdout);
1666 use std::io::BufRead;
1667
1668 for (idx, line_res) in reader.lines().enumerate() {
1669 let line = line_res.map_err(|e| e.to_string())?;
1670 let parsed = parse_graph_line(&line);
1671 graph_lines.push(parsed);
1672
1673 if (idx + 1) % 200 == 0 {
1675 let _ = tx.send((
1676 repo_resolved_path.clone(),
1677 tab_idx,
1678 TabPayload::Graph(Ok(graph_lines.clone())),
1679 ));
1680 }
1681 }
1682
1683 let status = child.wait().map_err(|e| e.to_string())?;
1685 if !status.success() && graph_lines.is_empty() {
1686 return Err("git log failed".to_string());
1687 }
1688
1689 Ok(graph_lines)
1690}
1691
1692pub fn load_tab_branches(
1693 repo_path: &Path,
1694) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1695 let repo = match Repository::open(repo_path) {
1696 Ok(r) => r,
1697 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1698 };
1699
1700 let mut local_branches = Vec::new();
1701 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1702 for (branch, _) in branches.flatten() {
1703 if let Ok(Some(name)) = branch.name() {
1704 let is_head = branch.is_head();
1705 let mut short_sha = String::new();
1706 let mut short_message = String::new();
1707 if let Ok(target) = branch.get().peel_to_commit() {
1708 let id = target.id();
1709 let id_str = id.to_string();
1710 short_sha = safe_sha_slice(&id_str, 7).to_string();
1711 if let Ok(Some(summary)) = target.summary() {
1712 short_message = summary.to_string();
1713 }
1714 }
1715 local_branches.push(BranchInfo {
1716 name: sanitize_text(name),
1717 is_head,
1718 short_sha,
1719 short_message: sanitize_text(&short_message),
1720 });
1721 }
1722 }
1723 }
1724 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1725
1726 let mut remote_branches = Vec::new();
1727 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1728 for (branch, _) in branches.flatten() {
1729 if let Ok(Some(name)) = branch.name() {
1730 if !name.ends_with("/HEAD") {
1731 let is_head = branch.is_head();
1732 let mut short_sha = String::new();
1733 let mut short_message = String::new();
1734 if let Ok(target) = branch.get().peel_to_commit() {
1735 let id = target.id();
1736 let id_str = id.to_string();
1737 short_sha = safe_sha_slice(&id_str, 7).to_string();
1738 if let Ok(Some(summary)) = target.summary() {
1739 short_message = summary.to_string();
1740 }
1741 }
1742 remote_branches.push(BranchInfo {
1743 name: sanitize_text(name),
1744 is_head,
1745 short_sha,
1746 short_message: sanitize_text(&short_message),
1747 });
1748 }
1749 }
1750 }
1751 }
1752 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1753
1754 (Ok(local_branches), Ok(remote_branches))
1755}
1756
1757pub fn load_tab_tags(
1758 repo_path: &Path,
1759) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1760 let repo = match Repository::open(repo_path) {
1761 Ok(r) => r,
1762 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1763 };
1764
1765 let mut local_tags = Vec::new();
1766 if let Ok(tags) = repo.tag_names(None) {
1767 for tag_opt in tags.iter() {
1768 if let Ok(Some(tag)) = tag_opt {
1769 let mut short_sha = String::new();
1770 let mut short_message = String::new();
1771 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1772 if let Ok(target) = reference.peel_to_commit() {
1773 let id = target.id();
1774 let id_str = id.to_string();
1775 short_sha = safe_sha_slice(&id_str, 7).to_string();
1776 if let Ok(Some(summary)) = target.summary() {
1777 short_message = summary.to_string();
1778 }
1779 }
1780 }
1781 local_tags.push(BranchInfo {
1782 name: sanitize_text(tag),
1783 is_head: false,
1784 short_sha,
1785 short_message: sanitize_text(&short_message),
1786 });
1787 }
1788 }
1789 }
1790 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1791
1792 (Ok(local_tags), Ok(Vec::new()))
1793}
1794
1795pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1796 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1797 let mut remotes_list = Vec::new();
1798 if let Ok(remotes) = repo.remotes() {
1799 for name in remotes.iter() {
1800 let Ok(Some(name)) = name else { continue };
1801 if let Ok(remote) = repo.find_remote(name) {
1802 let push_url = remote.pushurl().ok().flatten().map(String::from);
1803 let mut refspecs = Vec::new();
1804 for r in remote.refspecs() {
1805 if let Ok(s) = r.str() {
1806 refspecs.push(s.to_string());
1807 }
1808 }
1809 remotes_list.push(RemoteInfo {
1810 name: name.to_string(),
1811 url: remote.url().unwrap_or("(no url)").to_string(),
1812 push_url,
1813 refspecs,
1814 });
1815 }
1816 }
1817 }
1818 Ok(remotes_list)
1819}
1820
1821pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1822 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1823 let mut temp_stashes = Vec::new();
1824 let _ = repo.stash_foreach(|index, message, oid| {
1825 temp_stashes.push((index, message.to_string(), *oid));
1826 true
1827 });
1828
1829 let mut stashes = Vec::new();
1830 for (index, message, oid) in temp_stashes {
1831 let mut files = Vec::new();
1832 if let Ok(commit) = repo.find_commit(oid) {
1833 files = commit_changed_files(&repo, &commit);
1834 }
1835 stashes.push(StashInfo {
1836 index,
1837 message: sanitize_text(&message),
1838 commit_id: oid.to_string(),
1839 files,
1840 });
1841 }
1842 Ok(stashes)
1843}
1844
1845pub fn load_tab_overview(
1846 repo_path: &Path,
1847 _commit_limit: usize,
1848) -> Result<(Vec<CommitterStat>, bool), String> {
1849 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1850 let (stats, limit_reached) =
1851 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
1852 Ok((stats, limit_reached))
1853}
1854
1855pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1856 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1857 let submodules = repo.submodules().map_err(|e| e.to_string())?;
1858 let mut list = Vec::new();
1859 for sub in submodules {
1860 let name = sub.name().unwrap_or("").to_string();
1861 let path = sub.path().to_path_buf();
1862 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
1863 let commit_id = sub.index_id().map(|id| id.to_string());
1864 let head_id = sub.head_id().map(|id| id.to_string());
1865
1866 let mut is_initialized = true;
1867 let mut is_dirty = false;
1868
1869 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
1870 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
1871 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
1872 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
1873 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
1874 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
1875 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
1876 }
1877
1878 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
1879 }
1880 Ok(list)
1881}
1882
1883fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1888 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1889 std::collections::HashMap::new();
1890
1891 if let Ok(refs) = repo.references() {
1892 for reference in refs.flatten() {
1893 let Ok(target) = reference.peel_to_commit() else {
1895 continue;
1896 };
1897 let oid = target.id();
1898
1899 let Ok(full_name) = reference.name() else {
1900 continue;
1901 };
1902
1903 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1904 branch.to_string()
1905 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1906 format!("tag:{}", tag)
1907 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1908 if remote.ends_with("/HEAD") {
1910 continue;
1911 }
1912 format!("remote:{}", remote)
1913 } else {
1914 continue;
1915 };
1916
1917 map.entry(oid).or_default().push(label);
1918 }
1919 }
1920 map
1921}
1922
1923#[allow(clippy::type_complexity)]
1924static REF_MAP_CACHE: std::sync::OnceLock<
1925 std::sync::Mutex<
1926 std::collections::HashMap<
1927 String,
1928 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1929 >,
1930 >,
1931> = std::sync::OnceLock::new();
1932
1933fn get_cached_ref_map(
1934 repo: &Repository,
1935 repo_path: &Path,
1936) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1937 let cache_lock =
1938 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1939 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1940 let path_key = repo_path.to_string_lossy().to_string();
1941
1942 if let Some((map, loaded_at)) = cache.get(&path_key) {
1943 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1944 return map.clone();
1945 }
1946 }
1947
1948 let map = build_ref_map(repo);
1949 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1950 map
1951}
1952
1953pub fn invalidate_ref_map_cache(repo_path: &Path) {
1954 if let Some(cache_lock) = REF_MAP_CACHE.get() {
1955 if let Ok(mut cache) = cache_lock.lock() {
1956 cache.remove(&repo_path.to_string_lossy().to_string());
1957 }
1958 }
1959}
1960
1961const MAX_FILES_PER_SECTION: usize = 100;
1964
1965fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1967 let mut opts = StatusOptions::new();
1968 opts.include_untracked(true)
1969 .renames_head_to_index(true)
1970 .recurse_untracked_dirs(true)
1971 .show(StatusShow::IndexAndWorkdir);
1972 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1973 return;
1974 };
1975 for entry in statuses.iter() {
1976 let path = entry.path().unwrap_or("(unknown)").to_string();
1977 let flags = entry.status();
1978
1979 if flags.is_conflicted() {
1981 info.summary.conflicted += 1;
1982 } else {
1983 if flags.is_wt_new() {
1984 info.summary.untracked += 1;
1985 }
1986 if flags.is_wt_modified()
1987 || flags.is_wt_deleted()
1988 || flags.is_wt_renamed()
1989 || flags.is_wt_typechange()
1990 {
1991 info.summary.modified += 1;
1992 }
1993 if flags.is_index_new()
1994 || flags.is_index_modified()
1995 || flags.is_index_deleted()
1996 || flags.is_index_renamed()
1997 || flags.is_index_typechange()
1998 {
1999 info.summary.staged += 1;
2000 }
2001 }
2002
2003 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2006 if path_buf.is_dir() {
2007 continue;
2008 }
2009
2010 if flags.is_conflicted() {
2011 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2012 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2013 }
2014 continue;
2015 }
2016
2017 if (flags.is_index_new()
2019 || flags.is_index_modified()
2020 || flags.is_index_deleted()
2021 || flags.is_index_renamed()
2022 || flags.is_index_typechange())
2023 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2024 {
2025 let label = if flags.is_index_new() {
2026 "N"
2027 } else if flags.is_index_deleted() {
2028 "D"
2029 } else if flags.is_index_renamed() {
2030 "R"
2031 } else if flags.is_index_typechange() {
2032 "T"
2033 } else {
2034 "M"
2035 };
2036 info.changes.staged.push(FileEntry { path: path.clone(), label });
2037 }
2038
2039 if flags.is_wt_new() {
2041 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2042 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2043 }
2044 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2045 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2046 }
2047 } else if (flags.is_wt_modified()
2048 || flags.is_wt_deleted()
2049 || flags.is_wt_renamed()
2050 || flags.is_wt_typechange())
2051 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2052 {
2053 let label = if flags.is_wt_deleted() {
2054 "D"
2055 } else if flags.is_wt_renamed() {
2056 "R"
2057 } else if flags.is_wt_typechange() {
2058 "T"
2059 } else {
2060 "M"
2061 };
2062 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2063 }
2064 }
2065}
2066
2067fn collect_summary(repo: &Repository) -> RepoSummary {
2071 let mut s = RepoSummary::default();
2072 if let Ok(head) = repo.head() {
2074 s.branch = head.shorthand().ok().map(String::from);
2075 if let Ok(commit) = head.peel_to_commit() {
2076 s.last_commit_time = Some(commit.time().seconds());
2077 }
2078 }
2079 populate_worktree(repo, &mut s);
2080 populate_ahead_behind(repo, &mut s);
2081 s.state = match repo.state() {
2082 git2::RepositoryState::Clean => RepoState::Clean,
2083 git2::RepositoryState::Merge => RepoState::Merge,
2084 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2085 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2086 RepoState::CherryPick
2087 }
2088 git2::RepositoryState::Bisect => RepoState::Bisect,
2089 git2::RepositoryState::Rebase
2090 | git2::RepositoryState::RebaseInteractive
2091 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2092 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2093 RepoState::ApplyMailbox
2094 }
2095 };
2096 s
2097}
2098
2099fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2100 let mut opts = StatusOptions::new();
2101 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2102 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2103 return;
2104 };
2105 for entry in statuses.iter() {
2106 let flags = entry.status();
2107 if flags.is_conflicted() {
2108 s.conflicted += 1;
2109 continue;
2110 }
2111 if flags.is_wt_new() {
2112 s.untracked += 1;
2113 }
2114 if flags.is_wt_modified()
2115 || flags.is_wt_deleted()
2116 || flags.is_wt_renamed()
2117 || flags.is_wt_typechange()
2118 {
2119 s.modified += 1;
2120 }
2121 if flags.is_index_new()
2122 || flags.is_index_modified()
2123 || flags.is_index_deleted()
2124 || flags.is_index_renamed()
2125 || flags.is_index_typechange()
2126 {
2127 s.staged += 1;
2128 }
2129 }
2130}
2131
2132fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2136 let Ok(head) = repo.head() else { return };
2137 let Some(local_oid) = head.target() else {
2138 return;
2139 };
2140 let Ok(head_name) = head.name() else { return };
2141 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2142 return;
2143 };
2144 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2145 return;
2146 };
2147 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2148 return;
2149 };
2150 let Some(upstream_oid) = upstream_ref.target() else {
2151 return;
2152 };
2153 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2154 s.ahead = ahead;
2155 s.behind = behind;
2156 }
2157}
2158
2159fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2161 let buf = repo.branch_upstream_name(head_name).ok()?;
2162 let raw = std::str::from_utf8(&buf).ok()?;
2163 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2164}
2165
2166pub fn format_relative_time(secs: i64) -> String {
2168 if secs <= 0 {
2169 return "unknown".to_string();
2170 }
2171 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2172 let now = SystemTime::now();
2173 let Ok(elapsed) = now.duration_since(then) else {
2174 return "in the future".to_string();
2175 };
2176 let secs = elapsed.as_secs();
2177 let (n, unit) = if secs < 60 {
2178 (secs, "second")
2179 } else if secs < 3600 {
2180 (secs / 60, "minute")
2181 } else if secs < 86_400 {
2182 (secs / 3600, "hour")
2183 } else if secs < 86_400 * 30 {
2184 (secs / 86_400, "day")
2185 } else if secs < 86_400 * 365 {
2186 (secs / (86_400 * 30), "month")
2187 } else {
2188 (secs / (86_400 * 365), "year")
2189 };
2190 let plural = if n == 1 { "" } else { "s" };
2191 format!("{} {}{} ago", n, unit, plural)
2192}
2193
2194fn format_utc_date(secs: i64) -> String {
2196 if secs <= 0 {
2197 return "unknown".to_string();
2198 }
2199 let seconds_in_day = 86400;
2200 let day_number = secs / seconds_in_day;
2201 let time_of_day = secs % seconds_in_day;
2202
2203 let mut hour = time_of_day / 3600;
2204 let mut minute = (time_of_day % 3600) / 60;
2205 let mut second = time_of_day % 60;
2206 if hour < 0 {
2207 hour += 24;
2208 }
2209 if minute < 0 {
2210 minute += 60;
2211 }
2212 if second < 0 {
2213 second += 60;
2214 }
2215
2216 let z = day_number + 719468;
2218 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2219 let doe = (z - era * 146097) as u32;
2220 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2221 let y = (yoe as i32) + (era as i32) * 400;
2222 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2223 let mp = (5 * doy + 2) / 153;
2224 let d = doy - (153 * mp + 2) / 5 + 1;
2225 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2226 let y = y + if m <= 2 { 1 } else { 0 };
2227
2228 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2229}
2230
2231fn get_file_diff_inner(
2234 repo_path: &Path,
2235 commit_oid: &str,
2236 file_path: &str,
2237) -> Option<Vec<DiffLine>> {
2238 let repo = Repository::open(repo_path).ok()?;
2239 let oid = git2::Oid::from_str(commit_oid).ok()?;
2240 let commit = repo.find_commit(oid).ok()?;
2241
2242 let commit_tree = commit.tree().ok()?;
2243 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2245
2246 let mut opts = git2::DiffOptions::new();
2247 opts.pathspec(file_path);
2248
2249 let diff =
2250 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2251
2252 collect_diff_lines(&diff)
2253}
2254
2255fn get_worktree_diff_inner(
2260 repo_path: &Path,
2261 file_path: &str,
2262 staged: bool,
2263) -> Option<Vec<DiffLine>> {
2264 let repo = Repository::open(repo_path).ok()?;
2265 let mut opts = git2::DiffOptions::new();
2266 opts.pathspec(file_path);
2267 opts.include_untracked(true);
2268 opts.recurse_untracked_dirs(true);
2269 opts.show_untracked_content(true);
2270
2271 let diff = if staged {
2272 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2274 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2275 } else {
2276 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2278 };
2279
2280 collect_diff_lines(&diff)
2281}
2282
2283fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2285 let mut lines: Vec<DiffLine> = Vec::new();
2286 let mut current_hunk_idx = None;
2287 let mut hunk_count = 0;
2288 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2289 let kind = match line.origin() {
2290 '+' => DiffLineKind::Added,
2291 '-' => DiffLineKind::Removed,
2292 'H' => {
2293 current_hunk_idx = Some(hunk_count);
2294 hunk_count += 1;
2295 DiffLineKind::Header
2296 }
2297 ' ' => DiffLineKind::Context,
2298 _ => return true, };
2300 let content = String::from_utf8_lossy(line.content())
2301 .trim_end_matches('\n')
2302 .trim_end_matches('\r')
2303 .to_string();
2304 lines.push(DiffLine {
2305 kind,
2306 content,
2307 old_lineno: line.old_lineno(),
2308 new_lineno: line.new_lineno(),
2309 hunk_idx: current_hunk_idx,
2310 });
2311 true
2312 })
2313 .ok()?;
2314 Some(lines)
2315}
2316
2317fn parse_graph_line(line: &str) -> GraphLine {
2318 if line.contains("__TWIG_SEP__") {
2319 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2320 if parts.len() >= 5 {
2321 let graph_and_hash = parts[0];
2322 let decoration = parts[1].trim().to_string();
2323 let summary = parts[2].trim().to_string();
2324 let author = parts[3].trim().to_string();
2325 let date = parts[4].trim().to_string();
2326 let signature_status =
2327 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2328
2329 let char_count = graph_and_hash.chars().count();
2330 if char_count >= 40 {
2331 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2332 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2333 GraphLine {
2334 graph,
2335 commit: Some(GraphCommit {
2336 oid,
2337 decoration,
2338 summary,
2339 author,
2340 date,
2341 signature_status,
2342 }),
2343 }
2344 } else {
2345 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2346 }
2347 } else {
2348 GraphLine { graph: line.to_string(), commit: None }
2349 }
2350 } else {
2351 GraphLine { graph: line.to_string(), commit: None }
2352 }
2353}
2354
2355#[allow(dead_code)]
2356fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2357 let mut graph_lines = Vec::new();
2358 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2359
2360 let mut args = vec![
2361 "log".to_string(),
2362 "--graph".to_string(),
2363 "--all".to_string(),
2364 "--date=relative".to_string(),
2365 ];
2366 if graph_max_commits > 0 {
2367 args.push(format!("--max-count={}", graph_max_commits));
2368 }
2369 args.push(format!("--pretty=format:{}", format_str));
2370 args.push("--color=never".to_string());
2371
2372 let output = std::process::Command::new("git")
2373 .env("GIT_TERMINAL_PROMPT", "0")
2374 .env("GIT_SSH_COMMAND", ssh_command_val())
2375 .args(&args)
2376 .current_dir(repo_path)
2377 .output();
2378
2379 if let Ok(out) = output {
2380 if out.status.success() {
2381 let stdout_str = String::from_utf8_lossy(&out.stdout);
2382 for line in stdout_str.lines() {
2383 graph_lines.push(parse_graph_line(line));
2384 }
2385 }
2386 }
2387 graph_lines
2388}
2389
2390pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2391 let output = std::process::Command::new("git")
2392 .env("GIT_TERMINAL_PROMPT", "0")
2393 .env("GIT_SSH_COMMAND", ssh_command_val())
2394 .arg("checkout")
2395 .arg(branch_name)
2396 .current_dir(repo_path)
2397 .output()
2398 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2399
2400 if !output.status.success() {
2401 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2402 return Err(git2::Error::from_str(&err));
2403 }
2404 Ok(())
2405}
2406
2407pub fn checkout_remote_branch(
2408 repo_path: &Path,
2409 remote_branch_name: &str,
2410) -> Result<String, git2::Error> {
2411 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2412 if parts.len() < 2 {
2413 return Err(git2::Error::from_str("Invalid remote branch name"));
2414 }
2415 let local_name = parts[1];
2416
2417 let output = std::process::Command::new("git")
2418 .env("GIT_TERMINAL_PROMPT", "0")
2419 .env("GIT_SSH_COMMAND", ssh_command_val())
2420 .arg("checkout")
2421 .arg(local_name)
2422 .current_dir(repo_path)
2423 .output()
2424 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2425
2426 if output.status.success() {
2427 return Ok(format!("Switched to existing branch '{}'", local_name));
2428 }
2429
2430 let output = std::process::Command::new("git")
2431 .env("GIT_TERMINAL_PROMPT", "0")
2432 .env("GIT_SSH_COMMAND", ssh_command_val())
2433 .arg("checkout")
2434 .arg("--track")
2435 .arg(remote_branch_name)
2436 .current_dir(repo_path)
2437 .output()
2438 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2439
2440 if !output.status.success() {
2441 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2442 return Err(git2::Error::from_str(&err));
2443 }
2444
2445 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2446}
2447
2448pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2450 let repo = Repository::open(repo_path)?;
2451 let head = repo.head()?;
2452 let target_commit = head.peel_to_commit()?;
2453 repo.branch(branch_name, &target_commit, false)?;
2454 Ok(())
2455}
2456
2457pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2459 let repo = Repository::open(repo_path)?;
2460 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2461 branch.delete()?;
2462 Ok(())
2463}
2464
2465pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2467 let repo = Repository::open(repo_path)?;
2468 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2469 branch.delete()?;
2470 Ok(())
2471}
2472
2473pub fn create_tag(
2475 repo_path: &Path,
2476 tag_name: &str,
2477 commit_oid_str: &str,
2478) -> Result<(), git2::Error> {
2479 let repo = Repository::open(repo_path)?;
2480 let oid = git2::Oid::from_str(commit_oid_str)?;
2481 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2482 repo.tag_lightweight(tag_name, &target_object, false)?;
2483 Ok(())
2484}
2485
2486pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2488 let repo = Repository::open(repo_path)?;
2489 repo.tag_delete(tag_name)?;
2490 Ok(())
2491}
2492
2493pub fn delete_remote_tag(
2495 repo_path: &Path,
2496 remote_name: &str,
2497 tag_name: &str,
2498) -> Result<(), Box<dyn std::error::Error>> {
2499 let safe_remote = safe_ref(remote_name)?;
2500 let safe_tag = safe_ref(tag_name)?;
2501 let output = git_command()
2502 .arg("push")
2503 .arg(safe_remote)
2504 .arg("--delete")
2505 .arg(safe_tag)
2506 .current_dir(repo_path)
2507 .output()?;
2508 if !output.status.success() {
2509 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2510 return Err(err.into());
2511 }
2512 Ok(())
2513}
2514
2515pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2516 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2517 let output = git_command()
2518 .arg("checkout")
2519 .arg(safe_tag)
2520 .current_dir(repo_path)
2521 .output()
2522 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2523
2524 if !output.status.success() {
2525 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2526 return Err(git2::Error::from_str(&err));
2527 }
2528 Ok(())
2529}
2530
2531pub fn get_remote_tags(
2533 repo_path: &Path,
2534 remote_name: &str,
2535) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2536 let safe_remote = safe_ref(remote_name)?;
2537 let output = git_command()
2538 .arg("ls-remote")
2539 .arg("--tags")
2540 .arg(safe_remote)
2541 .current_dir(repo_path)
2542 .output()?;
2543
2544 if !output.status.success() {
2545 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2546 return Err(err.into());
2547 }
2548
2549 let stdout = String::from_utf8_lossy(&output.stdout);
2550 let repo = git2::Repository::open(repo_path)?;
2551 let mut tags_map = std::collections::HashMap::new();
2552
2553 for line in stdout.lines() {
2554 let parts: Vec<&str> = line.split_whitespace().collect();
2555 if parts.len() >= 2 {
2556 let sha = parts[0];
2557 let ref_name = parts[1];
2558 if ref_name.starts_with("refs/tags/") {
2559 let is_peeled = ref_name.ends_with("^{}");
2560 let clean_ref = if is_peeled {
2561 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2562 } else {
2563 ref_name
2564 };
2565 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2566 let short_sha = safe_sha_slice(sha, 7);
2567
2568 let mut short_message = String::new();
2570 if let Ok(oid) = git2::Oid::from_str(sha) {
2571 if let Ok(commit) = repo.find_commit(oid) {
2572 if let Ok(Some(summary)) = commit.summary() {
2573 short_message = summary.to_string();
2574 }
2575 }
2576 }
2577 if short_message.is_empty() {
2578 short_message = "(not fetched)".to_string();
2579 }
2580
2581 let sanitized_tag = sanitize_text(tag_name);
2582 let sanitized_msg = sanitize_text(&short_message);
2583
2584 if is_peeled {
2585 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2586 } else {
2587 tags_map
2588 .entry(sanitized_tag)
2589 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2590 }
2591 }
2592 }
2593 }
2594
2595 let mut tags = Vec::new();
2596 for (name, (short_sha, short_message)) in tags_map {
2597 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2598 }
2599 tags.sort_by(|a, b| b.name.cmp(&a.name));
2600 Ok(tags)
2601}
2602
2603pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2604 let mut s = String::new();
2605 for tag in tags {
2606 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2607 }
2608 s
2609}
2610
2611pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2612 let mut tags = Vec::new();
2613 for line in s.lines() {
2614 let parts: Vec<&str> = line.split('|').collect();
2615 if parts.len() >= 3 {
2616 tags.push(BranchInfo {
2617 name: parts[0].to_string(),
2618 is_head: false,
2619 short_sha: parts[1].to_string(),
2620 short_message: parts[2].to_string(),
2621 });
2622 }
2623 }
2624 tags
2625}
2626
2627pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2628 let mut repo = Repository::open(repo_path)?;
2629 repo.stash_drop(index)?;
2630 Ok(())
2631}
2632
2633pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2634 let stash_ref = format!("stash@{{{}}}", index);
2635 let output = std::process::Command::new("git")
2636 .env("GIT_TERMINAL_PROMPT", "0")
2637 .env("GIT_SSH_COMMAND", ssh_command_val())
2638 .arg("stash")
2639 .arg("apply")
2640 .arg(&stash_ref)
2641 .current_dir(repo_path)
2642 .output()
2643 .map_err(|e| e.to_string())?;
2644
2645 if !output.status.success() {
2646 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2647 return Err(err_msg);
2648 }
2649 Ok(())
2650}
2651
2652pub fn save_stash(
2653 repo_path: &Path,
2654 message: &str,
2655 include_untracked: bool,
2656 keep_index: bool,
2657) -> Result<(), String> {
2658 let mut cmd = std::process::Command::new("git");
2659 cmd.env("GIT_TERMINAL_PROMPT", "0")
2660 .env("GIT_SSH_COMMAND", ssh_command_val())
2661 .arg("stash")
2662 .arg("push");
2663
2664 if include_untracked {
2665 cmd.arg("--include-untracked");
2666 }
2667 if keep_index {
2668 cmd.arg("--keep-index");
2669 }
2670
2671 if !message.is_empty() {
2672 cmd.arg("-m").arg(message);
2673 }
2674
2675 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2676
2677 if !output.status.success() {
2678 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2679 return Err(err_msg);
2680 }
2681 Ok(())
2682}
2683
2684pub fn get_latest_change_time(item: &str) -> u64 {
2685 let path = expand_tilde(item);
2686 if !path.exists() {
2687 return 0;
2688 }
2689
2690 if path.join(".git").exists() {
2691 if let Ok(repo) = Repository::open(&path) {
2692 if let Ok(head) = repo.head() {
2693 if let Ok(commit) = head.peel_to_commit() {
2694 return commit.time().seconds() as u64;
2695 }
2696 }
2697 }
2698 }
2699
2700 if let Ok(meta) = std::fs::metadata(&path) {
2701 if let Ok(modified) = meta.modified() {
2702 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2703 return duration.as_secs();
2704 }
2705 }
2706 }
2707 0
2708}
2709
2710pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2711 if let Ok(repo) = Repository::open(repo_path) {
2712 if let Ok(head) = repo.head() {
2713 if let Ok(commit) = head.peel_to_commit() {
2714 if let Ok(msg) = commit.message() {
2715 return Some(msg.to_string());
2716 }
2717 }
2718 }
2719 }
2720 None
2721}
2722
2723pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2724 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2725 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2726 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2727
2728 let mut index = repo.index().map_err(|e| e.to_string())?;
2729 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2730 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2731
2732 let signature = repo
2733 .signature()
2734 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2735
2736 head_commit
2737 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2738 .map_err(|e| e.to_string())?;
2739
2740 Ok(())
2741}
2742
2743pub fn is_merging(repo_path: &Path) -> bool {
2748 repo_path.join(".git/MERGE_HEAD").exists()
2749}
2750
2751pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2754 let full_path = repo_path.join(file_path);
2755 let content = match std::fs::read_to_string(&full_path) {
2756 Ok(s) => s,
2757 Err(_) => return Vec::new(),
2758 };
2759
2760 let mut lines = Vec::new();
2761 let mut in_ours = false;
2762 let mut in_theirs = false;
2763
2764 for line in content.lines() {
2765 if line.starts_with("<<<<<<<") {
2766 in_ours = true;
2767 in_theirs = false;
2768 lines.push(DiffLine {
2769 kind: DiffLineKind::ConflictSeparator,
2770 content: line.to_string(),
2771 old_lineno: None,
2772 new_lineno: None,
2773 hunk_idx: None,
2774 });
2775 } else if line.starts_with("=======") {
2776 in_ours = false;
2777 in_theirs = true;
2778 lines.push(DiffLine {
2779 kind: DiffLineKind::ConflictSeparator,
2780 content: line.to_string(),
2781 old_lineno: None,
2782 new_lineno: None,
2783 hunk_idx: None,
2784 });
2785 } else if line.starts_with(">>>>>>>") {
2786 in_ours = false;
2787 in_theirs = false;
2788 lines.push(DiffLine {
2789 kind: DiffLineKind::ConflictSeparator,
2790 content: line.to_string(),
2791 old_lineno: None,
2792 new_lineno: None,
2793 hunk_idx: None,
2794 });
2795 } else if in_ours {
2796 lines.push(DiffLine {
2797 kind: DiffLineKind::ConflictOurs,
2798 content: line.to_string(),
2799 old_lineno: None,
2800 new_lineno: None,
2801 hunk_idx: None,
2802 });
2803 } else if in_theirs {
2804 lines.push(DiffLine {
2805 kind: DiffLineKind::ConflictTheirs,
2806 content: line.to_string(),
2807 old_lineno: None,
2808 new_lineno: None,
2809 hunk_idx: None,
2810 });
2811 } else {
2812 lines.push(DiffLine {
2813 kind: DiffLineKind::Context,
2814 content: line.to_string(),
2815 old_lineno: None,
2816 new_lineno: None,
2817 hunk_idx: None,
2818 });
2819 }
2820 }
2821 lines
2822}
2823
2824pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2827 let output1 = std::process::Command::new("git")
2828 .env("GIT_TERMINAL_PROMPT", "0")
2829 .env("GIT_SSH_COMMAND", ssh_command_val())
2830 .args(["checkout", "--ours", file_path])
2831 .current_dir(repo_path)
2832 .output()
2833 .map_err(|e| e.to_string())?;
2834 if !output1.status.success() {
2835 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2836 }
2837 stage_file(repo_path, file_path)?;
2838 Ok(())
2839}
2840
2841pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2844 let output1 = std::process::Command::new("git")
2845 .env("GIT_TERMINAL_PROMPT", "0")
2846 .env("GIT_SSH_COMMAND", ssh_command_val())
2847 .args(["checkout", "--theirs", file_path])
2848 .current_dir(repo_path)
2849 .output()
2850 .map_err(|e| e.to_string())?;
2851 if !output1.status.success() {
2852 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2853 }
2854 stage_file(repo_path, file_path)?;
2855 Ok(())
2856}
2857
2858pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2860 stage_file(repo_path, file_path)
2861}
2862
2863pub fn resolve_conflict_hunk(
2867 repo_path: &Path,
2868 file_path: &str,
2869 hunk_idx: usize,
2870 accept_ours: bool,
2871) -> Result<(), String> {
2872 let full_path = repo_path.join(file_path);
2873 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2874
2875 let mut new_lines = Vec::new();
2876 let mut lines_iter = content.lines().peekable();
2877 let mut current_hunk_idx = 0;
2878
2879 while let Some(line) = lines_iter.next() {
2880 if line.starts_with("<<<<<<<") {
2881 let mut ours_block = Vec::new();
2882 let mut theirs_block = Vec::new();
2883
2884 let mut found_separator = false;
2886 while let Some(&next_line) = lines_iter.peek() {
2887 if next_line.starts_with("=======") {
2888 lines_iter.next(); found_separator = true;
2890 break;
2891 }
2892 if let Some(line) = lines_iter.next() {
2893 ours_block.push(line.to_string());
2894 }
2895 }
2896
2897 let mut found_end = false;
2899 let mut end_line_marker = ">>>>>>>".to_string();
2900 while let Some(&next_line) = lines_iter.peek() {
2901 if next_line.starts_with(">>>>>>>") {
2902 if let Some(marker) = lines_iter.next() {
2903 end_line_marker = marker.to_string(); }
2905 found_end = true;
2906 break;
2907 }
2908 if let Some(line) = lines_iter.next() {
2909 theirs_block.push(line.to_string());
2910 }
2911 }
2912
2913 if current_hunk_idx == hunk_idx {
2914 if accept_ours {
2915 new_lines.extend(ours_block);
2916 } else {
2917 new_lines.extend(theirs_block);
2918 }
2919 } else {
2920 new_lines.push(line.to_string());
2921 new_lines.extend(ours_block);
2922 if found_separator {
2923 new_lines.push("=======".to_string());
2924 }
2925 new_lines.extend(theirs_block);
2926 if found_end {
2927 new_lines.push(end_line_marker);
2928 }
2929 }
2930
2931 current_hunk_idx += 1;
2932 } else {
2933 new_lines.push(line.to_string());
2934 }
2935 }
2936
2937 let mut new_content = new_lines.join("\n");
2938 if content.ends_with('\n') && !new_content.ends_with('\n') {
2939 new_content.push('\n');
2940 }
2941 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2942
2943 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2945 let has_conflict_markers = updated_content
2946 .lines()
2947 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2948
2949 if !has_conflict_markers {
2950 stage_file(repo_path, file_path)?;
2951 }
2952
2953 Ok(())
2954}
2955
2956pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2958 let output = std::process::Command::new("git")
2959 .env("GIT_TERMINAL_PROMPT", "0")
2960 .env("GIT_SSH_COMMAND", ssh_command_val())
2961 .args(["merge", "--abort"])
2962 .current_dir(repo_path)
2963 .output()
2964 .map_err(|e| e.to_string())?;
2965 if !output.status.success() {
2966 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2967 }
2968 Ok(())
2969}
2970
2971pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2973 let output = std::process::Command::new("git")
2974 .env("GIT_TERMINAL_PROMPT", "0")
2975 .env("GIT_SSH_COMMAND", ssh_command_val())
2976 .args(["merge", "--continue"])
2977 .env("GIT_EDITOR", "true")
2978 .current_dir(repo_path)
2979 .output()
2980 .map_err(|e| e.to_string())?;
2981 if !output.status.success() {
2982 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2983 }
2984 Ok(())
2985}
2986
2987pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2989 let repo = Repository::open(repo_path).ok()?;
2990 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2991 let _upstream = branch.upstream().ok()?;
2992 let local_ref = branch.get().name().ok()?;
2993 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
2994 remote_buf.as_str().ok().map(|s| s.to_string())
2995}
2996
2997pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2999 get_branch_upstream_remote(repo_path, branch_name).is_some()
3000}
3001
3002pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3004 let repo = Repository::open(repo_path).ok()?;
3005 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3006 if branch.upstream().is_ok() {
3007 if let Ok(local_ref) = branch.get().name() {
3008 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3009 if let Ok(name) = remote_buf.as_str() {
3010 return Some((name.to_string(), false));
3011 }
3012 }
3013 }
3014 }
3015 let remotes = repo.remotes().ok()?;
3016 let first_remote = remotes.iter().next()?.ok()??.to_string();
3017 Some((first_remote, true))
3018}
3019
3020pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3022 if let Ok(repo) = Repository::open(repo_path) {
3023 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3024 if let Ok(commit) = repo.find_commit(oid) {
3025 return commit.parent_count() == 0;
3026 }
3027 }
3028 }
3029 false
3030}
3031
3032pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3033 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3034 let mut worktrees_list = Vec::new();
3035 if let Ok(worktree_names) = repo.worktrees() {
3036 for name in worktree_names.iter() {
3037 let Ok(Some(wt_name)) = name else { continue };
3038 if let Ok(wt) = repo.find_worktree(wt_name) {
3039 let path = wt.path().to_path_buf();
3040 let mut branch = None;
3041 if let Ok(wt_repo) = Repository::open(&path) {
3042 if let Ok(head) = wt_repo.head() {
3043 branch = head.shorthand().map(String::from).ok();
3044 }
3045 }
3046
3047 let mut is_locked = false;
3048 let mut lock_reason = None;
3049 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3050 is_locked = true;
3051 if let Some(reason) = reason_opt {
3052 if !reason.is_empty() {
3053 lock_reason = Some(reason);
3054 }
3055 }
3056 }
3057
3058 worktrees_list.push(WorktreeInfo {
3059 name: wt_name.to_string(),
3060 path,
3061 branch,
3062 is_locked,
3063 lock_reason,
3064 });
3065 }
3066 }
3067 }
3068 Ok(worktrees_list)
3069}
3070
3071pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3072 let output = std::process::Command::new("git")
3073 .arg("worktree")
3074 .arg("add")
3075 .arg(wt_path)
3076 .arg(branch)
3077 .current_dir(repo_path)
3078 .output()
3079 .map_err(|e| e.to_string())?;
3080
3081 if !output.status.success() {
3082 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3083 }
3084 Ok(())
3085}
3086
3087pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3088 let output = std::process::Command::new("git")
3089 .arg("worktree")
3090 .arg("lock")
3091 .arg("--reason")
3092 .arg(reason)
3093 .arg(name)
3094 .current_dir(repo_path)
3095 .output()
3096 .map_err(|e| e.to_string())?;
3097
3098 if !output.status.success() {
3099 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3100 }
3101 Ok(())
3102}
3103
3104pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3105 let output = std::process::Command::new("git")
3106 .arg("worktree")
3107 .arg("unlock")
3108 .arg(name)
3109 .current_dir(repo_path)
3110 .output()
3111 .map_err(|e| e.to_string())?;
3112
3113 if !output.status.success() {
3114 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3115 }
3116 Ok(())
3117}
3118
3119pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3120 let mut cmd = std::process::Command::new("git");
3121 cmd.arg("worktree").arg("remove");
3122 if force {
3123 cmd.arg("--force");
3124 }
3125 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3126
3127 if !output.status.success() {
3128 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3129 }
3130 Ok(())
3131}
3132
3133pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3134 let output = std::process::Command::new("git")
3135 .arg("worktree")
3136 .arg("prune")
3137 .current_dir(repo_path)
3138 .output()
3139 .map_err(|e| e.to_string())?;
3140
3141 if !output.status.success() {
3142 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3143 }
3144 Ok(())
3145}
3146
3147pub fn load_tab_forge_issues(repo_path: &Path) -> Result<Vec<ForgeIssue>, String> {
3148 let mut cmd = std::process::Command::new("gh");
3149 cmd.arg("issue")
3150 .arg("list")
3151 .arg("--limit")
3152 .arg("50")
3153 .arg("--json")
3154 .arg("number,title,state,author,assignees,url")
3155 .current_dir(repo_path);
3156
3157 let output = match cmd.output() {
3158 Ok(out) => out,
3159 Err(e) => {
3160 return Err(format!(
3161 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3162 e
3163 ));
3164 }
3165 };
3166
3167 if !output.status.success() {
3168 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3169 return Err(format!(
3170 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3171 err_msg
3172 ));
3173 }
3174
3175 #[derive(serde::Deserialize)]
3176 struct GhAuthor {
3177 login: String,
3178 }
3179
3180 #[derive(serde::Deserialize)]
3181 struct GhAssignee {
3182 login: String,
3183 }
3184
3185 #[derive(serde::Deserialize)]
3186 struct GhIssue {
3187 number: u32,
3188 title: String,
3189 state: String,
3190 author: Option<GhAuthor>,
3191 assignees: Option<Vec<GhAssignee>>,
3192 url: String,
3193 }
3194
3195 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3196 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3197
3198 let issues = raw_issues
3199 .into_iter()
3200 .map(|item| {
3201 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3202 let assignees =
3203 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3204 ForgeIssue {
3205 number: item.number,
3206 title: item.title,
3207 state: item.state,
3208 author,
3209 assignees,
3210 url: item.url,
3211 }
3212 })
3213 .collect();
3214
3215 Ok(issues)
3216}
3217
3218pub fn resolve_and_checkout_issue_branch(
3219 repo_path: &Path,
3220 issue_number: u32,
3221) -> Result<String, String> {
3222 let mut cmd = std::process::Command::new("gh");
3223 cmd.arg("issue")
3224 .arg("view")
3225 .arg(issue_number.to_string())
3226 .arg("--json")
3227 .arg("developmentBranch")
3228 .current_dir(repo_path);
3229
3230 if let Ok(output) = cmd.output() {
3231 if output.status.success() {
3232 #[derive(serde::Deserialize)]
3233 struct GhDevBranch {
3234 name: String,
3235 }
3236 #[derive(serde::Deserialize)]
3237 struct GhIssueView {
3238 #[serde(rename = "developmentBranch")]
3239 development_branch: Option<GhDevBranch>,
3240 }
3241 if let Ok(parsed) = serde_json::from_slice::<GhIssueView>(&output.stdout) {
3242 if let Some(dev_branch) = parsed.development_branch {
3243 if !dev_branch.name.is_empty() {
3244 let name = dev_branch.name;
3245 if checkout_local_branch(repo_path, &name).is_ok() {
3246 return Ok(format!("Checked out linked local branch '{}'", name));
3247 }
3248 if let Ok(msg) = checkout_remote_branch(repo_path, &name) {
3249 return Ok(format!("Checked out linked remote branch '{}'", msg));
3250 }
3251 let checkout_out = std::process::Command::new("git")
3252 .arg("checkout")
3253 .arg("-b")
3254 .arg(&name)
3255 .current_dir(repo_path)
3256 .output();
3257 if let Ok(out) = checkout_out {
3258 if out.status.success() {
3259 return Ok(format!(
3260 "Created and switched to linked branch '{}'",
3261 name
3262 ));
3263 }
3264 }
3265 }
3266 }
3267 }
3268 }
3269 }
3270
3271 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3272 let target_pattern = format!("{}", issue_number);
3273 let mut found_local = None;
3274 let mut found_remote = None;
3275
3276 if let Ok(branches) = repo.branches(None) {
3277 for (branch, branch_type) in branches.flatten() {
3278 if let Ok(Some(name)) = branch.name() {
3279 if name.contains(&target_pattern) {
3280 match branch_type {
3281 git2::BranchType::Local => {
3282 found_local = Some(name.to_string());
3283 break;
3284 }
3285 git2::BranchType::Remote => {
3286 found_remote = Some(name.to_string());
3287 }
3288 }
3289 }
3290 }
3291 }
3292 }
3293
3294 if let Some(local_name) = found_local {
3295 checkout_local_branch(repo_path, &local_name).map_err(|e| e.to_string())?;
3296 return Ok(format!("Checked out matched local branch '{}'", local_name));
3297 }
3298
3299 if let Some(remote_name) = found_remote {
3300 let msg = checkout_remote_branch(repo_path, &remote_name).map_err(|e| e.to_string())?;
3301 return Ok(format!("Checked out matched remote branch: {}", msg));
3302 }
3303
3304 let new_branch_name = format!("issue-{}", issue_number);
3305 let checkout_out = std::process::Command::new("git")
3306 .arg("checkout")
3307 .arg("-b")
3308 .arg(&new_branch_name)
3309 .current_dir(repo_path)
3310 .output()
3311 .map_err(|e| e.to_string())?;
3312
3313 if !checkout_out.status.success() {
3314 let err = String::from_utf8_lossy(&checkout_out.stderr).trim().to_string();
3315 return Err(format!("Failed to create new branch: {}", err));
3316 }
3317
3318 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
3319}
3320
3321pub fn open_browser(url: &str) {
3322 #[cfg(target_os = "macos")]
3323 let _ = std::process::Command::new("open").arg(url).status();
3324 #[cfg(target_os = "windows")]
3325 let _ = std::process::Command::new("cmd").args(["/C", "start", url]).status();
3326 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3327 let _ = std::process::Command::new("xdg-open").arg(url).status();
3328}
3329
3330#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3331pub struct ForgeIssue {
3332 pub number: u32,
3333 pub title: String,
3334 pub state: String,
3335 pub author: String,
3336 pub assignees: Vec<String>,
3337 pub url: String,
3338}
3339
3340pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
3341 let remotes = load_tab_remotes(repo_path).ok()?;
3342 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
3343}
3344
3345pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
3346 let remotes = load_tab_remotes(repo_path).ok()?;
3347 remotes.into_iter().next().map(|r| r.url)
3348}
3349
3350#[cfg(test)]
3351#[allow(clippy::unwrap_used, clippy::panic)]
3352mod tests {
3353 use super::*;
3354 use std::fs::File;
3355
3356 #[test]
3357 fn test_match_pattern() {
3358 assert!(match_pattern("test.psd", "*.psd"));
3359 assert!(match_pattern("assets/test.psd", "*.psd"));
3360 assert!(match_pattern("assets/img.png", "assets/*.png"));
3361 assert!(match_pattern("src/app.rs", "src/*"));
3362 assert!(match_pattern("test.txt", "test.txt"));
3363 assert!(!match_pattern("src/app.rs", "*.psd"));
3364 }
3365 use std::io::Write;
3366
3367 #[test]
3368 fn test_commit_amend() {
3369 let mut temp_path = std::env::temp_dir();
3370 temp_path.push(format!(
3371 "twig_test_{}",
3372 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3373 ));
3374 std::fs::create_dir_all(&temp_path).unwrap();
3375
3376 let repo = Repository::init(&temp_path).unwrap();
3378
3379 let mut config = repo.config().unwrap();
3381 config.set_str("user.name", "Test User").unwrap();
3382 config.set_str("user.email", "test@example.com").unwrap();
3383
3384 let file_path = temp_path.join("test.txt");
3386 let mut file = File::create(&file_path).unwrap();
3387 writeln!(file, "initial content").unwrap();
3388
3389 stage_file(&temp_path, "test.txt").unwrap();
3391 commit_changes(&temp_path, "initial commit").unwrap();
3392
3393 let msg = get_last_commit_message(&temp_path).unwrap();
3395 assert_eq!(msg, "initial commit");
3396
3397 commit_amend(&temp_path, "amended commit").unwrap();
3399
3400 let amended_msg = get_last_commit_message(&temp_path).unwrap();
3402 assert_eq!(amended_msg, "amended commit");
3403
3404 let _ = std::fs::remove_dir_all(&temp_path);
3406 }
3407
3408 #[test]
3409 fn test_commit_signatures_collection() {
3410 let mut temp_path = std::env::temp_dir();
3411 temp_path.push(format!(
3412 "twig_test_sig_{}",
3413 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3414 ));
3415 std::fs::create_dir_all(&temp_path).unwrap();
3416
3417 let repo = Repository::init(&temp_path).unwrap();
3419
3420 let mut config = repo.config().unwrap();
3422 config.set_str("user.name", "Test User").unwrap();
3423 config.set_str("user.email", "test@example.com").unwrap();
3424
3425 let file_path = temp_path.join("test.txt");
3427 let mut file = File::create(&file_path).unwrap();
3428 writeln!(file, "initial content").unwrap();
3429
3430 stage_file(&temp_path, "test.txt").unwrap();
3432 commit_changes(&temp_path, "initial commit").unwrap();
3433
3434 let sigs = collect_signatures(&temp_path, 0);
3436 assert_eq!(sigs.len(), 1);
3437 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3438 let sig_status = sigs.get(&head_oid).unwrap();
3439 assert_eq!(sig_status, "N");
3440
3441 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
3443 assert_eq!(commits.len(), 1);
3444 assert_eq!(commits[0].signature_status, "N");
3445 assert!(commits[0].files.is_empty());
3446
3447 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
3449 assert_eq!(files.len(), 1);
3450 assert_eq!(files[0].path, "test.txt");
3451 assert_eq!(files[0].label, "N");
3452
3453 let graph = collect_graph_lines(&temp_path, 1000);
3455 assert_eq!(graph.len(), 1);
3456 assert!(graph[0].commit.is_some());
3457 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
3458
3459 let _ = std::fs::remove_dir_all(&temp_path);
3461 }
3462
3463 #[test]
3464 fn test_ref_map_cache_behavior() {
3465 let temp_dir = std::env::temp_dir();
3466 let repo_path = temp_dir.join("test_ref_map_repo");
3467 let _ = std::fs::remove_dir_all(&repo_path);
3468 std::fs::create_dir_all(&repo_path).unwrap();
3469
3470 let repo = Repository::init(&repo_path).unwrap();
3471
3472 let map1 = get_cached_ref_map(&repo, &repo_path);
3474
3475 let map2 = get_cached_ref_map(&repo, &repo_path);
3477 assert_eq!(map1.len(), map2.len());
3478
3479 invalidate_ref_map_cache(&repo_path);
3481
3482 let _ = std::fs::remove_dir_all(&repo_path);
3484 }
3485
3486 #[test]
3487 fn test_get_latest_change_time() {
3488 let mut temp_path = std::env::temp_dir();
3489 temp_path.push(format!(
3490 "twig_test_{}",
3491 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3492 ));
3493 std::fs::create_dir_all(&temp_path).unwrap();
3494
3495 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
3496 assert!(change_time > 0);
3497
3498 let _ = std::fs::remove_dir_all(&temp_path);
3499 }
3500
3501 #[test]
3502 fn test_committer_stats() {
3503 let mut temp_path = std::env::temp_dir();
3504 temp_path.push(format!(
3505 "twig_test_{}",
3506 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3507 ));
3508 std::fs::create_dir_all(&temp_path).unwrap();
3509
3510 let repo = Repository::init(&temp_path).unwrap();
3512
3513 let mut config = repo.config().unwrap();
3515 config.set_str("user.name", "Test User").unwrap();
3516 config.set_str("user.email", "test@example.com").unwrap();
3517
3518 let file_path = temp_path.join("test.txt");
3520 let mut file = File::create(&file_path).unwrap();
3521 writeln!(file, "initial content").unwrap();
3522
3523 stage_file(&temp_path, "test.txt").unwrap();
3525 commit_changes(&temp_path, "initial commit").unwrap();
3526
3527 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
3529 assert_eq!(stats.len(), 1);
3530 assert_eq!(stats[0].name, "Test User");
3531 assert_eq!(stats[0].email, "test@example.com");
3532 assert_eq!(stats[0].count, 1);
3533 assert!(!limit_reached);
3534
3535 let _ = std::fs::remove_dir_all(&temp_path);
3537 }
3538
3539 #[test]
3540 fn test_untracked_files_in_unstaged() {
3541 let mut temp_path = std::env::temp_dir();
3542 temp_path.push(format!(
3543 "twig_test_{}",
3544 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3545 ));
3546 std::fs::create_dir_all(&temp_path).unwrap();
3547
3548 let _repo = Repository::init(&temp_path).unwrap();
3550
3551 let file_path = temp_path.join("untracked.txt");
3553 let mut file = File::create(&file_path).unwrap();
3554 writeln!(file, "hello untracked").unwrap();
3555
3556 let untracked_dir = temp_path.join("untracked_dir");
3558 std::fs::create_dir_all(&untracked_dir).unwrap();
3559 let nested_file_path = untracked_dir.join("nested.txt");
3560 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
3561
3562 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3564 match detail {
3565 ItemDetail::Repo { info, .. } => {
3566 let unstaged_paths: Vec<String> =
3568 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
3569 let untracked_paths: Vec<String> =
3570 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
3571
3572 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
3574 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
3575 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
3576 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
3577
3578 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
3580 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
3581 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
3582 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
3583 }
3584 _ => panic!("Expected ItemDetail::Repo"),
3585 }
3586
3587 let _ = std::fs::remove_dir_all(&temp_path);
3589 }
3590
3591 #[test]
3592 fn test_stage_new_and_deleted_files() {
3593 let mut temp_path = std::env::temp_dir();
3594 temp_path.push(format!(
3595 "twig_test_{}",
3596 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3597 ));
3598 std::fs::create_dir_all(&temp_path).unwrap();
3599
3600 let repo = Repository::init(&temp_path).unwrap();
3602
3603 let mut config = repo.config().unwrap();
3605 config.set_str("user.name", "Test User").unwrap();
3606 config.set_str("user.email", "test@example.com").unwrap();
3607
3608 let init_file = temp_path.join("init.txt");
3610 std::fs::write(&init_file, "initial").unwrap();
3611 stage_file(&temp_path, "init.txt").unwrap();
3612 commit_changes(&temp_path, "initial commit").unwrap();
3613
3614 let untracked_file = temp_path.join("untracked.txt");
3616 std::fs::write(&untracked_file, "new file content").unwrap();
3617
3618 stage_file(&temp_path, "untracked.txt").unwrap();
3620
3621 std::fs::remove_file(&init_file).unwrap();
3623
3624 stage_file(&temp_path, "init.txt").unwrap();
3626
3627 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3629 match detail {
3630 ItemDetail::Repo { info, .. } => {
3631 assert_eq!(info.changes.staged.len(), 2);
3633 let paths: Vec<String> =
3634 info.changes.staged.iter().map(|f| f.path.clone()).collect();
3635 assert!(paths.contains(&"untracked.txt".to_string()));
3636 assert!(paths.contains(&"init.txt".to_string()));
3637 }
3638 _ => panic!("Expected ItemDetail::Repo"),
3639 }
3640
3641 let _ = std::fs::remove_dir_all(&temp_path);
3643 }
3644
3645 #[test]
3646 fn test_discard_file_changes_all_cases() {
3647 let mut temp_path = std::env::temp_dir();
3648 temp_path.push(format!(
3649 "twig_test_{}",
3650 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3651 ));
3652 std::fs::create_dir_all(&temp_path).unwrap();
3653
3654 let repo = Repository::init(&temp_path).unwrap();
3656
3657 let mut config = repo.config().unwrap();
3659 config.set_str("user.name", "Test User").unwrap();
3660 config.set_str("user.email", "test@example.com").unwrap();
3661
3662 let file_tracked = temp_path.join("tracked.txt");
3664 std::fs::write(&file_tracked, "original content\n").unwrap();
3665 stage_file(&temp_path, "tracked.txt").unwrap();
3666 commit_changes(&temp_path, "initial commit").unwrap();
3667
3668 let file_untracked = temp_path.join("untracked.txt");
3670 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
3671 assert!(file_untracked.exists());
3672 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
3673 assert!(!file_untracked.exists());
3674
3675 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
3677 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3678 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3679
3680 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
3682 stage_file(&temp_path, "tracked.txt").unwrap();
3683 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3685 match detail {
3686 ItemDetail::Repo { info, .. } => {
3687 assert!(!info.changes.staged.is_empty());
3688 }
3689 _ => panic!("Expected ItemDetail::Repo"),
3690 }
3691 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
3692 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3693 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3695 match detail {
3696 ItemDetail::Repo { info, .. } => {
3697 assert!(info.changes.staged.is_empty());
3698 assert!(info.changes.unstaged.is_empty());
3699 }
3700 _ => panic!("Expected ItemDetail::Repo"),
3701 }
3702
3703 std::fs::remove_file(&file_tracked).unwrap();
3705 assert!(!file_tracked.exists());
3706 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3707 assert!(file_tracked.exists());
3708 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3709
3710 let _ = std::fs::remove_dir_all(&temp_path);
3712 }
3713
3714 #[test]
3715 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3716 fn test_stage_unstage_by_hunk() {
3717 let mut temp_path = std::env::temp_dir();
3718 temp_path.push(format!(
3719 "twig_test_{}",
3720 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3721 ));
3722 std::fs::create_dir_all(&temp_path).unwrap();
3723
3724 let repo = Repository::init(&temp_path).unwrap();
3726
3727 let mut config = repo.config().unwrap();
3729 config.set_str("user.name", "Test User").unwrap();
3730 config.set_str("user.email", "test@example.com").unwrap();
3731
3732 let file_path = temp_path.join("multihunk.txt");
3734 let mut file = File::create(&file_path).unwrap();
3735 for i in 1..=20 {
3736 writeln!(file, "Line {}", i).unwrap();
3737 }
3738 drop(file);
3739
3740 stage_file(&temp_path, "multihunk.txt").unwrap();
3742 commit_changes(&temp_path, "initial commit").unwrap();
3743
3744 let mut file = File::create(&file_path).unwrap();
3746 for i in 1..=20 {
3747 if i == 2 || i == 18 {
3748 writeln!(file, "Line {} modified", i).unwrap();
3749 } else {
3750 writeln!(file, "Line {}", i).unwrap();
3751 }
3752 }
3753 drop(file);
3754
3755 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3757 let mut hunk_ranges = Vec::new();
3759 let mut current_start = None;
3760 for (i, line) in diff_lines.iter().enumerate() {
3761 if line.kind == DiffLineKind::Header {
3762 if let Some(start) = current_start {
3763 hunk_ranges.push(start..i);
3764 }
3765 current_start = Some(i);
3766 }
3767 }
3768 if let Some(start) = current_start {
3769 hunk_ranges.push(start..diff_lines.len());
3770 }
3771
3772 assert_eq!(hunk_ranges.len(), 2);
3774
3775 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3777 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
3778
3779 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3781 let staged_content: String =
3782 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3783 assert!(staged_content.contains("Line 18 modified"));
3784 assert!(!staged_content.contains("Line 2 modified"));
3785
3786 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3788 let unstaged_content: String =
3789 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3790 assert!(unstaged_content.contains("Line 2 modified"));
3791 assert!(!unstaged_content.contains("Line 18 modified"));
3792
3793 let staged_hunk_ranges = {
3795 let mut ranges = Vec::new();
3796 let mut current_start = None;
3797 for (i, line) in staged_diff.iter().enumerate() {
3798 if line.kind == DiffLineKind::Header {
3799 if let Some(start) = current_start {
3800 ranges.push(start..i);
3801 }
3802 current_start = Some(i);
3803 }
3804 }
3805 if let Some(start) = current_start {
3806 ranges.push(start..staged_diff.len());
3807 }
3808 ranges
3809 };
3810 assert_eq!(staged_hunk_ranges.len(), 1);
3811 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
3812 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
3813
3814 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3816 assert!(staged_diff_after.is_empty());
3817
3818 let _ = std::fs::remove_dir_all(&temp_path);
3820 }
3821
3822 #[test]
3823 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3824 fn test_discard_hunk() {
3825 let mut temp_path = std::env::temp_dir();
3826 temp_path.push(format!(
3827 "twig_test_{}",
3828 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3829 ));
3830 std::fs::create_dir_all(&temp_path).unwrap();
3831
3832 let repo = Repository::init(&temp_path).unwrap();
3834
3835 let mut config = repo.config().unwrap();
3837 config.set_str("user.name", "Test User").unwrap();
3838 config.set_str("user.email", "test@example.com").unwrap();
3839
3840 let file_path = temp_path.join("discardhunk.txt");
3842 let mut file = File::create(&file_path).unwrap();
3843 for i in 1..=20 {
3844 writeln!(file, "Line {}", i).unwrap();
3845 }
3846 drop(file);
3847
3848 stage_file(&temp_path, "discardhunk.txt").unwrap();
3850 commit_changes(&temp_path, "initial commit").unwrap();
3851
3852 let mut file = File::create(&file_path).unwrap();
3854 for i in 1..=20 {
3855 if i == 2 || i == 18 {
3856 writeln!(file, "Line {} modified", i).unwrap();
3857 } else {
3858 writeln!(file, "Line {}", i).unwrap();
3859 }
3860 }
3861 drop(file);
3862
3863 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
3865 let mut hunk_ranges = Vec::new();
3867 let mut current_start = None;
3868 for (i, line) in diff_lines.iter().enumerate() {
3869 if line.kind == DiffLineKind::Header {
3870 if let Some(start) = current_start {
3871 hunk_ranges.push(start..i);
3872 }
3873 current_start = Some(i);
3874 }
3875 }
3876 if let Some(start) = current_start {
3877 hunk_ranges.push(start..diff_lines.len());
3878 }
3879
3880 assert_eq!(hunk_ranges.len(), 2);
3882
3883 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3885 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
3886
3887 let contents = std::fs::read_to_string(&file_path).unwrap();
3889 assert!(contents.contains("Line 2 modified"));
3890 assert!(contents.contains("Line 18\n"));
3891 assert!(!contents.contains("Line 18 modified"));
3892
3893 let _ = std::fs::remove_dir_all(&temp_path);
3895 }
3896
3897 #[test]
3898 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3899 fn test_stage_unstage_discard_line() {
3900 let mut temp_path = std::env::temp_dir();
3901 temp_path.push(format!(
3902 "twig_test_{}",
3903 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3904 ));
3905 std::fs::create_dir_all(&temp_path).unwrap();
3906
3907 let repo = Repository::init(&temp_path).unwrap();
3908 let mut config = repo.config().unwrap();
3909 config.set_str("user.name", "Test User").unwrap();
3910 config.set_str("user.email", "test@example.com").unwrap();
3911
3912 let file_path = temp_path.join("line_test.txt");
3914 let mut file = File::create(&file_path).unwrap();
3915 writeln!(file, "line A").unwrap();
3916 writeln!(file, "line B").unwrap();
3917 writeln!(file, "line C").unwrap();
3918 drop(file);
3919
3920 stage_file(&temp_path, "line_test.txt").unwrap();
3921 commit_changes(&temp_path, "initial").unwrap();
3922
3923 let mut file = File::create(&file_path).unwrap();
3925 writeln!(file, "line A modified").unwrap();
3926 writeln!(file, "line B").unwrap();
3927 writeln!(file, "line C modified").unwrap();
3928 drop(file);
3929
3930 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3931 let mut hunk_ranges = Vec::new();
3932 let mut current_start = None;
3933 for (i, line) in diff_lines.iter().enumerate() {
3934 if line.kind == DiffLineKind::Header {
3935 if let Some(start) = current_start {
3936 hunk_ranges.push(start..i);
3937 }
3938 current_start = Some(i);
3939 }
3940 }
3941 if let Some(start) = current_start {
3942 hunk_ranges.push(start..diff_lines.len());
3943 }
3944
3945 assert_eq!(hunk_ranges.len(), 1);
3946 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3947
3948 assert_eq!(hunk0[2].content, "line A modified");
3949 assert_eq!(hunk0[5].content, "line C modified");
3950
3951 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3953
3954 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3956 assert!(
3957 staged_diff
3958 .iter()
3959 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3960 );
3961 assert!(
3962 !staged_diff
3963 .iter()
3964 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3965 );
3966
3967 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3969 assert!(
3970 !unstaged_diff
3971 .iter()
3972 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3973 );
3974 assert!(
3975 unstaged_diff
3976 .iter()
3977 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3978 );
3979
3980 assert_eq!(staged_diff[2].content, "line A modified");
3982 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3983
3984 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3986
3987 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3989 assert_eq!(unstaged_diff2[5].content, "line C modified");
3990 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3991
3992 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3993 let remove_idx = unstaged_diff3
3994 .iter()
3995 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3996 .unwrap();
3997 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3998
3999 let contents = std::fs::read_to_string(&file_path).unwrap();
4001 assert!(contents.contains("line A modified"));
4002 assert!(contents.contains("line B"));
4003 assert!(contents.contains("line C\n"));
4004 assert!(!contents.contains("line C modified"));
4005
4006 let _ = std::fs::remove_dir_all(&temp_path);
4008 }
4009
4010 #[test]
4011 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4012 fn test_stage_unstage_discard_all_changes() {
4013 let mut temp_path = std::env::temp_dir();
4014 temp_path.push(format!(
4015 "twig_test_all_{}",
4016 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4017 ));
4018 std::fs::create_dir_all(&temp_path).unwrap();
4019
4020 let repo = Repository::init(&temp_path).unwrap();
4022
4023 let mut config = repo.config().unwrap();
4025 config.set_str("user.name", "Test User").unwrap();
4026 config.set_str("user.email", "test@example.com").unwrap();
4027
4028 let file_path = temp_path.join("tracked.txt");
4030 std::fs::write(&file_path, "original content\n").unwrap();
4031 stage_file(&temp_path, "tracked.txt").unwrap();
4032 commit_changes(&temp_path, "initial").unwrap();
4033
4034 std::fs::write(&file_path, "modified content\n").unwrap();
4036 let untracked_path = temp_path.join("untracked.txt");
4037 std::fs::write(&untracked_path, "untracked content\n").unwrap();
4038
4039 let status = repo.statuses(None).unwrap();
4041 assert_eq!(status.len(), 2);
4042
4043 stage_all_changes(&temp_path).unwrap();
4045
4046 let status = repo.statuses(None).unwrap();
4048 for entry in status.iter() {
4049 assert!(
4050 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
4051 );
4052 }
4053
4054 unstage_all_changes(&temp_path).unwrap();
4056
4057 let status = repo.statuses(None).unwrap();
4059 for entry in status.iter() {
4060 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
4061 }
4062
4063 discard_all_changes(&temp_path).unwrap();
4065
4066 let status = repo.statuses(None).unwrap();
4068 assert_eq!(status.len(), 0);
4069
4070 let contents = std::fs::read_to_string(&file_path).unwrap();
4072 assert_eq!(contents, "original content\n");
4073 assert!(!untracked_path.exists());
4074
4075 let _ = std::fs::remove_dir_all(&temp_path);
4077 }
4078
4079 #[test]
4080 fn test_merge_conflicts_flow() {
4081 let mut temp_path = std::env::temp_dir();
4082 temp_path.push(format!(
4083 "twig_test_conflict_{}",
4084 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4085 ));
4086 std::fs::create_dir_all(&temp_path).unwrap();
4087
4088 let repo = Repository::init(&temp_path).unwrap();
4090
4091 let mut config = repo.config().unwrap();
4093 config.set_str("user.name", "Test User").unwrap();
4094 config.set_str("user.email", "test@example.com").unwrap();
4095
4096 let file_path = temp_path.join("conflict.txt");
4098 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4099 stage_file(&temp_path, "conflict.txt").unwrap();
4100 commit_changes(&temp_path, "initial commit").unwrap();
4101
4102 let output = std::process::Command::new("git")
4104 .env("GIT_TERMINAL_PROMPT", "0")
4105 .env("GIT_SSH_COMMAND", ssh_command_val())
4106 .args(["symbolic-ref", "--short", "HEAD"])
4107 .current_dir(&temp_path)
4108 .output()
4109 .unwrap();
4110 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4111
4112 std::process::Command::new("git")
4114 .env("GIT_TERMINAL_PROMPT", "0")
4115 .env("GIT_SSH_COMMAND", ssh_command_val())
4116 .args(["checkout", "-b", "feature"])
4117 .current_dir(&temp_path)
4118 .output()
4119 .unwrap();
4120
4121 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
4122 stage_file(&temp_path, "conflict.txt").unwrap();
4123 commit_changes(&temp_path, "feature commit").unwrap();
4124
4125 std::process::Command::new("git")
4127 .env("GIT_TERMINAL_PROMPT", "0")
4128 .env("GIT_SSH_COMMAND", ssh_command_val())
4129 .args(["checkout", &main_branch])
4130 .current_dir(&temp_path)
4131 .output()
4132 .unwrap();
4133
4134 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
4135 stage_file(&temp_path, "conflict.txt").unwrap();
4136 commit_changes(&temp_path, "main commit").unwrap();
4137
4138 assert!(!is_merging(&temp_path));
4140 let merge_output = std::process::Command::new("git")
4141 .env("GIT_TERMINAL_PROMPT", "0")
4142 .env("GIT_SSH_COMMAND", ssh_command_val())
4143 .args(["merge", "feature"])
4144 .current_dir(&temp_path)
4145 .output()
4146 .unwrap();
4147
4148 assert!(!merge_output.status.success());
4149 assert!(is_merging(&temp_path));
4150
4151 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
4153 assert!(!diff.is_empty());
4154 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
4155 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
4156 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
4157 assert!(has_separator);
4158 assert!(has_ours);
4159 assert!(has_theirs);
4160
4161 abort_merge(&temp_path).unwrap();
4163 assert!(!is_merging(&temp_path));
4164
4165 std::process::Command::new("git")
4167 .env("GIT_TERMINAL_PROMPT", "0")
4168 .env("GIT_SSH_COMMAND", ssh_command_val())
4169 .args(["merge", "feature"])
4170 .current_dir(&temp_path)
4171 .output()
4172 .unwrap();
4173 assert!(is_merging(&temp_path));
4174
4175 resolve_ours(&temp_path, "conflict.txt").unwrap();
4177 let contents = std::fs::read_to_string(&file_path).unwrap();
4178 assert!(contents.contains("line 2 on main"));
4179 assert!(!contents.contains("<<<<<<<"));
4180
4181 continue_merge(&temp_path).unwrap();
4183 assert!(!is_merging(&temp_path));
4184
4185 std::process::Command::new("git")
4187 .env("GIT_TERMINAL_PROMPT", "0")
4188 .env("GIT_SSH_COMMAND", ssh_command_val())
4189 .args(["reset", "--hard", "HEAD~1"])
4190 .current_dir(&temp_path)
4191 .output()
4192 .unwrap();
4193
4194 std::process::Command::new("git")
4195 .env("GIT_TERMINAL_PROMPT", "0")
4196 .env("GIT_SSH_COMMAND", ssh_command_val())
4197 .args(["merge", "feature"])
4198 .current_dir(&temp_path)
4199 .output()
4200 .unwrap();
4201 assert!(is_merging(&temp_path));
4202
4203 resolve_theirs(&temp_path, "conflict.txt").unwrap();
4204 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4205 assert!(contents_theirs.contains("line 2 on feature"));
4206 assert!(!contents_theirs.contains("<<<<<<<"));
4207
4208 continue_merge(&temp_path).unwrap();
4209 assert!(!is_merging(&temp_path));
4210
4211 let _ = std::fs::remove_dir_all(&temp_path);
4213 }
4214
4215 #[test]
4216 fn test_resolve_conflict_hunk() {
4217 let mut temp_path = std::env::temp_dir();
4218 temp_path.push(format!(
4219 "twig_test_hunk_conflict_{}",
4220 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4221 ));
4222 std::fs::create_dir_all(&temp_path).unwrap();
4223
4224 let repo = Repository::init(&temp_path).unwrap();
4226
4227 let mut config = repo.config().unwrap();
4229 config.set_str("user.name", "Test User").unwrap();
4230 config.set_str("user.email", "test@example.com").unwrap();
4231
4232 let file_path = temp_path.join("conflict.txt");
4234 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";
4235 std::fs::write(&file_path, initial_lines).unwrap();
4236 stage_file(&temp_path, "conflict.txt").unwrap();
4237 commit_changes(&temp_path, "initial commit").unwrap();
4238
4239 let output = std::process::Command::new("git")
4241 .env("GIT_TERMINAL_PROMPT", "0")
4242 .env("GIT_SSH_COMMAND", ssh_command_val())
4243 .args(["symbolic-ref", "--short", "HEAD"])
4244 .current_dir(&temp_path)
4245 .output()
4246 .unwrap();
4247 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4248
4249 std::process::Command::new("git")
4251 .env("GIT_TERMINAL_PROMPT", "0")
4252 .env("GIT_SSH_COMMAND", ssh_command_val())
4253 .args(["checkout", "-b", "feature"])
4254 .current_dir(&temp_path)
4255 .output()
4256 .unwrap();
4257 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";
4258 std::fs::write(&file_path, feature_lines).unwrap();
4259 stage_file(&temp_path, "conflict.txt").unwrap();
4260 commit_changes(&temp_path, "feature commit").unwrap();
4261
4262 std::process::Command::new("git")
4264 .env("GIT_TERMINAL_PROMPT", "0")
4265 .env("GIT_SSH_COMMAND", ssh_command_val())
4266 .args(["checkout", &main_branch])
4267 .current_dir(&temp_path)
4268 .output()
4269 .unwrap();
4270 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";
4271 std::fs::write(&file_path, main_lines).unwrap();
4272 stage_file(&temp_path, "conflict.txt").unwrap();
4273 commit_changes(&temp_path, "main commit").unwrap();
4274
4275 let merge_output = std::process::Command::new("git")
4277 .env("GIT_TERMINAL_PROMPT", "0")
4278 .env("GIT_SSH_COMMAND", ssh_command_val())
4279 .args(["merge", "feature"])
4280 .current_dir(&temp_path)
4281 .output()
4282 .unwrap();
4283 assert!(!merge_output.status.success());
4284 assert!(is_merging(&temp_path));
4285
4286 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
4288 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
4289 assert!(contents_after_first.contains("line 2 on main"));
4291 assert!(!contents_after_first.contains("line 2 on feature"));
4292 assert!(contents_after_first.contains("<<<<<<<"));
4294 assert!(contents_after_first.contains("line 11 on main"));
4295 assert!(contents_after_first.contains("line 11 on feature"));
4296
4297 assert!(is_merging(&temp_path));
4299
4300 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
4305 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
4306 assert!(contents_after_second.contains("line 2 on main"));
4308 assert!(contents_after_second.contains("line 11 on feature"));
4309 assert!(!contents_after_second.contains("<<<<<<<"));
4310
4311 let status = repo.statuses(None).unwrap();
4313 assert_eq!(status.len(), 1);
4314 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
4315
4316 continue_merge(&temp_path).unwrap();
4318 assert!(!is_merging(&temp_path));
4319
4320 let _ = std::fs::remove_dir_all(&temp_path);
4322 }
4323
4324 #[test]
4325 fn test_branch_and_commit_helpers() {
4326 let mut temp_path = std::env::temp_dir();
4327 temp_path.push(format!(
4328 "twig_test_helpers_{}",
4329 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4330 ));
4331 let _ = std::fs::remove_dir_all(&temp_path);
4332 std::fs::create_dir_all(&temp_path).unwrap();
4333
4334 let repo = Repository::init(&temp_path).unwrap();
4336
4337 let mut config = repo.config().unwrap();
4339 config.set_str("user.name", "Test User").unwrap();
4340 config.set_str("user.email", "test@example.com").unwrap();
4341
4342 let file_path = temp_path.join("test.txt");
4344 std::fs::write(&file_path, "root content").unwrap();
4345 stage_file(&temp_path, "test.txt").unwrap();
4346 commit_changes(&temp_path, "root commit").unwrap();
4347
4348 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4349 assert!(is_root_commit(&temp_path, &head_oid));
4350
4351 std::fs::write(&file_path, "second content").unwrap();
4353 stage_file(&temp_path, "test.txt").unwrap();
4354 commit_changes(&temp_path, "second commit").unwrap();
4355
4356 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
4357 assert!(!is_root_commit(&temp_path, &new_head_oid));
4358
4359 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
4364 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
4365 "master"
4366 } else {
4367 "main"
4368 };
4369 let target = get_branch_push_target(&temp_path, active_branch);
4370 assert!(target.is_some());
4371 let (remote_name, set_upstream) = target.unwrap();
4372 assert_eq!(remote_name, "origin");
4373 assert!(set_upstream);
4374
4375 let mut git_config = repo.config().unwrap();
4377 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
4378 git_config
4379 .set_str(
4380 &format!("branch.{}.merge", active_branch),
4381 &format!("refs/heads/{}", active_branch),
4382 )
4383 .unwrap();
4384
4385 assert!(has_upstream_remote(&temp_path, active_branch));
4387 assert_eq!(
4388 get_branch_upstream_remote(&temp_path, active_branch),
4389 Some("origin".to_string())
4390 );
4391
4392 let target_tracking = get_branch_push_target(&temp_path, active_branch);
4394 assert!(target_tracking.is_some());
4395 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
4396 assert_eq!(remote_name_tr, "origin");
4397 assert!(!set_upstream_tr);
4398
4399 let _ = std::fs::remove_dir_all(&temp_path);
4401 }
4402
4403 #[test]
4404 fn test_core_helpers_comprehensive() {
4405 assert_eq!(format_relative_time(0), "unknown");
4407 assert_eq!(format_relative_time(-100), "unknown");
4408
4409 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
4410 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
4411 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
4412 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
4413 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
4414 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
4415 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
4416
4417 assert_eq!(format_utc_date(0), "unknown");
4419 assert_eq!(format_utc_date(-1), "unknown");
4420 let date_str = format_utc_date(1700000000);
4421 assert!(date_str.contains("2023-11-14"));
4422
4423 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
4425 let tilde_path = expand_tilde("~/path");
4426 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
4427
4428 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
4430 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
4431
4432 assert_eq!(safe_sha_slice("12345", 10), "12345");
4434 assert_eq!(safe_sha_slice("12345", 3), "123");
4435
4436 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
4438 assert!(safe_ref("-invalid").is_err());
4439 assert!(safe_ref(" ").is_err());
4440
4441 let mut status = RepoSummary::default();
4443 assert!(status.is_clean());
4444 assert!(status.is_synced());
4445 assert!(status.unchanged());
4446
4447 status.modified = 1;
4448 assert!(!status.is_clean());
4449 assert!(!status.unchanged());
4450
4451 status.modified = 0;
4452 status.ahead = 1;
4453 assert!(!status.is_synced());
4454 assert!(!status.unchanged());
4455
4456 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
4458 assert!(data_not_loaded.is_not_loaded());
4459 assert!(!data_not_loaded.is_loading());
4460 assert!(!data_not_loaded.is_loaded());
4461 assert_eq!(data_not_loaded.len(), 0);
4462 assert!(data_not_loaded.is_empty());
4463 assert!(data_not_loaded.as_ref().is_none());
4464 assert!(data_not_loaded.first().is_none());
4465 assert!(data_not_loaded.get(0).is_none());
4466 assert_eq!(data_not_loaded.iter().count(), 0);
4467 assert_eq!(data_not_loaded.as_slice().len(), 0);
4468
4469 let data_loading: TabData<Vec<i32>> = TabData::Loading;
4470 assert!(!data_loading.is_not_loaded());
4471 assert!(data_loading.is_loading());
4472
4473 let data_loaded = TabData::Loaded(vec![10, 20]);
4474 assert!(!data_loaded.is_not_loaded());
4475 assert!(!data_loaded.is_loading());
4476 assert!(data_loaded.is_loaded());
4477 assert_eq!(data_loaded.len(), 2);
4478 assert!(!data_loaded.is_empty());
4479 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
4480 assert_eq!(data_loaded.first().unwrap(), &10);
4481 assert_eq!(data_loaded.get(1).unwrap(), &20);
4482 assert_eq!(data_loaded.iter().count(), 2);
4483 assert_eq!(data_loaded.as_slice(), &[10, 20]);
4484 }
4485}