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