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