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