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, Instant, SystemTime, UNIX_EPOCH};
19
20use git2::{Repository, StatusOptions, StatusShow};
21
22#[derive(Debug, Clone)]
29pub enum ItemStatus {
30 Loading,
31 Missing,
32 Directory,
33 GitRepo(Option<RepoSummary>),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum RepoState {
38 #[default]
39 Clean,
40 Merge,
41 Revert,
42 CherryPick,
43 Bisect,
44 Rebase,
45 ApplyMailbox,
46}
47
48#[derive(Debug, Default, Clone)]
51pub struct RepoSummary {
52 pub branch: Option<String>,
55 pub staged: usize,
56 pub modified: usize,
57 pub untracked: usize,
58 pub conflicted: usize,
59 pub ahead: usize,
60 pub behind: usize,
61 pub state: RepoState,
62 pub last_commit_time: Option<i64>,
63}
64
65impl RepoSummary {
66 pub fn is_clean(&self) -> bool {
67 self.staged + self.modified + self.untracked + self.conflicted == 0
68 }
69 pub fn is_synced(&self) -> bool {
70 self.ahead + self.behind == 0
71 }
72 pub fn unchanged(&self) -> bool {
73 self.is_clean() && self.is_synced()
74 }
75}
76
77#[derive(Debug, Clone)]
80pub enum ItemDetail {
81 Missing { resolved: PathBuf },
82 Directory { resolved: PathBuf },
83 Repo { resolved: PathBuf, info: Box<RepoInfo> },
84 Error { resolved: PathBuf, message: String },
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct BranchInfo {
89 pub name: String,
90 pub is_head: bool,
91 pub short_sha: String,
92 pub short_message: String,
93}
94
95#[derive(Debug, Clone, Default)]
96pub struct FileRevision {
97 pub commit_oid: String,
98 pub author: String,
99 pub date: String,
100 pub when: String,
101 pub summary: String,
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct StashInfo {
106 pub index: usize,
107 pub message: String,
108 pub commit_id: String,
109 pub files: Vec<FileEntry>,
110}
111
112#[derive(Debug, Clone, Default)]
113pub struct CommitterStat {
114 pub name: String,
115 pub email: String,
116 pub count: usize,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Default)]
120pub enum TabData<T> {
121 #[default]
122 NotLoaded,
123 Loading,
124 Loaded(T),
125 Error(String),
126}
127
128impl<T> TabData<T> {
129 pub fn is_not_loaded(&self) -> bool {
130 matches!(self, TabData::NotLoaded)
131 }
132 pub fn is_loading(&self) -> bool {
133 matches!(self, TabData::Loading)
134 }
135 #[allow(dead_code)]
136 pub fn is_loaded(&self) -> bool {
137 matches!(self, TabData::Loaded(_))
138 }
139 pub fn as_ref(&self) -> Option<&T> {
140 match self {
141 TabData::Loaded(val) => Some(val),
142 _ => None,
143 }
144 }
145}
146
147impl<T> TabData<Vec<T>> {
148 pub fn len(&self) -> usize {
149 self.as_ref().map(|v| v.len()).unwrap_or(0)
150 }
151 pub fn is_empty(&self) -> bool {
152 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
153 }
154 pub fn first(&self) -> Option<&T> {
155 self.as_ref().and_then(|v| v.first())
156 }
157 pub fn get(&self, index: usize) -> Option<&T> {
158 self.as_ref().and_then(|v| v.get(index))
159 }
160 pub fn iter(&self) -> std::slice::Iter<'_, T> {
161 match self {
162 TabData::Loaded(v) => v.iter(),
163 _ => [].iter(),
164 }
165 }
166 pub fn as_slice(&self) -> &[T] {
167 match self {
168 TabData::Loaded(v) => v.as_slice(),
169 _ => &[],
170 }
171 }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct WorktreeInfo {
176 pub name: String,
177 pub path: PathBuf,
178 pub branch: Option<String>,
179 pub is_locked: bool,
180 pub lock_reason: Option<String>,
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
184pub struct SubmoduleInfo {
185 pub name: String,
186 pub path: PathBuf,
187 pub url: String,
188 pub commit_id: Option<String>,
189 pub head_id: Option<String>,
190 pub is_initialized: bool,
191 pub is_dirty: bool,
192}
193
194#[derive(Debug, Clone)]
195pub enum TabPayload {
196 Files(Result<Vec<String>, String>),
197 Graph(Result<Vec<GraphLine>, String>),
198 Branches { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
199 Tags { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
200 Remotes(Result<Vec<RemoteInfo>, String>),
201 Stashes(Result<Vec<StashInfo>, String>),
202 Overview(Result<(Vec<CommitterStat>, bool), String>),
203 Worktrees(Result<Vec<WorktreeInfo>, String>),
204 Submodules(Result<Vec<SubmoduleInfo>, String>),
205 Reflog(Result<Vec<ReflogEntry>, String>),
206 ForgeIssues(Result<Vec<ForgeIssue>, String>),
207 ForgePRs(Result<Vec<ForgePR>, String>),
208 PRComments(Result<Vec<ForgePRComment>, String>),
209}
210
211#[derive(Debug, Default, Clone)]
212pub struct RepoInfo {
213 pub branch: Option<String>,
214 pub head: Option<HeadInfo>,
215 pub remotes: TabData<Vec<RemoteInfo>>,
216 pub upstream: Option<String>,
218 pub summary: RepoSummary,
219 pub changes: WorktreeChanges,
221 pub commits: Vec<CommitEntry>,
223 pub graph_lines: TabData<Vec<GraphLine>>,
225 pub local_branches: TabData<Vec<BranchInfo>>,
227 pub remote_branches: TabData<Vec<BranchInfo>>,
229 pub local_tags: TabData<Vec<BranchInfo>>,
231 pub remote_tags: TabData<Vec<BranchInfo>>,
233 pub remote_tags_loaded: bool,
235 pub remote_tags_attempted: bool,
237 pub files: TabData<Vec<String>>,
239 pub stashes: TabData<Vec<StashInfo>>,
241 pub worktrees: TabData<Vec<WorktreeInfo>>,
243 pub submodules: TabData<Vec<SubmoduleInfo>>,
245 pub reflog: TabData<Vec<ReflogEntry>>,
247 pub forge_issues: TabData<Vec<ForgeIssue>>,
248 pub forge_prs: TabData<Vec<ForgePR>>,
249 pub committer_stats: TabData<Vec<CommitterStat>>,
251 pub committer_stats_limit_reached: bool,
253 pub tab_loaded_at: [Option<std::time::Instant>; 12],
255 pub tab_loading: [bool; 12],
257 pub lfs_files: std::collections::HashSet<String>,
259 pub lfs_installed: bool,
261 pub lfs_storage_size: Option<u64>,
263}
264
265#[derive(Debug, Clone)]
266pub struct HeadInfo {
267 pub short_id: String,
268 pub summary: String,
269 pub author: String,
270 pub when: String,
271}
272
273#[derive(Debug, Clone)]
274pub struct ReflogEntry {
275 pub index: usize,
276 pub target_oid: String,
277 pub selector: String,
278 pub command: String,
279 pub message: String,
280 pub when: String,
281 pub date: String,
282}
283
284#[derive(Debug, Clone)]
285pub struct RemoteInfo {
286 pub name: String,
287 pub url: String,
288 pub push_url: Option<String>,
289 pub refspecs: Vec<String>,
290}
291
292#[derive(Debug, Clone)]
293pub struct CommitEntry {
294 pub id: String,
296 pub oid: String,
298 pub author: String,
299 pub when: String,
300 pub date: String,
301 pub summary: String,
302 pub message: String,
303 pub refs: Vec<String>,
306 pub files: Vec<FileEntry>,
308 pub signature_status: String,
310}
311
312#[derive(Debug, Clone)]
313pub struct GraphLine {
314 pub graph: String,
315 pub commit: Option<GraphCommit>,
316}
317
318#[derive(Debug, Clone)]
319pub struct GraphCommit {
320 pub oid: String,
321 pub decoration: String,
322 pub summary: String,
323 pub author: String,
324 pub date: String,
325 pub signature_status: String,
327}
328
329#[derive(Debug, Clone)]
331pub struct FileEntry {
332 pub path: String,
334 pub label: &'static str,
336}
337
338#[derive(Debug, Default, Clone)]
341pub struct WorktreeChanges {
342 pub staged: Vec<FileEntry>,
343 pub unstaged: Vec<FileEntry>,
344 pub untracked: Vec<FileEntry>,
345 pub conflicted: Vec<FileEntry>,
346}
347
348#[derive(Debug, Clone, PartialEq)]
352pub enum DiffLineKind {
353 Header,
355 Added,
357 Removed,
359 Context,
361 ConflictOurs,
363 ConflictTheirs,
365 ConflictSeparator,
367}
368
369#[derive(Debug, Clone)]
371pub struct DiffLine {
372 pub kind: DiffLineKind,
373 pub content: String,
375 pub old_lineno: Option<u32>,
376 pub new_lineno: Option<u32>,
377 pub hunk_idx: Option<usize>,
378}
379
380pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
384 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
385}
386
387pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
394 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
395}
396
397pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
400 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
401 let mut index = repo.index().map_err(|e| e.to_string())?;
402 let full_path = repo_path.join(file_path);
403 if full_path.exists() {
404 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
405 } else {
406 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
407 }
408 index.write().map_err(|e| e.to_string())?;
409 Ok(())
410}
411
412pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
417 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
418 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
420 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
421 .map_err(|e| e.to_string())?;
422 } else {
423 let mut index = repo.index().map_err(|e| e.to_string())?;
425 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
426 index.write().map_err(|e| e.to_string())?;
427 }
428 Ok(())
429}
430
431pub fn sanitize_text(s: &str) -> String {
432 let mut result = String::with_capacity(s.len());
433 let bytes = s.as_bytes();
434 let mut i = 0;
435 while i < bytes.len() {
436 if bytes[i] == 0x1B {
437 i += 1;
439 if i < bytes.len() && bytes[i] == b'[' {
440 i += 1;
441 while i < bytes.len() {
442 let c = bytes[i];
443 i += 1;
444 if c.is_ascii_alphabetic() {
446 break;
447 }
448 }
449 } else {
450 while i < bytes.len() {
452 let c = bytes[i];
453 i += 1;
454 if c == b' ' || c.is_ascii_alphabetic() {
455 break;
456 }
457 }
458 }
459 } else {
460 let c = bytes[i];
461 if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
463 result.push(' ');
464 } else {
465 result.push(c as char);
466 }
467 i += 1;
468 }
469 }
470 result
471}
472
473pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
474 let mut end = len.min(sha.len());
475 while end > 0 && !sha.is_char_boundary(end) {
476 end -= 1;
477 }
478 &sha[..end]
479}
480
481fn ssh_command_val() -> &'static str {
482 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
483 "ssh -o StrictHostKeyChecking=yes"
484 } else {
485 "ssh -o StrictHostKeyChecking=accept-new"
486 }
487}
488
489fn git_command() -> std::process::Command {
490 let mut cmd = std::process::Command::new("git");
491 cmd.env("GIT_TERMINAL_PROMPT", "0");
492 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
493 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
494 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
495 cmd
496}
497
498const GIT_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
500
501fn run_git_with_timeout(
508 mut cmd: std::process::Command,
509 timeout: Duration,
510) -> Result<std::process::Output, Box<dyn std::error::Error>> {
511 if timeout.is_zero() {
512 return Ok(cmd.output()?);
513 }
514
515 cmd.stdin(std::process::Stdio::null())
516 .stdout(std::process::Stdio::piped())
517 .stderr(std::process::Stdio::piped());
518
519 let mut child = cmd.spawn()?;
520
521 let mut out_pipe = child.stdout.take();
522 let mut err_pipe = child.stderr.take();
523 let out_reader = std::thread::spawn(move || {
524 let mut buf = Vec::new();
525 if let Some(p) = out_pipe.as_mut() {
526 let _ = std::io::Read::read_to_end(p, &mut buf);
527 }
528 buf
529 });
530 let err_reader = std::thread::spawn(move || {
531 let mut buf = Vec::new();
532 if let Some(p) = err_pipe.as_mut() {
533 let _ = std::io::Read::read_to_end(p, &mut buf);
534 }
535 buf
536 });
537
538 let started = Instant::now();
539 let status = loop {
540 match child.try_wait() {
541 Ok(Some(status)) => break Some(status),
542 Ok(None) => {
543 if started.elapsed() >= timeout {
544 let _ = child.kill();
545 let _ = child.wait();
546 break None;
547 }
548 std::thread::sleep(GIT_TIMEOUT_POLL_INTERVAL);
549 }
550 Err(e) => {
551 let _ = child.kill();
552 let _ = child.wait();
553 return Err(e.into());
554 }
555 }
556 };
557
558 let stdout = out_reader.join().unwrap_or_default();
559 let stderr = err_reader.join().unwrap_or_default();
560
561 match status {
562 Some(status) => Ok(std::process::Output { status, stdout, stderr }),
563 None => {
564 Err(format!("git did not respond within {}s and was cancelled", timeout.as_secs())
565 .into())
566 }
567 }
568}
569
570pub fn safe_ref(r: &str) -> Result<&str, String> {
571 let trimmed = r.trim();
572 if trimmed.starts_with('-') {
573 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
574 }
575 if trimmed.is_empty() {
576 return Err("Ref name cannot be empty".to_string());
577 }
578 Ok(trimmed)
579}
580
581pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
583 let output = git_command()
584 .arg("add")
585 .arg("-A")
586 .current_dir(repo_path)
587 .output()
588 .map_err(|e| e.to_string())?;
589
590 if output.status.success() {
591 Ok(())
592 } else {
593 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
594 }
595}
596
597pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
599 let output =
600 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
601
602 if output.status.success() {
603 Ok(())
604 } else {
605 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
606 }
607}
608
609pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
611 let _ = git_command().arg("reset").current_dir(repo_path).output();
613
614 let checkout_out = git_command()
616 .arg("checkout")
617 .arg("--")
618 .arg(".")
619 .current_dir(repo_path)
620 .output()
621 .map_err(|e| e.to_string())?;
622
623 if !checkout_out.status.success() {
624 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
625 }
626
627 let clean_out = git_command()
629 .arg("clean")
630 .arg("-fd")
631 .current_dir(repo_path)
632 .output()
633 .map_err(|e| e.to_string())?;
634
635 if !clean_out.status.success() {
636 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
637 }
638
639 Ok(())
640}
641
642pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
644 apply_hunk_patch(repo_path, file_path, hunk, false, true)
645}
646
647pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
649 apply_hunk_patch(repo_path, file_path, hunk, true, true)
650}
651
652pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
654 apply_hunk_patch(repo_path, file_path, hunk, true, false)
655}
656
657pub fn stage_line(
659 repo_path: &Path,
660 file_path: &str,
661 hunk: &[DiffLine],
662 selected_line_idx: usize,
663) -> Result<(), String> {
664 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
665}
666
667pub fn unstage_line(
669 repo_path: &Path,
670 file_path: &str,
671 hunk: &[DiffLine],
672 selected_line_idx: usize,
673) -> Result<(), String> {
674 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
675}
676
677pub fn discard_line(
679 repo_path: &Path,
680 file_path: &str,
681 hunk: &[DiffLine],
682 selected_line_idx: usize,
683) -> Result<(), String> {
684 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
685}
686
687fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
688 if !header.starts_with("@@") {
689 return None;
690 }
691 let parts: Vec<&str> = header.split("@@").collect();
692 if parts.len() < 3 {
693 return None;
694 }
695 let meta = parts[1].trim();
696 let subparts: Vec<&str> = meta.split_whitespace().collect();
697 if subparts.len() < 2 {
698 return None;
699 }
700
701 let parse_part = |p: &str| -> (usize, usize) {
702 let s = p.trim_start_matches(['-', '+']);
703 let comps: Vec<&str> = s.split(',').collect();
704 let start = comps[0].parse::<usize>().unwrap_or(0);
705 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
706 (start, count)
707 };
708
709 let (old_start, old_count) = parse_part(subparts[0]);
710 let (new_start, new_count) = parse_part(subparts[1]);
711 Some((old_start, old_count, new_start, new_count))
712}
713
714fn apply_line_patch_inner(
715 repo_path: &Path,
716 file_path: &str,
717 hunk: &[DiffLine],
718 selected_line_idx_in_hunk: usize,
719 revert: bool,
720 target_has_modification: bool,
721 cached: bool,
722) -> Result<(), String> {
723 use std::io::Write;
724 use std::process::{Command, Stdio};
725
726 let path_check = std::path::Path::new(file_path);
727 if path_check.is_absolute()
728 || path_check.components().any(|c| c == std::path::Component::ParentDir)
729 {
730 return Err("Path escapes repository root".to_string());
731 }
732
733 if hunk.is_empty() {
734 return Err("Empty hunk".to_string());
735 }
736
737 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
738 Some(line) => line,
739 None => return Err("Invalid line index".to_string()),
740 };
741
742 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
743 return Err("Selected line is not a modification (must be + or -)".to_string());
744 }
745
746 let header_line = &hunk[0];
747 let (old_start, _old_count, new_start, _new_count) =
748 match parse_hunk_header(&header_line.content) {
749 Some(coords) => coords,
750 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
751 };
752
753 let mut patch_lines = Vec::new();
754 let mut new_old_count = 0;
755 let mut new_new_count = 0;
756
757 for (i, line) in hunk.iter().enumerate() {
758 if i == 0 {
759 continue;
760 }
761
762 if i == selected_line_idx_in_hunk {
763 if revert {
764 match line.kind {
765 DiffLineKind::Added => {
766 patch_lines.push(DiffLine {
767 kind: DiffLineKind::Removed,
768 content: line.content.clone(),
769 old_lineno: None,
770 new_lineno: None,
771 hunk_idx: None,
772 });
773 new_old_count += 1;
774 }
775 DiffLineKind::Removed => {
776 patch_lines.push(DiffLine {
777 kind: DiffLineKind::Added,
778 content: line.content.clone(),
779 old_lineno: None,
780 new_lineno: None,
781 hunk_idx: None,
782 });
783 new_new_count += 1;
784 }
785 _ => {}
786 }
787 } else {
788 match line.kind {
789 DiffLineKind::Added => {
790 patch_lines.push(DiffLine {
791 kind: DiffLineKind::Added,
792 content: line.content.clone(),
793 old_lineno: None,
794 new_lineno: None,
795 hunk_idx: None,
796 });
797 new_new_count += 1;
798 }
799 DiffLineKind::Removed => {
800 patch_lines.push(DiffLine {
801 kind: DiffLineKind::Removed,
802 content: line.content.clone(),
803 old_lineno: None,
804 new_lineno: None,
805 hunk_idx: None,
806 });
807 new_old_count += 1;
808 }
809 _ => {}
810 }
811 }
812 } else {
813 match line.kind {
814 DiffLineKind::Context => {
815 patch_lines.push(line.clone());
816 new_old_count += 1;
817 new_new_count += 1;
818 }
819 DiffLineKind::Added => {
820 if target_has_modification {
821 patch_lines.push(DiffLine {
822 kind: DiffLineKind::Context,
823 content: line.content.clone(),
824 old_lineno: None,
825 new_lineno: None,
826 hunk_idx: None,
827 });
828 new_old_count += 1;
829 new_new_count += 1;
830 } else {
831 }
833 }
834 DiffLineKind::Removed => {
835 if target_has_modification {
836 } else {
838 patch_lines.push(DiffLine {
839 kind: DiffLineKind::Context,
840 content: line.content.clone(),
841 old_lineno: None,
842 new_lineno: None,
843 hunk_idx: None,
844 });
845 new_old_count += 1;
846 new_new_count += 1;
847 }
848 }
849 _ => {}
850 }
851 }
852 }
853
854 let mut patch = String::new();
855 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
856 patch.push_str(&format!("--- a/{}\n", file_path));
857 patch.push_str(&format!("+++ b/{}\n", file_path));
858 patch.push_str(&format!(
859 "@@ -{},{} +{},{} @@\n",
860 old_start, new_old_count, new_start, new_new_count
861 ));
862
863 for line in patch_lines {
864 let prefix = match line.kind {
865 DiffLineKind::Added => "+",
866 DiffLineKind::Removed => "-",
867 DiffLineKind::Context => " ",
868 DiffLineKind::Header => "",
869 _ => "",
870 };
871 patch.push_str(prefix);
872 patch.push_str(&line.content);
873 patch.push('\n');
874 }
875
876 let mut args = vec!["apply"];
877 if cached {
878 args.push("--cached");
879 }
880 args.push("-");
881
882 let mut cmd = Command::new("git");
883 let mut child = cmd
884 .env("GIT_TERMINAL_PROMPT", "0")
885 .env("GIT_SSH_COMMAND", ssh_command_val())
886 .args(&args)
887 .current_dir(repo_path)
888 .stdin(Stdio::piped())
889 .stdout(Stdio::piped())
890 .stderr(Stdio::piped())
891 .spawn()
892 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
893
894 if let Some(mut stdin) = child.stdin.take() {
895 stdin
896 .write_all(patch.as_bytes())
897 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
898 }
899
900 let output =
901 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
902
903 if !output.status.success() {
904 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
905 return Err(format!("git apply failed: {}", err_msg.trim()));
906 }
907
908 Ok(())
909}
910
911fn apply_hunk_patch(
912 repo_path: &Path,
913 file_path: &str,
914 hunk: &[DiffLine],
915 reverse: bool,
916 cached: bool,
917) -> Result<(), String> {
918 use std::io::Write;
919 use std::process::{Command, Stdio};
920
921 let path_check = std::path::Path::new(file_path);
922 if path_check.is_absolute()
923 || path_check.components().any(|c| c == std::path::Component::ParentDir)
924 {
925 return Err("Path escapes repository root".to_string());
926 }
927
928 let mut patch = String::new();
929 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
930 patch.push_str(&format!("--- a/{}\n", file_path));
931 patch.push_str(&format!("+++ b/{}\n", file_path));
932 for line in hunk {
933 let prefix = match line.kind {
934 DiffLineKind::Added => "+",
935 DiffLineKind::Removed => "-",
936 DiffLineKind::Context => " ",
937 DiffLineKind::Header => "",
938 _ => "",
939 };
940 patch.push_str(prefix);
941 patch.push_str(&line.content);
942 patch.push('\n');
943 }
944
945 let mut args = vec!["apply"];
946 if cached {
947 args.push("--cached");
948 }
949 if reverse {
950 args.push("--reverse");
951 }
952 args.push("-");
953
954 let mut cmd = Command::new("git");
955 let mut child = cmd
956 .env("GIT_TERMINAL_PROMPT", "0")
957 .env("GIT_SSH_COMMAND", ssh_command_val())
958 .args(&args)
959 .current_dir(repo_path)
960 .stdin(Stdio::piped())
961 .stdout(Stdio::piped())
962 .stderr(Stdio::piped())
963 .spawn()
964 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
965
966 if let Some(mut stdin) = child.stdin.take() {
967 stdin
968 .write_all(patch.as_bytes())
969 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
970 }
971
972 let output =
973 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
974
975 if !output.status.success() {
976 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
977 return Err(format!("git apply failed: {}", err_msg.trim()));
978 }
979
980 Ok(())
981}
982
983pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
988 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
989
990 if staged {
991 unstage_file(repo_path, file_path)?;
993 }
994
995 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
997 status.contains(git2::Status::WT_NEW)
998 } else {
999 false
1000 };
1001
1002 if is_untracked {
1003 let full_path = repo_path.join(file_path);
1004 if full_path.exists() {
1005 if full_path.is_file() {
1006 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
1007 } else if full_path.is_dir() {
1008 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
1009 }
1010 }
1011 } else {
1012 let mut checkout_opts = git2::build::CheckoutBuilder::new();
1014 checkout_opts.path(Path::new(file_path));
1015 checkout_opts.force();
1016 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
1017 }
1018
1019 Ok(())
1020}
1021
1022pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
1025 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1026 let mut index = repo.index().map_err(|e| e.to_string())?;
1027 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
1028 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
1029
1030 let signature = repo
1031 .signature()
1032 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
1033
1034 let mut parents = Vec::new();
1036 let mut has_head = false;
1037 if let Ok(head) = repo.head() {
1038 if let Ok(parent_commit) = head.peel_to_commit() {
1039 has_head = true;
1040 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
1042 if parent_tree.id() == tree_id {
1043 return Err("No staged changes to commit".to_string());
1044 }
1045 parents.push(parent_commit);
1046 }
1047 }
1048
1049 if !has_head && index.is_empty() {
1050 return Err("No staged changes to commit (index is empty)".to_string());
1051 }
1052
1053 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1054
1055 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
1056 .map_err(|e| e.to_string())?;
1057
1058 Ok(())
1059}
1060
1061pub fn expand_tilde(s: &str) -> PathBuf {
1067 if s == "~" {
1068 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
1069 }
1070 if let Some(stripped) = s.strip_prefix("~/")
1071 && let Some(home) = dirs::home_dir()
1072 {
1073 return home.join(stripped);
1074 }
1075 PathBuf::from(s)
1076}
1077
1078pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1080 let repo = Repository::open(repo_path)?;
1081 repo.remote(name, url)?;
1082 Ok(())
1083}
1084
1085pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1087 let repo = Repository::open(repo_path)?;
1088 repo.remote_delete(name)?;
1089 Ok(())
1090}
1091
1092pub fn inspect_summary(item: &str) -> ItemStatus {
1094 let path = expand_tilde(item);
1095 if !path.is_dir() {
1096 return ItemStatus::Missing;
1097 }
1098 if !path.join(".git").exists() {
1099 return ItemStatus::Directory;
1100 }
1101 match Repository::open(&path) {
1102 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1103 Err(_) => ItemStatus::GitRepo(None),
1104 }
1105}
1106
1107pub fn inspect_detail(
1109 item: &str,
1110 commit_limit: usize,
1111 graph_max_commits: usize,
1112 enable_commit_signatures: bool,
1113) -> ItemDetail {
1114 let resolved = expand_tilde(item);
1115 if !resolved.is_dir() {
1116 return ItemDetail::Missing { resolved };
1117 }
1118 if !resolved.join(".git").exists() {
1119 return ItemDetail::Directory { resolved };
1120 }
1121 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1122 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1123 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1124 }
1125}
1126
1127fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1128 let mut sigs = std::collections::HashMap::new();
1129 let mut cmd = std::process::Command::new("git");
1130 cmd.env("GIT_TERMINAL_PROMPT", "0")
1131 .env("GIT_SSH_COMMAND", ssh_command_val())
1132 .arg("log")
1133 .arg("--all");
1134
1135 if limit > 0 {
1136 cmd.arg(format!("-n{}", limit));
1137 }
1138
1139 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1140
1141 if let Ok(out) = cmd.output() {
1142 if out.status.success() {
1143 let stdout_str = String::from_utf8_lossy(&out.stdout);
1144 for line in stdout_str.lines() {
1145 let parts: Vec<&str> = line.split_whitespace().collect();
1146 if parts.len() == 2 {
1147 sigs.insert(parts[0].to_string(), parts[1].to_string());
1148 } else if parts.len() == 1 {
1149 sigs.insert(parts[0].to_string(), "N".to_string());
1150 }
1151 }
1152 }
1153 }
1154 sigs
1155}
1156
1157#[derive(serde::Serialize, serde::Deserialize, Clone)]
1158struct CachedCommit {
1159 id: String,
1160 author: String,
1161 date: String,
1162 summary: String,
1163 message: String,
1164 time: i64,
1165}
1166
1167fn hash_path(path: &Path) -> String {
1168 use std::collections::hash_map::DefaultHasher;
1169 use std::hash::{Hash, Hasher};
1170 let mut hasher = DefaultHasher::new();
1171 path.hash(&mut hasher);
1172 format!("{:x}", hasher.finish())
1173}
1174
1175fn collect_commits(
1176 repo: &Repository,
1177 limit: usize,
1178 repo_path: &Path,
1179 enable_commit_signatures: bool,
1180) -> Result<Vec<CommitEntry>, git2::Error> {
1181 let mut walk = repo.revwalk()?;
1182 if walk.push_head().is_err() {
1183 return Ok(Vec::new());
1184 }
1185 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1186
1187 let mut commits = Vec::new();
1188 let oids: Vec<Result<git2::Oid, git2::Error>> =
1189 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1190
1191 let sig_map = if enable_commit_signatures {
1192 collect_signatures(repo_path, limit)
1193 } else {
1194 std::collections::HashMap::new()
1195 };
1196 let ref_map = get_cached_ref_map(repo, repo_path);
1197
1198 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1200 if let Some(ref dir) = cache_dir {
1201 let _ = std::fs::create_dir_all(dir);
1202 }
1203 let hash = hash_path(repo_path);
1204 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1205 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1206 .as_ref()
1207 .and_then(|f| std::fs::read_to_string(f).ok())
1208 .and_then(|s| serde_json::from_str(&s).ok())
1209 .unwrap_or_default();
1210
1211 let mut cache_updated = false;
1212
1213 for id in oids {
1214 let oid = id?;
1215 let oid_str = oid.to_string();
1216 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1217 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1218 let files = Vec::new();
1219
1220 if let Some(cached) = cache.get(&oid_str) {
1221 let when = format_relative_time(cached.time);
1222 commits.push(CommitEntry {
1223 id: cached.id.clone(),
1224 oid: oid_str,
1225 author: sanitize_text(&cached.author),
1226 when,
1227 date: cached.date.clone(),
1228 summary: sanitize_text(&cached.summary),
1229 message: sanitize_text(&cached.message),
1230 refs,
1231 files,
1232 signature_status: sig_status,
1233 });
1234 } else if let Ok(commit) = repo.find_commit(oid) {
1235 let short_id = format!("{:.7}", commit.id());
1236 let summary =
1237 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1238 let author = commit.author();
1239 let author_name = author.name().unwrap_or("?");
1240 let author_email = author.email().unwrap_or("?");
1241 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1242 let time_secs = commit.time().seconds();
1243 let when = format_relative_time(time_secs);
1244 let date = format_utc_date(time_secs);
1245 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1246
1247 let cached = CachedCommit {
1248 id: short_id.clone(),
1249 author: author_str.clone(),
1250 date: date.clone(),
1251 summary: summary.clone(),
1252 message: message.clone(),
1253 time: time_secs,
1254 };
1255 cache.insert(oid_str.clone(), cached);
1256 cache_updated = true;
1257
1258 commits.push(CommitEntry {
1259 id: short_id,
1260 oid: oid_str,
1261 author: author_str,
1262 when,
1263 date,
1264 summary,
1265 message,
1266 refs,
1267 files,
1268 signature_status: sig_status,
1269 });
1270 }
1271 }
1272
1273 if cache_updated {
1274 if let Some(ref f) = cache_file {
1275 if let Ok(json) = serde_json::to_string(&cache) {
1276 let _ = std::fs::write(f, json);
1277 }
1278 }
1279 }
1280
1281 Ok(commits)
1282}
1283
1284pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1285 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1286 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1287 if walk.push_head().is_err() {
1288 return Ok(Vec::new());
1289 }
1290 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1291
1292 let mut revisions = Vec::new();
1293
1294 for oid_res in walk {
1295 let oid = oid_res.map_err(|e| e.to_string())?;
1296 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1297
1298 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1299 let mut modified = false;
1300
1301 if commit.parent_count() > 0 {
1302 for i in 0..commit.parent_count() {
1303 if let Ok(parent) = commit.parent(i) {
1304 if let Ok(parent_tree) = parent.tree() {
1305 let mut diff_opts = git2::DiffOptions::new();
1306 diff_opts.pathspec(file_path);
1307 if let Ok(diff) = repo.diff_tree_to_tree(
1308 Some(&parent_tree),
1309 Some(&commit_tree),
1310 Some(&mut diff_opts),
1311 ) {
1312 if diff.deltas().len() > 0 {
1313 modified = true;
1314 break;
1315 }
1316 }
1317 }
1318 }
1319 }
1320 } else {
1321 let mut diff_opts = git2::DiffOptions::new();
1322 diff_opts.pathspec(file_path);
1323 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1324 {
1325 if diff.deltas().len() > 0 {
1326 modified = true;
1327 }
1328 }
1329 }
1330
1331 if modified {
1332 let author = commit.author();
1333 let author_name = author.name().unwrap_or("?");
1334 let author_email = author.email().unwrap_or("?");
1335 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1336 let time_secs = commit.time().seconds();
1337 let when = format_relative_time(time_secs);
1338 let date = format_utc_date(time_secs);
1339 let summary =
1340 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1341
1342 revisions.push(FileRevision {
1343 commit_oid: oid.to_string(),
1344 author: author_str,
1345 date,
1346 when,
1347 summary,
1348 });
1349 }
1350 }
1351
1352 Ok(revisions)
1353}
1354
1355#[derive(Debug, Clone)]
1356pub struct BlameEntry {
1357 pub line_no: usize,
1358 pub commit_id: String,
1359 pub author: String,
1360 pub date: String,
1361}
1362
1363pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1364 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1365 let mut opts = git2::BlameOptions::new();
1366 let blame = repo
1367 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1368 .map_err(|e| e.to_string())?;
1369
1370 let mut entries = Vec::new();
1371 for i in 0..blame.len() {
1372 if let Some(hunk) = blame.get_index(i) {
1373 let commit_id = hunk.final_commit_id().to_string();
1374 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1375
1376 let mut author_name = "Unknown".to_string();
1377 let mut date_str = "unknown".to_string();
1378 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1379 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1380 date_str = format_utc_date(commit.time().seconds());
1381 }
1382
1383 let start = hunk.final_start_line();
1384 let count = hunk.lines_in_hunk();
1385 for line_idx in 0..count {
1386 entries.push(BlameEntry {
1387 line_no: start + line_idx,
1388 commit_id: short_id.to_string(),
1389 author: author_name.clone(),
1390 date: date_str.clone(),
1391 });
1392 }
1393 }
1394 }
1395
1396 entries.sort_by_key(|e| e.line_no);
1397 Ok(entries)
1398}
1399
1400fn collect_committer_stats(
1401 repo: &Repository,
1402 limit: usize,
1403) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1404 let mut walk = repo.revwalk()?;
1405 if walk.push_head().is_err() {
1406 return Ok((Vec::new(), false));
1407 }
1408 let mut counts = std::collections::HashMap::new();
1409 let mut count = 0;
1410 let mut limit_reached = false;
1411 for id in walk {
1412 let oid = id?;
1413 if let Ok(commit) = repo.find_commit(oid) {
1414 let author = commit.author();
1415 let name = author.name().unwrap_or("?").to_string();
1416 let email = author.email().unwrap_or("?").to_string();
1417 let key = (name, email);
1418 *counts.entry(key).or_insert(0) += 1;
1419 count += 1;
1420 if count >= limit {
1421 limit_reached = true;
1422 break;
1423 }
1424 }
1425 }
1426
1427 let mut stats: Vec<CommitterStat> = counts
1428 .into_iter()
1429 .map(|((name, email), count)| CommitterStat { name, email, count })
1430 .collect();
1431
1432 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1433
1434 Ok((stats, limit_reached))
1435}
1436
1437fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1441 let commit_tree = match commit.tree() {
1442 Ok(t) => t,
1443 Err(_) => return Vec::new(),
1444 };
1445 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1448
1449 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1450 Ok(d) => d,
1451 Err(_) => return Vec::new(),
1452 };
1453
1454 let mut files = Vec::new();
1455 for delta in diff.deltas() {
1456 if files.len() >= MAX_FILES_PER_SECTION {
1457 break;
1458 }
1459 let path = delta
1460 .new_file()
1461 .path()
1462 .or_else(|| delta.old_file().path())
1463 .map(|p| p.to_string_lossy().into_owned())
1464 .unwrap_or_else(|| "(unknown)".to_string());
1465
1466 let label: &'static str = match delta.status() {
1467 git2::Delta::Added => "N",
1468 git2::Delta::Deleted => "D",
1469 git2::Delta::Modified => "M",
1470 git2::Delta::Renamed => "R",
1471 git2::Delta::Typechange => "T",
1472 _ => "M",
1473 };
1474 files.push(FileEntry { path, label });
1475 }
1476 files
1477}
1478
1479pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1480 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1481 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1482 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1483 Ok(commit_changed_files(&repo, &commit))
1484}
1485fn match_pattern(path: &str, pattern: &str) -> bool {
1486 if let Some(suffix) = pattern.strip_prefix('*') {
1487 return path.ends_with(suffix);
1488 }
1489 if let Some(prefix) = pattern.strip_suffix('*') {
1490 return path.starts_with(prefix);
1491 }
1492 if pattern.contains('*') {
1493 let parts: Vec<&str> = pattern.split('*').collect();
1494 if parts.len() == 2 {
1495 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1496 }
1497 }
1498 path == pattern || path.ends_with(&format!("/{}", pattern))
1499}
1500
1501static LFS_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1502
1503fn is_lfs_installed() -> bool {
1504 *LFS_INSTALLED.get_or_init(|| {
1505 if let Ok(output) = std::process::Command::new("git").arg("lfs").arg("--version").output() {
1506 output.status.success()
1507 } else {
1508 false
1509 }
1510 })
1511}
1512
1513fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1514 let mut tracked = std::collections::HashSet::new();
1515
1516 let lfs_dir = repo_path.join(".git").join("lfs");
1518 let gitattributes_path = repo_path.join(".gitattributes");
1519 let has_lfs_attributes = if gitattributes_path.exists() {
1520 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1521 content.contains("filter=lfs")
1522 || content.contains("diff=lfs")
1523 || content.contains("merge=lfs")
1524 } else {
1525 false
1526 }
1527 } else {
1528 false
1529 };
1530
1531 if !lfs_dir.exists() && !has_lfs_attributes {
1532 return (false, tracked);
1533 }
1534
1535 let installed = is_lfs_installed();
1536 if installed {
1537 if let Ok(output) = std::process::Command::new("git")
1538 .arg("lfs")
1539 .arg("ls-files")
1540 .arg("-n")
1541 .current_dir(repo_path)
1542 .output()
1543 {
1544 if output.status.success() {
1545 if let Ok(stdout) = String::from_utf8(output.stdout) {
1546 for line in stdout.lines() {
1547 let trimmed = line.trim();
1548 if !trimmed.is_empty() {
1549 tracked.insert(trimmed.to_string());
1550 }
1551 }
1552 }
1553 }
1554 }
1555 }
1556
1557 let gitattributes_path = repo_path.join(".gitattributes");
1558 if gitattributes_path.exists() {
1559 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1560 let mut patterns = Vec::new();
1561 for line in content.lines() {
1562 let line = line.trim();
1563 if line.is_empty() || line.starts_with('#') {
1564 continue;
1565 }
1566 if line.contains("filter=lfs")
1567 || line.contains("diff=lfs")
1568 || line.contains("merge=lfs")
1569 {
1570 if let Some(pattern) = line.split_whitespace().next() {
1571 patterns.push(pattern.to_string());
1572 }
1573 }
1574 }
1575
1576 if !patterns.is_empty() {
1577 if let Ok(repo) = Repository::open(repo_path) {
1578 if let Ok(index) = repo.index() {
1579 for entry in index.iter() {
1580 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1581 for pat in &patterns {
1582 if match_pattern(path_str, pat) {
1583 tracked.insert(path_str.to_string());
1584 break;
1585 }
1586 }
1587 }
1588 }
1589 }
1590 }
1591 }
1592 }
1593 }
1594
1595 (installed, tracked)
1596}
1597
1598fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1599 let lfs_path = repo_path.join(".git").join("lfs");
1600 if !lfs_path.exists() {
1601 return None;
1602 }
1603
1604 fn calculate_dir_size(dir: &Path) -> u64 {
1605 let mut size = 0;
1606 if let Ok(entries) = std::fs::read_dir(dir) {
1607 for entry in entries.flatten() {
1608 if let Ok(metadata) = entry.metadata() {
1609 if metadata.is_dir() {
1610 size += calculate_dir_size(&entry.path());
1611 } else {
1612 size += metadata.len();
1613 }
1614 }
1615 }
1616 }
1617 size
1618 }
1619
1620 Some(calculate_dir_size(&lfs_path))
1621}
1622
1623fn collect_info(
1626 path: &Path,
1627 commit_limit: usize,
1628 _graph_max_commits: usize,
1629 enable_commit_signatures: bool,
1630) -> Result<RepoInfo, git2::Error> {
1631 let repo = Repository::open(path)?;
1632 let mut summary = RepoSummary::default();
1633 if let Ok(head) = repo.head() {
1634 summary.branch = head.shorthand().ok().map(String::from);
1635 }
1636 populate_ahead_behind(&repo, &mut summary);
1637
1638 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1639 let (lfs_inst, lfs_f) = get_lfs_info(path);
1640 info.lfs_installed = lfs_inst;
1641 info.lfs_files = lfs_f;
1642 info.lfs_storage_size = get_lfs_storage_size(path);
1643
1644 let is_detached = repo.head_detached().unwrap_or(false);
1645 if is_detached {
1646 info.branch = Some("HEAD".to_string());
1647 if let Ok(head) = repo.head() {
1648 if let Ok(commit) = head.peel_to_commit() {
1649 let short_id = format!("{:.7}", commit.id());
1650 let summary_text =
1651 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1652 let author = commit.author();
1653 let author_str = sanitize_text(&format!(
1654 "{} <{}>",
1655 author.name().unwrap_or("?"),
1656 author.email().unwrap_or("?")
1657 ));
1658 let when = format_relative_time(commit.time().seconds());
1659 info.head =
1660 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1661 }
1662 }
1663 } else if let Ok(head) = repo.head() {
1664 info.branch = head.shorthand().ok().map(String::from);
1665
1666 if let Ok(commit) = head.peel_to_commit() {
1667 let short_id = format!("{:.7}", commit.id());
1668 let summary_text =
1669 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1670 let author = commit.author();
1671 let author_str = sanitize_text(&format!(
1672 "{} <{}>",
1673 author.name().unwrap_or("?"),
1674 author.email().unwrap_or("?")
1675 ));
1676 let when = format_relative_time(commit.time().seconds());
1677 info.head =
1678 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1679 }
1680
1681 if let Ok(head_name) = head.name() {
1682 info.upstream = upstream_short_name(&repo, head_name);
1683 }
1684 }
1685
1686 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1687 info.commits = commits;
1688 }
1689
1690 populate_summary_and_file_changes(&repo, &mut info);
1691
1692 if let Ok(remotes) = load_tab_remotes(path) {
1693 info.remotes = TabData::Loaded(remotes);
1694 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1695 }
1696
1697 Ok(info)
1698}
1699
1700pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1701 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1702 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1703 let mut entries = Vec::new();
1704 for (i, entry) in reflog.iter().enumerate() {
1705 let target_oid = format!("{}", entry.id_new());
1706 let selector = format!("HEAD@{{{}}}", i);
1707 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1708
1709 let (command, message) = if let Some(pos) = msg.find(':') {
1710 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1711 } else {
1712 (String::new(), msg)
1713 };
1714
1715 let sig = entry.committer();
1716 let when_secs = sig.when().seconds();
1717 let when = format_relative_time(when_secs);
1718 let date = format_utc_date(when_secs);
1719
1720 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1721 }
1722 Ok(entries)
1723}
1724
1725pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1726 let output = std::process::Command::new("git")
1727 .env("GIT_TERMINAL_PROMPT", "0")
1728 .env("GIT_SSH_COMMAND", ssh_command_val())
1729 .arg("checkout")
1730 .arg(commit_oid)
1731 .current_dir(repo_path)
1732 .output()
1733 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1734
1735 if !output.status.success() {
1736 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1737 return Err(git2::Error::from_str(&err));
1738 }
1739 Ok(())
1740}
1741
1742pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1743 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1744 let mut files = Vec::new();
1745 if let Ok(index) = repo.index() {
1746 for entry in index.iter() {
1747 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1748 files.push(path_str.to_string());
1749 }
1750 }
1751 }
1752 Ok(files)
1753}
1754
1755pub fn load_tab_graph_stream(
1756 repo_path: &Path,
1757 graph_max_commits: usize,
1758 repo_resolved_path: String,
1759 tab_idx: usize,
1760 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1761) -> Result<Vec<GraphLine>, String> {
1762 let mut graph_lines = Vec::new();
1763 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1764
1765 let mut args = vec![
1766 "log".to_string(),
1767 "--graph".to_string(),
1768 "--all".to_string(),
1769 "--date=relative".to_string(),
1770 ];
1771 if graph_max_commits > 0 {
1772 args.push(format!("--max-count={}", graph_max_commits));
1773 }
1774 args.push(format!("--pretty=format:{}", format_str));
1775 args.push("--color=never".to_string());
1776
1777 let mut child = std::process::Command::new("git")
1778 .env("GIT_TERMINAL_PROMPT", "0")
1779 .env("GIT_SSH_COMMAND", ssh_command_val())
1780 .args(&args)
1781 .current_dir(repo_path)
1782 .stdout(std::process::Stdio::piped())
1783 .spawn()
1784 .map_err(|e| e.to_string())?;
1785
1786 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1787 let reader = std::io::BufReader::new(stdout);
1788 use std::io::BufRead;
1789
1790 for (idx, line_res) in reader.lines().enumerate() {
1791 let line = line_res.map_err(|e| e.to_string())?;
1792 let parsed = parse_graph_line(&line);
1793 graph_lines.push(parsed);
1794
1795 if (idx + 1) % 200 == 0 {
1797 let _ = tx.send((
1798 repo_resolved_path.clone(),
1799 tab_idx,
1800 TabPayload::Graph(Ok(graph_lines.clone())),
1801 ));
1802 }
1803 }
1804
1805 let status = child.wait().map_err(|e| e.to_string())?;
1807 if !status.success() && graph_lines.is_empty() {
1808 return Err("git log failed".to_string());
1809 }
1810
1811 Ok(graph_lines)
1812}
1813
1814pub fn load_tab_branches(
1815 repo_path: &Path,
1816) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1817 let repo = match Repository::open(repo_path) {
1818 Ok(r) => r,
1819 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1820 };
1821
1822 let mut local_branches = Vec::new();
1823 let is_detached = repo.head_detached().unwrap_or(false);
1824 if is_detached {
1825 let mut short_sha = String::new();
1826 let mut short_message = String::new();
1827 if let Ok(head) = repo.head() {
1828 if let Ok(target) = head.peel_to_commit() {
1829 let id = target.id();
1830 let id_str = id.to_string();
1831 short_sha = safe_sha_slice(&id_str, 7).to_string();
1832 if let Ok(Some(summary)) = target.summary() {
1833 short_message = summary.to_string();
1834 }
1835 }
1836 }
1837 local_branches.push(BranchInfo {
1838 name: "HEAD".to_string(),
1839 is_head: true,
1840 short_sha,
1841 short_message: sanitize_text(&short_message),
1842 });
1843 }
1844 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1845 for (branch, _) in branches.flatten() {
1846 if let Ok(Some(name)) = branch.name() {
1847 let is_head = branch.is_head();
1848 let mut short_sha = String::new();
1849 let mut short_message = String::new();
1850 if let Ok(target) = branch.get().peel_to_commit() {
1851 let id = target.id();
1852 let id_str = id.to_string();
1853 short_sha = safe_sha_slice(&id_str, 7).to_string();
1854 if let Ok(Some(summary)) = target.summary() {
1855 short_message = summary.to_string();
1856 }
1857 }
1858 local_branches.push(BranchInfo {
1859 name: sanitize_text(name),
1860 is_head,
1861 short_sha,
1862 short_message: sanitize_text(&short_message),
1863 });
1864 }
1865 }
1866 }
1867 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1868
1869 let mut remote_branches = Vec::new();
1870 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1871 for (branch, _) in branches.flatten() {
1872 if let Ok(Some(name)) = branch.name() {
1873 if !name.ends_with("/HEAD") {
1874 let is_head = branch.is_head();
1875 let mut short_sha = String::new();
1876 let mut short_message = String::new();
1877 if let Ok(target) = branch.get().peel_to_commit() {
1878 let id = target.id();
1879 let id_str = id.to_string();
1880 short_sha = safe_sha_slice(&id_str, 7).to_string();
1881 if let Ok(Some(summary)) = target.summary() {
1882 short_message = summary.to_string();
1883 }
1884 }
1885 remote_branches.push(BranchInfo {
1886 name: sanitize_text(name),
1887 is_head,
1888 short_sha,
1889 short_message: sanitize_text(&short_message),
1890 });
1891 }
1892 }
1893 }
1894 }
1895 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1896
1897 (Ok(local_branches), Ok(remote_branches))
1898}
1899
1900pub fn load_tab_tags(
1901 repo_path: &Path,
1902) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1903 let repo = match Repository::open(repo_path) {
1904 Ok(r) => r,
1905 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1906 };
1907
1908 let mut local_tags = Vec::new();
1909 if let Ok(tags) = repo.tag_names(None) {
1910 for tag_opt in tags.iter() {
1911 if let Ok(Some(tag)) = tag_opt {
1912 let mut short_sha = String::new();
1913 let mut short_message = String::new();
1914 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1915 if let Ok(target) = reference.peel_to_commit() {
1916 let id = target.id();
1917 let id_str = id.to_string();
1918 short_sha = safe_sha_slice(&id_str, 7).to_string();
1919 if let Ok(Some(summary)) = target.summary() {
1920 short_message = summary.to_string();
1921 }
1922 }
1923 }
1924 local_tags.push(BranchInfo {
1925 name: sanitize_text(tag),
1926 is_head: false,
1927 short_sha,
1928 short_message: sanitize_text(&short_message),
1929 });
1930 }
1931 }
1932 }
1933 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1934
1935 (Ok(local_tags), Ok(Vec::new()))
1936}
1937
1938pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1939 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1940 let mut remotes_list = Vec::new();
1941 if let Ok(remotes) = repo.remotes() {
1942 for name in remotes.iter() {
1943 let Ok(Some(name)) = name else { continue };
1944 if let Ok(remote) = repo.find_remote(name) {
1945 let push_url = remote.pushurl().ok().flatten().map(String::from);
1946 let mut refspecs = Vec::new();
1947 for r in remote.refspecs() {
1948 if let Ok(s) = r.str() {
1949 refspecs.push(s.to_string());
1950 }
1951 }
1952 remotes_list.push(RemoteInfo {
1953 name: name.to_string(),
1954 url: remote.url().unwrap_or("(no url)").to_string(),
1955 push_url,
1956 refspecs,
1957 });
1958 }
1959 }
1960 }
1961 Ok(remotes_list)
1962}
1963
1964pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1965 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1966 let mut temp_stashes = Vec::new();
1967 let _ = repo.stash_foreach(|index, message, oid| {
1968 temp_stashes.push((index, message.to_string(), *oid));
1969 true
1970 });
1971
1972 let mut stashes = Vec::new();
1973 for (index, message, oid) in temp_stashes {
1974 let mut files = Vec::new();
1975 if let Ok(commit) = repo.find_commit(oid) {
1976 files = commit_changed_files(&repo, &commit);
1977 }
1978 stashes.push(StashInfo {
1979 index,
1980 message: sanitize_text(&message),
1981 commit_id: oid.to_string(),
1982 files,
1983 });
1984 }
1985 Ok(stashes)
1986}
1987
1988pub fn load_tab_overview(
1989 repo_path: &Path,
1990 _commit_limit: usize,
1991) -> Result<(Vec<CommitterStat>, bool), String> {
1992 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1993 let (stats, limit_reached) =
1994 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
1995 Ok((stats, limit_reached))
1996}
1997
1998pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1999 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2000 let submodules = repo.submodules().map_err(|e| e.to_string())?;
2001 let mut list = Vec::new();
2002 for sub in submodules {
2003 let name = sub.name().unwrap_or("").to_string();
2004 let path = sub.path().to_path_buf();
2005 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
2006 let commit_id = sub.index_id().map(|id| id.to_string());
2007 let head_id = sub.head_id().map(|id| id.to_string());
2008
2009 let mut is_initialized = true;
2010 let mut is_dirty = false;
2011
2012 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
2013 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
2014 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
2015 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
2016 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
2017 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
2018 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
2019 }
2020
2021 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
2022 }
2023 Ok(list)
2024}
2025
2026fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2031 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
2032 std::collections::HashMap::new();
2033
2034 if let Ok(refs) = repo.references() {
2035 for reference in refs.flatten() {
2036 let Ok(target) = reference.peel_to_commit() else {
2038 continue;
2039 };
2040 let oid = target.id();
2041
2042 let Ok(full_name) = reference.name() else {
2043 continue;
2044 };
2045
2046 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
2047 branch.to_string()
2048 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
2049 format!("tag:{}", tag)
2050 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
2051 if remote.ends_with("/HEAD") {
2053 continue;
2054 }
2055 format!("remote:{}", remote)
2056 } else {
2057 continue;
2058 };
2059
2060 map.entry(oid).or_default().push(label);
2061 }
2062 }
2063
2064 if repo.head_detached().unwrap_or(false) {
2065 if let Ok(head) = repo.head() {
2066 if let Ok(target) = head.peel_to_commit() {
2067 let oid = target.id();
2068 let label = "head:HEAD".to_string();
2069 let mut entries = map.remove(&oid).unwrap_or_default();
2070 entries.insert(0, label);
2071 map.insert(oid, entries);
2072 }
2073 }
2074 }
2075
2076 map
2077}
2078
2079#[allow(clippy::type_complexity)]
2080static REF_MAP_CACHE: std::sync::OnceLock<
2081 std::sync::Mutex<
2082 std::collections::HashMap<
2083 String,
2084 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
2085 >,
2086 >,
2087> = std::sync::OnceLock::new();
2088
2089fn get_cached_ref_map(
2090 repo: &Repository,
2091 repo_path: &Path,
2092) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2093 let cache_lock =
2094 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
2095 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
2096 let path_key = repo_path.to_string_lossy().to_string();
2097
2098 if let Some((map, loaded_at)) = cache.get(&path_key) {
2099 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
2100 return map.clone();
2101 }
2102 }
2103
2104 let map = build_ref_map(repo);
2105 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
2106 map
2107}
2108
2109pub fn invalidate_ref_map_cache(repo_path: &Path) {
2110 if let Some(cache_lock) = REF_MAP_CACHE.get() {
2111 if let Ok(mut cache) = cache_lock.lock() {
2112 cache.remove(&repo_path.to_string_lossy().to_string());
2113 }
2114 }
2115}
2116
2117const MAX_FILES_PER_SECTION: usize = 100;
2120
2121fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
2123 let mut opts = StatusOptions::new();
2124 opts.include_untracked(true)
2125 .renames_head_to_index(true)
2126 .recurse_untracked_dirs(true)
2127 .show(StatusShow::IndexAndWorkdir);
2128 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2129 return;
2130 };
2131 for entry in statuses.iter() {
2132 let path = entry.path().unwrap_or("(unknown)").to_string();
2133 let flags = entry.status();
2134
2135 if flags.is_conflicted() {
2137 info.summary.conflicted += 1;
2138 } else {
2139 if flags.is_wt_new() {
2140 info.summary.untracked += 1;
2141 }
2142 if flags.is_wt_modified()
2143 || flags.is_wt_deleted()
2144 || flags.is_wt_renamed()
2145 || flags.is_wt_typechange()
2146 {
2147 info.summary.modified += 1;
2148 }
2149 if flags.is_index_new()
2150 || flags.is_index_modified()
2151 || flags.is_index_deleted()
2152 || flags.is_index_renamed()
2153 || flags.is_index_typechange()
2154 {
2155 info.summary.staged += 1;
2156 }
2157 }
2158
2159 let is_dir = if flags.is_wt_new() && !flags.is_index_new() {
2163 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2164 path_buf.is_dir()
2165 } else {
2166 false
2167 };
2168 if is_dir {
2169 continue;
2170 }
2171
2172 if flags.is_conflicted() {
2173 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2174 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2175 }
2176 continue;
2177 }
2178
2179 if (flags.is_index_new()
2181 || flags.is_index_modified()
2182 || flags.is_index_deleted()
2183 || flags.is_index_renamed()
2184 || flags.is_index_typechange())
2185 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2186 {
2187 let label = if flags.is_index_new() {
2188 "N"
2189 } else if flags.is_index_deleted() {
2190 "D"
2191 } else if flags.is_index_renamed() {
2192 "R"
2193 } else if flags.is_index_typechange() {
2194 "T"
2195 } else {
2196 "M"
2197 };
2198 info.changes.staged.push(FileEntry { path: path.clone(), label });
2199 }
2200
2201 if flags.is_wt_new() {
2203 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2204 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2205 }
2206 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2207 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2208 }
2209 } else if (flags.is_wt_modified()
2210 || flags.is_wt_deleted()
2211 || flags.is_wt_renamed()
2212 || flags.is_wt_typechange())
2213 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2214 {
2215 let label = if flags.is_wt_deleted() {
2216 "D"
2217 } else if flags.is_wt_renamed() {
2218 "R"
2219 } else if flags.is_wt_typechange() {
2220 "T"
2221 } else {
2222 "M"
2223 };
2224 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2225 }
2226 }
2227}
2228
2229fn collect_summary(repo: &Repository) -> RepoSummary {
2233 let mut s = RepoSummary::default();
2234 let is_detached = repo.head_detached().unwrap_or(false);
2235 if is_detached {
2236 s.branch = Some("HEAD".to_string());
2237 if let Ok(head) = repo.head() {
2238 if let Ok(commit) = head.peel_to_commit() {
2239 s.last_commit_time = Some(commit.time().seconds());
2240 }
2241 }
2242 } else if let Ok(head) = repo.head() {
2243 s.branch = head.shorthand().ok().map(String::from);
2244 if let Ok(commit) = head.peel_to_commit() {
2245 s.last_commit_time = Some(commit.time().seconds());
2246 }
2247 }
2248 populate_worktree(repo, &mut s);
2249 populate_ahead_behind(repo, &mut s);
2250 s.state = match repo.state() {
2251 git2::RepositoryState::Clean => RepoState::Clean,
2252 git2::RepositoryState::Merge => RepoState::Merge,
2253 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2254 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2255 RepoState::CherryPick
2256 }
2257 git2::RepositoryState::Bisect => RepoState::Bisect,
2258 git2::RepositoryState::Rebase
2259 | git2::RepositoryState::RebaseInteractive
2260 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2261 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2262 RepoState::ApplyMailbox
2263 }
2264 };
2265 s
2266}
2267
2268fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2269 let mut opts = StatusOptions::new();
2270 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2271 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2272 return;
2273 };
2274 for entry in statuses.iter() {
2275 let flags = entry.status();
2276 if flags.is_conflicted() {
2277 s.conflicted += 1;
2278 continue;
2279 }
2280 if flags.is_wt_new() {
2281 s.untracked += 1;
2282 }
2283 if flags.is_wt_modified()
2284 || flags.is_wt_deleted()
2285 || flags.is_wt_renamed()
2286 || flags.is_wt_typechange()
2287 {
2288 s.modified += 1;
2289 }
2290 if flags.is_index_new()
2291 || flags.is_index_modified()
2292 || flags.is_index_deleted()
2293 || flags.is_index_renamed()
2294 || flags.is_index_typechange()
2295 {
2296 s.staged += 1;
2297 }
2298 }
2299}
2300
2301fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2305 let Ok(head) = repo.head() else { return };
2306 let Some(local_oid) = head.target() else {
2307 return;
2308 };
2309 let Ok(head_name) = head.name() else { return };
2310 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2311 return;
2312 };
2313 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2314 return;
2315 };
2316 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2317 return;
2318 };
2319 let Some(upstream_oid) = upstream_ref.target() else {
2320 return;
2321 };
2322 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2323 s.ahead = ahead;
2324 s.behind = behind;
2325 }
2326}
2327
2328fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2330 let buf = repo.branch_upstream_name(head_name).ok()?;
2331 let raw = std::str::from_utf8(&buf).ok()?;
2332 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2333}
2334
2335pub fn format_relative_time(secs: i64) -> String {
2337 if secs <= 0 {
2338 return "unknown".to_string();
2339 }
2340 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2341 let now = SystemTime::now();
2342 let Ok(elapsed) = now.duration_since(then) else {
2343 return "in the future".to_string();
2344 };
2345 let secs = elapsed.as_secs();
2346 let (n, unit) = if secs < 60 {
2347 (secs, "second")
2348 } else if secs < 3600 {
2349 (secs / 60, "minute")
2350 } else if secs < 86_400 {
2351 (secs / 3600, "hour")
2352 } else if secs < 86_400 * 30 {
2353 (secs / 86_400, "day")
2354 } else if secs < 86_400 * 365 {
2355 (secs / (86_400 * 30), "month")
2356 } else {
2357 (secs / (86_400 * 365), "year")
2358 };
2359 let plural = if n == 1 { "" } else { "s" };
2360 format!("{} {}{} ago", n, unit, plural)
2361}
2362
2363fn format_utc_date(secs: i64) -> String {
2365 if secs <= 0 {
2366 return "unknown".to_string();
2367 }
2368 let seconds_in_day = 86400;
2369 let day_number = secs / seconds_in_day;
2370 let time_of_day = secs % seconds_in_day;
2371
2372 let mut hour = time_of_day / 3600;
2373 let mut minute = (time_of_day % 3600) / 60;
2374 let mut second = time_of_day % 60;
2375 if hour < 0 {
2376 hour += 24;
2377 }
2378 if minute < 0 {
2379 minute += 60;
2380 }
2381 if second < 0 {
2382 second += 60;
2383 }
2384
2385 let z = day_number + 719468;
2387 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2388 let doe = (z - era * 146097) as u32;
2389 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2390 let y = (yoe as i32) + (era as i32) * 400;
2391 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2392 let mp = (5 * doy + 2) / 153;
2393 let d = doy - (153 * mp + 2) / 5 + 1;
2394 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2395 let y = y + if m <= 2 { 1 } else { 0 };
2396
2397 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2398}
2399
2400fn get_file_diff_inner(
2403 repo_path: &Path,
2404 commit_oid: &str,
2405 file_path: &str,
2406) -> Option<Vec<DiffLine>> {
2407 let repo = Repository::open(repo_path).ok()?;
2408 let oid = git2::Oid::from_str(commit_oid).ok()?;
2409 let commit = repo.find_commit(oid).ok()?;
2410
2411 let commit_tree = commit.tree().ok()?;
2412 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2414
2415 let mut opts = git2::DiffOptions::new();
2416 opts.pathspec(file_path);
2417
2418 let diff =
2419 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2420
2421 collect_diff_lines(&diff)
2422}
2423
2424fn get_worktree_diff_inner(
2429 repo_path: &Path,
2430 file_path: &str,
2431 staged: bool,
2432) -> Option<Vec<DiffLine>> {
2433 let repo = Repository::open(repo_path).ok()?;
2434 let mut opts = git2::DiffOptions::new();
2435 opts.pathspec(file_path);
2436 opts.include_untracked(true);
2437 opts.recurse_untracked_dirs(true);
2438 opts.show_untracked_content(true);
2439
2440 let diff = if staged {
2441 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2443 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2444 } else {
2445 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2447 };
2448
2449 collect_diff_lines(&diff)
2450}
2451
2452fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2454 let mut lines: Vec<DiffLine> = Vec::new();
2455 let mut current_hunk_idx = None;
2456 let mut hunk_count = 0;
2457 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2458 let kind = match line.origin() {
2459 '+' => DiffLineKind::Added,
2460 '-' => DiffLineKind::Removed,
2461 'H' => {
2462 current_hunk_idx = Some(hunk_count);
2463 hunk_count += 1;
2464 DiffLineKind::Header
2465 }
2466 ' ' => DiffLineKind::Context,
2467 _ => return true, };
2469 let content = String::from_utf8_lossy(line.content())
2470 .trim_end_matches('\n')
2471 .trim_end_matches('\r')
2472 .to_string();
2473 lines.push(DiffLine {
2474 kind,
2475 content,
2476 old_lineno: line.old_lineno(),
2477 new_lineno: line.new_lineno(),
2478 hunk_idx: current_hunk_idx,
2479 });
2480 true
2481 })
2482 .ok()?;
2483 Some(lines)
2484}
2485
2486fn parse_graph_line(line: &str) -> GraphLine {
2487 if line.contains("__TWIG_SEP__") {
2488 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2489 if parts.len() >= 5 {
2490 let graph_and_hash = parts[0];
2491 let decoration = parts[1].trim().to_string();
2492 let summary = parts[2].trim().to_string();
2493 let author = parts[3].trim().to_string();
2494 let date = parts[4].trim().to_string();
2495 let signature_status =
2496 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2497
2498 let char_count = graph_and_hash.chars().count();
2499 if char_count >= 40 {
2500 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2501 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2502 GraphLine {
2503 graph,
2504 commit: Some(GraphCommit {
2505 oid,
2506 decoration,
2507 summary,
2508 author,
2509 date,
2510 signature_status,
2511 }),
2512 }
2513 } else {
2514 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2515 }
2516 } else {
2517 GraphLine { graph: line.to_string(), commit: None }
2518 }
2519 } else {
2520 GraphLine { graph: line.to_string(), commit: None }
2521 }
2522}
2523
2524#[allow(dead_code)]
2525fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2526 let mut graph_lines = Vec::new();
2527 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2528
2529 let mut args = vec![
2530 "log".to_string(),
2531 "--graph".to_string(),
2532 "--all".to_string(),
2533 "--date=relative".to_string(),
2534 ];
2535 if graph_max_commits > 0 {
2536 args.push(format!("--max-count={}", graph_max_commits));
2537 }
2538 args.push(format!("--pretty=format:{}", format_str));
2539 args.push("--color=never".to_string());
2540
2541 let output = std::process::Command::new("git")
2542 .env("GIT_TERMINAL_PROMPT", "0")
2543 .env("GIT_SSH_COMMAND", ssh_command_val())
2544 .args(&args)
2545 .current_dir(repo_path)
2546 .output();
2547
2548 if let Ok(out) = output {
2549 if out.status.success() {
2550 let stdout_str = String::from_utf8_lossy(&out.stdout);
2551 for line in stdout_str.lines() {
2552 graph_lines.push(parse_graph_line(line));
2553 }
2554 }
2555 }
2556 graph_lines
2557}
2558
2559pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2560 let output = std::process::Command::new("git")
2561 .env("GIT_TERMINAL_PROMPT", "0")
2562 .env("GIT_SSH_COMMAND", ssh_command_val())
2563 .arg("checkout")
2564 .arg(branch_name)
2565 .current_dir(repo_path)
2566 .output()
2567 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2568
2569 if !output.status.success() {
2570 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2571 return Err(git2::Error::from_str(&err));
2572 }
2573 Ok(())
2574}
2575
2576pub fn checkout_remote_branch(
2577 repo_path: &Path,
2578 remote_branch_name: &str,
2579) -> Result<String, git2::Error> {
2580 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2581 if parts.len() < 2 {
2582 return Err(git2::Error::from_str("Invalid remote branch name"));
2583 }
2584 let local_name = parts[1];
2585
2586 let output = std::process::Command::new("git")
2587 .env("GIT_TERMINAL_PROMPT", "0")
2588 .env("GIT_SSH_COMMAND", ssh_command_val())
2589 .arg("checkout")
2590 .arg(local_name)
2591 .current_dir(repo_path)
2592 .output()
2593 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2594
2595 if output.status.success() {
2596 return Ok(format!("Switched to existing branch '{}'", local_name));
2597 }
2598
2599 let output = std::process::Command::new("git")
2600 .env("GIT_TERMINAL_PROMPT", "0")
2601 .env("GIT_SSH_COMMAND", ssh_command_val())
2602 .arg("checkout")
2603 .arg("--track")
2604 .arg(remote_branch_name)
2605 .current_dir(repo_path)
2606 .output()
2607 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2608
2609 if !output.status.success() {
2610 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2611 return Err(git2::Error::from_str(&err));
2612 }
2613
2614 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2615}
2616
2617pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2619 let repo = Repository::open(repo_path)?;
2620 let head = repo.head()?;
2621 let target_commit = head.peel_to_commit()?;
2622 repo.branch(branch_name, &target_commit, false)?;
2623 Ok(())
2624}
2625
2626pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2628 let repo = Repository::open(repo_path)?;
2629 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2630 branch.delete()?;
2631 Ok(())
2632}
2633
2634pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2636 let repo = Repository::open(repo_path)?;
2637 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2638 branch.delete()?;
2639 Ok(())
2640}
2641
2642pub fn create_tag(
2644 repo_path: &Path,
2645 tag_name: &str,
2646 commit_oid_str: &str,
2647 message: Option<&str>,
2648 force: bool,
2649) -> Result<(), git2::Error> {
2650 let repo = Repository::open(repo_path)?;
2651 let oid = git2::Oid::from_str(commit_oid_str)?;
2652 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2653 if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2654 let tagger_sig = repo.signature();
2655 let default_sig;
2656 let tagger = match &tagger_sig {
2657 Ok(s) => s,
2658 Err(_) => {
2659 if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2660 default_sig = sig;
2661 &default_sig
2662 } else {
2663 return Err(git2::Error::from_str("Failed to create signature"));
2664 }
2665 }
2666 };
2667 repo.tag(tag_name, &target_object, tagger, msg, force)?;
2668 } else {
2669 repo.tag_lightweight(tag_name, &target_object, force)?;
2670 }
2671 Ok(())
2672}
2673
2674pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2676 if let Ok(repo) = Repository::open(repo_path) {
2677 let ref_name = format!("refs/tags/{}", tag_name);
2678 repo.find_reference(&ref_name).is_ok()
2679 } else {
2680 false
2681 }
2682}
2683
2684pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2686 let repo = Repository::open(repo_path)?;
2687 repo.tag_delete(tag_name)?;
2688 Ok(())
2689}
2690
2691pub fn delete_remote_tag(
2693 repo_path: &Path,
2694 remote_name: &str,
2695 tag_name: &str,
2696 timeout: Duration,
2697) -> Result<(), Box<dyn std::error::Error>> {
2698 let safe_remote = safe_ref(remote_name)?;
2699 let safe_tag = safe_ref(tag_name)?;
2700 let mut cmd = git_command();
2701 cmd.arg("push").arg(safe_remote).arg("--delete").arg(safe_tag).current_dir(repo_path);
2702 let output = run_git_with_timeout(cmd, timeout)?;
2703 if !output.status.success() {
2704 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2705 return Err(err.into());
2706 }
2707 Ok(())
2708}
2709
2710pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2711 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2712 let output = git_command()
2713 .arg("checkout")
2714 .arg(safe_tag)
2715 .current_dir(repo_path)
2716 .output()
2717 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2718
2719 if !output.status.success() {
2720 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2721 return Err(git2::Error::from_str(&err));
2722 }
2723 Ok(())
2724}
2725
2726pub fn get_remote_tags(
2728 repo_path: &Path,
2729 remote_name: &str,
2730 timeout: Duration,
2731) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2732 let safe_remote = safe_ref(remote_name)?;
2733 let mut cmd = git_command();
2734 cmd.arg("ls-remote").arg("--tags").arg(safe_remote).current_dir(repo_path);
2735 let output = run_git_with_timeout(cmd, timeout)?;
2736
2737 if !output.status.success() {
2738 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2739 return Err(err.into());
2740 }
2741
2742 let stdout = String::from_utf8_lossy(&output.stdout);
2743 let repo = git2::Repository::open(repo_path)?;
2744 let mut tags_map = std::collections::HashMap::new();
2745
2746 for line in stdout.lines() {
2747 let parts: Vec<&str> = line.split_whitespace().collect();
2748 if parts.len() >= 2 {
2749 let sha = parts[0];
2750 let ref_name = parts[1];
2751 if ref_name.starts_with("refs/tags/") {
2752 let is_peeled = ref_name.ends_with("^{}");
2753 let clean_ref = if is_peeled {
2754 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2755 } else {
2756 ref_name
2757 };
2758 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2759 let short_sha = safe_sha_slice(sha, 7);
2760
2761 let mut short_message = String::new();
2763 if let Ok(oid) = git2::Oid::from_str(sha) {
2764 if let Ok(commit) = repo.find_commit(oid) {
2765 if let Ok(Some(summary)) = commit.summary() {
2766 short_message = summary.to_string();
2767 }
2768 }
2769 }
2770 if short_message.is_empty() {
2771 short_message = "(not fetched)".to_string();
2772 }
2773
2774 let sanitized_tag = sanitize_text(tag_name);
2775 let sanitized_msg = sanitize_text(&short_message);
2776
2777 if is_peeled {
2778 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2779 } else {
2780 tags_map
2781 .entry(sanitized_tag)
2782 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2783 }
2784 }
2785 }
2786 }
2787
2788 let mut tags = Vec::new();
2789 for (name, (short_sha, short_message)) in tags_map {
2790 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2791 }
2792 tags.sort_by(|a, b| b.name.cmp(&a.name));
2793 Ok(tags)
2794}
2795
2796pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2797 let mut s = String::new();
2798 for tag in tags {
2799 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2800 }
2801 s
2802}
2803
2804pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2805 let mut tags = Vec::new();
2806 for line in s.lines() {
2807 let parts: Vec<&str> = line.split('|').collect();
2808 if parts.len() >= 3 {
2809 tags.push(BranchInfo {
2810 name: parts[0].to_string(),
2811 is_head: false,
2812 short_sha: parts[1].to_string(),
2813 short_message: parts[2].to_string(),
2814 });
2815 }
2816 }
2817 tags
2818}
2819
2820pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2821 let mut repo = Repository::open(repo_path)?;
2822 repo.stash_drop(index)?;
2823 Ok(())
2824}
2825
2826pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2827 let stash_ref = format!("stash@{{{}}}", index);
2828 let output = std::process::Command::new("git")
2829 .env("GIT_TERMINAL_PROMPT", "0")
2830 .env("GIT_SSH_COMMAND", ssh_command_val())
2831 .arg("stash")
2832 .arg("apply")
2833 .arg(&stash_ref)
2834 .current_dir(repo_path)
2835 .output()
2836 .map_err(|e| e.to_string())?;
2837
2838 if !output.status.success() {
2839 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2840 return Err(err_msg);
2841 }
2842 Ok(())
2843}
2844
2845pub fn save_stash(
2846 repo_path: &Path,
2847 message: &str,
2848 include_untracked: bool,
2849 keep_index: bool,
2850) -> Result<(), String> {
2851 let mut cmd = std::process::Command::new("git");
2852 cmd.env("GIT_TERMINAL_PROMPT", "0")
2853 .env("GIT_SSH_COMMAND", ssh_command_val())
2854 .arg("stash")
2855 .arg("push");
2856
2857 if include_untracked {
2858 cmd.arg("--include-untracked");
2859 }
2860 if keep_index {
2861 cmd.arg("--keep-index");
2862 }
2863
2864 if !message.is_empty() {
2865 cmd.arg("-m").arg(message);
2866 }
2867
2868 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2869
2870 if !output.status.success() {
2871 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2872 return Err(err_msg);
2873 }
2874 Ok(())
2875}
2876
2877pub fn get_latest_change_time(item: &str) -> u64 {
2878 let path = expand_tilde(item);
2879 if !path.exists() {
2880 return 0;
2881 }
2882
2883 if path.join(".git").exists() {
2884 if let Ok(repo) = Repository::open(&path) {
2885 if let Ok(head) = repo.head() {
2886 if let Ok(commit) = head.peel_to_commit() {
2887 return commit.time().seconds() as u64;
2888 }
2889 }
2890 }
2891 }
2892
2893 if let Ok(meta) = std::fs::metadata(&path) {
2894 if let Ok(modified) = meta.modified() {
2895 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2896 return duration.as_secs();
2897 }
2898 }
2899 }
2900 0
2901}
2902
2903pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2904 if let Ok(repo) = Repository::open(repo_path) {
2905 if let Ok(head) = repo.head() {
2906 if let Ok(commit) = head.peel_to_commit() {
2907 if let Ok(msg) = commit.message() {
2908 return Some(msg.to_string());
2909 }
2910 }
2911 }
2912 }
2913 None
2914}
2915
2916pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2917 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2918 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2919 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2920
2921 let mut index = repo.index().map_err(|e| e.to_string())?;
2922 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2923 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2924
2925 let signature = repo
2926 .signature()
2927 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2928
2929 head_commit
2930 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2931 .map_err(|e| e.to_string())?;
2932
2933 Ok(())
2934}
2935
2936pub fn is_merging(repo_path: &Path) -> bool {
2941 repo_path.join(".git/MERGE_HEAD").exists()
2942}
2943
2944pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2947 let full_path = repo_path.join(file_path);
2948 let content = match std::fs::read_to_string(&full_path) {
2949 Ok(s) => s,
2950 Err(_) => return Vec::new(),
2951 };
2952
2953 let mut lines = Vec::new();
2954 let mut in_ours = false;
2955 let mut in_theirs = false;
2956
2957 for line in content.lines() {
2958 if line.starts_with("<<<<<<<") {
2959 in_ours = true;
2960 in_theirs = false;
2961 lines.push(DiffLine {
2962 kind: DiffLineKind::ConflictSeparator,
2963 content: line.to_string(),
2964 old_lineno: None,
2965 new_lineno: None,
2966 hunk_idx: None,
2967 });
2968 } else if line.starts_with("=======") {
2969 in_ours = false;
2970 in_theirs = true;
2971 lines.push(DiffLine {
2972 kind: DiffLineKind::ConflictSeparator,
2973 content: line.to_string(),
2974 old_lineno: None,
2975 new_lineno: None,
2976 hunk_idx: None,
2977 });
2978 } else if line.starts_with(">>>>>>>") {
2979 in_ours = false;
2980 in_theirs = false;
2981 lines.push(DiffLine {
2982 kind: DiffLineKind::ConflictSeparator,
2983 content: line.to_string(),
2984 old_lineno: None,
2985 new_lineno: None,
2986 hunk_idx: None,
2987 });
2988 } else if in_ours {
2989 lines.push(DiffLine {
2990 kind: DiffLineKind::ConflictOurs,
2991 content: line.to_string(),
2992 old_lineno: None,
2993 new_lineno: None,
2994 hunk_idx: None,
2995 });
2996 } else if in_theirs {
2997 lines.push(DiffLine {
2998 kind: DiffLineKind::ConflictTheirs,
2999 content: line.to_string(),
3000 old_lineno: None,
3001 new_lineno: None,
3002 hunk_idx: None,
3003 });
3004 } else {
3005 lines.push(DiffLine {
3006 kind: DiffLineKind::Context,
3007 content: line.to_string(),
3008 old_lineno: None,
3009 new_lineno: None,
3010 hunk_idx: None,
3011 });
3012 }
3013 }
3014 lines
3015}
3016
3017pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
3020 let output1 = std::process::Command::new("git")
3021 .env("GIT_TERMINAL_PROMPT", "0")
3022 .env("GIT_SSH_COMMAND", ssh_command_val())
3023 .args(["checkout", "--ours", file_path])
3024 .current_dir(repo_path)
3025 .output()
3026 .map_err(|e| e.to_string())?;
3027 if !output1.status.success() {
3028 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3029 }
3030 stage_file(repo_path, file_path)?;
3031 Ok(())
3032}
3033
3034pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
3037 let output1 = std::process::Command::new("git")
3038 .env("GIT_TERMINAL_PROMPT", "0")
3039 .env("GIT_SSH_COMMAND", ssh_command_val())
3040 .args(["checkout", "--theirs", file_path])
3041 .current_dir(repo_path)
3042 .output()
3043 .map_err(|e| e.to_string())?;
3044 if !output1.status.success() {
3045 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3046 }
3047 stage_file(repo_path, file_path)?;
3048 Ok(())
3049}
3050
3051pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
3053 stage_file(repo_path, file_path)
3054}
3055
3056pub fn resolve_conflict_hunk(
3060 repo_path: &Path,
3061 file_path: &str,
3062 hunk_idx: usize,
3063 accept_ours: bool,
3064) -> Result<(), String> {
3065 let full_path = repo_path.join(file_path);
3066 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3067
3068 let mut new_lines = Vec::new();
3069 let mut lines_iter = content.lines().peekable();
3070 let mut current_hunk_idx = 0;
3071
3072 while let Some(line) = lines_iter.next() {
3073 if line.starts_with("<<<<<<<") {
3074 let mut ours_block = Vec::new();
3075 let mut theirs_block = Vec::new();
3076
3077 let mut found_separator = false;
3079 while let Some(&next_line) = lines_iter.peek() {
3080 if next_line.starts_with("=======") {
3081 lines_iter.next(); found_separator = true;
3083 break;
3084 }
3085 if let Some(line) = lines_iter.next() {
3086 ours_block.push(line.to_string());
3087 }
3088 }
3089
3090 let mut found_end = false;
3092 let mut end_line_marker = ">>>>>>>".to_string();
3093 while let Some(&next_line) = lines_iter.peek() {
3094 if next_line.starts_with(">>>>>>>") {
3095 if let Some(marker) = lines_iter.next() {
3096 end_line_marker = marker.to_string(); }
3098 found_end = true;
3099 break;
3100 }
3101 if let Some(line) = lines_iter.next() {
3102 theirs_block.push(line.to_string());
3103 }
3104 }
3105
3106 if current_hunk_idx == hunk_idx {
3107 if accept_ours {
3108 new_lines.extend(ours_block);
3109 } else {
3110 new_lines.extend(theirs_block);
3111 }
3112 } else {
3113 new_lines.push(line.to_string());
3114 new_lines.extend(ours_block);
3115 if found_separator {
3116 new_lines.push("=======".to_string());
3117 }
3118 new_lines.extend(theirs_block);
3119 if found_end {
3120 new_lines.push(end_line_marker);
3121 }
3122 }
3123
3124 current_hunk_idx += 1;
3125 } else {
3126 new_lines.push(line.to_string());
3127 }
3128 }
3129
3130 let mut new_content = new_lines.join("\n");
3131 if content.ends_with('\n') && !new_content.ends_with('\n') {
3132 new_content.push('\n');
3133 }
3134 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3135
3136 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3138 let has_conflict_markers = updated_content
3139 .lines()
3140 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3141
3142 if !has_conflict_markers {
3143 stage_file(repo_path, file_path)?;
3144 }
3145
3146 Ok(())
3147}
3148
3149pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3151 let output = std::process::Command::new("git")
3152 .env("GIT_TERMINAL_PROMPT", "0")
3153 .env("GIT_SSH_COMMAND", ssh_command_val())
3154 .args(["merge", "--abort"])
3155 .current_dir(repo_path)
3156 .output()
3157 .map_err(|e| e.to_string())?;
3158 if !output.status.success() {
3159 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3160 }
3161 Ok(())
3162}
3163
3164pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3166 let output = std::process::Command::new("git")
3167 .env("GIT_TERMINAL_PROMPT", "0")
3168 .env("GIT_SSH_COMMAND", ssh_command_val())
3169 .args(["merge", "--continue"])
3170 .env("GIT_EDITOR", "true")
3171 .current_dir(repo_path)
3172 .output()
3173 .map_err(|e| e.to_string())?;
3174 if !output.status.success() {
3175 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3176 }
3177 Ok(())
3178}
3179
3180pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3182 let repo = Repository::open(repo_path).ok()?;
3183 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3184 let _upstream = branch.upstream().ok()?;
3185 let local_ref = branch.get().name().ok()?;
3186 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3187 remote_buf.as_str().ok().map(|s| s.to_string())
3188}
3189
3190pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3192 get_branch_upstream_remote(repo_path, branch_name).is_some()
3193}
3194
3195pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3197 let repo = Repository::open(repo_path).ok()?;
3198 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3199 if branch.upstream().is_ok() {
3200 if let Ok(local_ref) = branch.get().name() {
3201 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3202 if let Ok(name) = remote_buf.as_str() {
3203 return Some((name.to_string(), false));
3204 }
3205 }
3206 }
3207 }
3208 let remotes = repo.remotes().ok()?;
3209 let first_remote = remotes.iter().next()?.ok()??.to_string();
3210 Some((first_remote, true))
3211}
3212
3213pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3215 if let Ok(repo) = Repository::open(repo_path) {
3216 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3217 if let Ok(commit) = repo.find_commit(oid) {
3218 return commit.parent_count() == 0;
3219 }
3220 }
3221 }
3222 false
3223}
3224
3225pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3226 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3227 let mut worktrees_list = Vec::new();
3228 if let Ok(worktree_names) = repo.worktrees() {
3229 for name in worktree_names.iter() {
3230 let Ok(Some(wt_name)) = name else { continue };
3231 if let Ok(wt) = repo.find_worktree(wt_name) {
3232 let path = wt.path().to_path_buf();
3233 let mut branch = None;
3234 if let Ok(wt_repo) = Repository::open(&path) {
3235 if let Ok(head) = wt_repo.head() {
3236 branch = head.shorthand().map(String::from).ok();
3237 }
3238 }
3239
3240 let mut is_locked = false;
3241 let mut lock_reason = None;
3242 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3243 is_locked = true;
3244 if let Some(reason) = reason_opt {
3245 if !reason.is_empty() {
3246 lock_reason = Some(reason);
3247 }
3248 }
3249 }
3250
3251 worktrees_list.push(WorktreeInfo {
3252 name: wt_name.to_string(),
3253 path,
3254 branch,
3255 is_locked,
3256 lock_reason,
3257 });
3258 }
3259 }
3260 }
3261 Ok(worktrees_list)
3262}
3263
3264pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3265 let output = std::process::Command::new("git")
3266 .arg("worktree")
3267 .arg("add")
3268 .arg(wt_path)
3269 .arg(branch)
3270 .current_dir(repo_path)
3271 .output()
3272 .map_err(|e| e.to_string())?;
3273
3274 if !output.status.success() {
3275 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3276 }
3277 Ok(())
3278}
3279
3280pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3281 let output = std::process::Command::new("git")
3282 .arg("worktree")
3283 .arg("lock")
3284 .arg("--reason")
3285 .arg(reason)
3286 .arg(name)
3287 .current_dir(repo_path)
3288 .output()
3289 .map_err(|e| e.to_string())?;
3290
3291 if !output.status.success() {
3292 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3293 }
3294 Ok(())
3295}
3296
3297pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3298 let output = std::process::Command::new("git")
3299 .arg("worktree")
3300 .arg("unlock")
3301 .arg(name)
3302 .current_dir(repo_path)
3303 .output()
3304 .map_err(|e| e.to_string())?;
3305
3306 if !output.status.success() {
3307 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3308 }
3309 Ok(())
3310}
3311
3312pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3313 let mut cmd = std::process::Command::new("git");
3314 cmd.arg("worktree").arg("remove");
3315 if force {
3316 cmd.arg("--force");
3317 }
3318 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3319
3320 if !output.status.success() {
3321 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3322 }
3323 Ok(())
3324}
3325
3326pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3327 let output = std::process::Command::new("git")
3328 .arg("worktree")
3329 .arg("prune")
3330 .current_dir(repo_path)
3331 .output()
3332 .map_err(|e| e.to_string())?;
3333
3334 if !output.status.success() {
3335 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3336 }
3337 Ok(())
3338}
3339
3340pub fn load_tab_forge_issues(
3341 repo_path: &Path,
3342 assigned_only: bool,
3343) -> Result<Vec<ForgeIssue>, String> {
3344 let mut cmd = std::process::Command::new("gh");
3345 cmd.arg("issue").arg("list");
3346 if assigned_only {
3347 cmd.arg("--assignee").arg("@me");
3348 }
3349 cmd.arg("--limit")
3350 .arg("50")
3351 .arg("--json")
3352 .arg("number,title,state,author,assignees,url")
3353 .current_dir(repo_path);
3354
3355 let output = match cmd.output() {
3356 Ok(out) => out,
3357 Err(e) => {
3358 return Err(format!(
3359 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3360 e
3361 ));
3362 }
3363 };
3364
3365 if !output.status.success() {
3366 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3367 return Err(format!(
3368 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3369 err_msg
3370 ));
3371 }
3372
3373 #[derive(serde::Deserialize)]
3374 struct GhAuthor {
3375 login: String,
3376 }
3377
3378 #[derive(serde::Deserialize)]
3379 struct GhAssignee {
3380 login: String,
3381 }
3382
3383 #[derive(serde::Deserialize)]
3384 struct GhIssue {
3385 number: u32,
3386 title: String,
3387 state: String,
3388 author: Option<GhAuthor>,
3389 assignees: Option<Vec<GhAssignee>>,
3390 url: String,
3391 }
3392
3393 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3394 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3395
3396 let issues = raw_issues
3397 .into_iter()
3398 .map(|item| {
3399 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3400 let assignees =
3401 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3402 ForgeIssue {
3403 number: item.number,
3404 title: item.title,
3405 state: item.state,
3406 author,
3407 assignees,
3408 url: item.url,
3409 }
3410 })
3411 .collect();
3412
3413 Ok(issues)
3414}
3415
3416#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3417pub struct ForgePR {
3418 pub number: u32,
3419 pub title: String,
3420 pub state: String,
3421 pub author: String,
3422 pub assignees: Vec<String>,
3423 pub url: String,
3424 pub head_ref: String,
3425 pub head_ref_oid: String,
3426 pub body: String,
3427 pub status_checks: Vec<CIStatusCheck>,
3428 pub reviews: Vec<PRReview>,
3429}
3430
3431#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3432pub struct ForgePRComment {
3433 pub path: String,
3434 pub line: Option<u32>,
3435 pub body: String,
3436 pub author: String,
3437 pub commit_id: String,
3438}
3439
3440#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3441pub struct CIStatusCheck {
3442 pub name: String,
3443 pub state: Option<String>,
3444 pub status: Option<String>,
3445 pub conclusion: Option<String>,
3446}
3447
3448#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3449pub struct PRReview {
3450 pub author: String,
3451 pub body: String,
3452 pub state: String,
3453}
3454
3455pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3456 let mut cmd = std::process::Command::new("gh");
3457 cmd.arg("pr")
3458 .arg("list")
3459 .arg("--limit")
3460 .arg("50")
3461 .arg("--json")
3462 .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3463 .current_dir(repo_path);
3464
3465 let output = match cmd.output() {
3466 Ok(out) => out,
3467 Err(e) => {
3468 return Err(format!(
3469 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3470 e
3471 ));
3472 }
3473 };
3474
3475 if !output.status.success() {
3476 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3477 return Err(format!(
3478 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3479 err_msg
3480 ));
3481 }
3482
3483 #[derive(serde::Deserialize)]
3484 struct GhAuthor {
3485 login: String,
3486 }
3487
3488 #[derive(serde::Deserialize)]
3489 struct GhAssignee {
3490 login: String,
3491 }
3492
3493 #[derive(serde::Deserialize)]
3494 struct GhReview {
3495 author: Option<GhAuthor>,
3496 body: String,
3497 state: String,
3498 }
3499
3500 #[derive(serde::Deserialize)]
3501 struct GhStatusCheck {
3502 name: String,
3503 state: Option<String>,
3504 status: Option<String>,
3505 conclusion: Option<String>,
3506 }
3507
3508 #[derive(serde::Deserialize)]
3509 struct GhPR {
3510 number: u32,
3511 title: String,
3512 state: String,
3513 author: Option<GhAuthor>,
3514 assignees: Option<Vec<GhAssignee>>,
3515 url: String,
3516 #[serde(rename = "headRefName")]
3517 head_ref_name: String,
3518 #[serde(rename = "headRefOid")]
3519 head_ref_oid: String,
3520 body: String,
3521 #[serde(rename = "statusCheckRollup")]
3522 status_check_rollup: Option<Vec<GhStatusCheck>>,
3523 reviews: Option<Vec<GhReview>>,
3524 }
3525
3526 let raw_prs: Vec<GhPR> = serde_json::from_slice(&output.stdout)
3527 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3528
3529 let prs = raw_prs
3530 .into_iter()
3531 .map(|item| {
3532 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3533 let assignees =
3534 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3535 let status_checks = item
3536 .status_check_rollup
3537 .unwrap_or_default()
3538 .into_iter()
3539 .map(|c| CIStatusCheck {
3540 name: c.name,
3541 state: c.state,
3542 status: c.status,
3543 conclusion: c.conclusion,
3544 })
3545 .collect();
3546 let reviews = item
3547 .reviews
3548 .unwrap_or_default()
3549 .into_iter()
3550 .map(|r| PRReview {
3551 author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3552 body: r.body,
3553 state: r.state,
3554 })
3555 .collect();
3556
3557 ForgePR {
3558 number: item.number,
3559 title: item.title,
3560 state: item.state,
3561 author,
3562 assignees,
3563 url: item.url,
3564 head_ref: item.head_ref_name,
3565 head_ref_oid: item.head_ref_oid,
3566 body: item.body,
3567 status_checks,
3568 reviews,
3569 }
3570 })
3571 .collect();
3572
3573 Ok(prs)
3574}
3575
3576pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3577 let mut cmd = std::process::Command::new("gh");
3578 cmd.arg("api")
3579 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3580 .current_dir(repo_path);
3581
3582 let output = match cmd.output() {
3583 Ok(out) => out,
3584 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3585 };
3586
3587 if !output.status.success() {
3588 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3589 return Err(format!("Failed to load PR comments: {}", err));
3590 }
3591
3592 #[derive(serde::Deserialize)]
3593 struct GhUser {
3594 login: String,
3595 }
3596
3597 #[derive(serde::Deserialize)]
3598 struct GhPRComment {
3599 path: String,
3600 line: Option<u32>,
3601 body: String,
3602 user: Option<GhUser>,
3603 #[serde(rename = "commit_id")]
3604 commit_id: String,
3605 }
3606
3607 let raw_comments: Vec<GhPRComment> = serde_json::from_slice(&output.stdout)
3608 .map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3609
3610 let comments = raw_comments
3611 .into_iter()
3612 .map(|c| ForgePRComment {
3613 path: c.path,
3614 line: c.line,
3615 body: c.body,
3616 author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3617 commit_id: c.commit_id,
3618 })
3619 .collect();
3620
3621 Ok(comments)
3622}
3623
3624pub fn add_pr_line_comment(
3625 repo_path: &Path,
3626 pr_number: u32,
3627 commit_id: &str,
3628 path: &str,
3629 line: u32,
3630 body: &str,
3631) -> Result<(), String> {
3632 let mut cmd = std::process::Command::new("gh");
3633 cmd.arg("api")
3634 .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3635 .arg("--method")
3636 .arg("POST")
3637 .arg("-F")
3638 .arg(format!("body={}", body))
3639 .arg("-F")
3640 .arg(format!("commit_id={}", commit_id))
3641 .arg("-F")
3642 .arg(format!("path={}", path))
3643 .arg("-F")
3644 .arg(format!("line={}", line))
3645 .arg("-F")
3646 .arg("side=RIGHT")
3647 .current_dir(repo_path);
3648
3649 let output = match cmd.output() {
3650 Ok(out) => out,
3651 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3652 };
3653
3654 if !output.status.success() {
3655 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3656 return Err(format!("Failed to add PR comment: {}", err));
3657 }
3658 Ok(())
3659}
3660
3661pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3662 let mut cmd = std::process::Command::new("gh");
3663 cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3664
3665 let output = cmd.output().map_err(|e| e.to_string())?;
3666 if !output.status.success() {
3667 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3668 return Err(format!("Failed to checkout PR branch: {}", err));
3669 }
3670 Ok(format!("Checked out branch for PR #{}", pr_number))
3671}
3672
3673pub fn resolve_and_checkout_issue_branch(
3674 repo_path: &Path,
3675 issue_number: u32,
3676) -> Result<String, String> {
3677 let mut cmd = std::process::Command::new("gh");
3678 cmd.arg("issue")
3679 .arg("view")
3680 .arg(issue_number.to_string())
3681 .arg("--json")
3682 .arg("developmentBranch")
3683 .current_dir(repo_path);
3684
3685 if let Ok(output) = cmd.output() {
3686 if output.status.success() {
3687 #[derive(serde::Deserialize)]
3688 struct GhDevBranch {
3689 name: String,
3690 }
3691 #[derive(serde::Deserialize)]
3692 struct GhIssueView {
3693 #[serde(rename = "developmentBranch")]
3694 development_branch: Option<GhDevBranch>,
3695 }
3696 if let Ok(parsed) = serde_json::from_slice::<GhIssueView>(&output.stdout) {
3697 if let Some(dev_branch) = parsed.development_branch {
3698 if !dev_branch.name.is_empty() {
3699 let name = dev_branch.name;
3700 if checkout_local_branch(repo_path, &name).is_ok() {
3701 return Ok(format!("Checked out linked local branch '{}'", name));
3702 }
3703 if let Ok(msg) = checkout_remote_branch(repo_path, &name) {
3704 return Ok(format!("Checked out linked remote branch '{}'", msg));
3705 }
3706 let checkout_out = std::process::Command::new("git")
3707 .arg("checkout")
3708 .arg("-b")
3709 .arg(&name)
3710 .current_dir(repo_path)
3711 .output();
3712 if let Ok(out) = checkout_out {
3713 if out.status.success() {
3714 return Ok(format!(
3715 "Created and switched to linked branch '{}'",
3716 name
3717 ));
3718 }
3719 }
3720 }
3721 }
3722 }
3723 }
3724 }
3725
3726 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3727 let target_pattern = format!("{}", issue_number);
3728 let mut found_local = None;
3729 let mut found_remote = None;
3730
3731 if let Ok(branches) = repo.branches(None) {
3732 for (branch, branch_type) in branches.flatten() {
3733 if let Ok(Some(name)) = branch.name() {
3734 if name.contains(&target_pattern) {
3735 match branch_type {
3736 git2::BranchType::Local => {
3737 found_local = Some(name.to_string());
3738 break;
3739 }
3740 git2::BranchType::Remote => {
3741 found_remote = Some(name.to_string());
3742 }
3743 }
3744 }
3745 }
3746 }
3747 }
3748
3749 if let Some(local_name) = found_local {
3750 checkout_local_branch(repo_path, &local_name).map_err(|e| e.to_string())?;
3751 return Ok(format!("Checked out matched local branch '{}'", local_name));
3752 }
3753
3754 if let Some(remote_name) = found_remote {
3755 let msg = checkout_remote_branch(repo_path, &remote_name).map_err(|e| e.to_string())?;
3756 return Ok(format!("Checked out matched remote branch: {}", msg));
3757 }
3758
3759 let new_branch_name = format!("issue-{}", issue_number);
3760 let checkout_out = std::process::Command::new("git")
3761 .arg("checkout")
3762 .arg("-b")
3763 .arg(&new_branch_name)
3764 .current_dir(repo_path)
3765 .output()
3766 .map_err(|e| e.to_string())?;
3767
3768 if !checkout_out.status.success() {
3769 let err = String::from_utf8_lossy(&checkout_out.stderr).trim().to_string();
3770 return Err(format!("Failed to create new branch: {}", err));
3771 }
3772
3773 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
3774}
3775
3776pub fn open_browser(url: &str) {
3777 #[cfg(target_os = "macos")]
3778 let _ = std::process::Command::new("open").arg(url).status();
3779 #[cfg(target_os = "windows")]
3780 let _ = std::process::Command::new("cmd").args(["/C", "start", url]).status();
3781 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3782 let _ = std::process::Command::new("xdg-open").arg(url).status();
3783}
3784
3785#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3786pub struct ForgeIssue {
3787 pub number: u32,
3788 pub title: String,
3789 pub state: String,
3790 pub author: String,
3791 pub assignees: Vec<String>,
3792 pub url: String,
3793}
3794
3795pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
3796 let remotes = load_tab_remotes(repo_path).ok()?;
3797 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
3798}
3799
3800pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
3801 let remotes = load_tab_remotes(repo_path).ok()?;
3802 remotes.into_iter().next().map(|r| r.url)
3803}
3804
3805#[cfg(test)]
3806#[allow(clippy::unwrap_used, clippy::panic)]
3807mod tests {
3808 use super::*;
3809 use std::fs::File;
3810
3811 #[test]
3812 fn test_match_pattern() {
3813 assert!(match_pattern("test.psd", "*.psd"));
3814 assert!(match_pattern("assets/test.psd", "*.psd"));
3815 assert!(match_pattern("assets/img.png", "assets/*.png"));
3816 assert!(match_pattern("src/app.rs", "src/*"));
3817 assert!(match_pattern("test.txt", "test.txt"));
3818 assert!(!match_pattern("src/app.rs", "*.psd"));
3819 }
3820
3821 #[test]
3822 fn test_run_git_with_timeout_kills_a_hanging_child() {
3823 let mut cmd = if cfg!(target_os = "windows") {
3825 let mut c = std::process::Command::new("cmd");
3826 c.arg("/c").arg("timeout").arg("/t").arg("30").arg("/nobreak");
3827 c
3828 } else {
3829 let mut c = std::process::Command::new("sleep");
3830 c.arg("30");
3831 c
3832 };
3833 cmd.stdin(std::process::Stdio::null());
3834
3835 let started = Instant::now();
3836 let res = run_git_with_timeout(cmd, Duration::from_millis(300));
3837 let elapsed = started.elapsed();
3838
3839 assert!(res.is_err(), "expected a timeout error, got {:?}", res);
3840 assert!(elapsed < Duration::from_secs(10), "child was not killed promptly: {:?}", elapsed);
3841 }
3842
3843 #[test]
3844 fn test_get_lfs_info() {
3845 let mut temp_path = std::env::temp_dir();
3846 temp_path.push(format!(
3847 "twig_test_lfs_{}",
3848 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3849 ));
3850 std::fs::create_dir_all(&temp_path).unwrap();
3851
3852 let _repo = Repository::init(&temp_path).unwrap();
3854 let lfs_dir = temp_path.join(".git").join("lfs");
3855 std::fs::create_dir_all(&lfs_dir).unwrap();
3856
3857 let (installed, tracked) = get_lfs_info(&temp_path);
3859
3860 assert!(tracked.is_empty());
3862
3863 let gitattributes_path = temp_path.join(".gitattributes");
3865 std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
3866
3867 let (installed2, _tracked2) = get_lfs_info(&temp_path);
3868 assert_eq!(installed, installed2);
3869
3870 let _ = std::fs::remove_dir_all(&temp_path);
3872 }
3873
3874 #[test]
3875 fn test_comprehensive_gitwig_core_coverage() {
3876 let mut temp_path = std::env::temp_dir();
3877 temp_path.push(format!(
3878 "twig_test_cov_{}",
3879 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3880 ));
3881 std::fs::create_dir_all(&temp_path).unwrap();
3882
3883 let repo = Repository::init(&temp_path).unwrap();
3885
3886 let mut config = repo.config().unwrap();
3888 config.set_str("user.name", "Test User").unwrap();
3889 config.set_str("user.email", "test@example.com").unwrap();
3890
3891 let file_path = temp_path.join("test.txt");
3893 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3894 stage_file(&temp_path, "test.txt").unwrap();
3895 commit_changes(&temp_path, "first commit").unwrap();
3896
3897 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3898 let head_oid = head_commit.id().to_string();
3899
3900 let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
3902 let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
3903
3904 let _ = stage_all_changes(&temp_path);
3906 let _ = unstage_all_changes(&temp_path);
3907
3908 std::fs::write(&file_path, "modified content").unwrap();
3909 let _ = discard_all_changes(&temp_path);
3910
3911 let _lfs_size = get_lfs_storage_size(&temp_path);
3913
3914 let _reflog = load_tab_reflog(&temp_path);
3916 let _tags = load_tab_tags(&temp_path);
3917 let _stashes = load_tab_stashes(&temp_path);
3918 let _submodules = load_tab_submodules(&temp_path);
3919 let _worktrees = load_tab_worktrees(&temp_path);
3920
3921 let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
3923 let _ = remote_delete(&temp_path, "upstream");
3924
3925 let wt_path = temp_path.join("wt-path");
3927 let _ = worktree_add(&temp_path, "new-branch", &wt_path);
3928 let _ = worktree_remove(&temp_path, "wt-path", true);
3929
3930 let _ = delete_tag(&temp_path, "v1.0.0");
3932
3933 let _ = apply_stash(&temp_path, 0);
3935 let _ = delete_stash(&temp_path, 0);
3936
3937 let _ = abort_merge(&temp_path);
3939 let _ = continue_merge(&temp_path);
3940
3941 let _ = is_merging(&temp_path);
3943 let _ = get_conflict_markers_diff(&temp_path, "test.txt");
3944 let _ = resolve_ours(&temp_path, "test.txt");
3945 let _ = resolve_theirs(&temp_path, "test.txt");
3946 let _ = mark_resolved(&temp_path, "test.txt");
3947
3948 let _target = get_branch_push_target(&temp_path, "master");
3950 let _target2 = get_branch_push_target(&temp_path, "main");
3951
3952 let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
3954 let _summary = inspect_summary(temp_path.to_str().unwrap());
3955
3956 let _ = std::fs::remove_dir_all(&temp_path);
3958 }
3959
3960 #[test]
3961 fn test_more_gitwig_core_coverage() {
3962 let mut temp_path = std::env::temp_dir();
3963 temp_path.push(format!(
3964 "twig_test_cov2_{}",
3965 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3966 ));
3967 std::fs::create_dir_all(&temp_path).unwrap();
3968
3969 let repo = Repository::init(&temp_path).unwrap();
3971
3972 let mut config = repo.config().unwrap();
3974 config.set_str("user.name", "Test User").unwrap();
3975 config.set_str("user.email", "test@example.com").unwrap();
3976
3977 let file_path = temp_path.join("test.txt");
3979 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3980 stage_file(&temp_path, "test.txt").unwrap();
3981 commit_changes(&temp_path, "first commit").unwrap();
3982
3983 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3984 let head_oid = head_commit.id().to_string();
3985
3986 let _ = checkout_commit(&temp_path, &head_oid);
3988 let _ = checkout_local_branch(&temp_path, "master");
3989 let _ = checkout_remote_branch(&temp_path, "origin/master");
3990 let _ = checkout_tag(&temp_path, "v1.0.0");
3991
3992 let _ = create_branch(&temp_path, "new-branch-2");
3994 let _ = delete_local_branch(&temp_path, "new-branch-2");
3995 let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
3996 let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
3997 let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0", Duration::from_secs(5));
3998 let _ = get_remote_tags(&temp_path, "origin", Duration::from_secs(5));
3999
4000 let tag_info = vec![BranchInfo {
4002 name: "v1.0.0".to_string(),
4003 is_head: false,
4004 short_sha: "abc1234".to_string(),
4005 short_message: "tag msg".to_string(),
4006 }];
4007 let serialized = serialize_tags(&tag_info);
4008 let deserialized = deserialize_tags(&serialized);
4009 assert_eq!(deserialized.len(), 1);
4010 assert_eq!(deserialized[0].name, "v1.0.0");
4011
4012 let _ = get_file_history(&temp_path, "test.txt");
4014 let _ = get_file_blame(&temp_path, "test.txt");
4015 let _ = get_commit_files(&temp_path, &head_oid);
4016
4017 let _ = load_tab_files(&temp_path);
4019 let _ = load_tab_branches(&temp_path);
4020 let _ = load_tab_remotes(&temp_path);
4021 let _ = load_tab_overview(&temp_path, 10);
4022
4023 let (tx, _rx) = std::sync::mpsc::channel();
4025 let _ =
4026 load_tab_graph_stream(&temp_path, 10, temp_path.to_str().unwrap().to_string(), 0, tx);
4027
4028 invalidate_ref_map_cache(&temp_path);
4030
4031 let _ = load_tab_forge_issues(&temp_path, false);
4033 let _ = load_tab_forge_issues(&temp_path, true);
4034 let _ = load_tab_forge_prs(&temp_path);
4035 let _ = load_pr_comments(&temp_path, 1);
4036 let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
4037
4038 let _ = std::fs::remove_dir_all(&temp_path);
4040 }
4041
4042 #[test]
4043 fn test_create_tag_annotated_and_force_update() {
4044 let mut temp_path = std::env::temp_dir();
4045 temp_path.push(format!(
4046 "twig_test_tag_force_{}",
4047 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4048 ));
4049 std::fs::create_dir_all(&temp_path).unwrap();
4050
4051 let repo = Repository::init(&temp_path).unwrap();
4052 let mut config = repo.config().unwrap();
4053 config.set_str("user.name", "Test User").unwrap();
4054 config.set_str("user.email", "test@example.com").unwrap();
4055
4056 let file_path = temp_path.join("test.txt");
4057 std::fs::write(&file_path, "commit 1\n").unwrap();
4058 stage_file(&temp_path, "test.txt").unwrap();
4059 commit_changes(&temp_path, "commit 1").unwrap();
4060
4061 let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
4062 let oid1 = head_commit1.id().to_string();
4063
4064 assert!(!tag_exists(&temp_path, "v1.0.1"));
4065 assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
4066 assert!(tag_exists(&temp_path, "v1.0.1"));
4067
4068 std::fs::write(&file_path, "commit 2\n").unwrap();
4070 stage_file(&temp_path, "test.txt").unwrap();
4071 commit_changes(&temp_path, "commit 2").unwrap();
4072
4073 let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
4074 let oid2 = head_commit2.id().to_string();
4075
4076 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
4077
4078 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
4080
4081 let _ = std::fs::remove_dir_all(&temp_path);
4082 }
4083
4084 use std::io::Write;
4085
4086 #[test]
4087 fn test_commit_amend() {
4088 let mut temp_path = std::env::temp_dir();
4089 temp_path.push(format!(
4090 "twig_test_amend_{}",
4091 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4092 ));
4093 std::fs::create_dir_all(&temp_path).unwrap();
4094
4095 let repo = Repository::init(&temp_path).unwrap();
4097
4098 let mut config = repo.config().unwrap();
4100 config.set_str("user.name", "Test User").unwrap();
4101 config.set_str("user.email", "test@example.com").unwrap();
4102
4103 let file_path = temp_path.join("test.txt");
4105 let mut file = File::create(&file_path).unwrap();
4106 writeln!(file, "initial content").unwrap();
4107
4108 stage_file(&temp_path, "test.txt").unwrap();
4110 commit_changes(&temp_path, "initial commit").unwrap();
4111
4112 let msg = get_last_commit_message(&temp_path).unwrap();
4114 assert_eq!(msg, "initial commit");
4115
4116 commit_amend(&temp_path, "amended commit").unwrap();
4118
4119 let amended_msg = get_last_commit_message(&temp_path).unwrap();
4121 assert_eq!(amended_msg, "amended commit");
4122
4123 let _ = std::fs::remove_dir_all(&temp_path);
4125 }
4126
4127 #[test]
4128 fn test_commit_signatures_collection() {
4129 let mut temp_path = std::env::temp_dir();
4130 temp_path.push(format!(
4131 "twig_test_sig_{}",
4132 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4133 ));
4134 std::fs::create_dir_all(&temp_path).unwrap();
4135
4136 let repo = Repository::init(&temp_path).unwrap();
4138
4139 let mut config = repo.config().unwrap();
4141 config.set_str("user.name", "Test User").unwrap();
4142 config.set_str("user.email", "test@example.com").unwrap();
4143
4144 let file_path = temp_path.join("test.txt");
4146 let mut file = File::create(&file_path).unwrap();
4147 writeln!(file, "initial content").unwrap();
4148
4149 stage_file(&temp_path, "test.txt").unwrap();
4151 commit_changes(&temp_path, "initial commit").unwrap();
4152
4153 let sigs = collect_signatures(&temp_path, 0);
4155 assert_eq!(sigs.len(), 1);
4156 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4157 let sig_status = sigs.get(&head_oid).unwrap();
4158 assert_eq!(sig_status, "N");
4159
4160 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4162 assert_eq!(commits.len(), 1);
4163 assert_eq!(commits[0].signature_status, "N");
4164 assert!(commits[0].files.is_empty());
4165
4166 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4168 assert_eq!(files.len(), 1);
4169 assert_eq!(files[0].path, "test.txt");
4170 assert_eq!(files[0].label, "N");
4171
4172 let graph = collect_graph_lines(&temp_path, 1000);
4174 assert_eq!(graph.len(), 1);
4175 assert!(graph[0].commit.is_some());
4176 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4177
4178 let _ = std::fs::remove_dir_all(&temp_path);
4180 }
4181
4182 #[test]
4183 fn test_ref_map_cache_behavior() {
4184 let temp_dir = std::env::temp_dir();
4185 let repo_path = temp_dir.join("test_ref_map_repo");
4186 let _ = std::fs::remove_dir_all(&repo_path);
4187 std::fs::create_dir_all(&repo_path).unwrap();
4188
4189 let repo = Repository::init(&repo_path).unwrap();
4190
4191 let map1 = get_cached_ref_map(&repo, &repo_path);
4193
4194 let map2 = get_cached_ref_map(&repo, &repo_path);
4196 assert_eq!(map1.len(), map2.len());
4197
4198 invalidate_ref_map_cache(&repo_path);
4200
4201 let _ = std::fs::remove_dir_all(&repo_path);
4203 }
4204
4205 #[test]
4206 fn test_get_latest_change_time() {
4207 let mut temp_path = std::env::temp_dir();
4208 temp_path.push(format!(
4209 "twig_test_time_{}",
4210 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4211 ));
4212 std::fs::create_dir_all(&temp_path).unwrap();
4213
4214 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
4215 assert!(change_time > 0);
4216
4217 let _ = std::fs::remove_dir_all(&temp_path);
4218 }
4219
4220 #[test]
4221 fn test_committer_stats() {
4222 let mut temp_path = std::env::temp_dir();
4223 temp_path.push(format!(
4224 "twig_test_stats_{}",
4225 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4226 ));
4227 std::fs::create_dir_all(&temp_path).unwrap();
4228
4229 let repo = Repository::init(&temp_path).unwrap();
4231
4232 let mut config = repo.config().unwrap();
4234 config.set_str("user.name", "Test User").unwrap();
4235 config.set_str("user.email", "test@example.com").unwrap();
4236
4237 let file_path = temp_path.join("test.txt");
4239 let mut file = File::create(&file_path).unwrap();
4240 writeln!(file, "initial content").unwrap();
4241
4242 stage_file(&temp_path, "test.txt").unwrap();
4244 commit_changes(&temp_path, "initial commit").unwrap();
4245
4246 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
4248 assert_eq!(stats.len(), 1);
4249 assert_eq!(stats[0].name, "Test User");
4250 assert_eq!(stats[0].email, "test@example.com");
4251 assert_eq!(stats[0].count, 1);
4252 assert!(!limit_reached);
4253
4254 let _ = std::fs::remove_dir_all(&temp_path);
4256 }
4257
4258 #[test]
4259 fn test_untracked_files_in_unstaged() {
4260 let mut temp_path = std::env::temp_dir();
4261 temp_path.push(format!(
4262 "twig_test_untracked_{}",
4263 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4264 ));
4265 std::fs::create_dir_all(&temp_path).unwrap();
4266
4267 let _repo = Repository::init(&temp_path).unwrap();
4269
4270 let file_path = temp_path.join("untracked.txt");
4272 let mut file = File::create(&file_path).unwrap();
4273 writeln!(file, "hello untracked").unwrap();
4274
4275 let untracked_dir = temp_path.join("untracked_dir");
4277 std::fs::create_dir_all(&untracked_dir).unwrap();
4278 let nested_file_path = untracked_dir.join("nested.txt");
4279 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
4280
4281 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4283 match detail {
4284 ItemDetail::Repo { info, .. } => {
4285 let unstaged_paths: Vec<String> =
4287 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
4288 let untracked_paths: Vec<String> =
4289 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
4290
4291 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
4293 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
4294 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
4295 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
4296
4297 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
4299 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
4300 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
4301 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
4302 }
4303 _ => panic!("Expected ItemDetail::Repo"),
4304 }
4305
4306 let _ = std::fs::remove_dir_all(&temp_path);
4308 }
4309
4310 #[test]
4311 fn test_stage_new_and_deleted_files() {
4312 let mut temp_path = std::env::temp_dir();
4313 temp_path.push(format!(
4314 "twig_test_new_del_{}",
4315 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4316 ));
4317 std::fs::create_dir_all(&temp_path).unwrap();
4318
4319 let repo = Repository::init(&temp_path).unwrap();
4321
4322 let mut config = repo.config().unwrap();
4324 config.set_str("user.name", "Test User").unwrap();
4325 config.set_str("user.email", "test@example.com").unwrap();
4326
4327 let init_file = temp_path.join("init.txt");
4329 std::fs::write(&init_file, "initial").unwrap();
4330 stage_file(&temp_path, "init.txt").unwrap();
4331 commit_changes(&temp_path, "initial commit").unwrap();
4332
4333 let untracked_file = temp_path.join("untracked.txt");
4335 std::fs::write(&untracked_file, "new file content").unwrap();
4336
4337 stage_file(&temp_path, "untracked.txt").unwrap();
4339
4340 std::fs::remove_file(&init_file).unwrap();
4342
4343 stage_file(&temp_path, "init.txt").unwrap();
4345
4346 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4348 match detail {
4349 ItemDetail::Repo { info, .. } => {
4350 assert_eq!(info.changes.staged.len(), 2);
4352 let paths: Vec<String> =
4353 info.changes.staged.iter().map(|f| f.path.clone()).collect();
4354 assert!(paths.contains(&"untracked.txt".to_string()));
4355 assert!(paths.contains(&"init.txt".to_string()));
4356 }
4357 _ => panic!("Expected ItemDetail::Repo"),
4358 }
4359
4360 let _ = std::fs::remove_dir_all(&temp_path);
4362 }
4363
4364 #[test]
4365 fn test_discard_file_changes_all_cases() {
4366 let mut temp_path = std::env::temp_dir();
4367 temp_path.push(format!(
4368 "twig_test_discard_all_{}",
4369 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4370 ));
4371 std::fs::create_dir_all(&temp_path).unwrap();
4372
4373 let repo = Repository::init(&temp_path).unwrap();
4375
4376 let mut config = repo.config().unwrap();
4378 config.set_str("user.name", "Test User").unwrap();
4379 config.set_str("user.email", "test@example.com").unwrap();
4380
4381 let file_tracked = temp_path.join("tracked.txt");
4383 std::fs::write(&file_tracked, "original content\n").unwrap();
4384 stage_file(&temp_path, "tracked.txt").unwrap();
4385 commit_changes(&temp_path, "initial commit").unwrap();
4386
4387 let file_untracked = temp_path.join("untracked.txt");
4389 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
4390 assert!(file_untracked.exists());
4391 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
4392 assert!(!file_untracked.exists());
4393
4394 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
4396 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4397 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4398
4399 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
4401 stage_file(&temp_path, "tracked.txt").unwrap();
4402 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4404 match detail {
4405 ItemDetail::Repo { info, .. } => {
4406 assert!(!info.changes.staged.is_empty());
4407 }
4408 _ => panic!("Expected ItemDetail::Repo"),
4409 }
4410 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
4411 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4412 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4414 match detail {
4415 ItemDetail::Repo { info, .. } => {
4416 assert!(info.changes.staged.is_empty());
4417 assert!(info.changes.unstaged.is_empty());
4418 }
4419 _ => panic!("Expected ItemDetail::Repo"),
4420 }
4421
4422 std::fs::remove_file(&file_tracked).unwrap();
4424 assert!(!file_tracked.exists());
4425 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4426 assert!(file_tracked.exists());
4427 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4428
4429 let _ = std::fs::remove_dir_all(&temp_path);
4431 }
4432
4433 #[test]
4434 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4435 fn test_stage_unstage_by_hunk() {
4436 let mut temp_path = std::env::temp_dir();
4437 temp_path.push(format!(
4438 "twig_test_hunk_{}",
4439 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4440 ));
4441 std::fs::create_dir_all(&temp_path).unwrap();
4442
4443 let repo = Repository::init(&temp_path).unwrap();
4445
4446 let mut config = repo.config().unwrap();
4448 config.set_str("user.name", "Test User").unwrap();
4449 config.set_str("user.email", "test@example.com").unwrap();
4450
4451 let file_path = temp_path.join("multihunk.txt");
4453 let mut file = File::create(&file_path).unwrap();
4454 for i in 1..=20 {
4455 writeln!(file, "Line {}", i).unwrap();
4456 }
4457 drop(file);
4458
4459 stage_file(&temp_path, "multihunk.txt").unwrap();
4461 commit_changes(&temp_path, "initial commit").unwrap();
4462
4463 let mut file = File::create(&file_path).unwrap();
4465 for i in 1..=20 {
4466 if i == 2 || i == 18 {
4467 writeln!(file, "Line {} modified", i).unwrap();
4468 } else {
4469 writeln!(file, "Line {}", i).unwrap();
4470 }
4471 }
4472 drop(file);
4473
4474 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4476 let mut hunk_ranges = Vec::new();
4478 let mut current_start = None;
4479 for (i, line) in diff_lines.iter().enumerate() {
4480 if line.kind == DiffLineKind::Header {
4481 if let Some(start) = current_start {
4482 hunk_ranges.push(start..i);
4483 }
4484 current_start = Some(i);
4485 }
4486 }
4487 if let Some(start) = current_start {
4488 hunk_ranges.push(start..diff_lines.len());
4489 }
4490
4491 assert_eq!(hunk_ranges.len(), 2);
4493
4494 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4496 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
4497
4498 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4500 let staged_content: String =
4501 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4502 assert!(staged_content.contains("Line 18 modified"));
4503 assert!(!staged_content.contains("Line 2 modified"));
4504
4505 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4507 let unstaged_content: String =
4508 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4509 assert!(unstaged_content.contains("Line 2 modified"));
4510 assert!(!unstaged_content.contains("Line 18 modified"));
4511
4512 let staged_hunk_ranges = {
4514 let mut ranges = Vec::new();
4515 let mut current_start = None;
4516 for (i, line) in staged_diff.iter().enumerate() {
4517 if line.kind == DiffLineKind::Header {
4518 if let Some(start) = current_start {
4519 ranges.push(start..i);
4520 }
4521 current_start = Some(i);
4522 }
4523 }
4524 if let Some(start) = current_start {
4525 ranges.push(start..staged_diff.len());
4526 }
4527 ranges
4528 };
4529 assert_eq!(staged_hunk_ranges.len(), 1);
4530 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
4531 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
4532
4533 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4535 assert!(staged_diff_after.is_empty());
4536
4537 let _ = std::fs::remove_dir_all(&temp_path);
4539 }
4540
4541 #[test]
4542 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4543 fn test_discard_hunk() {
4544 let mut temp_path = std::env::temp_dir();
4545 temp_path.push(format!(
4546 "twig_test_discard_h_{}",
4547 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4548 ));
4549 std::fs::create_dir_all(&temp_path).unwrap();
4550
4551 let repo = Repository::init(&temp_path).unwrap();
4553
4554 let mut config = repo.config().unwrap();
4556 config.set_str("user.name", "Test User").unwrap();
4557 config.set_str("user.email", "test@example.com").unwrap();
4558
4559 let file_path = temp_path.join("discardhunk.txt");
4561 let mut file = File::create(&file_path).unwrap();
4562 for i in 1..=20 {
4563 writeln!(file, "Line {}", i).unwrap();
4564 }
4565 drop(file);
4566
4567 stage_file(&temp_path, "discardhunk.txt").unwrap();
4569 commit_changes(&temp_path, "initial commit").unwrap();
4570
4571 let mut file = File::create(&file_path).unwrap();
4573 for i in 1..=20 {
4574 if i == 2 || i == 18 {
4575 writeln!(file, "Line {} modified", i).unwrap();
4576 } else {
4577 writeln!(file, "Line {}", i).unwrap();
4578 }
4579 }
4580 drop(file);
4581
4582 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
4584 let mut hunk_ranges = Vec::new();
4586 let mut current_start = None;
4587 for (i, line) in diff_lines.iter().enumerate() {
4588 if line.kind == DiffLineKind::Header {
4589 if let Some(start) = current_start {
4590 hunk_ranges.push(start..i);
4591 }
4592 current_start = Some(i);
4593 }
4594 }
4595 if let Some(start) = current_start {
4596 hunk_ranges.push(start..diff_lines.len());
4597 }
4598
4599 assert_eq!(hunk_ranges.len(), 2);
4601
4602 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4604 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
4605
4606 let contents = std::fs::read_to_string(&file_path).unwrap();
4608 assert!(contents.contains("Line 2 modified"));
4609 assert!(contents.contains("Line 18\n"));
4610 assert!(!contents.contains("Line 18 modified"));
4611
4612 let _ = std::fs::remove_dir_all(&temp_path);
4614 }
4615
4616 #[test]
4617 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4618 fn test_stage_unstage_discard_line() {
4619 let mut temp_path = std::env::temp_dir();
4620 temp_path.push(format!(
4621 "twig_test_line_{}",
4622 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4623 ));
4624 std::fs::create_dir_all(&temp_path).unwrap();
4625
4626 let repo = Repository::init(&temp_path).unwrap();
4627 let mut config = repo.config().unwrap();
4628 config.set_str("user.name", "Test User").unwrap();
4629 config.set_str("user.email", "test@example.com").unwrap();
4630
4631 let file_path = temp_path.join("line_test.txt");
4633 let mut file = File::create(&file_path).unwrap();
4634 writeln!(file, "line A").unwrap();
4635 writeln!(file, "line B").unwrap();
4636 writeln!(file, "line C").unwrap();
4637 drop(file);
4638
4639 stage_file(&temp_path, "line_test.txt").unwrap();
4640 commit_changes(&temp_path, "initial").unwrap();
4641
4642 let mut file = File::create(&file_path).unwrap();
4644 writeln!(file, "line A modified").unwrap();
4645 writeln!(file, "line B").unwrap();
4646 writeln!(file, "line C modified").unwrap();
4647 drop(file);
4648
4649 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4650 let mut hunk_ranges = Vec::new();
4651 let mut current_start = None;
4652 for (i, line) in diff_lines.iter().enumerate() {
4653 if line.kind == DiffLineKind::Header {
4654 if let Some(start) = current_start {
4655 hunk_ranges.push(start..i);
4656 }
4657 current_start = Some(i);
4658 }
4659 }
4660 if let Some(start) = current_start {
4661 hunk_ranges.push(start..diff_lines.len());
4662 }
4663
4664 assert_eq!(hunk_ranges.len(), 1);
4665 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
4666
4667 assert_eq!(hunk0[2].content, "line A modified");
4668 assert_eq!(hunk0[5].content, "line C modified");
4669
4670 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
4672
4673 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
4675 assert!(
4676 staged_diff
4677 .iter()
4678 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4679 );
4680 assert!(
4681 !staged_diff
4682 .iter()
4683 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4684 );
4685
4686 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4688 assert!(
4689 !unstaged_diff
4690 .iter()
4691 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4692 );
4693 assert!(
4694 unstaged_diff
4695 .iter()
4696 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4697 );
4698
4699 assert_eq!(staged_diff[2].content, "line A modified");
4701 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
4702
4703 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
4705
4706 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4708 assert_eq!(unstaged_diff2[5].content, "line C modified");
4709 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
4710
4711 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4712 let remove_idx = unstaged_diff3
4713 .iter()
4714 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
4715 .unwrap();
4716 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
4717
4718 let contents = std::fs::read_to_string(&file_path).unwrap();
4720 assert!(contents.contains("line A modified"));
4721 assert!(contents.contains("line B"));
4722 assert!(contents.contains("line C\n"));
4723 assert!(!contents.contains("line C modified"));
4724
4725 let _ = std::fs::remove_dir_all(&temp_path);
4727 }
4728
4729 #[test]
4730 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4731 fn test_stage_unstage_discard_all_changes() {
4732 let mut temp_path = std::env::temp_dir();
4733 temp_path.push(format!(
4734 "twig_test_all_{}",
4735 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4736 ));
4737 std::fs::create_dir_all(&temp_path).unwrap();
4738
4739 let repo = Repository::init(&temp_path).unwrap();
4741
4742 let mut config = repo.config().unwrap();
4744 config.set_str("user.name", "Test User").unwrap();
4745 config.set_str("user.email", "test@example.com").unwrap();
4746
4747 let file_path = temp_path.join("tracked.txt");
4749 std::fs::write(&file_path, "original content\n").unwrap();
4750 stage_file(&temp_path, "tracked.txt").unwrap();
4751 commit_changes(&temp_path, "initial").unwrap();
4752
4753 std::fs::write(&file_path, "modified content\n").unwrap();
4755 let untracked_path = temp_path.join("untracked.txt");
4756 std::fs::write(&untracked_path, "untracked content\n").unwrap();
4757
4758 let status = repo.statuses(None).unwrap();
4760 assert_eq!(status.len(), 2);
4761
4762 stage_all_changes(&temp_path).unwrap();
4764
4765 let status = repo.statuses(None).unwrap();
4767 for entry in status.iter() {
4768 assert!(
4769 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
4770 );
4771 }
4772
4773 unstage_all_changes(&temp_path).unwrap();
4775
4776 let status = repo.statuses(None).unwrap();
4778 for entry in status.iter() {
4779 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
4780 }
4781
4782 discard_all_changes(&temp_path).unwrap();
4784
4785 let status = repo.statuses(None).unwrap();
4787 assert_eq!(status.len(), 0);
4788
4789 let contents = std::fs::read_to_string(&file_path).unwrap();
4791 assert_eq!(contents, "original content\n");
4792 assert!(!untracked_path.exists());
4793
4794 let _ = std::fs::remove_dir_all(&temp_path);
4796 }
4797
4798 #[test]
4799 fn test_merge_conflicts_flow() {
4800 let mut temp_path = std::env::temp_dir();
4801 temp_path.push(format!(
4802 "twig_test_conflict_{}",
4803 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4804 ));
4805 std::fs::create_dir_all(&temp_path).unwrap();
4806
4807 let repo = Repository::init(&temp_path).unwrap();
4809
4810 let mut config = repo.config().unwrap();
4812 config.set_str("user.name", "Test User").unwrap();
4813 config.set_str("user.email", "test@example.com").unwrap();
4814
4815 let file_path = temp_path.join("conflict.txt");
4817 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4818 stage_file(&temp_path, "conflict.txt").unwrap();
4819 commit_changes(&temp_path, "initial commit").unwrap();
4820
4821 let output = std::process::Command::new("git")
4823 .env("GIT_TERMINAL_PROMPT", "0")
4824 .env("GIT_SSH_COMMAND", ssh_command_val())
4825 .args(["symbolic-ref", "--short", "HEAD"])
4826 .current_dir(&temp_path)
4827 .output()
4828 .unwrap();
4829 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4830
4831 std::process::Command::new("git")
4833 .env("GIT_TERMINAL_PROMPT", "0")
4834 .env("GIT_SSH_COMMAND", ssh_command_val())
4835 .args(["checkout", "-b", "feature"])
4836 .current_dir(&temp_path)
4837 .output()
4838 .unwrap();
4839
4840 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
4841 stage_file(&temp_path, "conflict.txt").unwrap();
4842 commit_changes(&temp_path, "feature commit").unwrap();
4843
4844 std::process::Command::new("git")
4846 .env("GIT_TERMINAL_PROMPT", "0")
4847 .env("GIT_SSH_COMMAND", ssh_command_val())
4848 .args(["checkout", &main_branch])
4849 .current_dir(&temp_path)
4850 .output()
4851 .unwrap();
4852
4853 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
4854 stage_file(&temp_path, "conflict.txt").unwrap();
4855 commit_changes(&temp_path, "main commit").unwrap();
4856
4857 assert!(!is_merging(&temp_path));
4859 let merge_output = std::process::Command::new("git")
4860 .env("GIT_TERMINAL_PROMPT", "0")
4861 .env("GIT_SSH_COMMAND", ssh_command_val())
4862 .args(["merge", "feature"])
4863 .current_dir(&temp_path)
4864 .output()
4865 .unwrap();
4866
4867 assert!(!merge_output.status.success());
4868 assert!(is_merging(&temp_path));
4869
4870 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
4872 assert!(!diff.is_empty());
4873 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
4874 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
4875 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
4876 assert!(has_separator);
4877 assert!(has_ours);
4878 assert!(has_theirs);
4879
4880 abort_merge(&temp_path).unwrap();
4882 assert!(!is_merging(&temp_path));
4883
4884 std::process::Command::new("git")
4886 .env("GIT_TERMINAL_PROMPT", "0")
4887 .env("GIT_SSH_COMMAND", ssh_command_val())
4888 .args(["merge", "feature"])
4889 .current_dir(&temp_path)
4890 .output()
4891 .unwrap();
4892 assert!(is_merging(&temp_path));
4893
4894 resolve_ours(&temp_path, "conflict.txt").unwrap();
4896 let contents = std::fs::read_to_string(&file_path).unwrap();
4897 assert!(contents.contains("line 2 on main"));
4898 assert!(!contents.contains("<<<<<<<"));
4899
4900 continue_merge(&temp_path).unwrap();
4902 assert!(!is_merging(&temp_path));
4903
4904 std::process::Command::new("git")
4906 .env("GIT_TERMINAL_PROMPT", "0")
4907 .env("GIT_SSH_COMMAND", ssh_command_val())
4908 .args(["reset", "--hard", "HEAD~1"])
4909 .current_dir(&temp_path)
4910 .output()
4911 .unwrap();
4912
4913 std::process::Command::new("git")
4914 .env("GIT_TERMINAL_PROMPT", "0")
4915 .env("GIT_SSH_COMMAND", ssh_command_val())
4916 .args(["merge", "feature"])
4917 .current_dir(&temp_path)
4918 .output()
4919 .unwrap();
4920 assert!(is_merging(&temp_path));
4921
4922 resolve_theirs(&temp_path, "conflict.txt").unwrap();
4923 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4924 assert!(contents_theirs.contains("line 2 on feature"));
4925 assert!(!contents_theirs.contains("<<<<<<<"));
4926
4927 continue_merge(&temp_path).unwrap();
4928 assert!(!is_merging(&temp_path));
4929
4930 let _ = std::fs::remove_dir_all(&temp_path);
4932 }
4933
4934 #[test]
4935 fn test_resolve_conflict_hunk() {
4936 let mut temp_path = std::env::temp_dir();
4937 temp_path.push(format!(
4938 "twig_test_hunk_conflict_{}",
4939 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4940 ));
4941 std::fs::create_dir_all(&temp_path).unwrap();
4942
4943 let repo = Repository::init(&temp_path).unwrap();
4945
4946 let mut config = repo.config().unwrap();
4948 config.set_str("user.name", "Test User").unwrap();
4949 config.set_str("user.email", "test@example.com").unwrap();
4950
4951 let file_path = temp_path.join("conflict.txt");
4953 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";
4954 std::fs::write(&file_path, initial_lines).unwrap();
4955 stage_file(&temp_path, "conflict.txt").unwrap();
4956 commit_changes(&temp_path, "initial commit").unwrap();
4957
4958 let output = std::process::Command::new("git")
4960 .env("GIT_TERMINAL_PROMPT", "0")
4961 .env("GIT_SSH_COMMAND", ssh_command_val())
4962 .args(["symbolic-ref", "--short", "HEAD"])
4963 .current_dir(&temp_path)
4964 .output()
4965 .unwrap();
4966 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4967
4968 std::process::Command::new("git")
4970 .env("GIT_TERMINAL_PROMPT", "0")
4971 .env("GIT_SSH_COMMAND", ssh_command_val())
4972 .args(["checkout", "-b", "feature"])
4973 .current_dir(&temp_path)
4974 .output()
4975 .unwrap();
4976 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";
4977 std::fs::write(&file_path, feature_lines).unwrap();
4978 stage_file(&temp_path, "conflict.txt").unwrap();
4979 commit_changes(&temp_path, "feature commit").unwrap();
4980
4981 std::process::Command::new("git")
4983 .env("GIT_TERMINAL_PROMPT", "0")
4984 .env("GIT_SSH_COMMAND", ssh_command_val())
4985 .args(["checkout", &main_branch])
4986 .current_dir(&temp_path)
4987 .output()
4988 .unwrap();
4989 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";
4990 std::fs::write(&file_path, main_lines).unwrap();
4991 stage_file(&temp_path, "conflict.txt").unwrap();
4992 commit_changes(&temp_path, "main commit").unwrap();
4993
4994 let merge_output = std::process::Command::new("git")
4996 .env("GIT_TERMINAL_PROMPT", "0")
4997 .env("GIT_SSH_COMMAND", ssh_command_val())
4998 .args(["merge", "feature"])
4999 .current_dir(&temp_path)
5000 .output()
5001 .unwrap();
5002 assert!(!merge_output.status.success());
5003 assert!(is_merging(&temp_path));
5004
5005 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
5007 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
5008 assert!(contents_after_first.contains("line 2 on main"));
5010 assert!(!contents_after_first.contains("line 2 on feature"));
5011 assert!(contents_after_first.contains("<<<<<<<"));
5013 assert!(contents_after_first.contains("line 11 on main"));
5014 assert!(contents_after_first.contains("line 11 on feature"));
5015
5016 assert!(is_merging(&temp_path));
5018
5019 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
5024 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
5025 assert!(contents_after_second.contains("line 2 on main"));
5027 assert!(contents_after_second.contains("line 11 on feature"));
5028 assert!(!contents_after_second.contains("<<<<<<<"));
5029
5030 let status = repo.statuses(None).unwrap();
5032 assert_eq!(status.len(), 1);
5033 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
5034
5035 continue_merge(&temp_path).unwrap();
5037 assert!(!is_merging(&temp_path));
5038
5039 let _ = std::fs::remove_dir_all(&temp_path);
5041 }
5042
5043 #[test]
5044 fn test_branch_and_commit_helpers() {
5045 let mut temp_path = std::env::temp_dir();
5046 temp_path.push(format!(
5047 "twig_test_helpers_{}",
5048 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5049 ));
5050 let _ = std::fs::remove_dir_all(&temp_path);
5051 std::fs::create_dir_all(&temp_path).unwrap();
5052
5053 let repo = Repository::init(&temp_path).unwrap();
5055
5056 let mut config = repo.config().unwrap();
5058 config.set_str("user.name", "Test User").unwrap();
5059 config.set_str("user.email", "test@example.com").unwrap();
5060
5061 let file_path = temp_path.join("test.txt");
5063 std::fs::write(&file_path, "root content").unwrap();
5064 stage_file(&temp_path, "test.txt").unwrap();
5065 commit_changes(&temp_path, "root commit").unwrap();
5066
5067 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5068 assert!(is_root_commit(&temp_path, &head_oid));
5069
5070 std::fs::write(&file_path, "second content").unwrap();
5072 stage_file(&temp_path, "test.txt").unwrap();
5073 commit_changes(&temp_path, "second commit").unwrap();
5074
5075 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
5076 assert!(!is_root_commit(&temp_path, &new_head_oid));
5077
5078 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
5083 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
5084 "master"
5085 } else {
5086 "main"
5087 };
5088 let target = get_branch_push_target(&temp_path, active_branch);
5089 assert!(target.is_some());
5090 let (remote_name, set_upstream) = target.unwrap();
5091 assert_eq!(remote_name, "origin");
5092 assert!(set_upstream);
5093
5094 let mut git_config = repo.config().unwrap();
5096 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5097 git_config
5098 .set_str(
5099 &format!("branch.{}.merge", active_branch),
5100 &format!("refs/heads/{}", active_branch),
5101 )
5102 .unwrap();
5103
5104 let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5106 repo.reference(
5107 &format!("refs/remotes/origin/{}", active_branch),
5108 commit_obj.id(),
5109 true,
5110 "create mock remote ref",
5111 )
5112 .unwrap();
5113
5114 assert!(has_upstream_remote(&temp_path, active_branch));
5116 assert_eq!(
5117 get_branch_upstream_remote(&temp_path, active_branch),
5118 Some("origin".to_string())
5119 );
5120
5121 let target_tracking = get_branch_push_target(&temp_path, active_branch);
5123 assert!(target_tracking.is_some());
5124 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5125 assert_eq!(remote_name_tr, "origin");
5126 assert!(!set_upstream_tr);
5127
5128 let _ = std::fs::remove_dir_all(&temp_path);
5130 }
5131
5132 #[test]
5133 fn test_core_helpers_comprehensive() {
5134 assert_eq!(format_relative_time(0), "unknown");
5136 assert_eq!(format_relative_time(-100), "unknown");
5137
5138 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5139 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5140 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5141 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5142 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5143 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5144 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5145
5146 assert_eq!(format_utc_date(0), "unknown");
5148 assert_eq!(format_utc_date(-1), "unknown");
5149 let date_str = format_utc_date(1700000000);
5150 assert!(date_str.contains("2023-11-14"));
5151
5152 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5154 let tilde_path = expand_tilde("~/path");
5155 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5156
5157 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5159 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5160
5161 assert_eq!(safe_sha_slice("12345", 10), "12345");
5163 assert_eq!(safe_sha_slice("12345", 3), "123");
5164
5165 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
5167 assert!(safe_ref("-invalid").is_err());
5168 assert!(safe_ref(" ").is_err());
5169
5170 let mut status = RepoSummary::default();
5172 assert!(status.is_clean());
5173 assert!(status.is_synced());
5174 assert!(status.unchanged());
5175
5176 status.modified = 1;
5177 assert!(!status.is_clean());
5178 assert!(!status.unchanged());
5179
5180 status.modified = 0;
5181 status.ahead = 1;
5182 assert!(!status.is_synced());
5183 assert!(!status.unchanged());
5184
5185 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5187 assert!(data_not_loaded.is_not_loaded());
5188 assert!(!data_not_loaded.is_loading());
5189 assert!(!data_not_loaded.is_loaded());
5190 assert_eq!(data_not_loaded.len(), 0);
5191 assert!(data_not_loaded.is_empty());
5192 assert!(data_not_loaded.as_ref().is_none());
5193 assert!(data_not_loaded.first().is_none());
5194 assert!(data_not_loaded.get(0).is_none());
5195 assert_eq!(data_not_loaded.iter().count(), 0);
5196 assert_eq!(data_not_loaded.as_slice().len(), 0);
5197
5198 let data_loading: TabData<Vec<i32>> = TabData::Loading;
5199 assert!(!data_loading.is_not_loaded());
5200 assert!(data_loading.is_loading());
5201
5202 let data_loaded = TabData::Loaded(vec![10, 20]);
5203 assert!(!data_loaded.is_not_loaded());
5204 assert!(!data_loaded.is_loading());
5205 assert!(data_loaded.is_loaded());
5206 assert_eq!(data_loaded.len(), 2);
5207 assert!(!data_loaded.is_empty());
5208 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
5209 assert_eq!(data_loaded.first().unwrap(), &10);
5210 assert_eq!(data_loaded.get(1).unwrap(), &20);
5211 assert_eq!(data_loaded.iter().count(), 2);
5212 assert_eq!(data_loaded.as_slice(), &[10, 20]);
5213 }
5214
5215 #[test]
5216 fn test_comprehensive_gitwig_core_coverage_booster() {
5217 let mut temp_path = std::env::temp_dir();
5219 temp_path.push(format!(
5220 "twig_test_boost_{}",
5221 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5222 ));
5223 std::fs::create_dir_all(&temp_path).unwrap();
5224
5225 let repo = Repository::init(&temp_path).unwrap();
5226
5227 {
5229 let mut config = repo.config().unwrap();
5230 config.set_str("user.name", "User A").unwrap();
5231 config.set_str("user.email", "a@example.com").unwrap();
5232 std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
5233 stage_file(&temp_path, "file1.txt").unwrap();
5234 commit_changes(&temp_path, "commit A").unwrap();
5235 }
5236
5237 {
5239 let mut config = repo.config().unwrap();
5240 config.set_str("user.name", "User B").unwrap();
5241 config.set_str("user.email", "b@example.com").unwrap();
5242 std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
5243 stage_file(&temp_path, "file2.txt").unwrap();
5244 commit_changes(&temp_path, "commit B").unwrap();
5245 }
5246
5247 let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
5249 assert_eq!(stats.len(), 2);
5250 assert_eq!(stats[0].count, 1);
5251 assert_eq!(stats[1].count, 1);
5252
5253 std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
5256 stage_file(&temp_path, "file1.txt").unwrap();
5257 commit_changes(&temp_path, "deleted file1").unwrap();
5258 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5259 let files = get_commit_files(&temp_path, &head_oid).unwrap();
5260 assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
5261
5262 let path_tilde = expand_tilde("~");
5264 assert!(path_tilde.is_absolute());
5265
5266 let lfs_dir = temp_path.join(".git").join("lfs");
5268 std::fs::create_dir_all(&lfs_dir).unwrap();
5269 let nested_dir = lfs_dir.join("objects").join("xx");
5270 std::fs::create_dir_all(&nested_dir).unwrap();
5271 std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
5272 let lfs_size = get_lfs_storage_size(&temp_path);
5273 assert_eq!(lfs_size, Some(5));
5274
5275 let mut sub = repo
5277 .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
5278 .unwrap();
5279 let _ = sub.init(false);
5280 let subs = load_tab_submodules(&temp_path).unwrap();
5281 assert!(!subs.is_empty());
5282
5283 let wt_path = temp_path.join("wt-booster");
5285 let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
5286 let _ = worktree_lock(&temp_path, "wt-booster", "lock reason");
5287 let _ = worktree_unlock(&temp_path, "wt-booster");
5288 let _ = worktree_prune(&temp_path);
5289 let _ = std::fs::remove_dir_all(&wt_path);
5290
5291 open_browser("");
5293
5294 let nonexistent = Path::new("/nonexistent_directory_twig_test");
5296 let _ = stage_file(nonexistent, "test.txt");
5297 let _ = unstage_file(nonexistent, "test.txt");
5298 let _ = discard_all_changes(nonexistent);
5299 let _ = discard_file_changes(nonexistent, "test.txt", false);
5300 let _ = commit_changes(nonexistent, "msg");
5301 let _ = remote_add(nonexistent, "origin", "url");
5302 let _ = get_file_history(nonexistent, "test.txt");
5303 let _ = get_file_blame(nonexistent, "test.txt");
5304 let _ = get_commit_files(nonexistent, "sha");
5305 let _ = load_tab_reflog(nonexistent);
5306 let _ = load_tab_files(nonexistent);
5307 let _ = load_tab_branches(nonexistent);
5308 let _ = load_tab_tags(nonexistent);
5309 let _ = load_tab_remotes(nonexistent);
5310 let _ = load_tab_stashes(nonexistent);
5311 let _ = load_tab_overview(nonexistent, 10);
5312 let _ = load_tab_submodules(nonexistent);
5313 let _ = checkout_remote_branch(nonexistent, "origin/branch");
5314 let _ = checkout_local_branch(nonexistent, "branch");
5315 let _ = checkout_tag(nonexistent, "tag");
5316 let _ = delete_local_branch(nonexistent, "branch");
5317 let _ = delete_remote_branch(nonexistent, "origin/branch");
5318 let _ = create_tag(nonexistent, "tag", "sha", None, false);
5319 let _ = delete_remote_tag(nonexistent, "origin", "tag", Duration::from_secs(5));
5320 let _ = get_remote_tags(nonexistent, "origin", Duration::from_secs(5));
5321 let _ = apply_stash(nonexistent, 0);
5322 let _ = delete_stash(nonexistent, 0);
5323 let _ = save_stash(nonexistent, "msg", false, false);
5324 let _ = commit_amend(nonexistent, "msg");
5325 let _ = resolve_ours(nonexistent, "file");
5326 let _ = resolve_theirs(nonexistent, "file");
5327 let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
5328 let _ = abort_merge(nonexistent);
5329 let _ = continue_merge(nonexistent);
5330 let _ = get_branch_upstream_remote(nonexistent, "branch");
5331 let _ = load_tab_worktrees(nonexistent);
5332 let _ = worktree_add(nonexistent, "branch", Path::new("path"));
5333 let _ = worktree_lock(nonexistent, "name", "reason");
5334 let _ = worktree_unlock(nonexistent, "name");
5335 let _ = worktree_remove(nonexistent, "name", true);
5336 let _ = worktree_prune(nonexistent);
5337 let _ = load_tab_forge_issues(nonexistent, true);
5338 let _ = load_tab_forge_prs(nonexistent);
5339 let _ = load_pr_comments(nonexistent, 1);
5340 let _ = checkout_pr_branch(nonexistent, 1);
5341 let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
5342 let _ = get_remote_url(nonexistent, "origin");
5343 let _ = get_default_remote_url(nonexistent);
5344 let _ = get_latest_change_time("/nonexistent");
5345
5346 let dummy_hunk = vec![
5347 DiffLine {
5348 kind: DiffLineKind::Header,
5349 content: "@@ -1,1 +1,1 @@".to_string(),
5350 old_lineno: None,
5351 new_lineno: None,
5352 hunk_idx: None,
5353 },
5354 DiffLine {
5355 kind: DiffLineKind::Added,
5356 content: "line".to_string(),
5357 old_lineno: None,
5358 new_lineno: None,
5359 hunk_idx: None,
5360 },
5361 ];
5362 let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
5363 let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
5364 let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
5365 let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
5366 let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
5367 let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
5368
5369 let _ = std::fs::remove_dir_all(&temp_path);
5371 }
5372}