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}
206
207#[derive(Debug, Default, Clone)]
208pub struct RepoInfo {
209 pub branch: Option<String>,
210 pub head: Option<HeadInfo>,
211 pub remotes: TabData<Vec<RemoteInfo>>,
212 pub upstream: Option<String>,
214 pub summary: RepoSummary,
215 pub changes: WorktreeChanges,
217 pub commits: Vec<CommitEntry>,
219 pub graph_lines: TabData<Vec<GraphLine>>,
221 pub local_branches: TabData<Vec<BranchInfo>>,
223 pub remote_branches: TabData<Vec<BranchInfo>>,
225 pub local_tags: TabData<Vec<BranchInfo>>,
227 pub remote_tags: TabData<Vec<BranchInfo>>,
229 pub remote_tags_loaded: bool,
231 pub remote_tags_attempted: bool,
233 pub files: TabData<Vec<String>>,
235 pub stashes: TabData<Vec<StashInfo>>,
237 pub worktrees: TabData<Vec<WorktreeInfo>>,
239 pub submodules: TabData<Vec<SubmoduleInfo>>,
241 pub reflog: TabData<Vec<ReflogEntry>>,
243 pub committer_stats: TabData<Vec<CommitterStat>>,
245 pub committer_stats_limit_reached: bool,
247 pub tab_loaded_at: [Option<std::time::Instant>; 10],
249 pub tab_loading: [bool; 10],
251}
252
253#[derive(Debug, Clone)]
254pub struct HeadInfo {
255 pub short_id: String,
256 pub summary: String,
257 pub author: String,
258 pub when: String,
259}
260
261#[derive(Debug, Clone)]
262pub struct ReflogEntry {
263 pub index: usize,
264 pub target_oid: String,
265 pub selector: String,
266 pub command: String,
267 pub message: String,
268 pub when: String,
269 pub date: String,
270}
271
272#[derive(Debug, Clone)]
273pub struct RemoteInfo {
274 pub name: String,
275 pub url: String,
276 pub push_url: Option<String>,
277 pub refspecs: Vec<String>,
278}
279
280#[derive(Debug, Clone)]
281pub struct CommitEntry {
282 pub id: String,
284 pub oid: String,
286 pub author: String,
287 pub when: String,
288 pub date: String,
289 pub summary: String,
290 pub message: String,
291 pub refs: Vec<String>,
294 pub files: Vec<FileEntry>,
296 pub signature_status: String,
298}
299
300#[derive(Debug, Clone)]
301pub struct GraphLine {
302 pub graph: String,
303 pub commit: Option<GraphCommit>,
304}
305
306#[derive(Debug, Clone)]
307pub struct GraphCommit {
308 pub oid: String,
309 pub decoration: String,
310 pub summary: String,
311 pub author: String,
312 pub date: String,
313 pub signature_status: String,
315}
316
317#[derive(Debug, Clone)]
319pub struct FileEntry {
320 pub path: String,
322 pub label: &'static str,
324}
325
326#[derive(Debug, Default, Clone)]
329pub struct WorktreeChanges {
330 pub staged: Vec<FileEntry>,
331 pub unstaged: Vec<FileEntry>,
332 pub untracked: Vec<FileEntry>,
333 pub conflicted: Vec<FileEntry>,
334}
335
336#[derive(Debug, Clone, PartialEq)]
340pub enum DiffLineKind {
341 Header,
343 Added,
345 Removed,
347 Context,
349 ConflictOurs,
351 ConflictTheirs,
353 ConflictSeparator,
355}
356
357#[derive(Debug, Clone)]
359pub struct DiffLine {
360 pub kind: DiffLineKind,
361 pub content: String,
363}
364
365pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
369 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
370}
371
372pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
379 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
380}
381
382pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
385 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
386 let mut index = repo.index().map_err(|e| e.to_string())?;
387 let full_path = repo_path.join(file_path);
388 if full_path.exists() {
389 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
390 } else {
391 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
392 }
393 index.write().map_err(|e| e.to_string())?;
394 Ok(())
395}
396
397pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
402 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
403 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
405 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
406 .map_err(|e| e.to_string())?;
407 } else {
408 let mut index = repo.index().map_err(|e| e.to_string())?;
410 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
411 index.write().map_err(|e| e.to_string())?;
412 }
413 Ok(())
414}
415
416pub fn sanitize_text(s: &str) -> String {
417 let mut result = String::with_capacity(s.len());
418 let bytes = s.as_bytes();
419 let mut i = 0;
420 while i < bytes.len() {
421 if bytes[i] == 0x1B {
422 i += 1;
424 if i < bytes.len() && bytes[i] == b'[' {
425 i += 1;
426 while i < bytes.len() {
427 let c = bytes[i];
428 i += 1;
429 if c.is_ascii_alphabetic() {
431 break;
432 }
433 }
434 } else {
435 while i < bytes.len() {
437 let c = bytes[i];
438 i += 1;
439 if c == b' ' || c.is_ascii_alphabetic() {
440 break;
441 }
442 }
443 }
444 } else {
445 let c = bytes[i];
446 if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
448 result.push(' ');
449 } else {
450 result.push(c as char);
451 }
452 i += 1;
453 }
454 }
455 result
456}
457
458pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
459 let mut end = len.min(sha.len());
460 while end > 0 && !sha.is_char_boundary(end) {
461 end -= 1;
462 }
463 &sha[..end]
464}
465
466fn ssh_command_val() -> &'static str {
467 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
468 "ssh -o StrictHostKeyChecking=yes"
469 } else {
470 "ssh -o StrictHostKeyChecking=accept-new"
471 }
472}
473
474fn git_command() -> std::process::Command {
475 let mut cmd = std::process::Command::new("git");
476 cmd.env("GIT_TERMINAL_PROMPT", "0");
477 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
478 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
479 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
480 cmd
481}
482
483pub fn safe_ref(r: &str) -> Result<&str, String> {
484 let trimmed = r.trim();
485 if trimmed.starts_with('-') {
486 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
487 }
488 if trimmed.is_empty() {
489 return Err("Ref name cannot be empty".to_string());
490 }
491 Ok(trimmed)
492}
493
494pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
496 let output = git_command()
497 .arg("add")
498 .arg("-A")
499 .current_dir(repo_path)
500 .output()
501 .map_err(|e| e.to_string())?;
502
503 if output.status.success() {
504 Ok(())
505 } else {
506 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
507 }
508}
509
510pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
512 let output =
513 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
514
515 if output.status.success() {
516 Ok(())
517 } else {
518 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
519 }
520}
521
522pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
524 let _ = git_command().arg("reset").current_dir(repo_path).output();
526
527 let checkout_out = git_command()
529 .arg("checkout")
530 .arg("--")
531 .arg(".")
532 .current_dir(repo_path)
533 .output()
534 .map_err(|e| e.to_string())?;
535
536 if !checkout_out.status.success() {
537 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
538 }
539
540 let clean_out = git_command()
542 .arg("clean")
543 .arg("-fd")
544 .current_dir(repo_path)
545 .output()
546 .map_err(|e| e.to_string())?;
547
548 if !clean_out.status.success() {
549 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
550 }
551
552 Ok(())
553}
554
555pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
557 apply_hunk_patch(repo_path, file_path, hunk, false, true)
558}
559
560pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
562 apply_hunk_patch(repo_path, file_path, hunk, true, true)
563}
564
565pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
567 apply_hunk_patch(repo_path, file_path, hunk, true, false)
568}
569
570pub fn stage_line(
572 repo_path: &Path,
573 file_path: &str,
574 hunk: &[DiffLine],
575 selected_line_idx: usize,
576) -> Result<(), String> {
577 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
578}
579
580pub fn unstage_line(
582 repo_path: &Path,
583 file_path: &str,
584 hunk: &[DiffLine],
585 selected_line_idx: usize,
586) -> Result<(), String> {
587 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
588}
589
590pub fn discard_line(
592 repo_path: &Path,
593 file_path: &str,
594 hunk: &[DiffLine],
595 selected_line_idx: usize,
596) -> Result<(), String> {
597 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
598}
599
600fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
601 if !header.starts_with("@@") {
602 return None;
603 }
604 let parts: Vec<&str> = header.split("@@").collect();
605 if parts.len() < 3 {
606 return None;
607 }
608 let meta = parts[1].trim();
609 let subparts: Vec<&str> = meta.split_whitespace().collect();
610 if subparts.len() < 2 {
611 return None;
612 }
613
614 let parse_part = |p: &str| -> (usize, usize) {
615 let s = p.trim_start_matches(['-', '+']);
616 let comps: Vec<&str> = s.split(',').collect();
617 let start = comps[0].parse::<usize>().unwrap_or(0);
618 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
619 (start, count)
620 };
621
622 let (old_start, old_count) = parse_part(subparts[0]);
623 let (new_start, new_count) = parse_part(subparts[1]);
624 Some((old_start, old_count, new_start, new_count))
625}
626
627fn apply_line_patch_inner(
628 repo_path: &Path,
629 file_path: &str,
630 hunk: &[DiffLine],
631 selected_line_idx_in_hunk: usize,
632 revert: bool,
633 target_has_modification: bool,
634 cached: bool,
635) -> Result<(), String> {
636 use std::io::Write;
637 use std::process::{Command, Stdio};
638
639 let path_check = std::path::Path::new(file_path);
640 if path_check.is_absolute()
641 || path_check.components().any(|c| c == std::path::Component::ParentDir)
642 {
643 return Err("Path escapes repository root".to_string());
644 }
645
646 if hunk.is_empty() {
647 return Err("Empty hunk".to_string());
648 }
649
650 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
651 Some(line) => line,
652 None => return Err("Invalid line index".to_string()),
653 };
654
655 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
656 return Err("Selected line is not a modification (must be + or -)".to_string());
657 }
658
659 let header_line = &hunk[0];
660 let (old_start, _old_count, new_start, _new_count) =
661 match parse_hunk_header(&header_line.content) {
662 Some(coords) => coords,
663 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
664 };
665
666 let mut patch_lines = Vec::new();
667 let mut new_old_count = 0;
668 let mut new_new_count = 0;
669
670 for (i, line) in hunk.iter().enumerate() {
671 if i == 0 {
672 continue;
673 }
674
675 if i == selected_line_idx_in_hunk {
676 if revert {
677 match line.kind {
678 DiffLineKind::Added => {
679 patch_lines.push(DiffLine {
680 kind: DiffLineKind::Removed,
681 content: line.content.clone(),
682 });
683 new_old_count += 1;
684 }
685 DiffLineKind::Removed => {
686 patch_lines.push(DiffLine {
687 kind: DiffLineKind::Added,
688 content: line.content.clone(),
689 });
690 new_new_count += 1;
691 }
692 _ => {}
693 }
694 } else {
695 match line.kind {
696 DiffLineKind::Added => {
697 patch_lines.push(DiffLine {
698 kind: DiffLineKind::Added,
699 content: line.content.clone(),
700 });
701 new_new_count += 1;
702 }
703 DiffLineKind::Removed => {
704 patch_lines.push(DiffLine {
705 kind: DiffLineKind::Removed,
706 content: line.content.clone(),
707 });
708 new_old_count += 1;
709 }
710 _ => {}
711 }
712 }
713 } else {
714 match line.kind {
715 DiffLineKind::Context => {
716 patch_lines.push(line.clone());
717 new_old_count += 1;
718 new_new_count += 1;
719 }
720 DiffLineKind::Added => {
721 if target_has_modification {
722 patch_lines.push(DiffLine {
723 kind: DiffLineKind::Context,
724 content: line.content.clone(),
725 });
726 new_old_count += 1;
727 new_new_count += 1;
728 } else {
729 }
731 }
732 DiffLineKind::Removed => {
733 if target_has_modification {
734 } else {
736 patch_lines.push(DiffLine {
737 kind: DiffLineKind::Context,
738 content: line.content.clone(),
739 });
740 new_old_count += 1;
741 new_new_count += 1;
742 }
743 }
744 _ => {}
745 }
746 }
747 }
748
749 let mut patch = String::new();
750 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
751 patch.push_str(&format!("--- a/{}\n", file_path));
752 patch.push_str(&format!("+++ b/{}\n", file_path));
753 patch.push_str(&format!(
754 "@@ -{},{} +{},{} @@\n",
755 old_start, new_old_count, new_start, new_new_count
756 ));
757
758 for line in patch_lines {
759 let prefix = match line.kind {
760 DiffLineKind::Added => "+",
761 DiffLineKind::Removed => "-",
762 DiffLineKind::Context => " ",
763 DiffLineKind::Header => "",
764 _ => "",
765 };
766 patch.push_str(prefix);
767 patch.push_str(&line.content);
768 patch.push('\n');
769 }
770
771 let mut args = vec!["apply"];
772 if cached {
773 args.push("--cached");
774 }
775 args.push("-");
776
777 let mut cmd = Command::new("git");
778 let mut child = cmd
779 .env("GIT_TERMINAL_PROMPT", "0")
780 .env("GIT_SSH_COMMAND", ssh_command_val())
781 .args(&args)
782 .current_dir(repo_path)
783 .stdin(Stdio::piped())
784 .stdout(Stdio::piped())
785 .stderr(Stdio::piped())
786 .spawn()
787 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
788
789 if let Some(mut stdin) = child.stdin.take() {
790 stdin
791 .write_all(patch.as_bytes())
792 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
793 }
794
795 let output =
796 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
797
798 if !output.status.success() {
799 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
800 return Err(format!("git apply failed: {}", err_msg.trim()));
801 }
802
803 Ok(())
804}
805
806fn apply_hunk_patch(
807 repo_path: &Path,
808 file_path: &str,
809 hunk: &[DiffLine],
810 reverse: bool,
811 cached: bool,
812) -> Result<(), String> {
813 use std::io::Write;
814 use std::process::{Command, Stdio};
815
816 let path_check = std::path::Path::new(file_path);
817 if path_check.is_absolute()
818 || path_check.components().any(|c| c == std::path::Component::ParentDir)
819 {
820 return Err("Path escapes repository root".to_string());
821 }
822
823 let mut patch = String::new();
824 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
825 patch.push_str(&format!("--- a/{}\n", file_path));
826 patch.push_str(&format!("+++ b/{}\n", file_path));
827 for line in hunk {
828 let prefix = match line.kind {
829 DiffLineKind::Added => "+",
830 DiffLineKind::Removed => "-",
831 DiffLineKind::Context => " ",
832 DiffLineKind::Header => "",
833 _ => "",
834 };
835 patch.push_str(prefix);
836 patch.push_str(&line.content);
837 patch.push('\n');
838 }
839
840 let mut args = vec!["apply"];
841 if cached {
842 args.push("--cached");
843 }
844 if reverse {
845 args.push("--reverse");
846 }
847 args.push("-");
848
849 let mut cmd = Command::new("git");
850 let mut child = cmd
851 .env("GIT_TERMINAL_PROMPT", "0")
852 .env("GIT_SSH_COMMAND", ssh_command_val())
853 .args(&args)
854 .current_dir(repo_path)
855 .stdin(Stdio::piped())
856 .stdout(Stdio::piped())
857 .stderr(Stdio::piped())
858 .spawn()
859 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
860
861 if let Some(mut stdin) = child.stdin.take() {
862 stdin
863 .write_all(patch.as_bytes())
864 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
865 }
866
867 let output =
868 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
869
870 if !output.status.success() {
871 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
872 return Err(format!("git apply failed: {}", err_msg.trim()));
873 }
874
875 Ok(())
876}
877
878pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
883 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
884
885 if staged {
886 unstage_file(repo_path, file_path)?;
888 }
889
890 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
892 status.contains(git2::Status::WT_NEW)
893 } else {
894 false
895 };
896
897 if is_untracked {
898 let full_path = repo_path.join(file_path);
899 if full_path.exists() {
900 if full_path.is_file() {
901 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
902 } else if full_path.is_dir() {
903 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
904 }
905 }
906 } else {
907 let mut checkout_opts = git2::build::CheckoutBuilder::new();
909 checkout_opts.path(Path::new(file_path));
910 checkout_opts.force();
911 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
912 }
913
914 Ok(())
915}
916
917pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
920 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
921 let mut index = repo.index().map_err(|e| e.to_string())?;
922 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
923 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
924
925 let signature = repo
926 .signature()
927 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
928
929 let mut parents = Vec::new();
931 let mut has_head = false;
932 if let Ok(head) = repo.head() {
933 if let Ok(parent_commit) = head.peel_to_commit() {
934 has_head = true;
935 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
937 if parent_tree.id() == tree_id {
938 return Err("No staged changes to commit".to_string());
939 }
940 parents.push(parent_commit);
941 }
942 }
943
944 if !has_head && index.is_empty() {
945 return Err("No staged changes to commit (index is empty)".to_string());
946 }
947
948 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
949
950 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
951 .map_err(|e| e.to_string())?;
952
953 Ok(())
954}
955
956pub fn expand_tilde(s: &str) -> PathBuf {
962 if s == "~" {
963 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
964 }
965 if let Some(stripped) = s.strip_prefix("~/")
966 && let Some(home) = dirs::home_dir()
967 {
968 return home.join(stripped);
969 }
970 PathBuf::from(s)
971}
972
973pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
975 let repo = Repository::open(repo_path)?;
976 repo.remote(name, url)?;
977 Ok(())
978}
979
980pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
982 let repo = Repository::open(repo_path)?;
983 repo.remote_delete(name)?;
984 Ok(())
985}
986
987pub fn inspect_summary(item: &str) -> ItemStatus {
989 let path = expand_tilde(item);
990 if !path.is_dir() {
991 return ItemStatus::Missing;
992 }
993 if !path.join(".git").exists() {
994 return ItemStatus::Directory;
995 }
996 match Repository::open(&path) {
997 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
998 Err(_) => ItemStatus::GitRepo(None),
999 }
1000}
1001
1002pub fn inspect_detail(
1004 item: &str,
1005 commit_limit: usize,
1006 graph_max_commits: usize,
1007 enable_commit_signatures: bool,
1008) -> ItemDetail {
1009 let resolved = expand_tilde(item);
1010 if !resolved.is_dir() {
1011 return ItemDetail::Missing { resolved };
1012 }
1013 if !resolved.join(".git").exists() {
1014 return ItemDetail::Directory { resolved };
1015 }
1016 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1017 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1018 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1019 }
1020}
1021
1022fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1023 let mut sigs = std::collections::HashMap::new();
1024 let mut cmd = std::process::Command::new("git");
1025 cmd.env("GIT_TERMINAL_PROMPT", "0")
1026 .env("GIT_SSH_COMMAND", ssh_command_val())
1027 .arg("log")
1028 .arg("--all");
1029
1030 if limit > 0 {
1031 cmd.arg(format!("-n{}", limit));
1032 }
1033
1034 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1035
1036 if let Ok(out) = cmd.output() {
1037 if out.status.success() {
1038 let stdout_str = String::from_utf8_lossy(&out.stdout);
1039 for line in stdout_str.lines() {
1040 let parts: Vec<&str> = line.split_whitespace().collect();
1041 if parts.len() == 2 {
1042 sigs.insert(parts[0].to_string(), parts[1].to_string());
1043 } else if parts.len() == 1 {
1044 sigs.insert(parts[0].to_string(), "N".to_string());
1045 }
1046 }
1047 }
1048 }
1049 sigs
1050}
1051
1052#[derive(serde::Serialize, serde::Deserialize, Clone)]
1053struct CachedCommit {
1054 id: String,
1055 author: String,
1056 date: String,
1057 summary: String,
1058 message: String,
1059 time: i64,
1060}
1061
1062fn hash_path(path: &Path) -> String {
1063 use std::collections::hash_map::DefaultHasher;
1064 use std::hash::{Hash, Hasher};
1065 let mut hasher = DefaultHasher::new();
1066 path.hash(&mut hasher);
1067 format!("{:x}", hasher.finish())
1068}
1069
1070fn collect_commits(
1071 repo: &Repository,
1072 limit: usize,
1073 repo_path: &Path,
1074 enable_commit_signatures: bool,
1075) -> Result<Vec<CommitEntry>, git2::Error> {
1076 let mut walk = repo.revwalk()?;
1077 if walk.push_head().is_err() {
1078 return Ok(Vec::new());
1079 }
1080 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1081
1082 let mut commits = Vec::new();
1083 let oids: Vec<Result<git2::Oid, git2::Error>> =
1084 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1085
1086 let sig_map = if enable_commit_signatures {
1087 collect_signatures(repo_path, limit)
1088 } else {
1089 std::collections::HashMap::new()
1090 };
1091 let ref_map = get_cached_ref_map(repo, repo_path);
1092
1093 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1095 if let Some(ref dir) = cache_dir {
1096 let _ = std::fs::create_dir_all(dir);
1097 }
1098 let hash = hash_path(repo_path);
1099 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1100 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1101 .as_ref()
1102 .and_then(|f| std::fs::read_to_string(f).ok())
1103 .and_then(|s| serde_json::from_str(&s).ok())
1104 .unwrap_or_default();
1105
1106 let mut cache_updated = false;
1107
1108 for id in oids {
1109 let oid = id?;
1110 let oid_str = oid.to_string();
1111 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1112 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1113 let files = Vec::new();
1114
1115 if let Some(cached) = cache.get(&oid_str) {
1116 let when = format_relative_time(cached.time);
1117 commits.push(CommitEntry {
1118 id: cached.id.clone(),
1119 oid: oid_str,
1120 author: sanitize_text(&cached.author),
1121 when,
1122 date: cached.date.clone(),
1123 summary: sanitize_text(&cached.summary),
1124 message: sanitize_text(&cached.message),
1125 refs,
1126 files,
1127 signature_status: sig_status,
1128 });
1129 } else if let Ok(commit) = repo.find_commit(oid) {
1130 let short_id = format!("{:.7}", commit.id());
1131 let summary =
1132 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1133 let author = commit.author();
1134 let author_name = author.name().unwrap_or("?");
1135 let author_email = author.email().unwrap_or("?");
1136 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1137 let time_secs = commit.time().seconds();
1138 let when = format_relative_time(time_secs);
1139 let date = format_utc_date(time_secs);
1140 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1141
1142 let cached = CachedCommit {
1143 id: short_id.clone(),
1144 author: author_str.clone(),
1145 date: date.clone(),
1146 summary: summary.clone(),
1147 message: message.clone(),
1148 time: time_secs,
1149 };
1150 cache.insert(oid_str.clone(), cached);
1151 cache_updated = true;
1152
1153 commits.push(CommitEntry {
1154 id: short_id,
1155 oid: oid_str,
1156 author: author_str,
1157 when,
1158 date,
1159 summary,
1160 message,
1161 refs,
1162 files,
1163 signature_status: sig_status,
1164 });
1165 }
1166 }
1167
1168 if cache_updated {
1169 if let Some(ref f) = cache_file {
1170 if let Ok(json) = serde_json::to_string(&cache) {
1171 let _ = std::fs::write(f, json);
1172 }
1173 }
1174 }
1175
1176 Ok(commits)
1177}
1178
1179pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1180 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1181 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1182 if walk.push_head().is_err() {
1183 return Ok(Vec::new());
1184 }
1185 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1186
1187 let mut revisions = Vec::new();
1188
1189 for oid_res in walk {
1190 let oid = oid_res.map_err(|e| e.to_string())?;
1191 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1192
1193 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1194 let mut modified = false;
1195
1196 if commit.parent_count() > 0 {
1197 for i in 0..commit.parent_count() {
1198 if let Ok(parent) = commit.parent(i) {
1199 if let Ok(parent_tree) = parent.tree() {
1200 let mut diff_opts = git2::DiffOptions::new();
1201 diff_opts.pathspec(file_path);
1202 if let Ok(diff) = repo.diff_tree_to_tree(
1203 Some(&parent_tree),
1204 Some(&commit_tree),
1205 Some(&mut diff_opts),
1206 ) {
1207 if diff.deltas().len() > 0 {
1208 modified = true;
1209 break;
1210 }
1211 }
1212 }
1213 }
1214 }
1215 } else {
1216 let mut diff_opts = git2::DiffOptions::new();
1217 diff_opts.pathspec(file_path);
1218 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1219 {
1220 if diff.deltas().len() > 0 {
1221 modified = true;
1222 }
1223 }
1224 }
1225
1226 if modified {
1227 let author = commit.author();
1228 let author_name = author.name().unwrap_or("?");
1229 let author_email = author.email().unwrap_or("?");
1230 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1231 let time_secs = commit.time().seconds();
1232 let when = format_relative_time(time_secs);
1233 let date = format_utc_date(time_secs);
1234 let summary =
1235 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1236
1237 revisions.push(FileRevision {
1238 commit_oid: oid.to_string(),
1239 author: author_str,
1240 date,
1241 when,
1242 summary,
1243 });
1244 }
1245 }
1246
1247 Ok(revisions)
1248}
1249
1250#[derive(Debug, Clone)]
1251pub struct BlameEntry {
1252 pub line_no: usize,
1253 pub commit_id: String,
1254 pub author: String,
1255 pub date: String,
1256}
1257
1258pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1259 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1260 let mut opts = git2::BlameOptions::new();
1261 let blame = repo
1262 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1263 .map_err(|e| e.to_string())?;
1264
1265 let mut entries = Vec::new();
1266 for i in 0..blame.len() {
1267 if let Some(hunk) = blame.get_index(i) {
1268 let commit_id = hunk.final_commit_id().to_string();
1269 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1270
1271 let mut author_name = "Unknown".to_string();
1272 let mut date_str = "unknown".to_string();
1273 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1274 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1275 date_str = format_utc_date(commit.time().seconds());
1276 }
1277
1278 let start = hunk.final_start_line();
1279 let count = hunk.lines_in_hunk();
1280 for line_idx in 0..count {
1281 entries.push(BlameEntry {
1282 line_no: start + line_idx,
1283 commit_id: short_id.to_string(),
1284 author: author_name.clone(),
1285 date: date_str.clone(),
1286 });
1287 }
1288 }
1289 }
1290
1291 entries.sort_by_key(|e| e.line_no);
1292 Ok(entries)
1293}
1294
1295fn collect_committer_stats(
1296 repo: &Repository,
1297 limit: usize,
1298) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1299 let mut walk = repo.revwalk()?;
1300 if walk.push_head().is_err() {
1301 return Ok((Vec::new(), false));
1302 }
1303 let mut counts = std::collections::HashMap::new();
1304 let mut count = 0;
1305 let mut limit_reached = false;
1306 for id in walk {
1307 let oid = id?;
1308 if let Ok(commit) = repo.find_commit(oid) {
1309 let author = commit.author();
1310 let name = author.name().unwrap_or("?").to_string();
1311 let email = author.email().unwrap_or("?").to_string();
1312 let key = (name, email);
1313 *counts.entry(key).or_insert(0) += 1;
1314 count += 1;
1315 if count >= limit {
1316 limit_reached = true;
1317 break;
1318 }
1319 }
1320 }
1321
1322 let mut stats: Vec<CommitterStat> = counts
1323 .into_iter()
1324 .map(|((name, email), count)| CommitterStat { name, email, count })
1325 .collect();
1326
1327 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1328
1329 Ok((stats, limit_reached))
1330}
1331
1332fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1336 let commit_tree = match commit.tree() {
1337 Ok(t) => t,
1338 Err(_) => return Vec::new(),
1339 };
1340 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1343
1344 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1345 Ok(d) => d,
1346 Err(_) => return Vec::new(),
1347 };
1348
1349 let mut files = Vec::new();
1350 for delta in diff.deltas() {
1351 if files.len() >= MAX_FILES_PER_SECTION {
1352 break;
1353 }
1354 let path = delta
1355 .new_file()
1356 .path()
1357 .or_else(|| delta.old_file().path())
1358 .map(|p| p.to_string_lossy().into_owned())
1359 .unwrap_or_else(|| "(unknown)".to_string());
1360
1361 let label: &'static str = match delta.status() {
1362 git2::Delta::Added => "N",
1363 git2::Delta::Deleted => "D",
1364 git2::Delta::Modified => "M",
1365 git2::Delta::Renamed => "R",
1366 git2::Delta::Typechange => "T",
1367 _ => "M",
1368 };
1369 files.push(FileEntry { path, label });
1370 }
1371 files
1372}
1373
1374pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1375 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1376 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1377 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1378 Ok(commit_changed_files(&repo, &commit))
1379}
1380
1381fn collect_info(
1384 path: &Path,
1385 commit_limit: usize,
1386 _graph_max_commits: usize,
1387 enable_commit_signatures: bool,
1388) -> Result<RepoInfo, git2::Error> {
1389 let repo = Repository::open(path)?;
1390 let mut summary = RepoSummary::default();
1391 if let Ok(head) = repo.head() {
1392 summary.branch = head.shorthand().ok().map(String::from);
1393 }
1394 populate_ahead_behind(&repo, &mut summary);
1395
1396 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1397
1398 if let Ok(head) = repo.head() {
1399 info.branch = head.shorthand().ok().map(String::from);
1400
1401 if let Ok(commit) = head.peel_to_commit() {
1402 let short_id = format!("{:.7}", commit.id());
1403 let summary_text =
1404 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1405 let author = commit.author();
1406 let author_str = sanitize_text(&format!(
1407 "{} <{}>",
1408 author.name().unwrap_or("?"),
1409 author.email().unwrap_or("?")
1410 ));
1411 let when = format_relative_time(commit.time().seconds());
1412 info.head =
1413 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1414 }
1415
1416 if let Ok(head_name) = head.name() {
1417 info.upstream = upstream_short_name(&repo, head_name);
1418 }
1419 }
1420
1421 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1422 info.commits = commits;
1423 }
1424
1425 populate_summary_and_file_changes(&repo, &mut info);
1426
1427 if let Ok(remotes) = load_tab_remotes(path) {
1428 info.remotes = TabData::Loaded(remotes);
1429 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1430 }
1431
1432 Ok(info)
1433}
1434
1435pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1436 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1437 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1438 let mut entries = Vec::new();
1439 for (i, entry) in reflog.iter().enumerate() {
1440 let target_oid = format!("{}", entry.id_new());
1441 let selector = format!("HEAD@{{{}}}", i);
1442 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1443
1444 let (command, message) = if let Some(pos) = msg.find(':') {
1445 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1446 } else {
1447 (String::new(), msg)
1448 };
1449
1450 let sig = entry.committer();
1451 let when_secs = sig.when().seconds();
1452 let when = format_relative_time(when_secs);
1453 let date = format_utc_date(when_secs);
1454
1455 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1456 }
1457 Ok(entries)
1458}
1459
1460pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1461 let output = std::process::Command::new("git")
1462 .env("GIT_TERMINAL_PROMPT", "0")
1463 .env("GIT_SSH_COMMAND", ssh_command_val())
1464 .arg("checkout")
1465 .arg(commit_oid)
1466 .current_dir(repo_path)
1467 .output()
1468 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1469
1470 if !output.status.success() {
1471 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1472 return Err(git2::Error::from_str(&err));
1473 }
1474 Ok(())
1475}
1476
1477pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1478 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1479 let mut files = Vec::new();
1480 if let Ok(index) = repo.index() {
1481 for entry in index.iter() {
1482 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1483 files.push(path_str.to_string());
1484 }
1485 }
1486 }
1487 Ok(files)
1488}
1489
1490pub fn load_tab_graph_stream(
1491 repo_path: &Path,
1492 graph_max_commits: usize,
1493 repo_resolved_path: String,
1494 tab_idx: usize,
1495 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1496) -> Result<Vec<GraphLine>, String> {
1497 let mut graph_lines = Vec::new();
1498 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1499
1500 let mut args = vec![
1501 "log".to_string(),
1502 "--graph".to_string(),
1503 "--all".to_string(),
1504 "--date=relative".to_string(),
1505 ];
1506 if graph_max_commits > 0 {
1507 args.push(format!("--max-count={}", graph_max_commits));
1508 }
1509 args.push(format!("--pretty=format:{}", format_str));
1510 args.push("--color=never".to_string());
1511
1512 let mut child = std::process::Command::new("git")
1513 .env("GIT_TERMINAL_PROMPT", "0")
1514 .env("GIT_SSH_COMMAND", ssh_command_val())
1515 .args(&args)
1516 .current_dir(repo_path)
1517 .stdout(std::process::Stdio::piped())
1518 .spawn()
1519 .map_err(|e| e.to_string())?;
1520
1521 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1522 let reader = std::io::BufReader::new(stdout);
1523 use std::io::BufRead;
1524
1525 for (idx, line_res) in reader.lines().enumerate() {
1526 let line = line_res.map_err(|e| e.to_string())?;
1527 let parsed = parse_graph_line(&line);
1528 graph_lines.push(parsed);
1529
1530 if (idx + 1) % 200 == 0 {
1532 let _ = tx.send((
1533 repo_resolved_path.clone(),
1534 tab_idx,
1535 TabPayload::Graph(Ok(graph_lines.clone())),
1536 ));
1537 }
1538 }
1539
1540 let status = child.wait().map_err(|e| e.to_string())?;
1542 if !status.success() && graph_lines.is_empty() {
1543 return Err("git log failed".to_string());
1544 }
1545
1546 Ok(graph_lines)
1547}
1548
1549pub fn load_tab_branches(
1550 repo_path: &Path,
1551) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1552 let repo = match Repository::open(repo_path) {
1553 Ok(r) => r,
1554 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1555 };
1556
1557 let mut local_branches = Vec::new();
1558 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1559 for (branch, _) in branches.flatten() {
1560 if let Ok(Some(name)) = branch.name() {
1561 let is_head = branch.is_head();
1562 let mut short_sha = String::new();
1563 let mut short_message = String::new();
1564 if let Ok(target) = branch.get().peel_to_commit() {
1565 let id = target.id();
1566 let id_str = id.to_string();
1567 short_sha = safe_sha_slice(&id_str, 7).to_string();
1568 if let Ok(Some(summary)) = target.summary() {
1569 short_message = summary.to_string();
1570 }
1571 }
1572 local_branches.push(BranchInfo {
1573 name: sanitize_text(name),
1574 is_head,
1575 short_sha,
1576 short_message: sanitize_text(&short_message),
1577 });
1578 }
1579 }
1580 }
1581 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1582
1583 let mut remote_branches = Vec::new();
1584 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1585 for (branch, _) in branches.flatten() {
1586 if let Ok(Some(name)) = branch.name() {
1587 if !name.ends_with("/HEAD") {
1588 let is_head = branch.is_head();
1589 let mut short_sha = String::new();
1590 let mut short_message = String::new();
1591 if let Ok(target) = branch.get().peel_to_commit() {
1592 let id = target.id();
1593 let id_str = id.to_string();
1594 short_sha = safe_sha_slice(&id_str, 7).to_string();
1595 if let Ok(Some(summary)) = target.summary() {
1596 short_message = summary.to_string();
1597 }
1598 }
1599 remote_branches.push(BranchInfo {
1600 name: sanitize_text(name),
1601 is_head,
1602 short_sha,
1603 short_message: sanitize_text(&short_message),
1604 });
1605 }
1606 }
1607 }
1608 }
1609 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1610
1611 (Ok(local_branches), Ok(remote_branches))
1612}
1613
1614pub fn load_tab_tags(
1615 repo_path: &Path,
1616) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1617 let repo = match Repository::open(repo_path) {
1618 Ok(r) => r,
1619 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1620 };
1621
1622 let mut local_tags = Vec::new();
1623 if let Ok(tags) = repo.tag_names(None) {
1624 for tag_opt in tags.iter() {
1625 if let Ok(Some(tag)) = tag_opt {
1626 let mut short_sha = String::new();
1627 let mut short_message = String::new();
1628 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1629 if let Ok(target) = reference.peel_to_commit() {
1630 let id = target.id();
1631 let id_str = id.to_string();
1632 short_sha = safe_sha_slice(&id_str, 7).to_string();
1633 if let Ok(Some(summary)) = target.summary() {
1634 short_message = summary.to_string();
1635 }
1636 }
1637 }
1638 local_tags.push(BranchInfo {
1639 name: sanitize_text(tag),
1640 is_head: false,
1641 short_sha,
1642 short_message: sanitize_text(&short_message),
1643 });
1644 }
1645 }
1646 }
1647 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1648
1649 (Ok(local_tags), Ok(Vec::new()))
1650}
1651
1652pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1653 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1654 let mut remotes_list = Vec::new();
1655 if let Ok(remotes) = repo.remotes() {
1656 for name in remotes.iter() {
1657 let Ok(Some(name)) = name else { continue };
1658 if let Ok(remote) = repo.find_remote(name) {
1659 let push_url = remote.pushurl().ok().flatten().map(String::from);
1660 let mut refspecs = Vec::new();
1661 for r in remote.refspecs() {
1662 if let Ok(s) = r.str() {
1663 refspecs.push(s.to_string());
1664 }
1665 }
1666 remotes_list.push(RemoteInfo {
1667 name: name.to_string(),
1668 url: remote.url().unwrap_or("(no url)").to_string(),
1669 push_url,
1670 refspecs,
1671 });
1672 }
1673 }
1674 }
1675 Ok(remotes_list)
1676}
1677
1678pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1679 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1680 let mut temp_stashes = Vec::new();
1681 let _ = repo.stash_foreach(|index, message, oid| {
1682 temp_stashes.push((index, message.to_string(), *oid));
1683 true
1684 });
1685
1686 let mut stashes = Vec::new();
1687 for (index, message, oid) in temp_stashes {
1688 let mut files = Vec::new();
1689 if let Ok(commit) = repo.find_commit(oid) {
1690 files = commit_changed_files(&repo, &commit);
1691 }
1692 stashes.push(StashInfo {
1693 index,
1694 message: sanitize_text(&message),
1695 commit_id: oid.to_string(),
1696 files,
1697 });
1698 }
1699 Ok(stashes)
1700}
1701
1702pub fn load_tab_overview(
1703 repo_path: &Path,
1704 _commit_limit: usize,
1705) -> Result<(Vec<CommitterStat>, bool), String> {
1706 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1707 let (stats, limit_reached) =
1708 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
1709 Ok((stats, limit_reached))
1710}
1711
1712pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1713 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1714 let submodules = repo.submodules().map_err(|e| e.to_string())?;
1715 let mut list = Vec::new();
1716 for sub in submodules {
1717 let name = sub.name().unwrap_or("").to_string();
1718 let path = sub.path().to_path_buf();
1719 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
1720 let commit_id = sub.index_id().map(|id| id.to_string());
1721 let head_id = sub.head_id().map(|id| id.to_string());
1722
1723 let mut is_initialized = true;
1724 let mut is_dirty = false;
1725
1726 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
1727 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
1728 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
1729 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
1730 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
1731 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
1732 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
1733 }
1734
1735 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
1736 }
1737 Ok(list)
1738}
1739
1740fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1745 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1746 std::collections::HashMap::new();
1747
1748 if let Ok(refs) = repo.references() {
1749 for reference in refs.flatten() {
1750 let Ok(target) = reference.peel_to_commit() else {
1752 continue;
1753 };
1754 let oid = target.id();
1755
1756 let Ok(full_name) = reference.name() else {
1757 continue;
1758 };
1759
1760 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1761 branch.to_string()
1762 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1763 format!("tag:{}", tag)
1764 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1765 if remote.ends_with("/HEAD") {
1767 continue;
1768 }
1769 format!("remote:{}", remote)
1770 } else {
1771 continue;
1772 };
1773
1774 map.entry(oid).or_default().push(label);
1775 }
1776 }
1777 map
1778}
1779
1780#[allow(clippy::type_complexity)]
1781static REF_MAP_CACHE: std::sync::OnceLock<
1782 std::sync::Mutex<
1783 std::collections::HashMap<
1784 String,
1785 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1786 >,
1787 >,
1788> = std::sync::OnceLock::new();
1789
1790fn get_cached_ref_map(
1791 repo: &Repository,
1792 repo_path: &Path,
1793) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1794 let cache_lock =
1795 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1796 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1797 let path_key = repo_path.to_string_lossy().to_string();
1798
1799 if let Some((map, loaded_at)) = cache.get(&path_key) {
1800 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1801 return map.clone();
1802 }
1803 }
1804
1805 let map = build_ref_map(repo);
1806 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1807 map
1808}
1809
1810pub fn invalidate_ref_map_cache(repo_path: &Path) {
1811 if let Some(cache_lock) = REF_MAP_CACHE.get() {
1812 if let Ok(mut cache) = cache_lock.lock() {
1813 cache.remove(&repo_path.to_string_lossy().to_string());
1814 }
1815 }
1816}
1817
1818const MAX_FILES_PER_SECTION: usize = 100;
1821
1822fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1824 let mut opts = StatusOptions::new();
1825 opts.include_untracked(true)
1826 .renames_head_to_index(true)
1827 .recurse_untracked_dirs(true)
1828 .show(StatusShow::IndexAndWorkdir);
1829 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1830 return;
1831 };
1832 for entry in statuses.iter() {
1833 let path = entry.path().unwrap_or("(unknown)").to_string();
1834 let flags = entry.status();
1835
1836 if flags.is_conflicted() {
1838 info.summary.conflicted += 1;
1839 } else {
1840 if flags.is_wt_new() {
1841 info.summary.untracked += 1;
1842 }
1843 if flags.is_wt_modified()
1844 || flags.is_wt_deleted()
1845 || flags.is_wt_renamed()
1846 || flags.is_wt_typechange()
1847 {
1848 info.summary.modified += 1;
1849 }
1850 if flags.is_index_new()
1851 || flags.is_index_modified()
1852 || flags.is_index_deleted()
1853 || flags.is_index_renamed()
1854 || flags.is_index_typechange()
1855 {
1856 info.summary.staged += 1;
1857 }
1858 }
1859
1860 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
1863 if path_buf.is_dir() {
1864 continue;
1865 }
1866
1867 if flags.is_conflicted() {
1868 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
1869 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
1870 }
1871 continue;
1872 }
1873
1874 if (flags.is_index_new()
1876 || flags.is_index_modified()
1877 || flags.is_index_deleted()
1878 || flags.is_index_renamed()
1879 || flags.is_index_typechange())
1880 && info.changes.staged.len() < MAX_FILES_PER_SECTION
1881 {
1882 let label = if flags.is_index_new() {
1883 "N"
1884 } else if flags.is_index_deleted() {
1885 "D"
1886 } else if flags.is_index_renamed() {
1887 "R"
1888 } else if flags.is_index_typechange() {
1889 "T"
1890 } else {
1891 "M"
1892 };
1893 info.changes.staged.push(FileEntry { path: path.clone(), label });
1894 }
1895
1896 if flags.is_wt_new() {
1898 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
1899 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
1900 }
1901 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
1902 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
1903 }
1904 } else if (flags.is_wt_modified()
1905 || flags.is_wt_deleted()
1906 || flags.is_wt_renamed()
1907 || flags.is_wt_typechange())
1908 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
1909 {
1910 let label = if flags.is_wt_deleted() {
1911 "D"
1912 } else if flags.is_wt_renamed() {
1913 "R"
1914 } else if flags.is_wt_typechange() {
1915 "T"
1916 } else {
1917 "M"
1918 };
1919 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
1920 }
1921 }
1922}
1923
1924fn collect_summary(repo: &Repository) -> RepoSummary {
1928 let mut s = RepoSummary::default();
1929 if let Ok(head) = repo.head() {
1931 s.branch = head.shorthand().ok().map(String::from);
1932 if let Ok(commit) = head.peel_to_commit() {
1933 s.last_commit_time = Some(commit.time().seconds());
1934 }
1935 }
1936 populate_worktree(repo, &mut s);
1937 populate_ahead_behind(repo, &mut s);
1938 s.state = match repo.state() {
1939 git2::RepositoryState::Clean => RepoState::Clean,
1940 git2::RepositoryState::Merge => RepoState::Merge,
1941 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
1942 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
1943 RepoState::CherryPick
1944 }
1945 git2::RepositoryState::Bisect => RepoState::Bisect,
1946 git2::RepositoryState::Rebase
1947 | git2::RepositoryState::RebaseInteractive
1948 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
1949 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
1950 RepoState::ApplyMailbox
1951 }
1952 };
1953 s
1954}
1955
1956fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
1957 let mut opts = StatusOptions::new();
1958 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
1959 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1960 return;
1961 };
1962 for entry in statuses.iter() {
1963 let flags = entry.status();
1964 if flags.is_conflicted() {
1965 s.conflicted += 1;
1966 continue;
1967 }
1968 if flags.is_wt_new() {
1969 s.untracked += 1;
1970 }
1971 if flags.is_wt_modified()
1972 || flags.is_wt_deleted()
1973 || flags.is_wt_renamed()
1974 || flags.is_wt_typechange()
1975 {
1976 s.modified += 1;
1977 }
1978 if flags.is_index_new()
1979 || flags.is_index_modified()
1980 || flags.is_index_deleted()
1981 || flags.is_index_renamed()
1982 || flags.is_index_typechange()
1983 {
1984 s.staged += 1;
1985 }
1986 }
1987}
1988
1989fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
1993 let Ok(head) = repo.head() else { return };
1994 let Some(local_oid) = head.target() else {
1995 return;
1996 };
1997 let Ok(head_name) = head.name() else { return };
1998 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
1999 return;
2000 };
2001 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2002 return;
2003 };
2004 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2005 return;
2006 };
2007 let Some(upstream_oid) = upstream_ref.target() else {
2008 return;
2009 };
2010 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2011 s.ahead = ahead;
2012 s.behind = behind;
2013 }
2014}
2015
2016fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2018 let buf = repo.branch_upstream_name(head_name).ok()?;
2019 let raw = std::str::from_utf8(&buf).ok()?;
2020 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2021}
2022
2023pub fn format_relative_time(secs: i64) -> String {
2025 if secs <= 0 {
2026 return "unknown".to_string();
2027 }
2028 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2029 let now = SystemTime::now();
2030 let Ok(elapsed) = now.duration_since(then) else {
2031 return "in the future".to_string();
2032 };
2033 let secs = elapsed.as_secs();
2034 let (n, unit) = if secs < 60 {
2035 (secs, "second")
2036 } else if secs < 3600 {
2037 (secs / 60, "minute")
2038 } else if secs < 86_400 {
2039 (secs / 3600, "hour")
2040 } else if secs < 86_400 * 30 {
2041 (secs / 86_400, "day")
2042 } else if secs < 86_400 * 365 {
2043 (secs / (86_400 * 30), "month")
2044 } else {
2045 (secs / (86_400 * 365), "year")
2046 };
2047 let plural = if n == 1 { "" } else { "s" };
2048 format!("{} {}{} ago", n, unit, plural)
2049}
2050
2051fn format_utc_date(secs: i64) -> String {
2053 if secs <= 0 {
2054 return "unknown".to_string();
2055 }
2056 let seconds_in_day = 86400;
2057 let day_number = secs / seconds_in_day;
2058 let time_of_day = secs % seconds_in_day;
2059
2060 let mut hour = time_of_day / 3600;
2061 let mut minute = (time_of_day % 3600) / 60;
2062 let mut second = time_of_day % 60;
2063 if hour < 0 {
2064 hour += 24;
2065 }
2066 if minute < 0 {
2067 minute += 60;
2068 }
2069 if second < 0 {
2070 second += 60;
2071 }
2072
2073 let z = day_number + 719468;
2075 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2076 let doe = (z - era * 146097) as u32;
2077 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2078 let y = (yoe as i32) + (era as i32) * 400;
2079 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2080 let mp = (5 * doy + 2) / 153;
2081 let d = doy - (153 * mp + 2) / 5 + 1;
2082 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2083 let y = y + if m <= 2 { 1 } else { 0 };
2084
2085 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2086}
2087
2088fn get_file_diff_inner(
2091 repo_path: &Path,
2092 commit_oid: &str,
2093 file_path: &str,
2094) -> Option<Vec<DiffLine>> {
2095 let repo = Repository::open(repo_path).ok()?;
2096 let oid = git2::Oid::from_str(commit_oid).ok()?;
2097 let commit = repo.find_commit(oid).ok()?;
2098
2099 let commit_tree = commit.tree().ok()?;
2100 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2102
2103 let mut opts = git2::DiffOptions::new();
2104 opts.pathspec(file_path);
2105
2106 let diff =
2107 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2108
2109 collect_diff_lines(&diff)
2110}
2111
2112fn get_worktree_diff_inner(
2117 repo_path: &Path,
2118 file_path: &str,
2119 staged: bool,
2120) -> Option<Vec<DiffLine>> {
2121 let repo = Repository::open(repo_path).ok()?;
2122 let mut opts = git2::DiffOptions::new();
2123 opts.pathspec(file_path);
2124 opts.include_untracked(true);
2125 opts.recurse_untracked_dirs(true);
2126 opts.show_untracked_content(true);
2127
2128 let diff = if staged {
2129 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2131 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2132 } else {
2133 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2135 };
2136
2137 collect_diff_lines(&diff)
2138}
2139
2140fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2142 let mut lines: Vec<DiffLine> = Vec::new();
2143 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
2144 let kind = match line.origin() {
2145 '+' => DiffLineKind::Added,
2146 '-' => DiffLineKind::Removed,
2147 'H' => DiffLineKind::Header,
2148 ' ' => DiffLineKind::Context,
2149 _ => return true, };
2151 let content = String::from_utf8_lossy(line.content())
2152 .trim_end_matches('\n')
2153 .trim_end_matches('\r')
2154 .to_string();
2155 lines.push(DiffLine { kind, content });
2156 true
2157 })
2158 .ok()?;
2159 Some(lines)
2160}
2161
2162fn parse_graph_line(line: &str) -> GraphLine {
2163 if line.contains("__TWIG_SEP__") {
2164 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2165 if parts.len() >= 5 {
2166 let graph_and_hash = parts[0];
2167 let decoration = parts[1].trim().to_string();
2168 let summary = parts[2].trim().to_string();
2169 let author = parts[3].trim().to_string();
2170 let date = parts[4].trim().to_string();
2171 let signature_status =
2172 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2173
2174 let char_count = graph_and_hash.chars().count();
2175 if char_count >= 40 {
2176 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2177 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2178 GraphLine {
2179 graph,
2180 commit: Some(GraphCommit {
2181 oid,
2182 decoration,
2183 summary,
2184 author,
2185 date,
2186 signature_status,
2187 }),
2188 }
2189 } else {
2190 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2191 }
2192 } else {
2193 GraphLine { graph: line.to_string(), commit: None }
2194 }
2195 } else {
2196 GraphLine { graph: line.to_string(), commit: None }
2197 }
2198}
2199
2200#[allow(dead_code)]
2201fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2202 let mut graph_lines = Vec::new();
2203 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2204
2205 let mut args = vec![
2206 "log".to_string(),
2207 "--graph".to_string(),
2208 "--all".to_string(),
2209 "--date=relative".to_string(),
2210 ];
2211 if graph_max_commits > 0 {
2212 args.push(format!("--max-count={}", graph_max_commits));
2213 }
2214 args.push(format!("--pretty=format:{}", format_str));
2215 args.push("--color=never".to_string());
2216
2217 let output = std::process::Command::new("git")
2218 .env("GIT_TERMINAL_PROMPT", "0")
2219 .env("GIT_SSH_COMMAND", ssh_command_val())
2220 .args(&args)
2221 .current_dir(repo_path)
2222 .output();
2223
2224 if let Ok(out) = output {
2225 if out.status.success() {
2226 let stdout_str = String::from_utf8_lossy(&out.stdout);
2227 for line in stdout_str.lines() {
2228 graph_lines.push(parse_graph_line(line));
2229 }
2230 }
2231 }
2232 graph_lines
2233}
2234
2235pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2236 let output = std::process::Command::new("git")
2237 .env("GIT_TERMINAL_PROMPT", "0")
2238 .env("GIT_SSH_COMMAND", ssh_command_val())
2239 .arg("checkout")
2240 .arg(branch_name)
2241 .current_dir(repo_path)
2242 .output()
2243 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2244
2245 if !output.status.success() {
2246 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2247 return Err(git2::Error::from_str(&err));
2248 }
2249 Ok(())
2250}
2251
2252pub fn checkout_remote_branch(
2253 repo_path: &Path,
2254 remote_branch_name: &str,
2255) -> Result<String, git2::Error> {
2256 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2257 if parts.len() < 2 {
2258 return Err(git2::Error::from_str("Invalid remote branch name"));
2259 }
2260 let local_name = parts[1];
2261
2262 let output = std::process::Command::new("git")
2263 .env("GIT_TERMINAL_PROMPT", "0")
2264 .env("GIT_SSH_COMMAND", ssh_command_val())
2265 .arg("checkout")
2266 .arg(local_name)
2267 .current_dir(repo_path)
2268 .output()
2269 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2270
2271 if output.status.success() {
2272 return Ok(format!("Switched to existing branch '{}'", local_name));
2273 }
2274
2275 let output = std::process::Command::new("git")
2276 .env("GIT_TERMINAL_PROMPT", "0")
2277 .env("GIT_SSH_COMMAND", ssh_command_val())
2278 .arg("checkout")
2279 .arg("--track")
2280 .arg(remote_branch_name)
2281 .current_dir(repo_path)
2282 .output()
2283 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2284
2285 if !output.status.success() {
2286 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2287 return Err(git2::Error::from_str(&err));
2288 }
2289
2290 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2291}
2292
2293pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2295 let repo = Repository::open(repo_path)?;
2296 let head = repo.head()?;
2297 let target_commit = head.peel_to_commit()?;
2298 repo.branch(branch_name, &target_commit, false)?;
2299 Ok(())
2300}
2301
2302pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2304 let repo = Repository::open(repo_path)?;
2305 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2306 branch.delete()?;
2307 Ok(())
2308}
2309
2310pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2312 let repo = Repository::open(repo_path)?;
2313 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2314 branch.delete()?;
2315 Ok(())
2316}
2317
2318pub fn create_tag(
2320 repo_path: &Path,
2321 tag_name: &str,
2322 commit_oid_str: &str,
2323) -> Result<(), git2::Error> {
2324 let repo = Repository::open(repo_path)?;
2325 let oid = git2::Oid::from_str(commit_oid_str)?;
2326 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2327 repo.tag_lightweight(tag_name, &target_object, false)?;
2328 Ok(())
2329}
2330
2331pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2333 let repo = Repository::open(repo_path)?;
2334 repo.tag_delete(tag_name)?;
2335 Ok(())
2336}
2337
2338pub fn delete_remote_tag(
2340 repo_path: &Path,
2341 remote_name: &str,
2342 tag_name: &str,
2343) -> Result<(), Box<dyn std::error::Error>> {
2344 let safe_remote = safe_ref(remote_name)?;
2345 let safe_tag = safe_ref(tag_name)?;
2346 let output = git_command()
2347 .arg("push")
2348 .arg(safe_remote)
2349 .arg("--delete")
2350 .arg(safe_tag)
2351 .current_dir(repo_path)
2352 .output()?;
2353 if !output.status.success() {
2354 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2355 return Err(err.into());
2356 }
2357 Ok(())
2358}
2359
2360pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2361 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2362 let output = git_command()
2363 .arg("checkout")
2364 .arg(safe_tag)
2365 .current_dir(repo_path)
2366 .output()
2367 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2368
2369 if !output.status.success() {
2370 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2371 return Err(git2::Error::from_str(&err));
2372 }
2373 Ok(())
2374}
2375
2376pub fn get_remote_tags(
2378 repo_path: &Path,
2379 remote_name: &str,
2380) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2381 let safe_remote = safe_ref(remote_name)?;
2382 let output = git_command()
2383 .arg("ls-remote")
2384 .arg("--tags")
2385 .arg(safe_remote)
2386 .current_dir(repo_path)
2387 .output()?;
2388
2389 if !output.status.success() {
2390 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2391 return Err(err.into());
2392 }
2393
2394 let stdout = String::from_utf8_lossy(&output.stdout);
2395 let repo = git2::Repository::open(repo_path)?;
2396 let mut tags_map = std::collections::HashMap::new();
2397
2398 for line in stdout.lines() {
2399 let parts: Vec<&str> = line.split_whitespace().collect();
2400 if parts.len() >= 2 {
2401 let sha = parts[0];
2402 let ref_name = parts[1];
2403 if ref_name.starts_with("refs/tags/") {
2404 let is_peeled = ref_name.ends_with("^{}");
2405 let clean_ref = if is_peeled {
2406 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2407 } else {
2408 ref_name
2409 };
2410 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2411 let short_sha = safe_sha_slice(sha, 7);
2412
2413 let mut short_message = String::new();
2415 if let Ok(oid) = git2::Oid::from_str(sha) {
2416 if let Ok(commit) = repo.find_commit(oid) {
2417 if let Ok(Some(summary)) = commit.summary() {
2418 short_message = summary.to_string();
2419 }
2420 }
2421 }
2422 if short_message.is_empty() {
2423 short_message = "(not fetched)".to_string();
2424 }
2425
2426 let sanitized_tag = sanitize_text(tag_name);
2427 let sanitized_msg = sanitize_text(&short_message);
2428
2429 if is_peeled {
2430 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2431 } else {
2432 tags_map
2433 .entry(sanitized_tag)
2434 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2435 }
2436 }
2437 }
2438 }
2439
2440 let mut tags = Vec::new();
2441 for (name, (short_sha, short_message)) in tags_map {
2442 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2443 }
2444 tags.sort_by(|a, b| b.name.cmp(&a.name));
2445 Ok(tags)
2446}
2447
2448pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2449 let mut s = String::new();
2450 for tag in tags {
2451 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2452 }
2453 s
2454}
2455
2456pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2457 let mut tags = Vec::new();
2458 for line in s.lines() {
2459 let parts: Vec<&str> = line.split('|').collect();
2460 if parts.len() >= 3 {
2461 tags.push(BranchInfo {
2462 name: parts[0].to_string(),
2463 is_head: false,
2464 short_sha: parts[1].to_string(),
2465 short_message: parts[2].to_string(),
2466 });
2467 }
2468 }
2469 tags
2470}
2471
2472pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2473 let mut repo = Repository::open(repo_path)?;
2474 repo.stash_drop(index)?;
2475 Ok(())
2476}
2477
2478pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2479 let stash_ref = format!("stash@{{{}}}", index);
2480 let output = std::process::Command::new("git")
2481 .env("GIT_TERMINAL_PROMPT", "0")
2482 .env("GIT_SSH_COMMAND", ssh_command_val())
2483 .arg("stash")
2484 .arg("apply")
2485 .arg(&stash_ref)
2486 .current_dir(repo_path)
2487 .output()
2488 .map_err(|e| e.to_string())?;
2489
2490 if !output.status.success() {
2491 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2492 return Err(err_msg);
2493 }
2494 Ok(())
2495}
2496
2497pub fn save_stash(
2498 repo_path: &Path,
2499 message: &str,
2500 include_untracked: bool,
2501 keep_index: bool,
2502) -> Result<(), String> {
2503 let mut cmd = std::process::Command::new("git");
2504 cmd.env("GIT_TERMINAL_PROMPT", "0")
2505 .env("GIT_SSH_COMMAND", ssh_command_val())
2506 .arg("stash")
2507 .arg("push");
2508
2509 if include_untracked {
2510 cmd.arg("--include-untracked");
2511 }
2512 if keep_index {
2513 cmd.arg("--keep-index");
2514 }
2515
2516 if !message.is_empty() {
2517 cmd.arg("-m").arg(message);
2518 }
2519
2520 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2521
2522 if !output.status.success() {
2523 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2524 return Err(err_msg);
2525 }
2526 Ok(())
2527}
2528
2529pub fn get_latest_change_time(item: &str) -> u64 {
2530 let path = expand_tilde(item);
2531 if !path.exists() {
2532 return 0;
2533 }
2534
2535 if path.join(".git").exists() {
2536 if let Ok(repo) = Repository::open(&path) {
2537 if let Ok(head) = repo.head() {
2538 if let Ok(commit) = head.peel_to_commit() {
2539 return commit.time().seconds() as u64;
2540 }
2541 }
2542 }
2543 }
2544
2545 if let Ok(meta) = std::fs::metadata(&path) {
2546 if let Ok(modified) = meta.modified() {
2547 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2548 return duration.as_secs();
2549 }
2550 }
2551 }
2552 0
2553}
2554
2555pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2556 if let Ok(repo) = Repository::open(repo_path) {
2557 if let Ok(head) = repo.head() {
2558 if let Ok(commit) = head.peel_to_commit() {
2559 if let Ok(msg) = commit.message() {
2560 return Some(msg.to_string());
2561 }
2562 }
2563 }
2564 }
2565 None
2566}
2567
2568pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2569 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2570 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2571 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2572
2573 let mut index = repo.index().map_err(|e| e.to_string())?;
2574 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2575 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2576
2577 let signature = repo
2578 .signature()
2579 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2580
2581 head_commit
2582 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2583 .map_err(|e| e.to_string())?;
2584
2585 Ok(())
2586}
2587
2588pub fn is_merging(repo_path: &Path) -> bool {
2593 repo_path.join(".git/MERGE_HEAD").exists()
2594}
2595
2596pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2599 let full_path = repo_path.join(file_path);
2600 let content = match std::fs::read_to_string(&full_path) {
2601 Ok(s) => s,
2602 Err(_) => return Vec::new(),
2603 };
2604
2605 let mut lines = Vec::new();
2606 let mut in_ours = false;
2607 let mut in_theirs = false;
2608
2609 for line in content.lines() {
2610 if line.starts_with("<<<<<<<") {
2611 in_ours = true;
2612 in_theirs = false;
2613 lines.push(DiffLine {
2614 kind: DiffLineKind::ConflictSeparator,
2615 content: line.to_string(),
2616 });
2617 } else if line.starts_with("=======") {
2618 in_ours = false;
2619 in_theirs = true;
2620 lines.push(DiffLine {
2621 kind: DiffLineKind::ConflictSeparator,
2622 content: line.to_string(),
2623 });
2624 } else if line.starts_with(">>>>>>>") {
2625 in_ours = false;
2626 in_theirs = false;
2627 lines.push(DiffLine {
2628 kind: DiffLineKind::ConflictSeparator,
2629 content: line.to_string(),
2630 });
2631 } else if in_ours {
2632 lines.push(DiffLine { kind: DiffLineKind::ConflictOurs, content: line.to_string() });
2633 } else if in_theirs {
2634 lines.push(DiffLine { kind: DiffLineKind::ConflictTheirs, content: line.to_string() });
2635 } else {
2636 lines.push(DiffLine { kind: DiffLineKind::Context, content: line.to_string() });
2637 }
2638 }
2639 lines
2640}
2641
2642pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2645 let output1 = std::process::Command::new("git")
2646 .env("GIT_TERMINAL_PROMPT", "0")
2647 .env("GIT_SSH_COMMAND", ssh_command_val())
2648 .args(["checkout", "--ours", file_path])
2649 .current_dir(repo_path)
2650 .output()
2651 .map_err(|e| e.to_string())?;
2652 if !output1.status.success() {
2653 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2654 }
2655 stage_file(repo_path, file_path)?;
2656 Ok(())
2657}
2658
2659pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2662 let output1 = std::process::Command::new("git")
2663 .env("GIT_TERMINAL_PROMPT", "0")
2664 .env("GIT_SSH_COMMAND", ssh_command_val())
2665 .args(["checkout", "--theirs", file_path])
2666 .current_dir(repo_path)
2667 .output()
2668 .map_err(|e| e.to_string())?;
2669 if !output1.status.success() {
2670 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2671 }
2672 stage_file(repo_path, file_path)?;
2673 Ok(())
2674}
2675
2676pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2678 stage_file(repo_path, file_path)
2679}
2680
2681pub fn resolve_conflict_hunk(
2685 repo_path: &Path,
2686 file_path: &str,
2687 hunk_idx: usize,
2688 accept_ours: bool,
2689) -> Result<(), String> {
2690 let full_path = repo_path.join(file_path);
2691 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2692
2693 let mut new_lines = Vec::new();
2694 let mut lines_iter = content.lines().peekable();
2695 let mut current_hunk_idx = 0;
2696
2697 while let Some(line) = lines_iter.next() {
2698 if line.starts_with("<<<<<<<") {
2699 let mut ours_block = Vec::new();
2700 let mut theirs_block = Vec::new();
2701
2702 let mut found_separator = false;
2704 while let Some(&next_line) = lines_iter.peek() {
2705 if next_line.starts_with("=======") {
2706 lines_iter.next(); found_separator = true;
2708 break;
2709 }
2710 if let Some(line) = lines_iter.next() {
2711 ours_block.push(line.to_string());
2712 }
2713 }
2714
2715 let mut found_end = false;
2717 let mut end_line_marker = ">>>>>>>".to_string();
2718 while let Some(&next_line) = lines_iter.peek() {
2719 if next_line.starts_with(">>>>>>>") {
2720 if let Some(marker) = lines_iter.next() {
2721 end_line_marker = marker.to_string(); }
2723 found_end = true;
2724 break;
2725 }
2726 if let Some(line) = lines_iter.next() {
2727 theirs_block.push(line.to_string());
2728 }
2729 }
2730
2731 if current_hunk_idx == hunk_idx {
2732 if accept_ours {
2733 new_lines.extend(ours_block);
2734 } else {
2735 new_lines.extend(theirs_block);
2736 }
2737 } else {
2738 new_lines.push(line.to_string());
2739 new_lines.extend(ours_block);
2740 if found_separator {
2741 new_lines.push("=======".to_string());
2742 }
2743 new_lines.extend(theirs_block);
2744 if found_end {
2745 new_lines.push(end_line_marker);
2746 }
2747 }
2748
2749 current_hunk_idx += 1;
2750 } else {
2751 new_lines.push(line.to_string());
2752 }
2753 }
2754
2755 let mut new_content = new_lines.join("\n");
2756 if content.ends_with('\n') && !new_content.ends_with('\n') {
2757 new_content.push('\n');
2758 }
2759 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2760
2761 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2763 let has_conflict_markers = updated_content
2764 .lines()
2765 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2766
2767 if !has_conflict_markers {
2768 stage_file(repo_path, file_path)?;
2769 }
2770
2771 Ok(())
2772}
2773
2774pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2776 let output = std::process::Command::new("git")
2777 .env("GIT_TERMINAL_PROMPT", "0")
2778 .env("GIT_SSH_COMMAND", ssh_command_val())
2779 .args(["merge", "--abort"])
2780 .current_dir(repo_path)
2781 .output()
2782 .map_err(|e| e.to_string())?;
2783 if !output.status.success() {
2784 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2785 }
2786 Ok(())
2787}
2788
2789pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2791 let output = std::process::Command::new("git")
2792 .env("GIT_TERMINAL_PROMPT", "0")
2793 .env("GIT_SSH_COMMAND", ssh_command_val())
2794 .args(["merge", "--continue"])
2795 .env("GIT_EDITOR", "true")
2796 .current_dir(repo_path)
2797 .output()
2798 .map_err(|e| e.to_string())?;
2799 if !output.status.success() {
2800 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2801 }
2802 Ok(())
2803}
2804
2805pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2807 let repo = Repository::open(repo_path).ok()?;
2808 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2809 let _upstream = branch.upstream().ok()?;
2810 let local_ref = branch.get().name().ok()?;
2811 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
2812 remote_buf.as_str().ok().map(|s| s.to_string())
2813}
2814
2815pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2817 get_branch_upstream_remote(repo_path, branch_name).is_some()
2818}
2819
2820pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
2822 let repo = Repository::open(repo_path).ok()?;
2823 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2824 if branch.upstream().is_ok() {
2825 if let Ok(local_ref) = branch.get().name() {
2826 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
2827 if let Ok(name) = remote_buf.as_str() {
2828 return Some((name.to_string(), false));
2829 }
2830 }
2831 }
2832 }
2833 let remotes = repo.remotes().ok()?;
2834 let first_remote = remotes.iter().next()?.ok()??.to_string();
2835 Some((first_remote, true))
2836}
2837
2838pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
2840 if let Ok(repo) = Repository::open(repo_path) {
2841 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
2842 if let Ok(commit) = repo.find_commit(oid) {
2843 return commit.parent_count() == 0;
2844 }
2845 }
2846 }
2847 false
2848}
2849
2850pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
2851 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2852 let mut worktrees_list = Vec::new();
2853 if let Ok(worktree_names) = repo.worktrees() {
2854 for name in worktree_names.iter() {
2855 let Ok(Some(wt_name)) = name else { continue };
2856 if let Ok(wt) = repo.find_worktree(wt_name) {
2857 let path = wt.path().to_path_buf();
2858 let mut branch = None;
2859 if let Ok(wt_repo) = Repository::open(&path) {
2860 if let Ok(head) = wt_repo.head() {
2861 branch = head.shorthand().map(String::from).ok();
2862 }
2863 }
2864
2865 let mut is_locked = false;
2866 let mut lock_reason = None;
2867 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
2868 is_locked = true;
2869 if let Some(reason) = reason_opt {
2870 if !reason.is_empty() {
2871 lock_reason = Some(reason);
2872 }
2873 }
2874 }
2875
2876 worktrees_list.push(WorktreeInfo {
2877 name: wt_name.to_string(),
2878 path,
2879 branch,
2880 is_locked,
2881 lock_reason,
2882 });
2883 }
2884 }
2885 }
2886 Ok(worktrees_list)
2887}
2888
2889pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
2890 let output = std::process::Command::new("git")
2891 .arg("worktree")
2892 .arg("add")
2893 .arg(wt_path)
2894 .arg(branch)
2895 .current_dir(repo_path)
2896 .output()
2897 .map_err(|e| e.to_string())?;
2898
2899 if !output.status.success() {
2900 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2901 }
2902 Ok(())
2903}
2904
2905pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
2906 let output = std::process::Command::new("git")
2907 .arg("worktree")
2908 .arg("lock")
2909 .arg("--reason")
2910 .arg(reason)
2911 .arg(name)
2912 .current_dir(repo_path)
2913 .output()
2914 .map_err(|e| e.to_string())?;
2915
2916 if !output.status.success() {
2917 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2918 }
2919 Ok(())
2920}
2921
2922pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
2923 let output = std::process::Command::new("git")
2924 .arg("worktree")
2925 .arg("unlock")
2926 .arg(name)
2927 .current_dir(repo_path)
2928 .output()
2929 .map_err(|e| e.to_string())?;
2930
2931 if !output.status.success() {
2932 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2933 }
2934 Ok(())
2935}
2936
2937pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
2938 let mut cmd = std::process::Command::new("git");
2939 cmd.arg("worktree").arg("remove");
2940 if force {
2941 cmd.arg("--force");
2942 }
2943 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
2944
2945 if !output.status.success() {
2946 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2947 }
2948 Ok(())
2949}
2950
2951pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
2952 let output = std::process::Command::new("git")
2953 .arg("worktree")
2954 .arg("prune")
2955 .current_dir(repo_path)
2956 .output()
2957 .map_err(|e| e.to_string())?;
2958
2959 if !output.status.success() {
2960 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2961 }
2962 Ok(())
2963}
2964
2965#[cfg(test)]
2966#[allow(clippy::unwrap_used, clippy::panic)]
2967mod tests {
2968 use super::*;
2969 use std::fs::File;
2970 use std::io::Write;
2971
2972 #[test]
2973 fn test_commit_amend() {
2974 let mut temp_path = std::env::temp_dir();
2975 temp_path.push(format!(
2976 "twig_test_{}",
2977 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2978 ));
2979 std::fs::create_dir_all(&temp_path).unwrap();
2980
2981 let repo = Repository::init(&temp_path).unwrap();
2983
2984 let mut config = repo.config().unwrap();
2986 config.set_str("user.name", "Test User").unwrap();
2987 config.set_str("user.email", "test@example.com").unwrap();
2988
2989 let file_path = temp_path.join("test.txt");
2991 let mut file = File::create(&file_path).unwrap();
2992 writeln!(file, "initial content").unwrap();
2993
2994 stage_file(&temp_path, "test.txt").unwrap();
2996 commit_changes(&temp_path, "initial commit").unwrap();
2997
2998 let msg = get_last_commit_message(&temp_path).unwrap();
3000 assert_eq!(msg, "initial commit");
3001
3002 commit_amend(&temp_path, "amended commit").unwrap();
3004
3005 let amended_msg = get_last_commit_message(&temp_path).unwrap();
3007 assert_eq!(amended_msg, "amended commit");
3008
3009 let _ = std::fs::remove_dir_all(&temp_path);
3011 }
3012
3013 #[test]
3014 fn test_commit_signatures_collection() {
3015 let mut temp_path = std::env::temp_dir();
3016 temp_path.push(format!(
3017 "twig_test_sig_{}",
3018 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3019 ));
3020 std::fs::create_dir_all(&temp_path).unwrap();
3021
3022 let repo = Repository::init(&temp_path).unwrap();
3024
3025 let mut config = repo.config().unwrap();
3027 config.set_str("user.name", "Test User").unwrap();
3028 config.set_str("user.email", "test@example.com").unwrap();
3029
3030 let file_path = temp_path.join("test.txt");
3032 let mut file = File::create(&file_path).unwrap();
3033 writeln!(file, "initial content").unwrap();
3034
3035 stage_file(&temp_path, "test.txt").unwrap();
3037 commit_changes(&temp_path, "initial commit").unwrap();
3038
3039 let sigs = collect_signatures(&temp_path, 0);
3041 assert_eq!(sigs.len(), 1);
3042 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3043 let sig_status = sigs.get(&head_oid).unwrap();
3044 assert_eq!(sig_status, "N");
3045
3046 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
3048 assert_eq!(commits.len(), 1);
3049 assert_eq!(commits[0].signature_status, "N");
3050 assert!(commits[0].files.is_empty());
3051
3052 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
3054 assert_eq!(files.len(), 1);
3055 assert_eq!(files[0].path, "test.txt");
3056 assert_eq!(files[0].label, "N");
3057
3058 let graph = collect_graph_lines(&temp_path, 1000);
3060 assert_eq!(graph.len(), 1);
3061 assert!(graph[0].commit.is_some());
3062 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
3063
3064 let _ = std::fs::remove_dir_all(&temp_path);
3066 }
3067
3068 #[test]
3069 fn test_ref_map_cache_behavior() {
3070 let temp_dir = std::env::temp_dir();
3071 let repo_path = temp_dir.join("test_ref_map_repo");
3072 let _ = std::fs::remove_dir_all(&repo_path);
3073 std::fs::create_dir_all(&repo_path).unwrap();
3074
3075 let repo = Repository::init(&repo_path).unwrap();
3076
3077 let map1 = get_cached_ref_map(&repo, &repo_path);
3079
3080 let map2 = get_cached_ref_map(&repo, &repo_path);
3082 assert_eq!(map1.len(), map2.len());
3083
3084 invalidate_ref_map_cache(&repo_path);
3086
3087 let _ = std::fs::remove_dir_all(&repo_path);
3089 }
3090
3091 #[test]
3092 fn test_get_latest_change_time() {
3093 let mut temp_path = std::env::temp_dir();
3094 temp_path.push(format!(
3095 "twig_test_{}",
3096 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3097 ));
3098 std::fs::create_dir_all(&temp_path).unwrap();
3099
3100 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
3101 assert!(change_time > 0);
3102
3103 let _ = std::fs::remove_dir_all(&temp_path);
3104 }
3105
3106 #[test]
3107 fn test_committer_stats() {
3108 let mut temp_path = std::env::temp_dir();
3109 temp_path.push(format!(
3110 "twig_test_{}",
3111 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3112 ));
3113 std::fs::create_dir_all(&temp_path).unwrap();
3114
3115 let repo = Repository::init(&temp_path).unwrap();
3117
3118 let mut config = repo.config().unwrap();
3120 config.set_str("user.name", "Test User").unwrap();
3121 config.set_str("user.email", "test@example.com").unwrap();
3122
3123 let file_path = temp_path.join("test.txt");
3125 let mut file = File::create(&file_path).unwrap();
3126 writeln!(file, "initial content").unwrap();
3127
3128 stage_file(&temp_path, "test.txt").unwrap();
3130 commit_changes(&temp_path, "initial commit").unwrap();
3131
3132 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
3134 assert_eq!(stats.len(), 1);
3135 assert_eq!(stats[0].name, "Test User");
3136 assert_eq!(stats[0].email, "test@example.com");
3137 assert_eq!(stats[0].count, 1);
3138 assert!(!limit_reached);
3139
3140 let _ = std::fs::remove_dir_all(&temp_path);
3142 }
3143
3144 #[test]
3145 fn test_untracked_files_in_unstaged() {
3146 let mut temp_path = std::env::temp_dir();
3147 temp_path.push(format!(
3148 "twig_test_{}",
3149 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3150 ));
3151 std::fs::create_dir_all(&temp_path).unwrap();
3152
3153 let _repo = Repository::init(&temp_path).unwrap();
3155
3156 let file_path = temp_path.join("untracked.txt");
3158 let mut file = File::create(&file_path).unwrap();
3159 writeln!(file, "hello untracked").unwrap();
3160
3161 let untracked_dir = temp_path.join("untracked_dir");
3163 std::fs::create_dir_all(&untracked_dir).unwrap();
3164 let nested_file_path = untracked_dir.join("nested.txt");
3165 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
3166
3167 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3169 match detail {
3170 ItemDetail::Repo { info, .. } => {
3171 let unstaged_paths: Vec<String> =
3173 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
3174 let untracked_paths: Vec<String> =
3175 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
3176
3177 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
3179 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
3180 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
3181 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
3182
3183 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
3185 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
3186 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
3187 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
3188 }
3189 _ => panic!("Expected ItemDetail::Repo"),
3190 }
3191
3192 let _ = std::fs::remove_dir_all(&temp_path);
3194 }
3195
3196 #[test]
3197 fn test_stage_new_and_deleted_files() {
3198 let mut temp_path = std::env::temp_dir();
3199 temp_path.push(format!(
3200 "twig_test_{}",
3201 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3202 ));
3203 std::fs::create_dir_all(&temp_path).unwrap();
3204
3205 let repo = Repository::init(&temp_path).unwrap();
3207
3208 let mut config = repo.config().unwrap();
3210 config.set_str("user.name", "Test User").unwrap();
3211 config.set_str("user.email", "test@example.com").unwrap();
3212
3213 let init_file = temp_path.join("init.txt");
3215 std::fs::write(&init_file, "initial").unwrap();
3216 stage_file(&temp_path, "init.txt").unwrap();
3217 commit_changes(&temp_path, "initial commit").unwrap();
3218
3219 let untracked_file = temp_path.join("untracked.txt");
3221 std::fs::write(&untracked_file, "new file content").unwrap();
3222
3223 stage_file(&temp_path, "untracked.txt").unwrap();
3225
3226 std::fs::remove_file(&init_file).unwrap();
3228
3229 stage_file(&temp_path, "init.txt").unwrap();
3231
3232 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3234 match detail {
3235 ItemDetail::Repo { info, .. } => {
3236 assert_eq!(info.changes.staged.len(), 2);
3238 let paths: Vec<String> =
3239 info.changes.staged.iter().map(|f| f.path.clone()).collect();
3240 assert!(paths.contains(&"untracked.txt".to_string()));
3241 assert!(paths.contains(&"init.txt".to_string()));
3242 }
3243 _ => panic!("Expected ItemDetail::Repo"),
3244 }
3245
3246 let _ = std::fs::remove_dir_all(&temp_path);
3248 }
3249
3250 #[test]
3251 fn test_discard_file_changes_all_cases() {
3252 let mut temp_path = std::env::temp_dir();
3253 temp_path.push(format!(
3254 "twig_test_{}",
3255 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3256 ));
3257 std::fs::create_dir_all(&temp_path).unwrap();
3258
3259 let repo = Repository::init(&temp_path).unwrap();
3261
3262 let mut config = repo.config().unwrap();
3264 config.set_str("user.name", "Test User").unwrap();
3265 config.set_str("user.email", "test@example.com").unwrap();
3266
3267 let file_tracked = temp_path.join("tracked.txt");
3269 std::fs::write(&file_tracked, "original content\n").unwrap();
3270 stage_file(&temp_path, "tracked.txt").unwrap();
3271 commit_changes(&temp_path, "initial commit").unwrap();
3272
3273 let file_untracked = temp_path.join("untracked.txt");
3275 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
3276 assert!(file_untracked.exists());
3277 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
3278 assert!(!file_untracked.exists());
3279
3280 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
3282 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3283 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3284
3285 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
3287 stage_file(&temp_path, "tracked.txt").unwrap();
3288 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3290 match detail {
3291 ItemDetail::Repo { info, .. } => {
3292 assert!(!info.changes.staged.is_empty());
3293 }
3294 _ => panic!("Expected ItemDetail::Repo"),
3295 }
3296 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
3297 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3298 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3300 match detail {
3301 ItemDetail::Repo { info, .. } => {
3302 assert!(info.changes.staged.is_empty());
3303 assert!(info.changes.unstaged.is_empty());
3304 }
3305 _ => panic!("Expected ItemDetail::Repo"),
3306 }
3307
3308 std::fs::remove_file(&file_tracked).unwrap();
3310 assert!(!file_tracked.exists());
3311 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3312 assert!(file_tracked.exists());
3313 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3314
3315 let _ = std::fs::remove_dir_all(&temp_path);
3317 }
3318
3319 #[test]
3320 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3321 fn test_stage_unstage_by_hunk() {
3322 let mut temp_path = std::env::temp_dir();
3323 temp_path.push(format!(
3324 "twig_test_{}",
3325 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3326 ));
3327 std::fs::create_dir_all(&temp_path).unwrap();
3328
3329 let repo = Repository::init(&temp_path).unwrap();
3331
3332 let mut config = repo.config().unwrap();
3334 config.set_str("user.name", "Test User").unwrap();
3335 config.set_str("user.email", "test@example.com").unwrap();
3336
3337 let file_path = temp_path.join("multihunk.txt");
3339 let mut file = File::create(&file_path).unwrap();
3340 for i in 1..=20 {
3341 writeln!(file, "Line {}", i).unwrap();
3342 }
3343 drop(file);
3344
3345 stage_file(&temp_path, "multihunk.txt").unwrap();
3347 commit_changes(&temp_path, "initial commit").unwrap();
3348
3349 let mut file = File::create(&file_path).unwrap();
3351 for i in 1..=20 {
3352 if i == 2 || i == 18 {
3353 writeln!(file, "Line {} modified", i).unwrap();
3354 } else {
3355 writeln!(file, "Line {}", i).unwrap();
3356 }
3357 }
3358 drop(file);
3359
3360 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3362 let mut hunk_ranges = Vec::new();
3364 let mut current_start = None;
3365 for (i, line) in diff_lines.iter().enumerate() {
3366 if line.kind == DiffLineKind::Header {
3367 if let Some(start) = current_start {
3368 hunk_ranges.push(start..i);
3369 }
3370 current_start = Some(i);
3371 }
3372 }
3373 if let Some(start) = current_start {
3374 hunk_ranges.push(start..diff_lines.len());
3375 }
3376
3377 assert_eq!(hunk_ranges.len(), 2);
3379
3380 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3382 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
3383
3384 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3386 let staged_content: String =
3387 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3388 assert!(staged_content.contains("Line 18 modified"));
3389 assert!(!staged_content.contains("Line 2 modified"));
3390
3391 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3393 let unstaged_content: String =
3394 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3395 assert!(unstaged_content.contains("Line 2 modified"));
3396 assert!(!unstaged_content.contains("Line 18 modified"));
3397
3398 let staged_hunk_ranges = {
3400 let mut ranges = Vec::new();
3401 let mut current_start = None;
3402 for (i, line) in staged_diff.iter().enumerate() {
3403 if line.kind == DiffLineKind::Header {
3404 if let Some(start) = current_start {
3405 ranges.push(start..i);
3406 }
3407 current_start = Some(i);
3408 }
3409 }
3410 if let Some(start) = current_start {
3411 ranges.push(start..staged_diff.len());
3412 }
3413 ranges
3414 };
3415 assert_eq!(staged_hunk_ranges.len(), 1);
3416 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
3417 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
3418
3419 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3421 assert!(staged_diff_after.is_empty());
3422
3423 let _ = std::fs::remove_dir_all(&temp_path);
3425 }
3426
3427 #[test]
3428 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3429 fn test_discard_hunk() {
3430 let mut temp_path = std::env::temp_dir();
3431 temp_path.push(format!(
3432 "twig_test_{}",
3433 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3434 ));
3435 std::fs::create_dir_all(&temp_path).unwrap();
3436
3437 let repo = Repository::init(&temp_path).unwrap();
3439
3440 let mut config = repo.config().unwrap();
3442 config.set_str("user.name", "Test User").unwrap();
3443 config.set_str("user.email", "test@example.com").unwrap();
3444
3445 let file_path = temp_path.join("discardhunk.txt");
3447 let mut file = File::create(&file_path).unwrap();
3448 for i in 1..=20 {
3449 writeln!(file, "Line {}", i).unwrap();
3450 }
3451 drop(file);
3452
3453 stage_file(&temp_path, "discardhunk.txt").unwrap();
3455 commit_changes(&temp_path, "initial commit").unwrap();
3456
3457 let mut file = File::create(&file_path).unwrap();
3459 for i in 1..=20 {
3460 if i == 2 || i == 18 {
3461 writeln!(file, "Line {} modified", i).unwrap();
3462 } else {
3463 writeln!(file, "Line {}", i).unwrap();
3464 }
3465 }
3466 drop(file);
3467
3468 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
3470 let mut hunk_ranges = Vec::new();
3472 let mut current_start = None;
3473 for (i, line) in diff_lines.iter().enumerate() {
3474 if line.kind == DiffLineKind::Header {
3475 if let Some(start) = current_start {
3476 hunk_ranges.push(start..i);
3477 }
3478 current_start = Some(i);
3479 }
3480 }
3481 if let Some(start) = current_start {
3482 hunk_ranges.push(start..diff_lines.len());
3483 }
3484
3485 assert_eq!(hunk_ranges.len(), 2);
3487
3488 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3490 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
3491
3492 let contents = std::fs::read_to_string(&file_path).unwrap();
3494 assert!(contents.contains("Line 2 modified"));
3495 assert!(contents.contains("Line 18\n"));
3496 assert!(!contents.contains("Line 18 modified"));
3497
3498 let _ = std::fs::remove_dir_all(&temp_path);
3500 }
3501
3502 #[test]
3503 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3504 fn test_stage_unstage_discard_line() {
3505 let mut temp_path = std::env::temp_dir();
3506 temp_path.push(format!(
3507 "twig_test_{}",
3508 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3509 ));
3510 std::fs::create_dir_all(&temp_path).unwrap();
3511
3512 let repo = Repository::init(&temp_path).unwrap();
3513 let mut config = repo.config().unwrap();
3514 config.set_str("user.name", "Test User").unwrap();
3515 config.set_str("user.email", "test@example.com").unwrap();
3516
3517 let file_path = temp_path.join("line_test.txt");
3519 let mut file = File::create(&file_path).unwrap();
3520 writeln!(file, "line A").unwrap();
3521 writeln!(file, "line B").unwrap();
3522 writeln!(file, "line C").unwrap();
3523 drop(file);
3524
3525 stage_file(&temp_path, "line_test.txt").unwrap();
3526 commit_changes(&temp_path, "initial").unwrap();
3527
3528 let mut file = File::create(&file_path).unwrap();
3530 writeln!(file, "line A modified").unwrap();
3531 writeln!(file, "line B").unwrap();
3532 writeln!(file, "line C modified").unwrap();
3533 drop(file);
3534
3535 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3536 let mut hunk_ranges = Vec::new();
3537 let mut current_start = None;
3538 for (i, line) in diff_lines.iter().enumerate() {
3539 if line.kind == DiffLineKind::Header {
3540 if let Some(start) = current_start {
3541 hunk_ranges.push(start..i);
3542 }
3543 current_start = Some(i);
3544 }
3545 }
3546 if let Some(start) = current_start {
3547 hunk_ranges.push(start..diff_lines.len());
3548 }
3549
3550 assert_eq!(hunk_ranges.len(), 1);
3551 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3552
3553 assert_eq!(hunk0[2].content, "line A modified");
3554 assert_eq!(hunk0[5].content, "line C modified");
3555
3556 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3558
3559 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3561 assert!(
3562 staged_diff
3563 .iter()
3564 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3565 );
3566 assert!(
3567 !staged_diff
3568 .iter()
3569 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3570 );
3571
3572 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3574 assert!(
3575 !unstaged_diff
3576 .iter()
3577 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3578 );
3579 assert!(
3580 unstaged_diff
3581 .iter()
3582 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3583 );
3584
3585 assert_eq!(staged_diff[2].content, "line A modified");
3587 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3588
3589 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3591
3592 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3594 assert_eq!(unstaged_diff2[5].content, "line C modified");
3595 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3596
3597 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3598 let remove_idx = unstaged_diff3
3599 .iter()
3600 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3601 .unwrap();
3602 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3603
3604 let contents = std::fs::read_to_string(&file_path).unwrap();
3606 assert!(contents.contains("line A modified"));
3607 assert!(contents.contains("line B"));
3608 assert!(contents.contains("line C\n"));
3609 assert!(!contents.contains("line C modified"));
3610
3611 let _ = std::fs::remove_dir_all(&temp_path);
3613 }
3614
3615 #[test]
3616 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3617 fn test_stage_unstage_discard_all_changes() {
3618 let mut temp_path = std::env::temp_dir();
3619 temp_path.push(format!(
3620 "twig_test_all_{}",
3621 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3622 ));
3623 std::fs::create_dir_all(&temp_path).unwrap();
3624
3625 let repo = Repository::init(&temp_path).unwrap();
3627
3628 let mut config = repo.config().unwrap();
3630 config.set_str("user.name", "Test User").unwrap();
3631 config.set_str("user.email", "test@example.com").unwrap();
3632
3633 let file_path = temp_path.join("tracked.txt");
3635 std::fs::write(&file_path, "original content\n").unwrap();
3636 stage_file(&temp_path, "tracked.txt").unwrap();
3637 commit_changes(&temp_path, "initial").unwrap();
3638
3639 std::fs::write(&file_path, "modified content\n").unwrap();
3641 let untracked_path = temp_path.join("untracked.txt");
3642 std::fs::write(&untracked_path, "untracked content\n").unwrap();
3643
3644 let status = repo.statuses(None).unwrap();
3646 assert_eq!(status.len(), 2);
3647
3648 stage_all_changes(&temp_path).unwrap();
3650
3651 let status = repo.statuses(None).unwrap();
3653 for entry in status.iter() {
3654 assert!(
3655 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
3656 );
3657 }
3658
3659 unstage_all_changes(&temp_path).unwrap();
3661
3662 let status = repo.statuses(None).unwrap();
3664 for entry in status.iter() {
3665 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
3666 }
3667
3668 discard_all_changes(&temp_path).unwrap();
3670
3671 let status = repo.statuses(None).unwrap();
3673 assert_eq!(status.len(), 0);
3674
3675 let contents = std::fs::read_to_string(&file_path).unwrap();
3677 assert_eq!(contents, "original content\n");
3678 assert!(!untracked_path.exists());
3679
3680 let _ = std::fs::remove_dir_all(&temp_path);
3682 }
3683
3684 #[test]
3685 fn test_merge_conflicts_flow() {
3686 let mut temp_path = std::env::temp_dir();
3687 temp_path.push(format!(
3688 "twig_test_conflict_{}",
3689 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3690 ));
3691 std::fs::create_dir_all(&temp_path).unwrap();
3692
3693 let repo = Repository::init(&temp_path).unwrap();
3695
3696 let mut config = repo.config().unwrap();
3698 config.set_str("user.name", "Test User").unwrap();
3699 config.set_str("user.email", "test@example.com").unwrap();
3700
3701 let file_path = temp_path.join("conflict.txt");
3703 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3704 stage_file(&temp_path, "conflict.txt").unwrap();
3705 commit_changes(&temp_path, "initial commit").unwrap();
3706
3707 let output = std::process::Command::new("git")
3709 .env("GIT_TERMINAL_PROMPT", "0")
3710 .env("GIT_SSH_COMMAND", ssh_command_val())
3711 .args(["symbolic-ref", "--short", "HEAD"])
3712 .current_dir(&temp_path)
3713 .output()
3714 .unwrap();
3715 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3716
3717 std::process::Command::new("git")
3719 .env("GIT_TERMINAL_PROMPT", "0")
3720 .env("GIT_SSH_COMMAND", ssh_command_val())
3721 .args(["checkout", "-b", "feature"])
3722 .current_dir(&temp_path)
3723 .output()
3724 .unwrap();
3725
3726 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
3727 stage_file(&temp_path, "conflict.txt").unwrap();
3728 commit_changes(&temp_path, "feature commit").unwrap();
3729
3730 std::process::Command::new("git")
3732 .env("GIT_TERMINAL_PROMPT", "0")
3733 .env("GIT_SSH_COMMAND", ssh_command_val())
3734 .args(["checkout", &main_branch])
3735 .current_dir(&temp_path)
3736 .output()
3737 .unwrap();
3738
3739 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
3740 stage_file(&temp_path, "conflict.txt").unwrap();
3741 commit_changes(&temp_path, "main commit").unwrap();
3742
3743 assert!(!is_merging(&temp_path));
3745 let merge_output = std::process::Command::new("git")
3746 .env("GIT_TERMINAL_PROMPT", "0")
3747 .env("GIT_SSH_COMMAND", ssh_command_val())
3748 .args(["merge", "feature"])
3749 .current_dir(&temp_path)
3750 .output()
3751 .unwrap();
3752
3753 assert!(!merge_output.status.success());
3754 assert!(is_merging(&temp_path));
3755
3756 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
3758 assert!(!diff.is_empty());
3759 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
3760 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
3761 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
3762 assert!(has_separator);
3763 assert!(has_ours);
3764 assert!(has_theirs);
3765
3766 abort_merge(&temp_path).unwrap();
3768 assert!(!is_merging(&temp_path));
3769
3770 std::process::Command::new("git")
3772 .env("GIT_TERMINAL_PROMPT", "0")
3773 .env("GIT_SSH_COMMAND", ssh_command_val())
3774 .args(["merge", "feature"])
3775 .current_dir(&temp_path)
3776 .output()
3777 .unwrap();
3778 assert!(is_merging(&temp_path));
3779
3780 resolve_ours(&temp_path, "conflict.txt").unwrap();
3782 let contents = std::fs::read_to_string(&file_path).unwrap();
3783 assert!(contents.contains("line 2 on main"));
3784 assert!(!contents.contains("<<<<<<<"));
3785
3786 continue_merge(&temp_path).unwrap();
3788 assert!(!is_merging(&temp_path));
3789
3790 std::process::Command::new("git")
3792 .env("GIT_TERMINAL_PROMPT", "0")
3793 .env("GIT_SSH_COMMAND", ssh_command_val())
3794 .args(["reset", "--hard", "HEAD~1"])
3795 .current_dir(&temp_path)
3796 .output()
3797 .unwrap();
3798
3799 std::process::Command::new("git")
3800 .env("GIT_TERMINAL_PROMPT", "0")
3801 .env("GIT_SSH_COMMAND", ssh_command_val())
3802 .args(["merge", "feature"])
3803 .current_dir(&temp_path)
3804 .output()
3805 .unwrap();
3806 assert!(is_merging(&temp_path));
3807
3808 resolve_theirs(&temp_path, "conflict.txt").unwrap();
3809 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
3810 assert!(contents_theirs.contains("line 2 on feature"));
3811 assert!(!contents_theirs.contains("<<<<<<<"));
3812
3813 continue_merge(&temp_path).unwrap();
3814 assert!(!is_merging(&temp_path));
3815
3816 let _ = std::fs::remove_dir_all(&temp_path);
3818 }
3819
3820 #[test]
3821 fn test_resolve_conflict_hunk() {
3822 let mut temp_path = std::env::temp_dir();
3823 temp_path.push(format!(
3824 "twig_test_hunk_conflict_{}",
3825 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3826 ));
3827 std::fs::create_dir_all(&temp_path).unwrap();
3828
3829 let repo = Repository::init(&temp_path).unwrap();
3831
3832 let mut config = repo.config().unwrap();
3834 config.set_str("user.name", "Test User").unwrap();
3835 config.set_str("user.email", "test@example.com").unwrap();
3836
3837 let file_path = temp_path.join("conflict.txt");
3839 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";
3840 std::fs::write(&file_path, initial_lines).unwrap();
3841 stage_file(&temp_path, "conflict.txt").unwrap();
3842 commit_changes(&temp_path, "initial commit").unwrap();
3843
3844 let output = std::process::Command::new("git")
3846 .env("GIT_TERMINAL_PROMPT", "0")
3847 .env("GIT_SSH_COMMAND", ssh_command_val())
3848 .args(["symbolic-ref", "--short", "HEAD"])
3849 .current_dir(&temp_path)
3850 .output()
3851 .unwrap();
3852 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3853
3854 std::process::Command::new("git")
3856 .env("GIT_TERMINAL_PROMPT", "0")
3857 .env("GIT_SSH_COMMAND", ssh_command_val())
3858 .args(["checkout", "-b", "feature"])
3859 .current_dir(&temp_path)
3860 .output()
3861 .unwrap();
3862 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";
3863 std::fs::write(&file_path, feature_lines).unwrap();
3864 stage_file(&temp_path, "conflict.txt").unwrap();
3865 commit_changes(&temp_path, "feature commit").unwrap();
3866
3867 std::process::Command::new("git")
3869 .env("GIT_TERMINAL_PROMPT", "0")
3870 .env("GIT_SSH_COMMAND", ssh_command_val())
3871 .args(["checkout", &main_branch])
3872 .current_dir(&temp_path)
3873 .output()
3874 .unwrap();
3875 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";
3876 std::fs::write(&file_path, main_lines).unwrap();
3877 stage_file(&temp_path, "conflict.txt").unwrap();
3878 commit_changes(&temp_path, "main commit").unwrap();
3879
3880 let merge_output = std::process::Command::new("git")
3882 .env("GIT_TERMINAL_PROMPT", "0")
3883 .env("GIT_SSH_COMMAND", ssh_command_val())
3884 .args(["merge", "feature"])
3885 .current_dir(&temp_path)
3886 .output()
3887 .unwrap();
3888 assert!(!merge_output.status.success());
3889 assert!(is_merging(&temp_path));
3890
3891 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
3893 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
3894 assert!(contents_after_first.contains("line 2 on main"));
3896 assert!(!contents_after_first.contains("line 2 on feature"));
3897 assert!(contents_after_first.contains("<<<<<<<"));
3899 assert!(contents_after_first.contains("line 11 on main"));
3900 assert!(contents_after_first.contains("line 11 on feature"));
3901
3902 assert!(is_merging(&temp_path));
3904
3905 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
3910 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
3911 assert!(contents_after_second.contains("line 2 on main"));
3913 assert!(contents_after_second.contains("line 11 on feature"));
3914 assert!(!contents_after_second.contains("<<<<<<<"));
3915
3916 let status = repo.statuses(None).unwrap();
3918 assert_eq!(status.len(), 1);
3919 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
3920
3921 continue_merge(&temp_path).unwrap();
3923 assert!(!is_merging(&temp_path));
3924
3925 let _ = std::fs::remove_dir_all(&temp_path);
3927 }
3928
3929 #[test]
3930 fn test_branch_and_commit_helpers() {
3931 let mut temp_path = std::env::temp_dir();
3932 temp_path.push(format!(
3933 "twig_test_helpers_{}",
3934 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3935 ));
3936 let _ = std::fs::remove_dir_all(&temp_path);
3937 std::fs::create_dir_all(&temp_path).unwrap();
3938
3939 let repo = Repository::init(&temp_path).unwrap();
3941
3942 let mut config = repo.config().unwrap();
3944 config.set_str("user.name", "Test User").unwrap();
3945 config.set_str("user.email", "test@example.com").unwrap();
3946
3947 let file_path = temp_path.join("test.txt");
3949 std::fs::write(&file_path, "root content").unwrap();
3950 stage_file(&temp_path, "test.txt").unwrap();
3951 commit_changes(&temp_path, "root commit").unwrap();
3952
3953 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3954 assert!(is_root_commit(&temp_path, &head_oid));
3955
3956 std::fs::write(&file_path, "second content").unwrap();
3958 stage_file(&temp_path, "test.txt").unwrap();
3959 commit_changes(&temp_path, "second commit").unwrap();
3960
3961 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
3962 assert!(!is_root_commit(&temp_path, &new_head_oid));
3963
3964 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
3969 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
3970 "master"
3971 } else {
3972 "main"
3973 };
3974 let target = get_branch_push_target(&temp_path, active_branch);
3975 assert!(target.is_some());
3976 let (remote_name, set_upstream) = target.unwrap();
3977 assert_eq!(remote_name, "origin");
3978 assert!(set_upstream);
3979
3980 let mut git_config = repo.config().unwrap();
3982 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
3983 git_config
3984 .set_str(
3985 &format!("branch.{}.merge", active_branch),
3986 &format!("refs/heads/{}", active_branch),
3987 )
3988 .unwrap();
3989
3990 assert!(has_upstream_remote(&temp_path, active_branch));
3992 assert_eq!(
3993 get_branch_upstream_remote(&temp_path, active_branch),
3994 Some("origin".to_string())
3995 );
3996
3997 let target_tracking = get_branch_push_target(&temp_path, active_branch);
3999 assert!(target_tracking.is_some());
4000 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
4001 assert_eq!(remote_name_tr, "origin");
4002 assert!(!set_upstream_tr);
4003
4004 let _ = std::fs::remove_dir_all(&temp_path);
4006 }
4007
4008 #[test]
4009 fn test_core_helpers_comprehensive() {
4010 assert_eq!(format_relative_time(0), "unknown");
4012 assert_eq!(format_relative_time(-100), "unknown");
4013
4014 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
4015 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
4016 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
4017 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
4018 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
4019 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
4020 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
4021
4022 assert_eq!(format_utc_date(0), "unknown");
4024 assert_eq!(format_utc_date(-1), "unknown");
4025 let date_str = format_utc_date(1700000000);
4026 assert!(date_str.contains("2023-11-14"));
4027
4028 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
4030 let tilde_path = expand_tilde("~/path");
4031 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
4032
4033 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
4035 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
4036
4037 assert_eq!(safe_sha_slice("12345", 10), "12345");
4039 assert_eq!(safe_sha_slice("12345", 3), "123");
4040
4041 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
4043 assert!(safe_ref("-invalid").is_err());
4044 assert!(safe_ref(" ").is_err());
4045
4046 let mut status = RepoSummary::default();
4048 assert!(status.is_clean());
4049 assert!(status.is_synced());
4050 assert!(status.unchanged());
4051
4052 status.modified = 1;
4053 assert!(!status.is_clean());
4054 assert!(!status.unchanged());
4055
4056 status.modified = 0;
4057 status.ahead = 1;
4058 assert!(!status.is_synced());
4059 assert!(!status.unchanged());
4060
4061 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
4063 assert!(data_not_loaded.is_not_loaded());
4064 assert!(!data_not_loaded.is_loading());
4065 assert!(!data_not_loaded.is_loaded());
4066 assert_eq!(data_not_loaded.len(), 0);
4067 assert!(data_not_loaded.is_empty());
4068 assert!(data_not_loaded.as_ref().is_none());
4069 assert!(data_not_loaded.first().is_none());
4070 assert!(data_not_loaded.get(0).is_none());
4071 assert_eq!(data_not_loaded.iter().count(), 0);
4072 assert_eq!(data_not_loaded.as_slice().len(), 0);
4073
4074 let data_loading: TabData<Vec<i32>> = TabData::Loading;
4075 assert!(!data_loading.is_not_loaded());
4076 assert!(data_loading.is_loading());
4077
4078 let data_loaded = TabData::Loaded(vec![10, 20]);
4079 assert!(!data_loaded.is_not_loaded());
4080 assert!(!data_loaded.is_loading());
4081 assert!(data_loaded.is_loaded());
4082 assert_eq!(data_loaded.len(), 2);
4083 assert!(!data_loaded.is_empty());
4084 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
4085 assert_eq!(data_loaded.first().unwrap(), &10);
4086 assert_eq!(data_loaded.get(1).unwrap(), &20);
4087 assert_eq!(data_loaded.iter().count(), 2);
4088 assert_eq!(data_loaded.as_slice(), &[10, 20]);
4089 }
4090}