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
22mod process;
23pub use process::{
24 detached_command, git_command, spawn_pipe_reader, ssh_command_val, summarize_stderr,
25 tool_command,
26};
27
28#[derive(Debug, Clone)]
35pub enum ItemStatus {
36 Loading,
37 Missing,
38 Directory,
39 GitRepo(Option<RepoSummary>),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum RepoState {
44 #[default]
45 Clean,
46 Merge,
47 Revert,
48 CherryPick,
49 Bisect,
50 Rebase,
51 ApplyMailbox,
52}
53
54#[derive(Debug, Default, Clone)]
57pub struct RepoSummary {
58 pub branch: Option<String>,
61 pub staged: usize,
62 pub modified: usize,
63 pub untracked: usize,
64 pub conflicted: usize,
65 pub ahead: usize,
66 pub behind: usize,
67 pub state: RepoState,
68 pub last_commit_time: Option<i64>,
69}
70
71impl RepoSummary {
72 pub fn is_clean(&self) -> bool {
73 self.staged + self.modified + self.untracked + self.conflicted == 0
74 }
75 pub fn is_synced(&self) -> bool {
76 self.ahead + self.behind == 0
77 }
78 pub fn unchanged(&self) -> bool {
79 self.is_clean() && self.is_synced()
80 }
81}
82
83#[derive(Debug, Clone)]
86pub enum ItemDetail {
87 Missing { resolved: PathBuf },
88 Directory { resolved: PathBuf },
89 Repo { resolved: PathBuf, info: Box<RepoInfo> },
90 Error { resolved: PathBuf, message: String },
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct BranchInfo {
95 pub name: String,
96 pub is_head: bool,
97 pub short_sha: String,
98 pub short_message: String,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct FileRevision {
103 pub commit_oid: String,
104 pub author: String,
105 pub date: String,
106 pub when: String,
107 pub summary: String,
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct StashInfo {
112 pub index: usize,
113 pub message: String,
114 pub commit_id: String,
115 pub files: Vec<FileEntry>,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct CommitterStat {
120 pub name: String,
121 pub email: String,
122 pub count: usize,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub enum TabData<T> {
127 #[default]
128 NotLoaded,
129 Loading,
130 Loaded(T),
131 Error(String),
132}
133
134impl<T> TabData<T> {
135 pub fn is_not_loaded(&self) -> bool {
136 matches!(self, TabData::NotLoaded)
137 }
138 pub fn is_loading(&self) -> bool {
139 matches!(self, TabData::Loading)
140 }
141 #[allow(dead_code)]
142 pub fn is_loaded(&self) -> bool {
143 matches!(self, TabData::Loaded(_))
144 }
145 pub fn as_ref(&self) -> Option<&T> {
146 match self {
147 TabData::Loaded(val) => Some(val),
148 _ => None,
149 }
150 }
151}
152
153impl<T> TabData<Vec<T>> {
154 pub fn len(&self) -> usize {
155 self.as_ref().map(|v| v.len()).unwrap_or(0)
156 }
157 pub fn is_empty(&self) -> bool {
158 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
159 }
160 pub fn first(&self) -> Option<&T> {
161 self.as_ref().and_then(|v| v.first())
162 }
163 pub fn get(&self, index: usize) -> Option<&T> {
164 self.as_ref().and_then(|v| v.get(index))
165 }
166 pub fn iter(&self) -> std::slice::Iter<'_, T> {
167 match self {
168 TabData::Loaded(v) => v.iter(),
169 _ => [].iter(),
170 }
171 }
172 pub fn as_slice(&self) -> &[T] {
173 match self {
174 TabData::Loaded(v) => v.as_slice(),
175 _ => &[],
176 }
177 }
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct WorktreeInfo {
182 pub name: String,
183 pub path: PathBuf,
184 pub branch: Option<String>,
185 pub is_locked: bool,
186 pub lock_reason: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct SubmoduleInfo {
191 pub name: String,
192 pub path: PathBuf,
193 pub url: String,
194 pub commit_id: Option<String>,
195 pub head_id: Option<String>,
196 pub is_initialized: bool,
197 pub is_dirty: bool,
198}
199
200#[derive(Debug, Clone)]
201pub enum TabPayload {
202 Files(Result<Vec<String>, String>),
203 Graph(Result<Vec<GraphLine>, String>),
204 Branches {
205 local: Result<Vec<BranchInfo>, String>,
206 remote: Result<Vec<BranchInfo>, String>,
207 },
208 Tags {
209 local: Result<Vec<BranchInfo>, String>,
210 remote: Result<Vec<BranchInfo>, String>,
211 },
212 Remotes(Result<Vec<RemoteInfo>, String>),
213 Stashes(Result<Vec<StashInfo>, String>),
214 Overview(Result<(Vec<CommitterStat>, bool), String>),
215 Worktrees(Result<Vec<WorktreeInfo>, String>),
216 Submodules(Result<Vec<SubmoduleInfo>, String>),
217 Reflog(Result<Vec<ReflogEntry>, String>),
218 Notice(String),
221 ForgeIssues(Result<Vec<ForgeIssue>, String>),
222 ForgePRs(Result<Vec<ForgePR>, String>),
223 PRComments {
226 pr_number: u32,
227 result: Result<Vec<ForgePRComment>, String>,
228 },
229}
230
231#[derive(Debug, Default, Clone)]
232pub struct RepoInfo {
233 pub branch: Option<String>,
234 pub head: Option<HeadInfo>,
235 pub remotes: TabData<Vec<RemoteInfo>>,
236 pub upstream: Option<String>,
238 pub summary: RepoSummary,
239 pub changes: WorktreeChanges,
241 pub commits: Vec<CommitEntry>,
243 pub graph_lines: TabData<Vec<GraphLine>>,
245 pub local_branches: TabData<Vec<BranchInfo>>,
247 pub remote_branches: TabData<Vec<BranchInfo>>,
249 pub local_tags: TabData<Vec<BranchInfo>>,
251 pub remote_tags: TabData<Vec<BranchInfo>>,
253 pub remote_tags_loaded: bool,
255 pub remote_tags_attempted: bool,
257 pub files: TabData<Vec<String>>,
259 pub stashes: TabData<Vec<StashInfo>>,
261 pub worktrees: TabData<Vec<WorktreeInfo>>,
263 pub submodules: TabData<Vec<SubmoduleInfo>>,
265 pub reflog: TabData<Vec<ReflogEntry>>,
267 pub forge_issues: TabData<Vec<ForgeIssue>>,
268 pub forge_prs: TabData<Vec<ForgePR>>,
269 pub committer_stats: TabData<Vec<CommitterStat>>,
271 pub committer_stats_limit_reached: bool,
273 pub tab_loaded_at: [Option<std::time::Instant>; 12],
275 pub tab_loading: [bool; 12],
277 pub lfs_files: std::collections::HashSet<String>,
279 pub lfs_installed: bool,
281 pub lfs_storage_size: Option<u64>,
283}
284
285impl RepoInfo {
286 pub fn reset_in_flight_loads(&mut self) {
291 fn unstick<T>(data: &mut TabData<T>) {
292 if data.is_loading() {
293 *data = TabData::NotLoaded;
294 }
295 }
296 self.tab_loading = [false; 12];
297 unstick(&mut self.remotes);
298 unstick(&mut self.graph_lines);
299 unstick(&mut self.local_branches);
300 unstick(&mut self.remote_branches);
301 unstick(&mut self.local_tags);
302 unstick(&mut self.remote_tags);
303 unstick(&mut self.files);
304 unstick(&mut self.stashes);
305 unstick(&mut self.worktrees);
306 unstick(&mut self.submodules);
307 unstick(&mut self.reflog);
308 unstick(&mut self.forge_issues);
309 unstick(&mut self.forge_prs);
310 unstick(&mut self.committer_stats);
311 }
312}
313
314#[derive(Debug, Clone)]
315pub struct HeadInfo {
316 pub short_id: String,
317 pub summary: String,
318 pub author: String,
319 pub when: String,
320}
321
322#[derive(Debug, Clone)]
323pub struct ReflogEntry {
324 pub index: usize,
325 pub target_oid: String,
326 pub selector: String,
327 pub command: String,
328 pub message: String,
329 pub when: String,
330 pub date: String,
331}
332
333#[derive(Debug, Clone)]
334pub struct RemoteInfo {
335 pub name: String,
336 pub url: String,
337 pub push_url: Option<String>,
338 pub refspecs: Vec<String>,
339}
340
341#[derive(Debug, Clone)]
342pub struct CommitEntry {
343 pub id: String,
345 pub oid: String,
347 pub author: String,
348 pub when: String,
349 pub date: String,
350 pub summary: String,
351 pub message: String,
352 pub refs: Vec<String>,
355 pub files: Vec<FileEntry>,
357 pub signature_status: String,
359}
360
361#[derive(Debug, Clone)]
362pub struct GraphLine {
363 pub graph: String,
364 pub commit: Option<GraphCommit>,
365}
366
367#[derive(Debug, Clone)]
368pub struct GraphCommit {
369 pub oid: String,
370 pub decoration: String,
371 pub summary: String,
372 pub author: String,
373 pub date: String,
374 pub signature_status: String,
376}
377
378#[derive(Debug, Clone)]
380pub struct FileEntry {
381 pub path: String,
383 pub label: &'static str,
385}
386
387#[derive(Debug, Default, Clone)]
390pub struct WorktreeChanges {
391 pub staged: Vec<FileEntry>,
392 pub unstaged: Vec<FileEntry>,
393 pub untracked: Vec<FileEntry>,
394 pub conflicted: Vec<FileEntry>,
395}
396
397#[derive(Debug, Clone, PartialEq)]
401pub enum DiffLineKind {
402 Header,
404 Added,
406 Removed,
408 Context,
410 ConflictOurs,
412 ConflictTheirs,
414 ConflictSeparator,
416}
417
418#[derive(Debug, Clone)]
420pub struct DiffLine {
421 pub kind: DiffLineKind,
422 pub content: String,
424 pub old_lineno: Option<u32>,
425 pub new_lineno: Option<u32>,
426 pub hunk_idx: Option<usize>,
427}
428
429pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
433 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
434}
435
436pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
443 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
444}
445
446pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
449 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
450 let mut index = repo.index().map_err(|e| e.to_string())?;
451 let full_path = repo_path.join(file_path);
452 if full_path.exists() {
453 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
454 } else {
455 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
456 }
457 index.write().map_err(|e| e.to_string())?;
458 Ok(())
459}
460
461pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
466 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
467 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
469 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
470 .map_err(|e| e.to_string())?;
471 } else {
472 let mut index = repo.index().map_err(|e| e.to_string())?;
474 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
475 index.write().map_err(|e| e.to_string())?;
476 }
477 Ok(())
478}
479
480pub fn sanitize_text(s: &str) -> String {
481 let mut result = String::with_capacity(s.len());
482 let mut chars = s.chars().peekable();
483 while let Some(c) = chars.next() {
484 if c == '\x1B' {
485 if let Some(&'[') = chars.peek() {
487 chars.next();
488 for seq_c in chars.by_ref() {
489 if seq_c.is_ascii_alphabetic() {
491 break;
492 }
493 }
494 } else {
495 for seq_c in chars.by_ref() {
497 if seq_c == ' ' || seq_c.is_ascii_alphabetic() {
498 break;
499 }
500 }
501 }
502 } else if (c < ' ' || c == '\x7F') && c != '\n' && c != '\r' && c != '\t' {
503 result.push(' ');
505 } else {
506 result.push(c);
507 }
508 }
509 result
510}
511
512pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
513 let mut end = len.min(sha.len());
514 while end > 0 && !sha.is_char_boundary(end) {
515 end -= 1;
516 }
517 &sha[..end]
518}
519
520const GIT_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
522
523fn run_git_with_timeout(
530 mut cmd: std::process::Command,
531 timeout: Duration,
532) -> Result<std::process::Output, Box<dyn std::error::Error>> {
533 if timeout.is_zero() {
534 return Ok(cmd.output()?);
535 }
536
537 cmd.stdin(std::process::Stdio::null())
538 .stdout(std::process::Stdio::piped())
539 .stderr(std::process::Stdio::piped());
540
541 let mut child = cmd.spawn()?;
542
543 let mut out_pipe = child.stdout.take();
544 let mut err_pipe = child.stderr.take();
545 let out_reader = std::thread::spawn(move || {
546 let mut buf = Vec::new();
547 if let Some(p) = out_pipe.as_mut() {
548 let _ = std::io::Read::read_to_end(p, &mut buf);
549 }
550 buf
551 });
552 let err_reader = std::thread::spawn(move || {
553 let mut buf = Vec::new();
554 if let Some(p) = err_pipe.as_mut() {
555 let _ = std::io::Read::read_to_end(p, &mut buf);
556 }
557 buf
558 });
559
560 let started = Instant::now();
561 let status = loop {
562 match child.try_wait() {
563 Ok(Some(status)) => break Some(status),
564 Ok(None) => {
565 if started.elapsed() >= timeout {
566 let _ = child.kill();
567 let _ = child.wait();
568 break None;
569 }
570 std::thread::sleep(GIT_TIMEOUT_POLL_INTERVAL);
571 }
572 Err(e) => {
573 let _ = child.kill();
574 let _ = child.wait();
575 return Err(e.into());
576 }
577 }
578 };
579
580 let stdout = out_reader.join().unwrap_or_default();
581 let stderr = err_reader.join().unwrap_or_default();
582
583 match status {
584 Some(status) => Ok(std::process::Output { status, stdout, stderr }),
585 None => {
586 Err(format!("git did not respond within {}s and was cancelled", timeout.as_secs())
587 .into())
588 }
589 }
590}
591
592pub fn safe_ref(r: &str) -> Result<&str, String> {
593 let trimmed = r.trim();
594 if trimmed.starts_with('-') {
595 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
596 }
597 if trimmed.is_empty() {
598 return Err("Ref name cannot be empty".to_string());
599 }
600 Ok(trimmed)
601}
602
603pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
605 let output = git_command()
606 .arg("add")
607 .arg("-A")
608 .current_dir(repo_path)
609 .output()
610 .map_err(|e| e.to_string())?;
611
612 if output.status.success() {
613 Ok(())
614 } else {
615 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
616 }
617}
618
619pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
621 let output =
622 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
623
624 if output.status.success() {
625 Ok(())
626 } else {
627 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
628 }
629}
630
631pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
633 let _ = git_command().arg("reset").current_dir(repo_path).output();
635
636 let checkout_out = git_command()
638 .arg("checkout")
639 .arg("--")
640 .arg(".")
641 .current_dir(repo_path)
642 .output()
643 .map_err(|e| e.to_string())?;
644
645 if !checkout_out.status.success() {
646 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
647 }
648
649 let clean_out = git_command()
651 .arg("clean")
652 .arg("-fd")
653 .current_dir(repo_path)
654 .output()
655 .map_err(|e| e.to_string())?;
656
657 if !clean_out.status.success() {
658 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
659 }
660
661 Ok(())
662}
663
664pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
666 apply_hunk_patch(repo_path, file_path, hunk, false, true)
667}
668
669pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
671 apply_hunk_patch(repo_path, file_path, hunk, true, true)
672}
673
674pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
676 apply_hunk_patch(repo_path, file_path, hunk, true, false)
677}
678
679pub fn stage_line(
681 repo_path: &Path,
682 file_path: &str,
683 hunk: &[DiffLine],
684 selected_line_idx: usize,
685) -> Result<(), String> {
686 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
687}
688
689pub fn unstage_line(
691 repo_path: &Path,
692 file_path: &str,
693 hunk: &[DiffLine],
694 selected_line_idx: usize,
695) -> Result<(), String> {
696 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
697}
698
699pub fn discard_line(
701 repo_path: &Path,
702 file_path: &str,
703 hunk: &[DiffLine],
704 selected_line_idx: usize,
705) -> Result<(), String> {
706 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
707}
708
709fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
710 if !header.starts_with("@@") {
711 return None;
712 }
713 let parts: Vec<&str> = header.split("@@").collect();
714 if parts.len() < 3 {
715 return None;
716 }
717 let meta = parts[1].trim();
718 let subparts: Vec<&str> = meta.split_whitespace().collect();
719 if subparts.len() < 2 {
720 return None;
721 }
722
723 let parse_part = |p: &str| -> (usize, usize) {
724 let s = p.trim_start_matches(['-', '+']);
725 let comps: Vec<&str> = s.split(',').collect();
726 let start = comps[0].parse::<usize>().unwrap_or(0);
727 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
728 (start, count)
729 };
730
731 let (old_start, old_count) = parse_part(subparts[0]);
732 let (new_start, new_count) = parse_part(subparts[1]);
733 Some((old_start, old_count, new_start, new_count))
734}
735
736fn apply_line_patch_inner(
737 repo_path: &Path,
738 file_path: &str,
739 hunk: &[DiffLine],
740 selected_line_idx_in_hunk: usize,
741 revert: bool,
742 target_has_modification: bool,
743 cached: bool,
744) -> Result<(), String> {
745 use std::io::Write;
746 use std::process::Stdio;
747
748 let path_check = std::path::Path::new(file_path);
749 if path_check.is_absolute()
750 || path_check.components().any(|c| c == std::path::Component::ParentDir)
751 {
752 return Err("Path escapes repository root".to_string());
753 }
754
755 if hunk.is_empty() {
756 return Err("Empty hunk".to_string());
757 }
758
759 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
760 Some(line) => line,
761 None => return Err("Invalid line index".to_string()),
762 };
763
764 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
765 return Err("Selected line is not a modification (must be + or -)".to_string());
766 }
767
768 let header_line = &hunk[0];
769 let (old_start, _old_count, new_start, _new_count) =
770 match parse_hunk_header(&header_line.content) {
771 Some(coords) => coords,
772 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
773 };
774
775 let mut patch_lines = Vec::new();
776 let mut new_old_count = 0;
777 let mut new_new_count = 0;
778
779 for (i, line) in hunk.iter().enumerate() {
780 if i == 0 {
781 continue;
782 }
783
784 if i == selected_line_idx_in_hunk {
785 if revert {
786 match line.kind {
787 DiffLineKind::Added => {
788 patch_lines.push(DiffLine {
789 kind: DiffLineKind::Removed,
790 content: line.content.clone(),
791 old_lineno: None,
792 new_lineno: None,
793 hunk_idx: None,
794 });
795 new_old_count += 1;
796 }
797 DiffLineKind::Removed => {
798 patch_lines.push(DiffLine {
799 kind: DiffLineKind::Added,
800 content: line.content.clone(),
801 old_lineno: None,
802 new_lineno: None,
803 hunk_idx: None,
804 });
805 new_new_count += 1;
806 }
807 _ => {}
808 }
809 } else {
810 match line.kind {
811 DiffLineKind::Added => {
812 patch_lines.push(DiffLine {
813 kind: DiffLineKind::Added,
814 content: line.content.clone(),
815 old_lineno: None,
816 new_lineno: None,
817 hunk_idx: None,
818 });
819 new_new_count += 1;
820 }
821 DiffLineKind::Removed => {
822 patch_lines.push(DiffLine {
823 kind: DiffLineKind::Removed,
824 content: line.content.clone(),
825 old_lineno: None,
826 new_lineno: None,
827 hunk_idx: None,
828 });
829 new_old_count += 1;
830 }
831 _ => {}
832 }
833 }
834 } else {
835 match line.kind {
836 DiffLineKind::Context => {
837 patch_lines.push(line.clone());
838 new_old_count += 1;
839 new_new_count += 1;
840 }
841 DiffLineKind::Added => {
842 if target_has_modification {
843 patch_lines.push(DiffLine {
844 kind: DiffLineKind::Context,
845 content: line.content.clone(),
846 old_lineno: None,
847 new_lineno: None,
848 hunk_idx: None,
849 });
850 new_old_count += 1;
851 new_new_count += 1;
852 } else {
853 }
855 }
856 DiffLineKind::Removed => {
857 if target_has_modification {
858 } else {
860 patch_lines.push(DiffLine {
861 kind: DiffLineKind::Context,
862 content: line.content.clone(),
863 old_lineno: None,
864 new_lineno: None,
865 hunk_idx: None,
866 });
867 new_old_count += 1;
868 new_new_count += 1;
869 }
870 }
871 _ => {}
872 }
873 }
874 }
875
876 let mut patch = String::new();
877 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
878 patch.push_str(&format!("--- a/{}\n", file_path));
879 patch.push_str(&format!("+++ b/{}\n", file_path));
880 patch.push_str(&format!(
881 "@@ -{},{} +{},{} @@\n",
882 old_start, new_old_count, new_start, new_new_count
883 ));
884
885 for line in patch_lines {
886 let prefix = match line.kind {
887 DiffLineKind::Added => "+",
888 DiffLineKind::Removed => "-",
889 DiffLineKind::Context => " ",
890 DiffLineKind::Header => "",
891 _ => "",
892 };
893 patch.push_str(prefix);
894 patch.push_str(&line.content);
895 patch.push('\n');
896 }
897
898 let mut args = vec!["apply"];
899 if cached {
900 args.push("--cached");
901 }
902 args.push("-");
903
904 let mut cmd = git_command();
905 let mut child = cmd
906 .args(&args)
907 .current_dir(repo_path)
908 .stdin(Stdio::piped())
909 .stdout(Stdio::piped())
910 .stderr(Stdio::piped())
911 .spawn()
912 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
913
914 if let Some(mut stdin) = child.stdin.take() {
915 stdin
916 .write_all(patch.as_bytes())
917 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
918 }
919
920 let output =
921 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
922
923 if !output.status.success() {
924 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
925 return Err(format!("git apply failed: {}", err_msg.trim()));
926 }
927
928 Ok(())
929}
930
931fn apply_hunk_patch(
932 repo_path: &Path,
933 file_path: &str,
934 hunk: &[DiffLine],
935 reverse: bool,
936 cached: bool,
937) -> Result<(), String> {
938 use std::io::Write;
939 use std::process::Stdio;
940
941 let path_check = std::path::Path::new(file_path);
942 if path_check.is_absolute()
943 || path_check.components().any(|c| c == std::path::Component::ParentDir)
944 {
945 return Err("Path escapes repository root".to_string());
946 }
947
948 let mut patch = String::new();
949 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
950 patch.push_str(&format!("--- a/{}\n", file_path));
951 patch.push_str(&format!("+++ b/{}\n", file_path));
952 for line in hunk {
953 let prefix = match line.kind {
954 DiffLineKind::Added => "+",
955 DiffLineKind::Removed => "-",
956 DiffLineKind::Context => " ",
957 DiffLineKind::Header => "",
958 _ => "",
959 };
960 patch.push_str(prefix);
961 patch.push_str(&line.content);
962 patch.push('\n');
963 }
964
965 let mut args = vec!["apply"];
966 if cached {
967 args.push("--cached");
968 }
969 if reverse {
970 args.push("--reverse");
971 }
972 args.push("-");
973
974 let mut cmd = git_command();
975 let mut child = cmd
976 .args(&args)
977 .current_dir(repo_path)
978 .stdin(Stdio::piped())
979 .stdout(Stdio::piped())
980 .stderr(Stdio::piped())
981 .spawn()
982 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
983
984 if let Some(mut stdin) = child.stdin.take() {
985 stdin
986 .write_all(patch.as_bytes())
987 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
988 }
989
990 let output =
991 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
992
993 if !output.status.success() {
994 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
995 return Err(format!("git apply failed: {}", err_msg.trim()));
996 }
997
998 Ok(())
999}
1000
1001pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
1006 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1007
1008 if staged {
1009 unstage_file(repo_path, file_path)?;
1011 }
1012
1013 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
1015 status.contains(git2::Status::WT_NEW)
1016 } else {
1017 false
1018 };
1019
1020 if is_untracked {
1021 let full_path = repo_path.join(file_path);
1022 if full_path.exists() {
1023 if full_path.is_file() {
1024 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
1025 } else if full_path.is_dir() {
1026 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
1027 }
1028 }
1029 } else {
1030 let mut checkout_opts = git2::build::CheckoutBuilder::new();
1032 checkout_opts.path(Path::new(file_path));
1033 checkout_opts.force();
1034 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
1035 }
1036
1037 Ok(())
1038}
1039
1040pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
1043 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1044 let mut index = repo.index().map_err(|e| e.to_string())?;
1045 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
1046 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
1047
1048 let signature = repo
1049 .signature()
1050 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
1051
1052 let mut parents = Vec::new();
1054 let mut has_head = false;
1055 if let Ok(head) = repo.head() {
1056 if let Ok(parent_commit) = head.peel_to_commit() {
1057 has_head = true;
1058 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
1060 if parent_tree.id() == tree_id {
1061 return Err("No staged changes to commit".to_string());
1062 }
1063 parents.push(parent_commit);
1064 }
1065 }
1066
1067 if !has_head && index.is_empty() {
1068 return Err("No staged changes to commit (index is empty)".to_string());
1069 }
1070
1071 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1072
1073 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
1074 .map_err(|e| e.to_string())?;
1075
1076 Ok(())
1077}
1078
1079pub fn expand_tilde(s: &str) -> PathBuf {
1085 if s == "~" {
1086 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
1087 }
1088 if let Some(stripped) = s.strip_prefix("~/")
1089 && let Some(home) = dirs::home_dir()
1090 {
1091 return home.join(stripped);
1092 }
1093 PathBuf::from(s)
1094}
1095
1096pub fn discover_repo_root(start: &Path) -> Option<PathBuf> {
1103 let mut dir = if start.is_dir() { Some(start) } else { start.parent() };
1104 while let Some(current) = dir {
1105 if current.join(".git").exists() {
1106 return Some(current.to_path_buf());
1107 }
1108 dir = current.parent();
1109 }
1110 None
1111}
1112
1113pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1115 let repo = Repository::open(repo_path)?;
1116 repo.remote(name, url)?;
1117 Ok(())
1118}
1119
1120pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1122 let repo = Repository::open(repo_path)?;
1123 repo.remote_delete(name)?;
1124 Ok(())
1125}
1126
1127pub fn inspect_summary(item: &str) -> ItemStatus {
1129 let path = expand_tilde(item);
1130 if !path.is_dir() {
1131 return ItemStatus::Missing;
1132 }
1133 if !path.join(".git").exists() {
1134 return ItemStatus::Directory;
1135 }
1136 match Repository::open(&path) {
1137 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1138 Err(_) => ItemStatus::GitRepo(None),
1139 }
1140}
1141
1142pub fn inspect_detail(
1144 item: &str,
1145 commit_limit: usize,
1146 graph_max_commits: usize,
1147 enable_commit_signatures: bool,
1148) -> ItemDetail {
1149 let resolved = expand_tilde(item);
1150 if !resolved.is_dir() {
1151 return ItemDetail::Missing { resolved };
1152 }
1153 if !resolved.join(".git").exists() {
1154 return ItemDetail::Directory { resolved };
1155 }
1156 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1157 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1158 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1159 }
1160}
1161
1162fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1163 let mut sigs = std::collections::HashMap::new();
1164 let mut cmd = git_command();
1165 cmd.arg("log").arg("--all");
1166
1167 if limit > 0 {
1168 cmd.arg(format!("-n{}", limit));
1169 }
1170
1171 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1172
1173 if let Ok(out) = cmd.output() {
1174 if out.status.success() {
1175 let stdout_str = String::from_utf8_lossy(&out.stdout);
1176 for line in stdout_str.lines() {
1177 let parts: Vec<&str> = line.split_whitespace().collect();
1178 if parts.len() == 2 {
1179 sigs.insert(parts[0].to_string(), parts[1].to_string());
1180 } else if parts.len() == 1 {
1181 sigs.insert(parts[0].to_string(), "N".to_string());
1182 }
1183 }
1184 }
1185 }
1186 sigs
1187}
1188
1189#[derive(serde::Serialize, serde::Deserialize, Clone)]
1190struct CachedCommit {
1191 id: String,
1192 author: String,
1193 date: String,
1194 summary: String,
1195 message: String,
1196 time: i64,
1197}
1198
1199fn hash_path(path: &Path) -> String {
1200 use std::collections::hash_map::DefaultHasher;
1201 use std::hash::{Hash, Hasher};
1202 let mut hasher = DefaultHasher::new();
1203 path.hash(&mut hasher);
1204 format!("{:x}", hasher.finish())
1205}
1206
1207fn collect_commits(
1208 repo: &Repository,
1209 limit: usize,
1210 repo_path: &Path,
1211 enable_commit_signatures: bool,
1212) -> Result<Vec<CommitEntry>, git2::Error> {
1213 let mut walk = repo.revwalk()?;
1214 if walk.push_head().is_err() {
1215 return Ok(Vec::new());
1216 }
1217 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1218
1219 let mut commits = Vec::new();
1220 let oids: Vec<Result<git2::Oid, git2::Error>> =
1221 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1222
1223 let sig_map = if enable_commit_signatures {
1224 collect_signatures(repo_path, limit)
1225 } else {
1226 std::collections::HashMap::new()
1227 };
1228 let ref_map = get_cached_ref_map(repo, repo_path);
1229
1230 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1232 if let Some(ref dir) = cache_dir {
1233 let _ = std::fs::create_dir_all(dir);
1234 }
1235 let hash = hash_path(repo_path);
1236 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("v2_{}.json", hash)));
1237 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1238 .as_ref()
1239 .and_then(|f| std::fs::read_to_string(f).ok())
1240 .and_then(|s| serde_json::from_str(&s).ok())
1241 .unwrap_or_default();
1242
1243 let mut cache_updated = false;
1244
1245 for id in oids {
1246 let oid = id?;
1247 let oid_str = oid.to_string();
1248 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1249 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1250 let files = Vec::new();
1251
1252 if let Some(cached) = cache.get(&oid_str) {
1253 let when = format_relative_time(cached.time);
1254 commits.push(CommitEntry {
1255 id: cached.id.clone(),
1256 oid: oid_str,
1257 author: sanitize_text(&cached.author),
1258 when,
1259 date: cached.date.clone(),
1260 summary: sanitize_text(&cached.summary),
1261 message: sanitize_text(&cached.message),
1262 refs,
1263 files,
1264 signature_status: sig_status,
1265 });
1266 } else if let Ok(commit) = repo.find_commit(oid) {
1267 let short_id = format!("{:.7}", commit.id());
1268 let summary =
1269 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1270 let author = commit.author();
1271 let author_name = author.name().unwrap_or("?");
1272 let author_email = author.email().unwrap_or("?");
1273 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1274 let time_secs = commit.time().seconds();
1275 let when = format_relative_time(time_secs);
1276 let date = format_utc_date(time_secs);
1277 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1278
1279 let cached = CachedCommit {
1280 id: short_id.clone(),
1281 author: author_str.clone(),
1282 date: date.clone(),
1283 summary: summary.clone(),
1284 message: message.clone(),
1285 time: time_secs,
1286 };
1287 cache.insert(oid_str.clone(), cached);
1288 cache_updated = true;
1289
1290 commits.push(CommitEntry {
1291 id: short_id,
1292 oid: oid_str,
1293 author: author_str,
1294 when,
1295 date,
1296 summary,
1297 message,
1298 refs,
1299 files,
1300 signature_status: sig_status,
1301 });
1302 }
1303 }
1304
1305 if cache_updated {
1306 if let Some(ref f) = cache_file {
1307 if let Ok(json) = serde_json::to_string(&cache) {
1308 let _ = std::fs::write(f, json);
1309 }
1310 }
1311 }
1312
1313 Ok(commits)
1314}
1315
1316pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1317 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1318 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1319 if walk.push_head().is_err() {
1320 return Ok(Vec::new());
1321 }
1322 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1323
1324 let mut revisions = Vec::new();
1325
1326 for oid_res in walk {
1327 let oid = oid_res.map_err(|e| e.to_string())?;
1328 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1329
1330 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1331 let mut modified = false;
1332
1333 if commit.parent_count() > 0 {
1334 for i in 0..commit.parent_count() {
1335 if let Ok(parent) = commit.parent(i) {
1336 if let Ok(parent_tree) = parent.tree() {
1337 let mut diff_opts = git2::DiffOptions::new();
1338 diff_opts.pathspec(file_path);
1339 if let Ok(diff) = repo.diff_tree_to_tree(
1340 Some(&parent_tree),
1341 Some(&commit_tree),
1342 Some(&mut diff_opts),
1343 ) {
1344 if diff.deltas().len() > 0 {
1345 modified = true;
1346 break;
1347 }
1348 }
1349 }
1350 }
1351 }
1352 } else {
1353 let mut diff_opts = git2::DiffOptions::new();
1354 diff_opts.pathspec(file_path);
1355 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1356 {
1357 if diff.deltas().len() > 0 {
1358 modified = true;
1359 }
1360 }
1361 }
1362
1363 if modified {
1364 let author = commit.author();
1365 let author_name = author.name().unwrap_or("?");
1366 let author_email = author.email().unwrap_or("?");
1367 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1368 let time_secs = commit.time().seconds();
1369 let when = format_relative_time(time_secs);
1370 let date = format_utc_date(time_secs);
1371 let summary =
1372 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1373
1374 revisions.push(FileRevision {
1375 commit_oid: oid.to_string(),
1376 author: author_str,
1377 date,
1378 when,
1379 summary,
1380 });
1381 }
1382 }
1383
1384 Ok(revisions)
1385}
1386
1387#[derive(Debug, Clone)]
1388pub struct BlameEntry {
1389 pub line_no: usize,
1390 pub commit_id: String,
1391 pub author: String,
1392 pub date: String,
1393}
1394
1395pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1396 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1397 let mut opts = git2::BlameOptions::new();
1398 let blame = repo
1399 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1400 .map_err(|e| e.to_string())?;
1401
1402 let mut entries = Vec::new();
1403 for i in 0..blame.len() {
1404 if let Some(hunk) = blame.get_index(i) {
1405 let commit_id = hunk.final_commit_id().to_string();
1406 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1407
1408 let mut author_name = "Unknown".to_string();
1409 let mut date_str = "unknown".to_string();
1410 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1411 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1412 date_str = format_utc_date(commit.time().seconds());
1413 }
1414
1415 let start = hunk.final_start_line();
1416 let count = hunk.lines_in_hunk();
1417 for line_idx in 0..count {
1418 entries.push(BlameEntry {
1419 line_no: start + line_idx,
1420 commit_id: short_id.to_string(),
1421 author: author_name.clone(),
1422 date: date_str.clone(),
1423 });
1424 }
1425 }
1426 }
1427
1428 entries.sort_by_key(|e| e.line_no);
1429 Ok(entries)
1430}
1431
1432fn collect_committer_stats(
1433 repo: &Repository,
1434 limit: usize,
1435) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1436 let mut walk = repo.revwalk()?;
1437 if walk.push_head().is_err() {
1438 return Ok((Vec::new(), false));
1439 }
1440 let mut counts = std::collections::HashMap::new();
1441 let mut count = 0;
1442 let mut limit_reached = false;
1443 for id in walk {
1444 let oid = id?;
1445 if let Ok(commit) = repo.find_commit(oid) {
1446 let author = commit.author();
1447 let name = author.name().unwrap_or("?").to_string();
1448 let email = author.email().unwrap_or("?").to_string();
1449 let key = (name, email);
1450 *counts.entry(key).or_insert(0) += 1;
1451 count += 1;
1452 if count >= limit {
1453 limit_reached = true;
1454 break;
1455 }
1456 }
1457 }
1458
1459 let mut stats: Vec<CommitterStat> = counts
1460 .into_iter()
1461 .map(|((name, email), count)| CommitterStat { name, email, count })
1462 .collect();
1463
1464 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1465
1466 Ok((stats, limit_reached))
1467}
1468
1469fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1473 let commit_tree = match commit.tree() {
1474 Ok(t) => t,
1475 Err(_) => return Vec::new(),
1476 };
1477 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1480
1481 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1482 Ok(d) => d,
1483 Err(_) => return Vec::new(),
1484 };
1485
1486 let mut files = Vec::new();
1487 for delta in diff.deltas() {
1488 if files.len() >= MAX_FILES_PER_SECTION {
1489 break;
1490 }
1491 let path = delta
1492 .new_file()
1493 .path()
1494 .or_else(|| delta.old_file().path())
1495 .map(|p| p.to_string_lossy().into_owned())
1496 .unwrap_or_else(|| "(unknown)".to_string());
1497
1498 let label: &'static str = match delta.status() {
1499 git2::Delta::Added => "N",
1500 git2::Delta::Deleted => "D",
1501 git2::Delta::Modified => "M",
1502 git2::Delta::Renamed => "R",
1503 git2::Delta::Typechange => "T",
1504 _ => "M",
1505 };
1506 files.push(FileEntry { path, label });
1507 }
1508 files
1509}
1510
1511pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1512 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1513 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1514 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1515 Ok(commit_changed_files(&repo, &commit))
1516}
1517fn match_pattern(path: &str, pattern: &str) -> bool {
1518 if let Some(suffix) = pattern.strip_prefix('*') {
1519 return path.ends_with(suffix);
1520 }
1521 if let Some(prefix) = pattern.strip_suffix('*') {
1522 return path.starts_with(prefix);
1523 }
1524 if pattern.contains('*') {
1525 let parts: Vec<&str> = pattern.split('*').collect();
1526 if parts.len() == 2 {
1527 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1528 }
1529 }
1530 path == pattern || path.ends_with(&format!("/{}", pattern))
1531}
1532
1533static LFS_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1534
1535fn is_lfs_installed() -> bool {
1536 *LFS_INSTALLED.get_or_init(|| {
1537 if let Ok(output) = git_command().arg("lfs").arg("--version").output() {
1538 output.status.success()
1539 } else {
1540 false
1541 }
1542 })
1543}
1544
1545fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1546 let mut tracked = std::collections::HashSet::new();
1547
1548 let lfs_dir = repo_path.join(".git").join("lfs");
1550 let gitattributes_path = repo_path.join(".gitattributes");
1551 let has_lfs_attributes = if gitattributes_path.exists() {
1552 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1553 content.contains("filter=lfs")
1554 || content.contains("diff=lfs")
1555 || content.contains("merge=lfs")
1556 } else {
1557 false
1558 }
1559 } else {
1560 false
1561 };
1562
1563 if !lfs_dir.exists() && !has_lfs_attributes {
1564 return (false, tracked);
1565 }
1566
1567 let installed = is_lfs_installed();
1568 if installed {
1569 if let Ok(output) =
1570 git_command().arg("lfs").arg("ls-files").arg("-n").current_dir(repo_path).output()
1571 {
1572 if output.status.success() {
1573 if let Ok(stdout) = String::from_utf8(output.stdout) {
1574 for line in stdout.lines() {
1575 let trimmed = line.trim();
1576 if !trimmed.is_empty() {
1577 tracked.insert(trimmed.to_string());
1578 }
1579 }
1580 }
1581 }
1582 }
1583 }
1584
1585 let gitattributes_path = repo_path.join(".gitattributes");
1586 if gitattributes_path.exists() {
1587 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1588 let mut patterns = Vec::new();
1589 for line in content.lines() {
1590 let line = line.trim();
1591 if line.is_empty() || line.starts_with('#') {
1592 continue;
1593 }
1594 if line.contains("filter=lfs")
1595 || line.contains("diff=lfs")
1596 || line.contains("merge=lfs")
1597 {
1598 if let Some(pattern) = line.split_whitespace().next() {
1599 patterns.push(pattern.to_string());
1600 }
1601 }
1602 }
1603
1604 if !patterns.is_empty() {
1605 if let Ok(repo) = Repository::open(repo_path) {
1606 if let Ok(index) = repo.index() {
1607 for entry in index.iter() {
1608 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1609 for pat in &patterns {
1610 if match_pattern(path_str, pat) {
1611 tracked.insert(path_str.to_string());
1612 break;
1613 }
1614 }
1615 }
1616 }
1617 }
1618 }
1619 }
1620 }
1621 }
1622
1623 (installed, tracked)
1624}
1625
1626fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1627 let lfs_path = repo_path.join(".git").join("lfs");
1628 if !lfs_path.exists() {
1629 return None;
1630 }
1631
1632 fn calculate_dir_size(dir: &Path) -> u64 {
1633 let mut size = 0;
1634 if let Ok(entries) = std::fs::read_dir(dir) {
1635 for entry in entries.flatten() {
1636 if let Ok(metadata) = entry.metadata() {
1637 if metadata.is_dir() {
1638 size += calculate_dir_size(&entry.path());
1639 } else {
1640 size += metadata.len();
1641 }
1642 }
1643 }
1644 }
1645 size
1646 }
1647
1648 Some(calculate_dir_size(&lfs_path))
1649}
1650
1651fn collect_info(
1654 path: &Path,
1655 commit_limit: usize,
1656 _graph_max_commits: usize,
1657 enable_commit_signatures: bool,
1658) -> Result<RepoInfo, git2::Error> {
1659 let repo = Repository::open(path)?;
1660 let mut summary = RepoSummary::default();
1661 if let Ok(head) = repo.head() {
1662 summary.branch = head.shorthand().ok().map(String::from);
1663 }
1664 populate_ahead_behind(&repo, &mut summary);
1665
1666 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1667 let (lfs_inst, lfs_f) = get_lfs_info(path);
1668 info.lfs_installed = lfs_inst;
1669 info.lfs_files = lfs_f;
1670 info.lfs_storage_size = get_lfs_storage_size(path);
1671
1672 let is_detached = repo.head_detached().unwrap_or(false);
1673 if is_detached {
1674 info.branch = Some("HEAD".to_string());
1675 if let Ok(head) = repo.head() {
1676 if let Ok(commit) = head.peel_to_commit() {
1677 let short_id = format!("{:.7}", commit.id());
1678 let summary_text =
1679 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1680 let author = commit.author();
1681 let author_str = sanitize_text(&format!(
1682 "{} <{}>",
1683 author.name().unwrap_or("?"),
1684 author.email().unwrap_or("?")
1685 ));
1686 let when = format_relative_time(commit.time().seconds());
1687 info.head =
1688 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1689 }
1690 }
1691 } else if let Ok(head) = repo.head() {
1692 info.branch = head.shorthand().ok().map(String::from);
1693
1694 if let Ok(commit) = head.peel_to_commit() {
1695 let short_id = format!("{:.7}", commit.id());
1696 let summary_text =
1697 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1698 let author = commit.author();
1699 let author_str = sanitize_text(&format!(
1700 "{} <{}>",
1701 author.name().unwrap_or("?"),
1702 author.email().unwrap_or("?")
1703 ));
1704 let when = format_relative_time(commit.time().seconds());
1705 info.head =
1706 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1707 }
1708
1709 if let Ok(head_name) = head.name() {
1710 info.upstream = upstream_short_name(&repo, head_name);
1711 }
1712 }
1713
1714 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1715 info.commits = commits;
1716 }
1717
1718 populate_summary_and_file_changes(&repo, &mut info);
1719
1720 if let Ok(remotes) = load_tab_remotes(path) {
1721 info.remotes = TabData::Loaded(remotes);
1722 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1723 }
1724
1725 Ok(info)
1726}
1727
1728pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1729 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1730 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1731 let mut entries = Vec::new();
1732 for (i, entry) in reflog.iter().enumerate() {
1733 let target_oid = format!("{}", entry.id_new());
1734 let selector = format!("HEAD@{{{}}}", i);
1735 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1736
1737 let (command, message) = if let Some(pos) = msg.find(':') {
1738 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1739 } else {
1740 (String::new(), msg)
1741 };
1742
1743 let sig = entry.committer();
1744 let when_secs = sig.when().seconds();
1745 let when = format_relative_time(when_secs);
1746 let date = format_utc_date(when_secs);
1747
1748 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1749 }
1750 Ok(entries)
1751}
1752
1753pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1754 let output = git_command()
1755 .arg("checkout")
1756 .arg(commit_oid)
1757 .current_dir(repo_path)
1758 .output()
1759 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1760
1761 if !output.status.success() {
1762 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1763 return Err(git2::Error::from_str(&err));
1764 }
1765 Ok(())
1766}
1767
1768pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1769 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1770 let mut files = Vec::new();
1771 if let Ok(index) = repo.index() {
1772 for entry in index.iter() {
1773 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1774 files.push(path_str.to_string());
1775 }
1776 }
1777 }
1778 Ok(files)
1779}
1780
1781#[derive(Debug, Clone, Default)]
1783pub struct GraphLoad {
1784 pub lines: Vec<GraphLine>,
1785 pub notice: Option<String>,
1789}
1790
1791pub fn load_tab_graph_stream(
1792 repo_path: &Path,
1793 graph_max_commits: usize,
1794 enable_commit_signatures: bool,
1795 repo_resolved_path: String,
1796 tab_idx: usize,
1797 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1798) -> Result<GraphLoad, String> {
1799 let mut graph_lines = Vec::new();
1800 let format_str = if enable_commit_signatures {
1803 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?"
1804 } else {
1805 "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad"
1806 };
1807
1808 let mut args = vec![
1809 "log".to_string(),
1810 "--graph".to_string(),
1811 "--all".to_string(),
1812 "--date=relative".to_string(),
1813 ];
1814 if graph_max_commits > 0 {
1815 args.push(format!("--max-count={}", graph_max_commits));
1816 }
1817 args.push(format!("--pretty=format:{}", format_str));
1818 args.push("--color=never".to_string());
1819
1820 let mut child =
1821 git_command().args(&args).current_dir(repo_path).spawn().map_err(|e| e.to_string())?;
1822
1823 let stderr_reader = spawn_pipe_reader(&mut child);
1826 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1827 let reader = std::io::BufReader::new(stdout);
1828 use std::io::BufRead;
1829
1830 for (idx, line_res) in reader.lines().enumerate() {
1831 let line = line_res.map_err(|e| e.to_string())?;
1832 let parsed = parse_graph_line(&line);
1833 graph_lines.push(parsed);
1834
1835 if (idx + 1) % 200 == 0 {
1837 let _ = tx.send((
1838 repo_resolved_path.clone(),
1839 tab_idx,
1840 TabPayload::Graph(Ok(graph_lines.clone())),
1841 ));
1842 }
1843 }
1844
1845 let status = child.wait().map_err(|e| e.to_string())?;
1847 let stderr = stderr_reader.join().unwrap_or_default();
1848 let notice = summarize_stderr(&stderr);
1849 if !status.success() && graph_lines.is_empty() {
1850 return Err(notice.unwrap_or_else(|| "git log failed".to_string()));
1851 }
1852
1853 Ok(GraphLoad { lines: graph_lines, notice })
1854}
1855
1856pub fn load_tab_branches(
1857 repo_path: &Path,
1858) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1859 let repo = match Repository::open(repo_path) {
1860 Ok(r) => r,
1861 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1862 };
1863
1864 let mut local_branches = Vec::new();
1865 let is_detached = repo.head_detached().unwrap_or(false);
1866 if is_detached {
1867 let mut short_sha = String::new();
1868 let mut short_message = String::new();
1869 if let Ok(head) = repo.head() {
1870 if let Ok(target) = head.peel_to_commit() {
1871 let id = target.id();
1872 let id_str = id.to_string();
1873 short_sha = safe_sha_slice(&id_str, 7).to_string();
1874 if let Ok(Some(summary)) = target.summary() {
1875 short_message = summary.to_string();
1876 }
1877 }
1878 }
1879 local_branches.push(BranchInfo {
1880 name: "HEAD".to_string(),
1881 is_head: true,
1882 short_sha,
1883 short_message: sanitize_text(&short_message),
1884 });
1885 }
1886 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1887 for (branch, _) in branches.flatten() {
1888 if let Ok(Some(name)) = branch.name() {
1889 let is_head = branch.is_head();
1890 let mut short_sha = String::new();
1891 let mut short_message = String::new();
1892 if let Ok(target) = branch.get().peel_to_commit() {
1893 let id = target.id();
1894 let id_str = id.to_string();
1895 short_sha = safe_sha_slice(&id_str, 7).to_string();
1896 if let Ok(Some(summary)) = target.summary() {
1897 short_message = summary.to_string();
1898 }
1899 }
1900 local_branches.push(BranchInfo {
1901 name: sanitize_text(name),
1902 is_head,
1903 short_sha,
1904 short_message: sanitize_text(&short_message),
1905 });
1906 }
1907 }
1908 }
1909 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1910
1911 let mut remote_branches = Vec::new();
1912 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1913 for (branch, _) in branches.flatten() {
1914 if let Ok(Some(name)) = branch.name() {
1915 if !name.ends_with("/HEAD") {
1916 let is_head = branch.is_head();
1917 let mut short_sha = String::new();
1918 let mut short_message = String::new();
1919 if let Ok(target) = branch.get().peel_to_commit() {
1920 let id = target.id();
1921 let id_str = id.to_string();
1922 short_sha = safe_sha_slice(&id_str, 7).to_string();
1923 if let Ok(Some(summary)) = target.summary() {
1924 short_message = summary.to_string();
1925 }
1926 }
1927 remote_branches.push(BranchInfo {
1928 name: sanitize_text(name),
1929 is_head,
1930 short_sha,
1931 short_message: sanitize_text(&short_message),
1932 });
1933 }
1934 }
1935 }
1936 }
1937 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1938
1939 (Ok(local_branches), Ok(remote_branches))
1940}
1941
1942pub fn load_tab_tags(
1943 repo_path: &Path,
1944) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1945 let repo = match Repository::open(repo_path) {
1946 Ok(r) => r,
1947 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1948 };
1949
1950 let mut local_tags = Vec::new();
1951 if let Ok(tags) = repo.tag_names(None) {
1952 for tag_opt in tags.iter() {
1953 if let Ok(Some(tag)) = tag_opt {
1954 let mut short_sha = String::new();
1955 let mut short_message = String::new();
1956 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1957 if let Ok(target) = reference.peel_to_commit() {
1958 let id = target.id();
1959 let id_str = id.to_string();
1960 short_sha = safe_sha_slice(&id_str, 7).to_string();
1961 if let Ok(Some(summary)) = target.summary() {
1962 short_message = summary.to_string();
1963 }
1964 }
1965 }
1966 local_tags.push(BranchInfo {
1967 name: sanitize_text(tag),
1968 is_head: false,
1969 short_sha,
1970 short_message: sanitize_text(&short_message),
1971 });
1972 }
1973 }
1974 }
1975 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1976
1977 (Ok(local_tags), Ok(Vec::new()))
1978}
1979
1980pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1981 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1982 let mut remotes_list = Vec::new();
1983 if let Ok(remotes) = repo.remotes() {
1984 for name in remotes.iter() {
1985 let Ok(Some(name)) = name else { continue };
1986 if let Ok(remote) = repo.find_remote(name) {
1987 let push_url = remote.pushurl().ok().flatten().map(String::from);
1988 let mut refspecs = Vec::new();
1989 for r in remote.refspecs() {
1990 if let Ok(s) = r.str() {
1991 refspecs.push(s.to_string());
1992 }
1993 }
1994 remotes_list.push(RemoteInfo {
1995 name: name.to_string(),
1996 url: remote.url().unwrap_or("(no url)").to_string(),
1997 push_url,
1998 refspecs,
1999 });
2000 }
2001 }
2002 }
2003 Ok(remotes_list)
2004}
2005
2006pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
2007 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2008 let mut temp_stashes = Vec::new();
2009 let _ = repo.stash_foreach(|index, message, oid| {
2010 temp_stashes.push((index, message.to_string(), *oid));
2011 true
2012 });
2013
2014 let mut stashes = Vec::new();
2015 for (index, message, oid) in temp_stashes {
2016 let mut files = Vec::new();
2017 if let Ok(commit) = repo.find_commit(oid) {
2018 files = commit_changed_files(&repo, &commit);
2019 }
2020 stashes.push(StashInfo {
2021 index,
2022 message: sanitize_text(&message),
2023 commit_id: oid.to_string(),
2024 files,
2025 });
2026 }
2027 Ok(stashes)
2028}
2029
2030pub fn load_tab_overview(
2031 repo_path: &Path,
2032 _commit_limit: usize,
2033) -> Result<(Vec<CommitterStat>, bool), String> {
2034 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2035 let (stats, limit_reached) =
2036 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
2037 Ok((stats, limit_reached))
2038}
2039
2040pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
2041 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2042 let submodules = repo.submodules().map_err(|e| e.to_string())?;
2043 let mut list = Vec::new();
2044 for sub in submodules {
2045 let name = sub.name().unwrap_or("").to_string();
2046 let path = sub.path().to_path_buf();
2047 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
2048 let commit_id = sub.index_id().map(|id| id.to_string());
2049 let head_id = sub.head_id().map(|id| id.to_string());
2050
2051 let mut is_initialized = true;
2052 let mut is_dirty = false;
2053
2054 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
2055 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
2056 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
2057 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
2058 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
2059 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
2060 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
2061 }
2062
2063 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
2064 }
2065 Ok(list)
2066}
2067
2068pub fn submodule_remove(repo_path: &Path, name: &str, path: &Path) -> Result<(), String> {
2080 let plain_relative = |p: &Path| {
2081 !p.as_os_str().is_empty()
2082 && p.components().all(|c| matches!(c, std::path::Component::Normal(_)))
2083 };
2084 if !plain_relative(path) {
2085 return Err(format!("Refusing to remove submodule at unsafe path '{}'", path.display()));
2086 }
2087 if !plain_relative(Path::new(name)) {
2088 return Err(format!("Refusing to remove submodule with unsafe name '{}'", name));
2089 }
2090 let path_str = path.to_str().ok_or("Submodule path is not valid UTF-8")?;
2091
2092 let run = |args: &[&str]| -> Result<Vec<u8>, String> {
2093 let output = git_command()
2094 .arg("--literal-pathspecs")
2095 .args(args)
2096 .current_dir(repo_path)
2097 .output()
2098 .map_err(|e| e.to_string())?;
2099 if !output.status.success() {
2100 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
2101 }
2102 Ok(output.stdout)
2103 };
2104
2105 let staged = run(&["ls-files", "--stage", "-z", "--", path_str])?;
2108 let entries: Vec<&[u8]> = staged.split(|b| *b == 0).filter(|e| !e.is_empty()).collect();
2109 let is_gitlink = match entries.as_slice() {
2110 [entry] => {
2111 let entry = String::from_utf8_lossy(entry);
2112 entry.starts_with("160000 ")
2113 && entry.split_once('\t').is_some_and(|(_, p)| p == path_str)
2114 }
2115 _ => false,
2116 };
2117 if !is_gitlink {
2118 return Err(format!("'{}' is not a submodule in the index; nothing was removed", path_str));
2119 }
2120
2121 run(&["submodule", "deinit", "-f", "--", path_str])?;
2122 run(&["rm", "-f", "--", path_str])?;
2123
2124 let module_path = run(&["rev-parse", "--git-path", &format!("modules/{}", name)])?;
2127 let module_dir = repo_path.join(String::from_utf8_lossy(&module_path).trim());
2128 if module_dir.is_dir() {
2129 std::fs::remove_dir_all(&module_dir).map_err(|e| {
2130 format!(
2131 "Submodule removed, but its module directory {} could not be deleted: {}",
2132 module_dir.display(),
2133 e
2134 )
2135 })?;
2136 }
2137 Ok(())
2138}
2139
2140fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2145 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
2146 std::collections::HashMap::new();
2147
2148 if let Ok(refs) = repo.references() {
2149 for reference in refs.flatten() {
2150 let Ok(target) = reference.peel_to_commit() else {
2152 continue;
2153 };
2154 let oid = target.id();
2155
2156 let Ok(full_name) = reference.name() else {
2157 continue;
2158 };
2159
2160 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
2161 branch.to_string()
2162 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
2163 format!("tag:{}", tag)
2164 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
2165 if remote.ends_with("/HEAD") {
2167 continue;
2168 }
2169 format!("remote:{}", remote)
2170 } else {
2171 continue;
2172 };
2173
2174 map.entry(oid).or_default().push(label);
2175 }
2176 }
2177
2178 if repo.head_detached().unwrap_or(false) {
2179 if let Ok(head) = repo.head() {
2180 if let Ok(target) = head.peel_to_commit() {
2181 let oid = target.id();
2182 let label = "head:HEAD".to_string();
2183 let mut entries = map.remove(&oid).unwrap_or_default();
2184 entries.insert(0, label);
2185 map.insert(oid, entries);
2186 }
2187 }
2188 }
2189
2190 map
2191}
2192
2193#[allow(clippy::type_complexity)]
2194static REF_MAP_CACHE: std::sync::OnceLock<
2195 std::sync::Mutex<
2196 std::collections::HashMap<
2197 String,
2198 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
2199 >,
2200 >,
2201> = std::sync::OnceLock::new();
2202
2203fn get_cached_ref_map(
2204 repo: &Repository,
2205 repo_path: &Path,
2206) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2207 let cache_lock =
2208 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
2209 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
2210 let path_key = repo_path.to_string_lossy().to_string();
2211
2212 if let Some((map, loaded_at)) = cache.get(&path_key) {
2213 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
2214 return map.clone();
2215 }
2216 }
2217
2218 let map = build_ref_map(repo);
2219 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
2220 map
2221}
2222
2223pub fn invalidate_ref_map_cache(repo_path: &Path) {
2224 if let Some(cache_lock) = REF_MAP_CACHE.get() {
2225 if let Ok(mut cache) = cache_lock.lock() {
2226 cache.remove(&repo_path.to_string_lossy().to_string());
2227 }
2228 }
2229}
2230
2231const MAX_FILES_PER_SECTION: usize = 100;
2234
2235fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
2237 let mut opts = StatusOptions::new();
2238 opts.include_untracked(true)
2239 .renames_head_to_index(true)
2240 .recurse_untracked_dirs(true)
2241 .show(StatusShow::IndexAndWorkdir);
2242 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2243 return;
2244 };
2245 for entry in statuses.iter() {
2246 let path = entry.path().unwrap_or("(unknown)").to_string();
2247 let flags = entry.status();
2248
2249 if flags.is_conflicted() {
2251 info.summary.conflicted += 1;
2252 } else {
2253 if flags.is_wt_new() {
2254 info.summary.untracked += 1;
2255 }
2256 if flags.is_wt_modified()
2257 || flags.is_wt_deleted()
2258 || flags.is_wt_renamed()
2259 || flags.is_wt_typechange()
2260 {
2261 info.summary.modified += 1;
2262 }
2263 if flags.is_index_new()
2264 || flags.is_index_modified()
2265 || flags.is_index_deleted()
2266 || flags.is_index_renamed()
2267 || flags.is_index_typechange()
2268 {
2269 info.summary.staged += 1;
2270 }
2271 }
2272
2273 let is_dir = if flags.is_wt_new() && !flags.is_index_new() {
2277 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2278 path_buf.is_dir()
2279 } else {
2280 false
2281 };
2282 if is_dir {
2283 continue;
2284 }
2285
2286 if flags.is_conflicted() {
2287 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2288 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2289 }
2290 continue;
2291 }
2292
2293 if (flags.is_index_new()
2295 || flags.is_index_modified()
2296 || flags.is_index_deleted()
2297 || flags.is_index_renamed()
2298 || flags.is_index_typechange())
2299 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2300 {
2301 let label = if flags.is_index_new() {
2302 "N"
2303 } else if flags.is_index_deleted() {
2304 "D"
2305 } else if flags.is_index_renamed() {
2306 "R"
2307 } else if flags.is_index_typechange() {
2308 "T"
2309 } else {
2310 "M"
2311 };
2312 info.changes.staged.push(FileEntry { path: path.clone(), label });
2313 }
2314
2315 if flags.is_wt_new() {
2317 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2318 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2319 }
2320 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2321 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2322 }
2323 } else if (flags.is_wt_modified()
2324 || flags.is_wt_deleted()
2325 || flags.is_wt_renamed()
2326 || flags.is_wt_typechange())
2327 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2328 {
2329 let label = if flags.is_wt_deleted() {
2330 "D"
2331 } else if flags.is_wt_renamed() {
2332 "R"
2333 } else if flags.is_wt_typechange() {
2334 "T"
2335 } else {
2336 "M"
2337 };
2338 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2339 }
2340 }
2341}
2342
2343fn collect_summary(repo: &Repository) -> RepoSummary {
2347 let mut s = RepoSummary::default();
2348 let is_detached = repo.head_detached().unwrap_or(false);
2349 if is_detached {
2350 s.branch = Some("HEAD".to_string());
2351 if let Ok(head) = repo.head() {
2352 if let Ok(commit) = head.peel_to_commit() {
2353 s.last_commit_time = Some(commit.time().seconds());
2354 }
2355 }
2356 } else if let Ok(head) = repo.head() {
2357 s.branch = head.shorthand().ok().map(String::from);
2358 if let Ok(commit) = head.peel_to_commit() {
2359 s.last_commit_time = Some(commit.time().seconds());
2360 }
2361 }
2362 populate_worktree(repo, &mut s);
2363 populate_ahead_behind(repo, &mut s);
2364 s.state = match repo.state() {
2365 git2::RepositoryState::Clean => RepoState::Clean,
2366 git2::RepositoryState::Merge => RepoState::Merge,
2367 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2368 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2369 RepoState::CherryPick
2370 }
2371 git2::RepositoryState::Bisect => RepoState::Bisect,
2372 git2::RepositoryState::Rebase
2373 | git2::RepositoryState::RebaseInteractive
2374 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2375 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2376 RepoState::ApplyMailbox
2377 }
2378 };
2379 s
2380}
2381
2382fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2383 let mut opts = StatusOptions::new();
2384 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2385 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2386 return;
2387 };
2388 for entry in statuses.iter() {
2389 let flags = entry.status();
2390 if flags.is_conflicted() {
2391 s.conflicted += 1;
2392 continue;
2393 }
2394 if flags.is_wt_new() {
2395 s.untracked += 1;
2396 }
2397 if flags.is_wt_modified()
2398 || flags.is_wt_deleted()
2399 || flags.is_wt_renamed()
2400 || flags.is_wt_typechange()
2401 {
2402 s.modified += 1;
2403 }
2404 if flags.is_index_new()
2405 || flags.is_index_modified()
2406 || flags.is_index_deleted()
2407 || flags.is_index_renamed()
2408 || flags.is_index_typechange()
2409 {
2410 s.staged += 1;
2411 }
2412 }
2413}
2414
2415fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2419 let Ok(head) = repo.head() else { return };
2420 let Some(local_oid) = head.target() else {
2421 return;
2422 };
2423 let Ok(head_name) = head.name() else { return };
2424 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2425 return;
2426 };
2427 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2428 return;
2429 };
2430 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2431 return;
2432 };
2433 let Some(upstream_oid) = upstream_ref.target() else {
2434 return;
2435 };
2436 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2437 s.ahead = ahead;
2438 s.behind = behind;
2439 }
2440}
2441
2442fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2444 let buf = repo.branch_upstream_name(head_name).ok()?;
2445 let raw = std::str::from_utf8(&buf).ok()?;
2446 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2447}
2448
2449pub fn format_relative_time(secs: i64) -> String {
2451 if secs <= 0 {
2452 return "unknown".to_string();
2453 }
2454 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2455 let now = SystemTime::now();
2456 let Ok(elapsed) = now.duration_since(then) else {
2457 return "in the future".to_string();
2458 };
2459 let secs = elapsed.as_secs();
2460 let (n, unit) = if secs < 60 {
2461 (secs, "second")
2462 } else if secs < 3600 {
2463 (secs / 60, "minute")
2464 } else if secs < 86_400 {
2465 (secs / 3600, "hour")
2466 } else if secs < 86_400 * 30 {
2467 (secs / 86_400, "day")
2468 } else if secs < 86_400 * 365 {
2469 (secs / (86_400 * 30), "month")
2470 } else {
2471 (secs / (86_400 * 365), "year")
2472 };
2473 let plural = if n == 1 { "" } else { "s" };
2474 format!("{} {}{} ago", n, unit, plural)
2475}
2476
2477fn format_utc_date(secs: i64) -> String {
2479 if secs <= 0 {
2480 return "unknown".to_string();
2481 }
2482 let seconds_in_day = 86400;
2483 let day_number = secs / seconds_in_day;
2484 let time_of_day = secs % seconds_in_day;
2485
2486 let mut hour = time_of_day / 3600;
2487 let mut minute = (time_of_day % 3600) / 60;
2488 let mut second = time_of_day % 60;
2489 if hour < 0 {
2490 hour += 24;
2491 }
2492 if minute < 0 {
2493 minute += 60;
2494 }
2495 if second < 0 {
2496 second += 60;
2497 }
2498
2499 let z = day_number + 719468;
2501 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2502 let doe = (z - era * 146097) as u32;
2503 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2504 let y = (yoe as i32) + (era as i32) * 400;
2505 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2506 let mp = (5 * doy + 2) / 153;
2507 let d = doy - (153 * mp + 2) / 5 + 1;
2508 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2509 let y = y + if m <= 2 { 1 } else { 0 };
2510
2511 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2512}
2513
2514fn get_file_diff_inner(
2517 repo_path: &Path,
2518 commit_oid: &str,
2519 file_path: &str,
2520) -> Option<Vec<DiffLine>> {
2521 let repo = Repository::open(repo_path).ok()?;
2522 let oid = git2::Oid::from_str(commit_oid).ok()?;
2523 let commit = repo.find_commit(oid).ok()?;
2524
2525 let commit_tree = commit.tree().ok()?;
2526 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2528
2529 let mut opts = git2::DiffOptions::new();
2530 opts.pathspec(file_path);
2531
2532 let diff =
2533 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2534
2535 collect_diff_lines(&diff)
2536}
2537
2538fn get_worktree_diff_inner(
2543 repo_path: &Path,
2544 file_path: &str,
2545 staged: bool,
2546) -> Option<Vec<DiffLine>> {
2547 let repo = Repository::open(repo_path).ok()?;
2548 let mut opts = git2::DiffOptions::new();
2549 opts.pathspec(file_path);
2550 opts.include_untracked(true);
2551 opts.recurse_untracked_dirs(true);
2552 opts.show_untracked_content(true);
2553
2554 let diff = if staged {
2555 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2557 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2558 } else {
2559 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2561 };
2562
2563 collect_diff_lines(&diff)
2564}
2565
2566fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2568 let mut lines: Vec<DiffLine> = Vec::new();
2569 let mut current_hunk_idx = None;
2570 let mut hunk_count = 0;
2571 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2572 let kind = match line.origin() {
2573 '+' => DiffLineKind::Added,
2574 '-' => DiffLineKind::Removed,
2575 'H' => {
2576 current_hunk_idx = Some(hunk_count);
2577 hunk_count += 1;
2578 DiffLineKind::Header
2579 }
2580 ' ' => DiffLineKind::Context,
2581 _ => return true, };
2583 let content = String::from_utf8_lossy(line.content())
2584 .trim_end_matches('\n')
2585 .trim_end_matches('\r')
2586 .to_string();
2587 lines.push(DiffLine {
2588 kind,
2589 content,
2590 old_lineno: line.old_lineno(),
2591 new_lineno: line.new_lineno(),
2592 hunk_idx: current_hunk_idx,
2593 });
2594 true
2595 })
2596 .ok()?;
2597 Some(lines)
2598}
2599
2600fn parse_graph_line(line: &str) -> GraphLine {
2601 if line.contains("__TWIG_SEP__") {
2602 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2603 if parts.len() >= 5 {
2604 let graph_and_hash = parts[0];
2605 let decoration = parts[1].trim().to_string();
2606 let summary = parts[2].trim().to_string();
2607 let author = parts[3].trim().to_string();
2608 let date = parts[4].trim().to_string();
2609 let signature_status =
2610 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2611
2612 let char_count = graph_and_hash.chars().count();
2613 if char_count >= 40 {
2614 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2615 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2616 GraphLine {
2617 graph,
2618 commit: Some(GraphCommit {
2619 oid,
2620 decoration,
2621 summary,
2622 author,
2623 date,
2624 signature_status,
2625 }),
2626 }
2627 } else {
2628 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2629 }
2630 } else {
2631 GraphLine { graph: line.to_string(), commit: None }
2632 }
2633 } else {
2634 GraphLine { graph: line.to_string(), commit: None }
2635 }
2636}
2637
2638#[allow(dead_code)]
2639fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2640 let mut graph_lines = Vec::new();
2641 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2642
2643 let mut args = vec![
2644 "log".to_string(),
2645 "--graph".to_string(),
2646 "--all".to_string(),
2647 "--date=relative".to_string(),
2648 ];
2649 if graph_max_commits > 0 {
2650 args.push(format!("--max-count={}", graph_max_commits));
2651 }
2652 args.push(format!("--pretty=format:{}", format_str));
2653 args.push("--color=never".to_string());
2654
2655 let output = git_command().args(&args).current_dir(repo_path).output();
2656
2657 if let Ok(out) = output {
2658 if out.status.success() {
2659 let stdout_str = String::from_utf8_lossy(&out.stdout);
2660 for line in stdout_str.lines() {
2661 graph_lines.push(parse_graph_line(line));
2662 }
2663 }
2664 }
2665 graph_lines
2666}
2667
2668pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2669 let output = git_command()
2670 .arg("checkout")
2671 .arg(branch_name)
2672 .current_dir(repo_path)
2673 .output()
2674 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2675
2676 if !output.status.success() {
2677 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2678 return Err(git2::Error::from_str(&err));
2679 }
2680 Ok(())
2681}
2682
2683pub fn checkout_remote_branch(
2684 repo_path: &Path,
2685 remote_branch_name: &str,
2686) -> Result<String, git2::Error> {
2687 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2688 if parts.len() < 2 {
2689 return Err(git2::Error::from_str("Invalid remote branch name"));
2690 }
2691 let local_name = parts[1];
2692
2693 let output = git_command()
2694 .arg("checkout")
2695 .arg(local_name)
2696 .current_dir(repo_path)
2697 .output()
2698 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2699
2700 if output.status.success() {
2701 return Ok(format!("Switched to existing branch '{}'", local_name));
2702 }
2703
2704 let output = git_command()
2705 .arg("checkout")
2706 .arg("--track")
2707 .arg(remote_branch_name)
2708 .current_dir(repo_path)
2709 .output()
2710 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2711
2712 if !output.status.success() {
2713 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2714 return Err(git2::Error::from_str(&err));
2715 }
2716
2717 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2718}
2719
2720pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2722 let repo = Repository::open(repo_path)?;
2723 let head = repo.head()?;
2724 let target_commit = head.peel_to_commit()?;
2725 repo.branch(branch_name, &target_commit, false)?;
2726 Ok(())
2727}
2728
2729pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2731 let repo = Repository::open(repo_path)?;
2732 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2733 branch.delete()?;
2734 Ok(())
2735}
2736
2737pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2739 let repo = Repository::open(repo_path)?;
2740 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2741 branch.delete()?;
2742 Ok(())
2743}
2744
2745pub fn create_tag(
2747 repo_path: &Path,
2748 tag_name: &str,
2749 commit_oid_str: &str,
2750 message: Option<&str>,
2751 force: bool,
2752) -> Result<(), git2::Error> {
2753 let repo = Repository::open(repo_path)?;
2754 let oid = git2::Oid::from_str(commit_oid_str)?;
2755 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2756 if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2757 let tagger_sig = repo.signature();
2758 let default_sig;
2759 let tagger = match &tagger_sig {
2760 Ok(s) => s,
2761 Err(_) => {
2762 if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2763 default_sig = sig;
2764 &default_sig
2765 } else {
2766 return Err(git2::Error::from_str("Failed to create signature"));
2767 }
2768 }
2769 };
2770 repo.tag(tag_name, &target_object, tagger, msg, force)?;
2771 } else {
2772 repo.tag_lightweight(tag_name, &target_object, force)?;
2773 }
2774 Ok(())
2775}
2776
2777pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2779 if let Ok(repo) = Repository::open(repo_path) {
2780 let ref_name = format!("refs/tags/{}", tag_name);
2781 repo.find_reference(&ref_name).is_ok()
2782 } else {
2783 false
2784 }
2785}
2786
2787pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2789 let repo = Repository::open(repo_path)?;
2790 repo.tag_delete(tag_name)?;
2791 Ok(())
2792}
2793
2794pub fn delete_remote_tag(
2796 repo_path: &Path,
2797 remote_name: &str,
2798 tag_name: &str,
2799 timeout: Duration,
2800) -> Result<(), Box<dyn std::error::Error>> {
2801 let safe_remote = safe_ref(remote_name)?;
2802 let safe_tag = safe_ref(tag_name)?;
2803 let mut cmd = git_command();
2804 cmd.arg("push").arg(safe_remote).arg("--delete").arg(safe_tag).current_dir(repo_path);
2805 let output = run_git_with_timeout(cmd, timeout)?;
2806 if !output.status.success() {
2807 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2808 return Err(err.into());
2809 }
2810 Ok(())
2811}
2812
2813pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2814 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2815 let output = git_command()
2816 .arg("checkout")
2817 .arg(safe_tag)
2818 .current_dir(repo_path)
2819 .output()
2820 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2821
2822 if !output.status.success() {
2823 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2824 return Err(git2::Error::from_str(&err));
2825 }
2826 Ok(())
2827}
2828
2829pub fn get_remote_tags(
2831 repo_path: &Path,
2832 remote_name: &str,
2833 timeout: Duration,
2834) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2835 let safe_remote = safe_ref(remote_name)?;
2836 let mut cmd = git_command();
2837 cmd.arg("ls-remote").arg("--tags").arg(safe_remote).current_dir(repo_path);
2838 let output = run_git_with_timeout(cmd, timeout)?;
2839
2840 if !output.status.success() {
2841 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2842 return Err(err.into());
2843 }
2844
2845 let stdout = String::from_utf8_lossy(&output.stdout);
2846 let repo = git2::Repository::open(repo_path)?;
2847 let mut tags_map = std::collections::HashMap::new();
2848
2849 for line in stdout.lines() {
2850 let parts: Vec<&str> = line.split_whitespace().collect();
2851 if parts.len() >= 2 {
2852 let sha = parts[0];
2853 let ref_name = parts[1];
2854 if ref_name.starts_with("refs/tags/") {
2855 let is_peeled = ref_name.ends_with("^{}");
2856 let clean_ref = if is_peeled {
2857 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2858 } else {
2859 ref_name
2860 };
2861 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2862 let short_sha = safe_sha_slice(sha, 7);
2863
2864 let mut short_message = String::new();
2866 if let Ok(oid) = git2::Oid::from_str(sha) {
2867 if let Ok(commit) = repo.find_commit(oid) {
2868 if let Ok(Some(summary)) = commit.summary() {
2869 short_message = summary.to_string();
2870 }
2871 }
2872 }
2873 if short_message.is_empty() {
2874 short_message = "(not fetched)".to_string();
2875 }
2876
2877 let sanitized_tag = sanitize_text(tag_name);
2878 let sanitized_msg = sanitize_text(&short_message);
2879
2880 if is_peeled {
2881 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2882 } else {
2883 tags_map
2884 .entry(sanitized_tag)
2885 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2886 }
2887 }
2888 }
2889 }
2890
2891 let mut tags = Vec::new();
2892 for (name, (short_sha, short_message)) in tags_map {
2893 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2894 }
2895 tags.sort_by(|a, b| b.name.cmp(&a.name));
2896 Ok(tags)
2897}
2898
2899pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2900 let mut s = String::new();
2901 for tag in tags {
2902 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2903 }
2904 s
2905}
2906
2907pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2908 let mut tags = Vec::new();
2909 for line in s.lines() {
2910 let parts: Vec<&str> = line.split('|').collect();
2911 if parts.len() >= 3 {
2912 tags.push(BranchInfo {
2913 name: parts[0].to_string(),
2914 is_head: false,
2915 short_sha: parts[1].to_string(),
2916 short_message: parts[2].to_string(),
2917 });
2918 }
2919 }
2920 tags
2921}
2922
2923pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2924 let mut repo = Repository::open(repo_path)?;
2925 repo.stash_drop(index)?;
2926 Ok(())
2927}
2928
2929pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2930 let stash_ref = format!("stash@{{{}}}", index);
2931 let output = git_command()
2932 .arg("stash")
2933 .arg("apply")
2934 .arg(&stash_ref)
2935 .current_dir(repo_path)
2936 .output()
2937 .map_err(|e| e.to_string())?;
2938
2939 if !output.status.success() {
2940 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2941 return Err(err_msg);
2942 }
2943 Ok(())
2944}
2945
2946pub fn save_stash(
2947 repo_path: &Path,
2948 message: &str,
2949 include_untracked: bool,
2950 keep_index: bool,
2951) -> Result<(), String> {
2952 let mut cmd = git_command();
2953 cmd.arg("stash").arg("push");
2954
2955 if include_untracked {
2956 cmd.arg("--include-untracked");
2957 }
2958 if keep_index {
2959 cmd.arg("--keep-index");
2960 }
2961
2962 if !message.is_empty() {
2963 cmd.arg("-m").arg(message);
2964 }
2965
2966 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2967
2968 if !output.status.success() {
2969 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2970 return Err(err_msg);
2971 }
2972 Ok(())
2973}
2974
2975pub fn get_latest_change_time(item: &str) -> u64 {
2976 let path = expand_tilde(item);
2977 if !path.exists() {
2978 return 0;
2979 }
2980
2981 if path.join(".git").exists() {
2982 if let Ok(repo) = Repository::open(&path) {
2983 if let Ok(head) = repo.head() {
2984 if let Ok(commit) = head.peel_to_commit() {
2985 return commit.time().seconds() as u64;
2986 }
2987 }
2988 }
2989 }
2990
2991 if let Ok(meta) = std::fs::metadata(&path) {
2992 if let Ok(modified) = meta.modified() {
2993 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2994 return duration.as_secs();
2995 }
2996 }
2997 }
2998 0
2999}
3000
3001pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
3002 if let Ok(repo) = Repository::open(repo_path) {
3003 if let Ok(head) = repo.head() {
3004 if let Ok(commit) = head.peel_to_commit() {
3005 if let Ok(msg) = commit.message() {
3006 return Some(msg.to_string());
3007 }
3008 }
3009 }
3010 }
3011 None
3012}
3013
3014pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
3015 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3016 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
3017 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
3018
3019 let mut index = repo.index().map_err(|e| e.to_string())?;
3020 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
3021 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
3022
3023 let signature = repo
3024 .signature()
3025 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
3026
3027 head_commit
3028 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
3029 .map_err(|e| e.to_string())?;
3030
3031 Ok(())
3032}
3033
3034pub fn is_merging(repo_path: &Path) -> bool {
3039 repo_path.join(".git/MERGE_HEAD").exists()
3040}
3041
3042pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
3045 let full_path = repo_path.join(file_path);
3046 let content = match std::fs::read_to_string(&full_path) {
3047 Ok(s) => s,
3048 Err(_) => return Vec::new(),
3049 };
3050
3051 let mut lines = Vec::new();
3052 let mut in_ours = false;
3053 let mut in_theirs = false;
3054
3055 for line in content.lines() {
3056 if line.starts_with("<<<<<<<") {
3057 in_ours = true;
3058 in_theirs = false;
3059 lines.push(DiffLine {
3060 kind: DiffLineKind::ConflictSeparator,
3061 content: line.to_string(),
3062 old_lineno: None,
3063 new_lineno: None,
3064 hunk_idx: None,
3065 });
3066 } else if line.starts_with("=======") {
3067 in_ours = false;
3068 in_theirs = true;
3069 lines.push(DiffLine {
3070 kind: DiffLineKind::ConflictSeparator,
3071 content: line.to_string(),
3072 old_lineno: None,
3073 new_lineno: None,
3074 hunk_idx: None,
3075 });
3076 } else if line.starts_with(">>>>>>>") {
3077 in_ours = false;
3078 in_theirs = false;
3079 lines.push(DiffLine {
3080 kind: DiffLineKind::ConflictSeparator,
3081 content: line.to_string(),
3082 old_lineno: None,
3083 new_lineno: None,
3084 hunk_idx: None,
3085 });
3086 } else if in_ours {
3087 lines.push(DiffLine {
3088 kind: DiffLineKind::ConflictOurs,
3089 content: line.to_string(),
3090 old_lineno: None,
3091 new_lineno: None,
3092 hunk_idx: None,
3093 });
3094 } else if in_theirs {
3095 lines.push(DiffLine {
3096 kind: DiffLineKind::ConflictTheirs,
3097 content: line.to_string(),
3098 old_lineno: None,
3099 new_lineno: None,
3100 hunk_idx: None,
3101 });
3102 } else {
3103 lines.push(DiffLine {
3104 kind: DiffLineKind::Context,
3105 content: line.to_string(),
3106 old_lineno: None,
3107 new_lineno: None,
3108 hunk_idx: None,
3109 });
3110 }
3111 }
3112 lines
3113}
3114
3115pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
3118 let output1 = git_command()
3119 .args(["checkout", "--ours", file_path])
3120 .current_dir(repo_path)
3121 .output()
3122 .map_err(|e| e.to_string())?;
3123 if !output1.status.success() {
3124 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3125 }
3126 stage_file(repo_path, file_path)?;
3127 Ok(())
3128}
3129
3130pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
3133 let output1 = git_command()
3134 .args(["checkout", "--theirs", file_path])
3135 .current_dir(repo_path)
3136 .output()
3137 .map_err(|e| e.to_string())?;
3138 if !output1.status.success() {
3139 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3140 }
3141 stage_file(repo_path, file_path)?;
3142 Ok(())
3143}
3144
3145pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
3147 stage_file(repo_path, file_path)
3148}
3149
3150pub fn resolve_conflict_hunk(
3154 repo_path: &Path,
3155 file_path: &str,
3156 hunk_idx: usize,
3157 accept_ours: bool,
3158) -> Result<(), String> {
3159 let full_path = repo_path.join(file_path);
3160 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3161
3162 let mut new_lines = Vec::new();
3163 let mut lines_iter = content.lines().peekable();
3164 let mut current_hunk_idx = 0;
3165
3166 while let Some(line) = lines_iter.next() {
3167 if line.starts_with("<<<<<<<") {
3168 let mut ours_block = Vec::new();
3169 let mut theirs_block = Vec::new();
3170
3171 let mut found_separator = false;
3173 while let Some(&next_line) = lines_iter.peek() {
3174 if next_line.starts_with("=======") {
3175 lines_iter.next(); found_separator = true;
3177 break;
3178 }
3179 if let Some(line) = lines_iter.next() {
3180 ours_block.push(line.to_string());
3181 }
3182 }
3183
3184 let mut found_end = false;
3186 let mut end_line_marker = ">>>>>>>".to_string();
3187 while let Some(&next_line) = lines_iter.peek() {
3188 if next_line.starts_with(">>>>>>>") {
3189 if let Some(marker) = lines_iter.next() {
3190 end_line_marker = marker.to_string(); }
3192 found_end = true;
3193 break;
3194 }
3195 if let Some(line) = lines_iter.next() {
3196 theirs_block.push(line.to_string());
3197 }
3198 }
3199
3200 if current_hunk_idx == hunk_idx {
3201 if accept_ours {
3202 new_lines.extend(ours_block);
3203 } else {
3204 new_lines.extend(theirs_block);
3205 }
3206 } else {
3207 new_lines.push(line.to_string());
3208 new_lines.extend(ours_block);
3209 if found_separator {
3210 new_lines.push("=======".to_string());
3211 }
3212 new_lines.extend(theirs_block);
3213 if found_end {
3214 new_lines.push(end_line_marker);
3215 }
3216 }
3217
3218 current_hunk_idx += 1;
3219 } else {
3220 new_lines.push(line.to_string());
3221 }
3222 }
3223
3224 let mut new_content = new_lines.join("\n");
3225 if content.ends_with('\n') && !new_content.ends_with('\n') {
3226 new_content.push('\n');
3227 }
3228 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3229
3230 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3232 let has_conflict_markers = updated_content
3233 .lines()
3234 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3235
3236 if !has_conflict_markers {
3237 stage_file(repo_path, file_path)?;
3238 }
3239
3240 Ok(())
3241}
3242
3243pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3245 let output = git_command()
3246 .args(["merge", "--abort"])
3247 .current_dir(repo_path)
3248 .output()
3249 .map_err(|e| e.to_string())?;
3250 if !output.status.success() {
3251 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3252 }
3253 Ok(())
3254}
3255
3256pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3258 let output = git_command()
3259 .args(["merge", "--continue"])
3260 .env("GIT_EDITOR", "true")
3261 .current_dir(repo_path)
3262 .output()
3263 .map_err(|e| e.to_string())?;
3264 if !output.status.success() {
3265 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3266 }
3267 Ok(())
3268}
3269
3270pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3272 let repo = Repository::open(repo_path).ok()?;
3273 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3274 let _upstream = branch.upstream().ok()?;
3275 let local_ref = branch.get().name().ok()?;
3276 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3277 remote_buf.as_str().ok().map(|s| s.to_string())
3278}
3279
3280pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3282 get_branch_upstream_remote(repo_path, branch_name).is_some()
3283}
3284
3285pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3287 let repo = Repository::open(repo_path).ok()?;
3288 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3289 if branch.upstream().is_ok() {
3290 if let Ok(local_ref) = branch.get().name() {
3291 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3292 if let Ok(name) = remote_buf.as_str() {
3293 return Some((name.to_string(), false));
3294 }
3295 }
3296 }
3297 }
3298 let remotes = repo.remotes().ok()?;
3299 let first_remote = remotes.iter().next()?.ok()??.to_string();
3300 Some((first_remote, true))
3301}
3302
3303pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3305 if let Ok(repo) = Repository::open(repo_path) {
3306 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3307 if let Ok(commit) = repo.find_commit(oid) {
3308 return commit.parent_count() == 0;
3309 }
3310 }
3311 }
3312 false
3313}
3314
3315pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3316 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3317 let mut worktrees_list = Vec::new();
3318 if let Ok(worktree_names) = repo.worktrees() {
3319 for name in worktree_names.iter() {
3320 let Ok(Some(wt_name)) = name else { continue };
3321 if let Ok(wt) = repo.find_worktree(wt_name) {
3322 let path = wt.path().to_path_buf();
3323 let mut branch = None;
3324 if let Ok(wt_repo) = Repository::open(&path) {
3325 if let Ok(head) = wt_repo.head() {
3326 branch = head.shorthand().map(String::from).ok();
3327 }
3328 }
3329
3330 let mut is_locked = false;
3331 let mut lock_reason = None;
3332 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3333 is_locked = true;
3334 if let Some(reason) = reason_opt {
3335 if !reason.is_empty() {
3336 lock_reason = Some(reason);
3337 }
3338 }
3339 }
3340
3341 worktrees_list.push(WorktreeInfo {
3342 name: wt_name.to_string(),
3343 path,
3344 branch,
3345 is_locked,
3346 lock_reason,
3347 });
3348 }
3349 }
3350 }
3351 Ok(worktrees_list)
3352}
3353
3354pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<bool, String> {
3360 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3361 let exists = repo.revparse_single(&format!("{}^{{commit}}", branch)).is_ok();
3362 let on_one_remote = || {
3363 let Ok(remotes) = repo.remotes() else { return false };
3364 let on = remotes.iter().flatten().flatten().filter(|remote| {
3365 repo.find_branch(&format!("{}/{}", remote, branch), git2::BranchType::Remote).is_ok()
3366 });
3367 on.count() == 1
3368 };
3369 let create = !exists && !on_one_remote();
3370
3371 let mut cmd = git_command();
3372 cmd.arg("worktree").arg("add");
3373 if create {
3374 cmd.arg("-b").arg(branch);
3375 }
3376 cmd.arg("--").arg(wt_path);
3377 if !create {
3378 cmd.arg(branch);
3379 }
3380 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
3381
3382 if !output.status.success() {
3383 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3384 }
3385 Ok(create)
3386}
3387
3388pub fn worktree_lock(repo_path: &Path, worktree: &Path, reason: &str) -> Result<(), String> {
3397 let output = git_command()
3398 .arg("worktree")
3399 .arg("lock")
3400 .arg("--reason")
3401 .arg(reason)
3402 .arg(worktree)
3403 .current_dir(repo_path)
3404 .output()
3405 .map_err(|e| e.to_string())?;
3406
3407 if !output.status.success() {
3408 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3409 }
3410 Ok(())
3411}
3412
3413pub fn worktree_unlock(repo_path: &Path, worktree: &Path) -> Result<(), String> {
3414 let output = git_command()
3415 .arg("worktree")
3416 .arg("unlock")
3417 .arg(worktree)
3418 .current_dir(repo_path)
3419 .output()
3420 .map_err(|e| e.to_string())?;
3421
3422 if !output.status.success() {
3423 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3424 }
3425 Ok(())
3426}
3427
3428pub fn worktree_remove(repo_path: &Path, worktree: &Path, force: bool) -> Result<(), String> {
3434 let mut cmd = git_command();
3435 cmd.arg("worktree").arg("remove");
3436 if force {
3437 cmd.arg("--force").arg("--force");
3438 }
3439 let output = cmd.arg(worktree).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3440
3441 if !output.status.success() {
3442 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3443 }
3444 Ok(())
3445}
3446
3447pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3448 let output = git_command()
3449 .arg("worktree")
3450 .arg("prune")
3451 .current_dir(repo_path)
3452 .output()
3453 .map_err(|e| e.to_string())?;
3454
3455 if !output.status.success() {
3456 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3457 }
3458 Ok(())
3459}
3460
3461pub fn load_tab_forge_issues(
3462 repo_path: &Path,
3463 assigned_only: bool,
3464) -> Result<Vec<ForgeIssue>, String> {
3465 let mut cmd = tool_command("gh");
3466 cmd.arg("issue").arg("list");
3467 if assigned_only {
3468 cmd.arg("--assignee").arg("@me");
3469 }
3470 cmd.arg("--limit")
3471 .arg("50")
3472 .arg("--json")
3473 .arg("number,title,state,author,assignees,url")
3474 .current_dir(repo_path);
3475
3476 let output = match cmd.output() {
3477 Ok(out) => out,
3478 Err(e) => {
3479 return Err(format!(
3480 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3481 e
3482 ));
3483 }
3484 };
3485
3486 if !output.status.success() {
3487 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3488 return Err(format!(
3489 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3490 err_msg
3491 ));
3492 }
3493
3494 #[derive(serde::Deserialize)]
3495 struct GhAuthor {
3496 login: String,
3497 }
3498
3499 #[derive(serde::Deserialize)]
3500 struct GhAssignee {
3501 login: String,
3502 }
3503
3504 #[derive(serde::Deserialize)]
3505 struct GhIssue {
3506 number: u32,
3507 title: String,
3508 state: String,
3509 author: Option<GhAuthor>,
3510 assignees: Option<Vec<GhAssignee>>,
3511 url: String,
3512 }
3513
3514 let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3515 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3516
3517 let issues = raw_issues
3518 .into_iter()
3519 .map(|item| {
3520 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3521 let assignees =
3522 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3523 ForgeIssue {
3524 number: item.number,
3525 title: item.title,
3526 state: item.state,
3527 author,
3528 assignees,
3529 url: item.url,
3530 }
3531 })
3532 .collect();
3533
3534 Ok(issues)
3535}
3536
3537#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3538pub struct ForgePR {
3539 pub number: u32,
3540 pub title: String,
3541 pub state: String,
3542 pub author: String,
3543 pub assignees: Vec<String>,
3544 pub url: String,
3545 pub head_ref: String,
3546 pub head_ref_oid: String,
3547 pub body: String,
3548 pub status_checks: Vec<CIStatusCheck>,
3549 pub reviews: Vec<PRReview>,
3550}
3551
3552#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3553pub struct ForgePRComment {
3554 pub path: String,
3555 pub line: Option<u32>,
3556 pub body: String,
3557 pub author: String,
3558 pub commit_id: String,
3559}
3560
3561#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3562pub struct CIStatusCheck {
3563 pub name: String,
3564 pub state: Option<String>,
3565 pub status: Option<String>,
3566 pub conclusion: Option<String>,
3567}
3568
3569#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3570pub struct PRReview {
3571 pub author: String,
3572 pub body: String,
3573 pub state: String,
3574}
3575
3576pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3577 let mut cmd = tool_command("gh");
3578 cmd.arg("pr")
3579 .arg("list")
3580 .arg("--limit")
3581 .arg("50")
3582 .arg("--json")
3583 .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3584 .current_dir(repo_path);
3585
3586 let output = match cmd.output() {
3587 Ok(out) => out,
3588 Err(e) => {
3589 return Err(format!(
3590 "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3591 e
3592 ));
3593 }
3594 };
3595
3596 if !output.status.success() {
3597 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3598 return Err(format!(
3599 "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3600 err_msg
3601 ));
3602 }
3603
3604 parse_forge_prs(&output.stdout)
3605}
3606
3607fn parse_forge_prs(json: &[u8]) -> Result<Vec<ForgePR>, String> {
3609 #[derive(serde::Deserialize)]
3610 struct GhAuthor {
3611 login: String,
3612 }
3613
3614 #[derive(serde::Deserialize)]
3615 struct GhAssignee {
3616 login: String,
3617 }
3618
3619 #[derive(serde::Deserialize)]
3620 struct GhReview {
3621 author: Option<GhAuthor>,
3622 body: String,
3623 state: String,
3624 }
3625
3626 #[derive(serde::Deserialize)]
3631 struct GhStatusCheck {
3632 name: Option<String>,
3633 context: Option<String>,
3634 state: Option<String>,
3635 status: Option<String>,
3636 conclusion: Option<String>,
3637 }
3638
3639 #[derive(serde::Deserialize)]
3640 struct GhPR {
3641 number: u32,
3642 title: String,
3643 state: String,
3644 author: Option<GhAuthor>,
3645 assignees: Option<Vec<GhAssignee>>,
3646 url: String,
3647 #[serde(rename = "headRefName")]
3648 head_ref_name: String,
3649 #[serde(rename = "headRefOid")]
3650 head_ref_oid: String,
3651 body: String,
3652 #[serde(rename = "statusCheckRollup")]
3653 status_check_rollup: Option<Vec<GhStatusCheck>>,
3654 reviews: Option<Vec<GhReview>>,
3655 }
3656
3657 let raw_prs: Vec<GhPR> = serde_json::from_slice(json)
3658 .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3659
3660 let prs = raw_prs
3661 .into_iter()
3662 .map(|item| {
3663 let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3664 let assignees =
3665 item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3666 let status_checks = item
3667 .status_check_rollup
3668 .unwrap_or_default()
3669 .into_iter()
3670 .map(|c| {
3671 let non_empty = |v: Option<String>| v.filter(|s| !s.is_empty());
3673 CIStatusCheck {
3674 name: c.name.or(c.context).unwrap_or_else(|| "unnamed check".to_string()),
3675 state: non_empty(c.state),
3676 status: non_empty(c.status),
3677 conclusion: non_empty(c.conclusion),
3678 }
3679 })
3680 .collect();
3681 let reviews = item
3682 .reviews
3683 .unwrap_or_default()
3684 .into_iter()
3685 .map(|r| PRReview {
3686 author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3687 body: r.body,
3688 state: r.state,
3689 })
3690 .collect();
3691
3692 ForgePR {
3693 number: item.number,
3694 title: item.title,
3695 state: item.state,
3696 author,
3697 assignees,
3698 url: item.url,
3699 head_ref: item.head_ref_name,
3700 head_ref_oid: item.head_ref_oid,
3701 body: item.body,
3702 status_checks,
3703 reviews,
3704 }
3705 })
3706 .collect();
3707
3708 Ok(prs)
3709}
3710
3711pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3712 let mut cmd = tool_command("gh");
3715 cmd.arg("api")
3716 .arg(format!("repos/:owner/:repo/pulls/{}/comments?per_page=100", pr_number))
3717 .arg("--paginate")
3718 .current_dir(repo_path);
3719
3720 let output = match cmd.output() {
3721 Ok(out) => out,
3722 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3723 };
3724
3725 if !output.status.success() {
3726 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3727 return Err(format!("Failed to load PR comments: {}", err));
3728 }
3729
3730 parse_pr_comments(&output.stdout)
3731}
3732
3733fn parse_pr_comments(json: &[u8]) -> Result<Vec<ForgePRComment>, String> {
3736 #[derive(serde::Deserialize)]
3737 struct GhUser {
3738 login: String,
3739 }
3740
3741 #[derive(serde::Deserialize)]
3742 struct GhPRComment {
3743 path: String,
3744 line: Option<u32>,
3745 body: String,
3746 user: Option<GhUser>,
3747 #[serde(rename = "commit_id")]
3748 commit_id: String,
3749 }
3750
3751 let mut comments = Vec::new();
3752 for page in serde_json::Deserializer::from_slice(json).into_iter::<Vec<GhPRComment>>() {
3753 let page = page.map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3754 comments.extend(page.into_iter().map(|c| ForgePRComment {
3755 path: c.path,
3756 line: c.line,
3757 body: c.body,
3758 author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3759 commit_id: c.commit_id,
3760 }));
3761 }
3762 Ok(comments)
3763}
3764
3765fn pr_line_comment_args(
3771 pr_number: u32,
3772 commit_id: &str,
3773 path: &str,
3774 line: u32,
3775 body: &str,
3776) -> Vec<String> {
3777 vec![
3778 "api".to_string(),
3779 format!("repos/:owner/:repo/pulls/{}/comments", pr_number),
3780 "--method".to_string(),
3781 "POST".to_string(),
3782 "-f".to_string(),
3783 format!("body={}", body),
3784 "-f".to_string(),
3785 format!("commit_id={}", commit_id),
3786 "-f".to_string(),
3787 format!("path={}", path),
3788 "-F".to_string(),
3789 format!("line={}", line),
3790 "-f".to_string(),
3791 "side=RIGHT".to_string(),
3792 ]
3793}
3794
3795pub fn add_pr_line_comment(
3796 repo_path: &Path,
3797 pr_number: u32,
3798 commit_id: &str,
3799 path: &str,
3800 line: u32,
3801 body: &str,
3802) -> Result<(), String> {
3803 let mut cmd = tool_command("gh");
3804 cmd.args(pr_line_comment_args(pr_number, commit_id, path, line, body)).current_dir(repo_path);
3805
3806 let output = match cmd.output() {
3807 Ok(out) => out,
3808 Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3809 };
3810
3811 if !output.status.success() {
3812 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3813 return Err(format!("Failed to add PR comment: {}", err));
3814 }
3815 Ok(())
3816}
3817
3818pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3819 let mut cmd = tool_command("gh");
3820 cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3821
3822 let output = cmd.output().map_err(|e| e.to_string())?;
3823 if !output.status.success() {
3824 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3825 return Err(format!("Failed to checkout PR branch: {}", err));
3826 }
3827 Ok(format!("Checked out branch for PR #{}", pr_number))
3828}
3829
3830pub fn resolve_and_checkout_issue_branch(
3843 repo_path: &Path,
3844 issue_number: u32,
3845) -> Result<String, String> {
3846 let linked = linked_issue_branches(repo_path, issue_number).unwrap_or_default();
3849 match linked.as_slice() {
3850 [] => checkout_issue_branch_by_name(repo_path, issue_number),
3851 [name] => checkout_linked_issue_branch(repo_path, issue_number, name),
3852 many => Err(format!(
3853 "Issue #{} has {} linked branches ({}); check one out from the Branches tab",
3854 issue_number,
3855 many.len(),
3856 many.join(", ")
3857 )),
3858 }
3859}
3860
3861fn linked_issue_branches(repo_path: &Path, issue_number: u32) -> Result<Vec<String>, String> {
3863 const QUERY: &str = "query($owner: String!, $repo: String!, $number: Int!) { \
3864 repository(owner: $owner, name: $repo) { issue(number: $number) { \
3865 linkedBranches(first: 10) { nodes { ref { name } } } } } }";
3866 let output = tool_command("gh")
3869 .args(["api", "graphql", "-f"])
3870 .arg(format!("query={}", QUERY))
3871 .args(["-F", "owner={owner}", "-F", "repo={repo}", "-F"])
3872 .arg(format!("number={}", issue_number))
3873 .current_dir(repo_path)
3874 .output()
3875 .map_err(|e| e.to_string())?;
3876 if !output.status.success() {
3877 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
3878 }
3879 parse_linked_branches(&output.stdout)
3880}
3881
3882fn parse_linked_branches(json: &[u8]) -> Result<Vec<String>, String> {
3885 #[derive(serde::Deserialize)]
3886 struct GhRef {
3887 name: String,
3888 }
3889 #[derive(serde::Deserialize)]
3890 struct GhLinkedBranch {
3891 #[serde(rename = "ref")]
3892 git_ref: Option<GhRef>,
3893 }
3894 #[derive(serde::Deserialize)]
3895 struct GhLinkedBranches {
3896 nodes: Vec<Option<GhLinkedBranch>>,
3897 }
3898 #[derive(serde::Deserialize)]
3899 struct GhIssue {
3900 #[serde(rename = "linkedBranches")]
3901 linked_branches: GhLinkedBranches,
3902 }
3903 #[derive(serde::Deserialize)]
3904 struct GhRepository {
3905 issue: Option<GhIssue>,
3906 }
3907 #[derive(serde::Deserialize)]
3908 struct GhData {
3909 repository: Option<GhRepository>,
3910 }
3911 #[derive(serde::Deserialize)]
3912 struct GhResponse {
3913 data: GhData,
3914 }
3915
3916 let response: GhResponse = serde_json::from_slice(json)
3917 .map_err(|e| format!("Failed to parse linked branches: {}", e))?;
3918 let mut names: Vec<String> = Vec::new();
3919 let links = response.data.repository.and_then(|r| r.issue).map(|i| i.linked_branches.nodes);
3920 for name in links.into_iter().flatten().flatten().filter_map(|n| n.git_ref).map(|r| r.name) {
3921 if !names.contains(&name) {
3922 names.push(name);
3923 }
3924 }
3925 Ok(names)
3926}
3927
3928pub fn branch_names_issue(branch: &str, issue_number: u32) -> bool {
3933 let number = issue_number.to_string();
3934 branch.match_indices(&number).any(|(at, _)| {
3935 let before = branch[..at].chars().next_back();
3936 let after = branch[at + number.len()..].chars().next();
3937 before.is_none_or(|c| matches!(c, '-' | '_' | '/' | '#'))
3938 && after.is_none_or(|c| matches!(c, '-' | '_' | '/'))
3939 })
3940}
3941
3942fn run_checkout(repo_path: &Path, args: &[&str]) -> Result<(), String> {
3944 let output = git_command()
3945 .arg("checkout")
3946 .args(args)
3947 .current_dir(repo_path)
3948 .output()
3949 .map_err(|e| e.to_string())?;
3950 if !output.status.success() {
3951 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
3952 }
3953 Ok(())
3954}
3955
3956fn checkout_linked_issue_branch(
3961 repo_path: &Path,
3962 issue_number: u32,
3963 branch: &str,
3964) -> Result<String, String> {
3965 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3966 if repo.find_branch(branch, git2::BranchType::Local).is_ok() {
3967 run_checkout(repo_path, &[branch, "--"])?;
3968 return Ok(format!("Checked out '{}', linked to issue #{}", branch, issue_number));
3969 }
3970 let mut on_remotes = Vec::new();
3971 if let Ok(remotes) = repo.remotes() {
3972 for remote in remotes.iter().flatten().flatten() {
3973 let tracking = format!("{}/{}", remote, branch);
3974 if repo.find_branch(&tracking, git2::BranchType::Remote).is_ok() {
3975 on_remotes.push(tracking);
3976 }
3977 }
3978 }
3979 match on_remotes.as_slice() {
3980 [tracking] => {
3981 run_checkout(repo_path, &["--track", tracking])?;
3982 Ok(format!(
3983 "Checked out '{}' from '{}', linked to issue #{}",
3984 branch, tracking, issue_number
3985 ))
3986 }
3987 [] => Err(format!(
3988 "Issue #{} is linked to branch '{}', which is not in this clone yet; fetch it first",
3989 issue_number, branch
3990 )),
3991 many => Err(format!(
3992 "Issue #{} is linked to branch '{}', found on several remotes ({}); check one out from the Branches tab",
3993 issue_number,
3994 branch,
3995 many.join(", ")
3996 )),
3997 }
3998}
3999
4000pub fn checkout_issue_branch_by_name(
4003 repo_path: &Path,
4004 issue_number: u32,
4005) -> Result<String, String> {
4006 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
4007 let mut local = Vec::new();
4008 let mut remote = Vec::new();
4009 for (branch, kind) in repo.branches(None).map_err(|e| e.to_string())?.flatten() {
4010 let Ok(Some(name)) = branch.name() else { continue };
4011 match kind {
4012 git2::BranchType::Local => {
4013 if branch_names_issue(name, issue_number) {
4014 local.push(name.to_string());
4015 }
4016 }
4017 git2::BranchType::Remote => {
4018 let Some((_, short)) = name.split_once('/') else { continue };
4020 if short != "HEAD" && branch_names_issue(short, issue_number) {
4021 remote.push(name.to_string());
4022 }
4023 }
4024 }
4025 }
4026 let ambiguous = |kind: &str, names: &[String]| {
4027 format!(
4028 "Several {} branches match issue #{} ({}); check one out from the Branches tab",
4029 kind,
4030 issue_number,
4031 names.join(", ")
4032 )
4033 };
4034
4035 match local.as_slice() {
4036 [name] => {
4037 run_checkout(repo_path, &[name, "--"])?;
4038 return Ok(format!("Checked out matched local branch '{}'", name));
4039 }
4040 [] => {}
4041 many => return Err(ambiguous("local", many)),
4042 }
4043 match remote.as_slice() {
4044 [name] => {
4045 run_checkout(repo_path, &["--track", name])?;
4046 return Ok(format!("Checked out matched remote branch '{}'", name));
4047 }
4048 [] => {}
4049 many => return Err(ambiguous("remote", many)),
4050 }
4051
4052 let new_branch_name = format!("issue-{}", issue_number);
4053 run_checkout(repo_path, &["-b", &new_branch_name])
4054 .map_err(|e| format!("Failed to create new branch: {}", e))?;
4055 Ok(format!("Created and switched to new branch '{}'", new_branch_name))
4056}
4057
4058pub fn open_browser(url: &str) {
4059 #[cfg(target_os = "macos")]
4060 let _ = detached_command("open").arg(url).status();
4061 #[cfg(target_os = "windows")]
4062 let _ = detached_command("cmd").args(["/C", "start", url]).status();
4063 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
4064 let _ = detached_command("xdg-open").arg(url).status();
4065}
4066
4067#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
4068pub struct ForgeIssue {
4069 pub number: u32,
4070 pub title: String,
4071 pub state: String,
4072 pub author: String,
4073 pub assignees: Vec<String>,
4074 pub url: String,
4075}
4076
4077pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
4078 let remotes = load_tab_remotes(repo_path).ok()?;
4079 remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
4080}
4081
4082pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
4083 let remotes = load_tab_remotes(repo_path).ok()?;
4084 remotes.into_iter().next().map(|r| r.url)
4085}
4086
4087#[cfg(test)]
4088#[allow(clippy::unwrap_used, clippy::panic)]
4089#[allow(clippy::disallowed_methods)]
4091mod tests {
4092 use super::*;
4093 use std::fs::File;
4094
4095 #[test]
4096 fn test_match_pattern() {
4097 assert!(match_pattern("test.psd", "*.psd"));
4098 assert!(match_pattern("assets/test.psd", "*.psd"));
4099 assert!(match_pattern("assets/img.png", "assets/*.png"));
4100 assert!(match_pattern("src/app.rs", "src/*"));
4101 assert!(match_pattern("test.txt", "test.txt"));
4102 assert!(!match_pattern("src/app.rs", "*.psd"));
4103 }
4104
4105 #[test]
4106 fn test_run_git_with_timeout_kills_a_hanging_child() {
4107 let mut cmd = if cfg!(target_os = "windows") {
4109 let mut c = std::process::Command::new("cmd");
4110 c.arg("/c").arg("timeout").arg("/t").arg("30").arg("/nobreak");
4111 c
4112 } else {
4113 let mut c = std::process::Command::new("sleep");
4114 c.arg("30");
4115 c
4116 };
4117 cmd.stdin(std::process::Stdio::null());
4118
4119 let started = Instant::now();
4120 let res = run_git_with_timeout(cmd, Duration::from_millis(300));
4121 let elapsed = started.elapsed();
4122
4123 assert!(res.is_err(), "expected a timeout error, got {:?}", res);
4124 assert!(elapsed < Duration::from_secs(10), "child was not killed promptly: {:?}", elapsed);
4125 }
4126
4127 #[test]
4128 fn test_get_lfs_info() {
4129 let mut temp_path = std::env::temp_dir();
4130 temp_path.push(format!(
4131 "twig_test_lfs_{}",
4132 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4133 ));
4134 std::fs::create_dir_all(&temp_path).unwrap();
4135
4136 let _repo = Repository::init(&temp_path).unwrap();
4138 let lfs_dir = temp_path.join(".git").join("lfs");
4139 std::fs::create_dir_all(&lfs_dir).unwrap();
4140
4141 let (installed, tracked) = get_lfs_info(&temp_path);
4143
4144 assert!(tracked.is_empty());
4146
4147 let gitattributes_path = temp_path.join(".gitattributes");
4149 std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
4150
4151 let (installed2, _tracked2) = get_lfs_info(&temp_path);
4152 assert_eq!(installed, installed2);
4153
4154 let _ = std::fs::remove_dir_all(&temp_path);
4156 }
4157
4158 #[test]
4159 fn test_comprehensive_gitwig_core_coverage() {
4160 let mut temp_path = std::env::temp_dir();
4161 temp_path.push(format!(
4162 "twig_test_cov_{}",
4163 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4164 ));
4165 std::fs::create_dir_all(&temp_path).unwrap();
4166
4167 let repo = Repository::init(&temp_path).unwrap();
4169
4170 let mut config = repo.config().unwrap();
4172 config.set_str("user.name", "Test User").unwrap();
4173 config.set_str("user.email", "test@example.com").unwrap();
4174
4175 let file_path = temp_path.join("test.txt");
4177 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4178 stage_file(&temp_path, "test.txt").unwrap();
4179 commit_changes(&temp_path, "first commit").unwrap();
4180
4181 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
4182 let head_oid = head_commit.id().to_string();
4183
4184 let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
4186 let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
4187
4188 let _ = stage_all_changes(&temp_path);
4190 let _ = unstage_all_changes(&temp_path);
4191
4192 std::fs::write(&file_path, "modified content").unwrap();
4193 let _ = discard_all_changes(&temp_path);
4194
4195 let _lfs_size = get_lfs_storage_size(&temp_path);
4197
4198 let _reflog = load_tab_reflog(&temp_path);
4200 let _tags = load_tab_tags(&temp_path);
4201 let _stashes = load_tab_stashes(&temp_path);
4202 let _submodules = load_tab_submodules(&temp_path);
4203 let _worktrees = load_tab_worktrees(&temp_path);
4204
4205 let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
4207 let _ = remote_delete(&temp_path, "upstream");
4208
4209 let wt_path = temp_path.join("wt-path");
4211 let _ = worktree_add(&temp_path, "new-branch", &wt_path);
4212 let _ = worktree_remove(&temp_path, &wt_path, true);
4213
4214 let _ = delete_tag(&temp_path, "v1.0.0");
4216
4217 let _ = apply_stash(&temp_path, 0);
4219 let _ = delete_stash(&temp_path, 0);
4220
4221 let _ = abort_merge(&temp_path);
4223 let _ = continue_merge(&temp_path);
4224
4225 let _ = is_merging(&temp_path);
4227 let _ = get_conflict_markers_diff(&temp_path, "test.txt");
4228 let _ = resolve_ours(&temp_path, "test.txt");
4229 let _ = resolve_theirs(&temp_path, "test.txt");
4230 let _ = mark_resolved(&temp_path, "test.txt");
4231
4232 let _target = get_branch_push_target(&temp_path, "master");
4234 let _target2 = get_branch_push_target(&temp_path, "main");
4235
4236 let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
4238 let _summary = inspect_summary(temp_path.to_str().unwrap());
4239
4240 let _ = std::fs::remove_dir_all(&temp_path);
4242 }
4243
4244 #[test]
4245 fn test_more_gitwig_core_coverage() {
4246 let mut temp_path = std::env::temp_dir();
4247 temp_path.push(format!(
4248 "twig_test_cov2_{}",
4249 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4250 ));
4251 std::fs::create_dir_all(&temp_path).unwrap();
4252
4253 let repo = Repository::init(&temp_path).unwrap();
4255
4256 let mut config = repo.config().unwrap();
4258 config.set_str("user.name", "Test User").unwrap();
4259 config.set_str("user.email", "test@example.com").unwrap();
4260
4261 let file_path = temp_path.join("test.txt");
4263 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4264 stage_file(&temp_path, "test.txt").unwrap();
4265 commit_changes(&temp_path, "first commit").unwrap();
4266
4267 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
4268 let head_oid = head_commit.id().to_string();
4269
4270 let _ = checkout_commit(&temp_path, &head_oid);
4272 let _ = checkout_local_branch(&temp_path, "master");
4273 let _ = checkout_remote_branch(&temp_path, "origin/master");
4274 let _ = checkout_tag(&temp_path, "v1.0.0");
4275
4276 let _ = create_branch(&temp_path, "new-branch-2");
4278 let _ = delete_local_branch(&temp_path, "new-branch-2");
4279 let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
4280 let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
4281 let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0", Duration::from_secs(5));
4282 let _ = get_remote_tags(&temp_path, "origin", Duration::from_secs(5));
4283
4284 let tag_info = vec![BranchInfo {
4286 name: "v1.0.0".to_string(),
4287 is_head: false,
4288 short_sha: "abc1234".to_string(),
4289 short_message: "tag msg".to_string(),
4290 }];
4291 let serialized = serialize_tags(&tag_info);
4292 let deserialized = deserialize_tags(&serialized);
4293 assert_eq!(deserialized.len(), 1);
4294 assert_eq!(deserialized[0].name, "v1.0.0");
4295
4296 let _ = get_file_history(&temp_path, "test.txt");
4298 let _ = get_file_blame(&temp_path, "test.txt");
4299 let _ = get_commit_files(&temp_path, &head_oid);
4300
4301 let _ = load_tab_files(&temp_path);
4303 let _ = load_tab_branches(&temp_path);
4304 let _ = load_tab_remotes(&temp_path);
4305 let _ = load_tab_overview(&temp_path, 10);
4306
4307 let (tx, _rx) = std::sync::mpsc::channel();
4309 let _ = load_tab_graph_stream(
4310 &temp_path,
4311 10,
4312 true,
4313 temp_path.to_str().unwrap().to_string(),
4314 0,
4315 tx,
4316 );
4317
4318 invalidate_ref_map_cache(&temp_path);
4320
4321 let _ = load_tab_forge_issues(&temp_path, false);
4323 let _ = load_tab_forge_issues(&temp_path, true);
4324 let _ = load_tab_forge_prs(&temp_path);
4325 let _ = load_pr_comments(&temp_path, 1);
4326 let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
4327
4328 let _ = std::fs::remove_dir_all(&temp_path);
4330 }
4331
4332 #[test]
4333 fn test_create_tag_annotated_and_force_update() {
4334 let mut temp_path = std::env::temp_dir();
4335 temp_path.push(format!(
4336 "twig_test_tag_force_{}",
4337 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4338 ));
4339 std::fs::create_dir_all(&temp_path).unwrap();
4340
4341 let repo = Repository::init(&temp_path).unwrap();
4342 let mut config = repo.config().unwrap();
4343 config.set_str("user.name", "Test User").unwrap();
4344 config.set_str("user.email", "test@example.com").unwrap();
4345
4346 let file_path = temp_path.join("test.txt");
4347 std::fs::write(&file_path, "commit 1\n").unwrap();
4348 stage_file(&temp_path, "test.txt").unwrap();
4349 commit_changes(&temp_path, "commit 1").unwrap();
4350
4351 let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
4352 let oid1 = head_commit1.id().to_string();
4353
4354 assert!(!tag_exists(&temp_path, "v1.0.1"));
4355 assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
4356 assert!(tag_exists(&temp_path, "v1.0.1"));
4357
4358 std::fs::write(&file_path, "commit 2\n").unwrap();
4360 stage_file(&temp_path, "test.txt").unwrap();
4361 commit_changes(&temp_path, "commit 2").unwrap();
4362
4363 let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
4364 let oid2 = head_commit2.id().to_string();
4365
4366 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
4367
4368 assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
4370
4371 let _ = std::fs::remove_dir_all(&temp_path);
4372 }
4373
4374 use std::io::Write;
4375
4376 #[test]
4377 fn test_commit_amend() {
4378 let mut temp_path = std::env::temp_dir();
4379 temp_path.push(format!(
4380 "twig_test_amend_{}",
4381 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4382 ));
4383 std::fs::create_dir_all(&temp_path).unwrap();
4384
4385 let repo = Repository::init(&temp_path).unwrap();
4387
4388 let mut config = repo.config().unwrap();
4390 config.set_str("user.name", "Test User").unwrap();
4391 config.set_str("user.email", "test@example.com").unwrap();
4392
4393 let file_path = temp_path.join("test.txt");
4395 let mut file = File::create(&file_path).unwrap();
4396 writeln!(file, "initial content").unwrap();
4397
4398 stage_file(&temp_path, "test.txt").unwrap();
4400 commit_changes(&temp_path, "initial commit").unwrap();
4401
4402 let msg = get_last_commit_message(&temp_path).unwrap();
4404 assert_eq!(msg, "initial commit");
4405
4406 commit_amend(&temp_path, "amended commit").unwrap();
4408
4409 let amended_msg = get_last_commit_message(&temp_path).unwrap();
4411 assert_eq!(amended_msg, "amended commit");
4412
4413 let _ = std::fs::remove_dir_all(&temp_path);
4415 }
4416
4417 #[test]
4418 fn test_commit_signatures_collection() {
4419 let mut temp_path = std::env::temp_dir();
4420 temp_path.push(format!(
4421 "twig_test_sig_{}",
4422 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4423 ));
4424 std::fs::create_dir_all(&temp_path).unwrap();
4425
4426 let repo = Repository::init(&temp_path).unwrap();
4428
4429 let mut config = repo.config().unwrap();
4431 config.set_str("user.name", "Test User").unwrap();
4432 config.set_str("user.email", "test@example.com").unwrap();
4433
4434 let file_path = temp_path.join("test.txt");
4436 let mut file = File::create(&file_path).unwrap();
4437 writeln!(file, "initial content").unwrap();
4438
4439 stage_file(&temp_path, "test.txt").unwrap();
4441 commit_changes(&temp_path, "initial commit").unwrap();
4442
4443 let sigs = collect_signatures(&temp_path, 0);
4445 assert_eq!(sigs.len(), 1);
4446 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4447 let sig_status = sigs.get(&head_oid).unwrap();
4448 assert_eq!(sig_status, "N");
4449
4450 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4452 assert_eq!(commits.len(), 1);
4453 assert_eq!(commits[0].signature_status, "N");
4454 assert!(commits[0].files.is_empty());
4455
4456 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4458 assert_eq!(files.len(), 1);
4459 assert_eq!(files[0].path, "test.txt");
4460 assert_eq!(files[0].label, "N");
4461
4462 let graph = collect_graph_lines(&temp_path, 1000);
4464 assert_eq!(graph.len(), 1);
4465 assert!(graph[0].commit.is_some());
4466 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4467
4468 let _ = std::fs::remove_dir_all(&temp_path);
4470 }
4471
4472 fn run_git(dir: &Path, args: &[&str]) {
4473 let out = git_command()
4474 .args(["-c", "user.name=Gitwig Test", "-c", "user.email=test@gitwig.invalid"])
4475 .args(["-c", "commit.gpgsign=false"])
4476 .args(args)
4477 .current_dir(dir)
4478 .output()
4479 .unwrap();
4480 assert!(out.status.success(), "git {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
4481 }
4482
4483 fn unique_temp_dir(tag: &str) -> PathBuf {
4484 let dir = std::env::temp_dir().join(format!(
4485 "twig_test_{}_{}",
4486 tag,
4487 SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
4488 ));
4489 std::fs::create_dir_all(&dir).unwrap();
4490 dir
4491 }
4492
4493 fn repo_with_branches(root: &Path, branches: &[&str]) -> PathBuf {
4495 let main = root.join("main");
4496 std::fs::create_dir_all(&main).unwrap();
4497 run_git(&main, &["init", "-q"]);
4498 run_git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]);
4499 for branch in branches {
4500 run_git(&main, &["branch", branch]);
4501 }
4502 main
4503 }
4504
4505 fn worktree_at<'a>(worktrees: &'a [WorktreeInfo], suffix: &str) -> &'a WorktreeInfo {
4506 worktrees
4507 .iter()
4508 .find(|wt| wt.path.ends_with(suffix))
4509 .unwrap_or_else(|| panic!("no worktree at {} in {:?}", suffix, worktrees))
4510 }
4511
4512 #[test]
4517 fn test_worktree_helpers_act_on_the_checkout_path_not_the_admin_name() {
4518 let root = unique_temp_dir("wt_admin_name");
4519 let main = repo_with_branches(&root, &["a", "b"]);
4520 let (x_foo, x_renamed, z_foo) =
4521 (root.join("x/foo"), root.join("x/renamed"), root.join("z/foo"));
4522 run_git(&main, &["worktree", "add", "-q", &x_foo.to_string_lossy(), "a"]);
4523 run_git(
4524 &main,
4525 &["worktree", "move", &x_foo.to_string_lossy(), &x_renamed.to_string_lossy()],
4526 );
4527 run_git(&main, &["worktree", "add", "-q", &z_foo.to_string_lossy(), "b"]);
4528 std::fs::write(z_foo.join("precious.txt"), "uncommitted work").unwrap();
4529
4530 let worktrees = load_tab_worktrees(&main).unwrap();
4531 let renamed = worktree_at(&worktrees, "x/renamed").clone();
4532 assert_eq!(renamed.name, "foo", "the moved worktree keeps its admin name");
4533
4534 worktree_lock(&main, &renamed.path, "testing").unwrap();
4535 assert!(worktree_at(&load_tab_worktrees(&main).unwrap(), "x/renamed").is_locked);
4536 worktree_unlock(&main, &renamed.path).unwrap();
4537 assert!(!worktree_at(&load_tab_worktrees(&main).unwrap(), "x/renamed").is_locked);
4538
4539 worktree_remove(&main, &renamed.path, false).unwrap();
4540 assert!(!x_renamed.exists(), "the selected worktree is removed");
4541 assert!(z_foo.join("precious.txt").exists(), "the other worktree is untouched");
4542 let _ = std::fs::remove_dir_all(&root);
4543 }
4544
4545 #[test]
4549 fn test_worktree_add_checks_out_existing_refs_and_creates_new_branches() {
4550 let root = unique_temp_dir("wt_add");
4551 let main = repo_with_branches(&root, &["existing"]);
4552 run_git(&main, &["tag", "v1"]);
4553 let branch_of = |wt: &Path| {
4554 let out =
4555 git_command().args(["rev-parse", "--abbrev-ref", "HEAD"]).current_dir(wt).output();
4556 String::from_utf8_lossy(&out.unwrap().stdout).trim().to_string()
4557 };
4558
4559 assert!(!worktree_add(&main, "existing", &root.join("a")).unwrap());
4560 assert_eq!(branch_of(&root.join("a")), "existing");
4561 assert!(worktree_add(&main, "feature/new", &root.join("b")).unwrap(), "created");
4562 assert_eq!(branch_of(&root.join("b")), "feature/new");
4563 assert!(!worktree_add(&main, "v1", &root.join("c")).unwrap());
4564 assert_eq!(branch_of(&root.join("c")), "HEAD", "a tag is checked out detached");
4565 assert!(worktree_add(&main, "dash", &root.join("-dash")).unwrap());
4567 let current = branch_of(&main);
4569 assert!(worktree_add(&main, ¤t, &root.join("d")).is_err());
4570
4571 let clone = root.join("clone");
4573 run_git(&root, &["clone", "-q", &main.to_string_lossy(), &clone.to_string_lossy()]);
4574 assert!(!worktree_add(&clone, "existing", &root.join("e")).unwrap());
4575 assert_eq!(branch_of(&root.join("e")), "existing");
4576 let upstream = git_command()
4577 .args(["rev-parse", "--abbrev-ref", "existing@{upstream}"])
4578 .current_dir(root.join("e"))
4579 .output()
4580 .unwrap();
4581 assert_eq!(String::from_utf8_lossy(&upstream.stdout).trim(), "origin/existing");
4582 let _ = std::fs::remove_dir_all(&root);
4583 }
4584
4585 #[test]
4586 fn test_worktree_remove_keeps_uncommitted_or_locked_work_unless_forced() {
4587 let root = unique_temp_dir("wt_remove_force");
4588 let main = repo_with_branches(&root, &["dirty", "locked"]);
4589 let (dirty, locked) = (root.join("dirty"), root.join("locked"));
4590 run_git(&main, &["worktree", "add", "-q", &dirty.to_string_lossy(), "dirty"]);
4591 run_git(&main, &["worktree", "add", "-q", &locked.to_string_lossy(), "locked"]);
4592 std::fs::write(dirty.join("wip.txt"), "untracked work").unwrap();
4593 run_git(&main, &["worktree", "lock", &locked.to_string_lossy()]);
4594
4595 assert!(worktree_remove(&main, &dirty, false).is_err());
4596 assert!(dirty.join("wip.txt").exists(), "a refused remove deletes nothing");
4597 assert!(worktree_remove(&main, &locked, false).is_err());
4598 assert!(locked.exists());
4599
4600 worktree_remove(&main, &dirty, true).unwrap();
4601 worktree_remove(&main, &locked, true).unwrap();
4602 assert!(!dirty.exists() && !locked.exists(), "force removes both");
4603 assert!(load_tab_worktrees(&main).unwrap().is_empty());
4604 let _ = std::fs::remove_dir_all(&root);
4605 }
4606
4607 #[test]
4611 fn test_submodule_remove_deletes_the_submodule_path_not_a_same_named_file() {
4612 let root = unique_temp_dir("submodule_remove");
4613 let lib = root.join("lib");
4614 std::fs::create_dir_all(&lib).unwrap();
4615 run_git(&lib, &["init", "-q"]);
4616 run_git(&lib, &["commit", "-q", "--allow-empty", "-m", "lib"]);
4617 let main = repo_with_branches(&root, &[]);
4618 std::fs::write(main.join("notes"), "tracked notes\n").unwrap();
4619 run_git(&main, &["add", "notes"]);
4620 run_git(&main, &["commit", "-q", "-m", "notes"]);
4621 std::fs::write(main.join("notes"), "tracked notes\nlocal edit\n").unwrap();
4622 run_git(
4623 &main,
4624 &["submodule", "add", "-q", "--name", "notes", &lib.to_string_lossy(), "vendor/n"],
4625 );
4626 run_git(&main, &["commit", "-q", "-m", "add submodule"]);
4627 assert!(main.join(".git/modules/notes").is_dir());
4628
4629 assert!(submodule_remove(&main, "notes", Path::new("notes")).is_err());
4631 assert!(submodule_remove(&main, "notes", Path::new("../lib")).is_err());
4632 assert!(submodule_remove(&main, "../notes", Path::new("vendor/n")).is_err());
4633 assert!(main.join("vendor/n").is_dir());
4634
4635 submodule_remove(&main, "notes", Path::new("vendor/n")).unwrap();
4636 assert_eq!(
4637 std::fs::read_to_string(main.join("notes")).unwrap(),
4638 "tracked notes\nlocal edit\n",
4639 "the same-named file keeps its local edit"
4640 );
4641 assert!(!main.join("vendor/n").exists());
4642 assert!(!main.join(".git/modules/notes").exists());
4643 assert!(!std::fs::read_to_string(main.join(".gitmodules")).unwrap().contains("vendor/n"));
4644 let status = git_command().args(["status", "--porcelain"]).current_dir(&main).output();
4645 let status = status.unwrap();
4646 assert!(status.status.success(), "the repository is still usable");
4647 let status = String::from_utf8_lossy(&status.stdout);
4649 assert!(status.lines().any(|l| l == "D vendor/n"), "{}", status);
4650 assert!(status.lines().any(|l| l == " M notes"), "{}", status);
4651 let listed = load_tab_submodules(&main).unwrap();
4654 let removed = listed.iter().find(|s| s.path == Path::new("vendor/n")).unwrap();
4655 assert!(removed.commit_id.is_none() && removed.head_id.is_some(), "{:?}", removed);
4656 let _ = std::fs::remove_dir_all(&root);
4657 }
4658
4659 #[test]
4662 fn test_pr_line_comment_args_send_text_fields_verbatim() {
4663 let args = pr_line_comment_args(42, "abc123", "src/{owner}.rs", 7, "@alice 123");
4664 let field =
4665 |flag: &str, value: &str| args.windows(2).any(|w| w[0] == flag && w[1] == value);
4666 assert!(field("-f", "body=@alice 123"), "{:?}", args);
4667 assert!(field("-f", "path=src/{owner}.rs"), "{:?}", args);
4668 assert!(field("-f", "commit_id=abc123"), "{:?}", args);
4669 assert!(field("-f", "side=RIGHT"), "{:?}", args);
4670 assert!(field("-F", "line=7"), "line is sent as a number: {:?}", args);
4671 assert_eq!(args.iter().filter(|a| *a == "-F").count(), 1, "{:?}", args);
4672 assert_eq!(args[1], "repos/:owner/:repo/pulls/42/comments");
4673 }
4674
4675 #[test]
4679 fn test_parse_forge_prs_accepts_commit_statuses_and_running_checks() {
4680 let json = br#"[{"number":42,"title":"Add retry","state":"OPEN",
4681 "author":{"login":"alice"},"assignees":[],"url":"https://github.com/o/r/pull/42",
4682 "headRefName":"retry","headRefOid":"abc","body":"",
4683 "statusCheckRollup":[
4684 {"__typename":"CheckRun","name":"build","workflowName":"CI","status":"COMPLETED",
4685 "conclusion":"SUCCESS","startedAt":"2026-01-01T00:00:00Z","completedAt":"2026-01-01T00:01:00Z","detailsUrl":"https://x"},
4686 {"__typename":"CheckRun","name":"lint","workflowName":"CI","status":"IN_PROGRESS",
4687 "conclusion":"","startedAt":"2026-01-01T00:00:00Z","completedAt":"0001-01-01T00:00:00Z","detailsUrl":"https://x"},
4688 {"__typename":"StatusContext","context":"ci/circleci","state":"FAILURE",
4689 "targetUrl":"https://circleci.com/x","startedAt":"2026-01-01T00:00:00Z"}],
4690 "reviews":[{"author":null,"body":"LGTM","state":"APPROVED"}]}]"#;
4691 let prs = parse_forge_prs(json).unwrap();
4692 let checks = &prs[0].status_checks;
4693 assert_eq!(checks.len(), 3);
4694 assert_eq!(checks[0].conclusion.as_deref(), Some("SUCCESS"));
4695 assert_eq!((checks[1].name.as_str(), checks[1].conclusion.as_deref()), ("lint", None));
4696 assert_eq!(checks[1].status.as_deref(), Some("IN_PROGRESS"));
4697 assert_eq!(checks[2].name, "ci/circleci");
4698 assert_eq!(checks[2].state.as_deref(), Some("FAILURE"));
4699 assert_eq!(prs[0].reviews[0].author, "none");
4700 }
4701
4702 #[test]
4705 fn test_parse_pr_comments_reads_every_page() {
4706 let comment = |n: u32| {
4707 format!(
4708 r#"{{"path":"a.rs","line":{},"body":"c{}","user":{{"login":"bob"}},"commit_id":"x"}}"#,
4709 n, n
4710 )
4711 };
4712 let page = |range: std::ops::Range<u32>| {
4713 format!("[{}]", range.map(comment).collect::<Vec<_>>().join(","))
4714 };
4715 let json = format!("{}\n{}", page(0..100), page(100..130));
4716 let comments = parse_pr_comments(json.as_bytes()).unwrap();
4717 assert_eq!(comments.len(), 130);
4718 assert_eq!(comments[129].body, "c129");
4719 assert!(parse_pr_comments(b"[]").unwrap().is_empty());
4720 let deleted_user = br#"[{"path":"a","line":null,"body":"b","user":null,"commit_id":"x"}]"#;
4721 assert_eq!(parse_pr_comments(deleted_user).unwrap()[0].author, "none");
4722 assert!(parse_pr_comments(b"[{").is_err());
4723 }
4724
4725 #[test]
4726 fn test_parse_linked_branches() {
4727 let json = br#"{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[
4728 {"ref":{"name":"12-login-fails"}},{"ref":null},{"ref":{"name":"12-login-fails"}},
4729 {"ref":{"name":"alt-12"}}]}}}}}"#;
4730 assert_eq!(parse_linked_branches(json).unwrap(), vec!["12-login-fails", "alt-12"]);
4731 let none = br#"{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}"#;
4732 assert!(parse_linked_branches(none).unwrap().is_empty());
4733 let no_issue = br#"{"data":{"repository":{"issue":null}}}"#;
4734 assert!(parse_linked_branches(no_issue).unwrap().is_empty());
4735 assert!(parse_linked_branches(b"not json").is_err());
4736 }
4737
4738 #[test]
4739 fn test_branch_names_issue_matches_whole_tokens_only() {
4740 for (branch, issue) in [
4741 ("fix-1-typo", 1),
4742 ("issue-12-login", 12),
4743 ("12-login", 12),
4744 ("feature/12", 12),
4745 ("gh#12", 12),
4746 ("issue_12", 12),
4747 ("v1.2-release-2", 2),
4748 ] {
4749 assert!(branch_names_issue(branch, issue), "{} names #{}", branch, issue);
4750 }
4751 for (branch, issue) in [
4752 ("v1.2-release", 1),
4753 ("v1.2-release", 2),
4754 ("issue-12-login", 1),
4755 ("issue-12-login", 2),
4756 ("issue-123", 12),
4757 ("issue12", 12),
4758 ("main", 1),
4759 ] {
4760 assert!(!branch_names_issue(branch, issue), "{} does not name #{}", branch, issue);
4761 }
4762 }
4763
4764 fn current_branch(repo: &Path) -> String {
4765 let out = git_command().args(["branch", "--show-current"]).current_dir(repo).output();
4766 String::from_utf8_lossy(&out.unwrap().stdout).trim().to_string()
4767 }
4768
4769 #[test]
4770 fn test_checkout_issue_branch_by_name_picks_the_one_branch_naming_the_issue() {
4771 let root = unique_temp_dir("issue_branch");
4772 let main = repo_with_branches(
4773 &root,
4774 &["fix-1-typo", "v1.2-release", "issue-12-login", "7-crash", "fix/7"],
4775 );
4776 let start = current_branch(&main);
4777
4778 assert_eq!(
4780 checkout_issue_branch_by_name(&main, 2).unwrap(),
4781 "Created and switched to new branch 'issue-2'"
4782 );
4783 assert_eq!(current_branch(&main), "issue-2");
4784 checkout_issue_branch_by_name(&main, 12).unwrap();
4785 assert_eq!(current_branch(&main), "issue-12-login");
4786 checkout_issue_branch_by_name(&main, 1).unwrap();
4787 assert_eq!(current_branch(&main), "fix-1-typo");
4788
4789 let err = checkout_issue_branch_by_name(&main, 7).unwrap_err();
4791 assert!(err.contains("7-crash") && err.contains("fix/7"), "{}", err);
4792 assert_eq!(current_branch(&main), "fix-1-typo");
4793
4794 run_git(&main, &["checkout", "-q", &start]);
4796 run_git(&main, &["branch", "fix-9-remote"]);
4797 let clone = root.join("clone");
4798 run_git(&root, &["clone", "-q", &main.to_string_lossy(), &clone.to_string_lossy()]);
4799 assert_eq!(
4800 checkout_issue_branch_by_name(&clone, 9).unwrap(),
4801 "Checked out matched remote branch 'origin/fix-9-remote'"
4802 );
4803 assert_eq!(current_branch(&clone), "fix-9-remote");
4804 let _ = std::fs::remove_dir_all(&root);
4805 }
4806
4807 #[test]
4810 fn test_checkout_linked_issue_branch_never_restores_a_same_named_path() {
4811 let root = unique_temp_dir("linked_branch");
4812 let main = repo_with_branches(&root, &[]);
4813 std::fs::create_dir_all(main.join("docs")).unwrap();
4814 std::fs::write(main.join("docs/guide.md"), "v1\n").unwrap();
4815 run_git(&main, &["add", "docs"]);
4816 run_git(&main, &["commit", "-q", "-m", "docs"]);
4817 run_git(&main, &["branch", "linked-5"]);
4818 std::fs::write(main.join("docs/guide.md"), "v1\nunsaved edit\n").unwrap();
4819
4820 let err = checkout_linked_issue_branch(&main, 5, "docs").unwrap_err();
4821 assert!(err.contains("not in this clone"), "{}", err);
4822 assert_eq!(
4823 std::fs::read_to_string(main.join("docs/guide.md")).unwrap(),
4824 "v1\nunsaved edit\n"
4825 );
4826 checkout_linked_issue_branch(&main, 5, "linked-5").unwrap();
4827 assert_eq!(current_branch(&main), "linked-5");
4828 let _ = std::fs::remove_dir_all(&root);
4829 }
4830
4831 fn make_repo_with_unverifiable_signature() -> PathBuf {
4836 use std::io::Write;
4837 use std::process::Stdio;
4838 let mut temp_path = std::env::temp_dir();
4839 temp_path.push(format!(
4840 "twig_test_nogpg_{}",
4841 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4842 ));
4843 std::fs::create_dir_all(&temp_path).unwrap();
4844 let repo = Repository::init(&temp_path).unwrap();
4845 let mut config = repo.config().unwrap();
4846 config.set_str("user.name", "Test User").unwrap();
4847 config.set_str("user.email", "test@example.com").unwrap();
4848 config.set_str("gpg.program", "/nonexistent/gitwig-test-no-gpg").unwrap();
4849
4850 let mut file = File::create(temp_path.join("test.txt")).unwrap();
4851 writeln!(file, "initial content").unwrap();
4852 stage_file(&temp_path, "test.txt").unwrap();
4853 commit_changes(&temp_path, "signed commit").unwrap();
4854
4855 let raw = git_command()
4857 .args(["cat-file", "-p", "HEAD"])
4858 .current_dir(&temp_path)
4859 .output()
4860 .unwrap();
4861 let mut signed = String::new();
4862 for line in String::from_utf8_lossy(&raw.stdout).lines() {
4863 signed.push_str(line);
4864 signed.push('\n');
4865 if line.starts_with("committer ") {
4866 signed.push_str("gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n");
4867 }
4868 }
4869 let mut hash_cmd = git_command();
4870 hash_cmd.args(["hash-object", "-t", "commit", "-w", "--stdin"]).current_dir(&temp_path);
4871 hash_cmd.stdin(Stdio::piped());
4872 let mut child = hash_cmd.spawn().unwrap();
4873 child.stdin.take().unwrap().write_all(signed.as_bytes()).unwrap();
4874 let out = child.wait_with_output().unwrap();
4875 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
4876 let new_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
4877 let head_ref = String::from_utf8_lossy(
4878 &git_command()
4879 .args(["symbolic-ref", "HEAD"])
4880 .current_dir(&temp_path)
4881 .output()
4882 .unwrap()
4883 .stdout,
4884 )
4885 .trim()
4886 .to_string();
4887 let upd = git_command()
4888 .args(["update-ref", &head_ref, &new_oid])
4889 .current_dir(&temp_path)
4890 .output()
4891 .unwrap();
4892 assert!(upd.status.success());
4893 temp_path
4894 }
4895
4896 #[test]
4897 fn graph_survives_missing_signature_program_and_reports_it() {
4898 let temp_path = make_repo_with_unverifiable_signature();
4899 let (tx, rx) = std::sync::mpsc::channel();
4900
4901 let load = load_tab_graph_stream(&temp_path, 100, true, String::new(), 2, tx)
4902 .expect("a missing gpg must not fail the graph");
4903 assert_eq!(load.lines.len(), 1);
4904 assert!(load.lines[0].commit.is_some());
4905 let notice = load.notice.expect("git's stderr must surface as a notice");
4906 assert!(
4907 notice.contains("/nonexistent/gitwig-test-no-gpg"),
4908 "notice must name the program git could not start: {notice}"
4909 );
4910 assert!(!notice.starts_with("error:"), "prefix should be stripped: {notice}");
4911 assert!(rx.try_recv().is_err(), "no batch is streamed for a one-commit graph");
4912
4913 let _ = std::fs::remove_dir_all(&temp_path);
4914 }
4915
4916 #[test]
4917 fn graph_skips_signature_lookup_when_disabled() {
4918 let temp_path = make_repo_with_unverifiable_signature();
4919 let (tx, _rx) = std::sync::mpsc::channel();
4920
4921 let load = load_tab_graph_stream(&temp_path, 100, false, String::new(), 2, tx).unwrap();
4922 assert_eq!(load.lines.len(), 1);
4923 assert_eq!(load.lines[0].commit.as_ref().unwrap().signature_status, "N");
4924 assert!(load.notice.is_none(), "no %G? means git never touches gpg");
4925
4926 let _ = std::fs::remove_dir_all(&temp_path);
4927 }
4928
4929 #[test]
4930 fn test_ref_map_cache_behavior() {
4931 let temp_dir = std::env::temp_dir();
4932 let repo_path = temp_dir.join("test_ref_map_repo");
4933 let _ = std::fs::remove_dir_all(&repo_path);
4934 std::fs::create_dir_all(&repo_path).unwrap();
4935
4936 let repo = Repository::init(&repo_path).unwrap();
4937
4938 let map1 = get_cached_ref_map(&repo, &repo_path);
4940
4941 let map2 = get_cached_ref_map(&repo, &repo_path);
4943 assert_eq!(map1.len(), map2.len());
4944
4945 invalidate_ref_map_cache(&repo_path);
4947
4948 let _ = std::fs::remove_dir_all(&repo_path);
4950 }
4951
4952 #[test]
4953 fn test_get_latest_change_time() {
4954 let mut temp_path = std::env::temp_dir();
4955 temp_path.push(format!(
4956 "twig_test_time_{}",
4957 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4958 ));
4959 std::fs::create_dir_all(&temp_path).unwrap();
4960
4961 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
4962 assert!(change_time > 0);
4963
4964 let _ = std::fs::remove_dir_all(&temp_path);
4965 }
4966
4967 #[test]
4968 fn test_committer_stats() {
4969 let mut temp_path = std::env::temp_dir();
4970 temp_path.push(format!(
4971 "twig_test_stats_{}",
4972 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4973 ));
4974 std::fs::create_dir_all(&temp_path).unwrap();
4975
4976 let repo = Repository::init(&temp_path).unwrap();
4978
4979 let mut config = repo.config().unwrap();
4981 config.set_str("user.name", "Test User").unwrap();
4982 config.set_str("user.email", "test@example.com").unwrap();
4983
4984 let file_path = temp_path.join("test.txt");
4986 let mut file = File::create(&file_path).unwrap();
4987 writeln!(file, "initial content").unwrap();
4988
4989 stage_file(&temp_path, "test.txt").unwrap();
4991 commit_changes(&temp_path, "initial commit").unwrap();
4992
4993 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
4995 assert_eq!(stats.len(), 1);
4996 assert_eq!(stats[0].name, "Test User");
4997 assert_eq!(stats[0].email, "test@example.com");
4998 assert_eq!(stats[0].count, 1);
4999 assert!(!limit_reached);
5000
5001 let _ = std::fs::remove_dir_all(&temp_path);
5003 }
5004
5005 #[test]
5006 fn test_untracked_files_in_unstaged() {
5007 let mut temp_path = std::env::temp_dir();
5008 temp_path.push(format!(
5009 "twig_test_untracked_{}",
5010 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5011 ));
5012 std::fs::create_dir_all(&temp_path).unwrap();
5013
5014 let _repo = Repository::init(&temp_path).unwrap();
5016
5017 let file_path = temp_path.join("untracked.txt");
5019 let mut file = File::create(&file_path).unwrap();
5020 writeln!(file, "hello untracked").unwrap();
5021
5022 let untracked_dir = temp_path.join("untracked_dir");
5024 std::fs::create_dir_all(&untracked_dir).unwrap();
5025 let nested_file_path = untracked_dir.join("nested.txt");
5026 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
5027
5028 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5030 match detail {
5031 ItemDetail::Repo { info, .. } => {
5032 let unstaged_paths: Vec<String> =
5034 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
5035 let untracked_paths: Vec<String> =
5036 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
5037
5038 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
5040 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
5041 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
5042 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
5043
5044 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
5046 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
5047 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
5048 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
5049 }
5050 _ => panic!("Expected ItemDetail::Repo"),
5051 }
5052
5053 let _ = std::fs::remove_dir_all(&temp_path);
5055 }
5056
5057 #[test]
5058 fn test_stage_new_and_deleted_files() {
5059 let mut temp_path = std::env::temp_dir();
5060 temp_path.push(format!(
5061 "twig_test_new_del_{}",
5062 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5063 ));
5064 std::fs::create_dir_all(&temp_path).unwrap();
5065
5066 let repo = Repository::init(&temp_path).unwrap();
5068
5069 let mut config = repo.config().unwrap();
5071 config.set_str("user.name", "Test User").unwrap();
5072 config.set_str("user.email", "test@example.com").unwrap();
5073
5074 let init_file = temp_path.join("init.txt");
5076 std::fs::write(&init_file, "initial").unwrap();
5077 stage_file(&temp_path, "init.txt").unwrap();
5078 commit_changes(&temp_path, "initial commit").unwrap();
5079
5080 let untracked_file = temp_path.join("untracked.txt");
5082 std::fs::write(&untracked_file, "new file content").unwrap();
5083
5084 stage_file(&temp_path, "untracked.txt").unwrap();
5086
5087 std::fs::remove_file(&init_file).unwrap();
5089
5090 stage_file(&temp_path, "init.txt").unwrap();
5092
5093 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5095 match detail {
5096 ItemDetail::Repo { info, .. } => {
5097 assert_eq!(info.changes.staged.len(), 2);
5099 let paths: Vec<String> =
5100 info.changes.staged.iter().map(|f| f.path.clone()).collect();
5101 assert!(paths.contains(&"untracked.txt".to_string()));
5102 assert!(paths.contains(&"init.txt".to_string()));
5103 }
5104 _ => panic!("Expected ItemDetail::Repo"),
5105 }
5106
5107 let _ = std::fs::remove_dir_all(&temp_path);
5109 }
5110
5111 #[test]
5112 fn test_discard_file_changes_all_cases() {
5113 let mut temp_path = std::env::temp_dir();
5114 temp_path.push(format!(
5115 "twig_test_discard_all_{}",
5116 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5117 ));
5118 std::fs::create_dir_all(&temp_path).unwrap();
5119
5120 let repo = Repository::init(&temp_path).unwrap();
5122
5123 let mut config = repo.config().unwrap();
5125 config.set_str("user.name", "Test User").unwrap();
5126 config.set_str("user.email", "test@example.com").unwrap();
5127
5128 let file_tracked = temp_path.join("tracked.txt");
5130 std::fs::write(&file_tracked, "original content\n").unwrap();
5131 stage_file(&temp_path, "tracked.txt").unwrap();
5132 commit_changes(&temp_path, "initial commit").unwrap();
5133
5134 let file_untracked = temp_path.join("untracked.txt");
5136 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
5137 assert!(file_untracked.exists());
5138 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
5139 assert!(!file_untracked.exists());
5140
5141 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
5143 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
5144 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5145
5146 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
5148 stage_file(&temp_path, "tracked.txt").unwrap();
5149 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5151 match detail {
5152 ItemDetail::Repo { info, .. } => {
5153 assert!(!info.changes.staged.is_empty());
5154 }
5155 _ => panic!("Expected ItemDetail::Repo"),
5156 }
5157 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
5158 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5159 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
5161 match detail {
5162 ItemDetail::Repo { info, .. } => {
5163 assert!(info.changes.staged.is_empty());
5164 assert!(info.changes.unstaged.is_empty());
5165 }
5166 _ => panic!("Expected ItemDetail::Repo"),
5167 }
5168
5169 std::fs::remove_file(&file_tracked).unwrap();
5171 assert!(!file_tracked.exists());
5172 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
5173 assert!(file_tracked.exists());
5174 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
5175
5176 let _ = std::fs::remove_dir_all(&temp_path);
5178 }
5179
5180 #[test]
5181 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5182 fn test_stage_unstage_by_hunk() {
5183 let mut temp_path = std::env::temp_dir();
5184 temp_path.push(format!(
5185 "twig_test_hunk_{}",
5186 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5187 ));
5188 std::fs::create_dir_all(&temp_path).unwrap();
5189
5190 let repo = Repository::init(&temp_path).unwrap();
5192
5193 let mut config = repo.config().unwrap();
5195 config.set_str("user.name", "Test User").unwrap();
5196 config.set_str("user.email", "test@example.com").unwrap();
5197
5198 let file_path = temp_path.join("multihunk.txt");
5200 let mut file = File::create(&file_path).unwrap();
5201 for i in 1..=20 {
5202 writeln!(file, "Line {}", i).unwrap();
5203 }
5204 drop(file);
5205
5206 stage_file(&temp_path, "multihunk.txt").unwrap();
5208 commit_changes(&temp_path, "initial commit").unwrap();
5209
5210 let mut file = File::create(&file_path).unwrap();
5212 for i in 1..=20 {
5213 if i == 2 || i == 18 {
5214 writeln!(file, "Line {} modified", i).unwrap();
5215 } else {
5216 writeln!(file, "Line {}", i).unwrap();
5217 }
5218 }
5219 drop(file);
5220
5221 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
5223 let mut hunk_ranges = Vec::new();
5225 let mut current_start = None;
5226 for (i, line) in diff_lines.iter().enumerate() {
5227 if line.kind == DiffLineKind::Header {
5228 if let Some(start) = current_start {
5229 hunk_ranges.push(start..i);
5230 }
5231 current_start = Some(i);
5232 }
5233 }
5234 if let Some(start) = current_start {
5235 hunk_ranges.push(start..diff_lines.len());
5236 }
5237
5238 assert_eq!(hunk_ranges.len(), 2);
5240
5241 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
5243 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
5244
5245 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
5247 let staged_content: String =
5248 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
5249 assert!(staged_content.contains("Line 18 modified"));
5250 assert!(!staged_content.contains("Line 2 modified"));
5251
5252 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
5254 let unstaged_content: String =
5255 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
5256 assert!(unstaged_content.contains("Line 2 modified"));
5257 assert!(!unstaged_content.contains("Line 18 modified"));
5258
5259 let staged_hunk_ranges = {
5261 let mut ranges = Vec::new();
5262 let mut current_start = None;
5263 for (i, line) in staged_diff.iter().enumerate() {
5264 if line.kind == DiffLineKind::Header {
5265 if let Some(start) = current_start {
5266 ranges.push(start..i);
5267 }
5268 current_start = Some(i);
5269 }
5270 }
5271 if let Some(start) = current_start {
5272 ranges.push(start..staged_diff.len());
5273 }
5274 ranges
5275 };
5276 assert_eq!(staged_hunk_ranges.len(), 1);
5277 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
5278 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
5279
5280 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
5282 assert!(staged_diff_after.is_empty());
5283
5284 let _ = std::fs::remove_dir_all(&temp_path);
5286 }
5287
5288 #[test]
5289 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5290 fn test_discard_hunk() {
5291 let mut temp_path = std::env::temp_dir();
5292 temp_path.push(format!(
5293 "twig_test_discard_h_{}",
5294 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5295 ));
5296 std::fs::create_dir_all(&temp_path).unwrap();
5297
5298 let repo = Repository::init(&temp_path).unwrap();
5300
5301 let mut config = repo.config().unwrap();
5303 config.set_str("user.name", "Test User").unwrap();
5304 config.set_str("user.email", "test@example.com").unwrap();
5305
5306 let file_path = temp_path.join("discardhunk.txt");
5308 let mut file = File::create(&file_path).unwrap();
5309 for i in 1..=20 {
5310 writeln!(file, "Line {}", i).unwrap();
5311 }
5312 drop(file);
5313
5314 stage_file(&temp_path, "discardhunk.txt").unwrap();
5316 commit_changes(&temp_path, "initial commit").unwrap();
5317
5318 let mut file = File::create(&file_path).unwrap();
5320 for i in 1..=20 {
5321 if i == 2 || i == 18 {
5322 writeln!(file, "Line {} modified", i).unwrap();
5323 } else {
5324 writeln!(file, "Line {}", i).unwrap();
5325 }
5326 }
5327 drop(file);
5328
5329 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
5331 let mut hunk_ranges = Vec::new();
5333 let mut current_start = None;
5334 for (i, line) in diff_lines.iter().enumerate() {
5335 if line.kind == DiffLineKind::Header {
5336 if let Some(start) = current_start {
5337 hunk_ranges.push(start..i);
5338 }
5339 current_start = Some(i);
5340 }
5341 }
5342 if let Some(start) = current_start {
5343 hunk_ranges.push(start..diff_lines.len());
5344 }
5345
5346 assert_eq!(hunk_ranges.len(), 2);
5348
5349 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
5351 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
5352
5353 let contents = std::fs::read_to_string(&file_path).unwrap();
5355 assert!(contents.contains("Line 2 modified"));
5356 assert!(contents.contains("Line 18\n"));
5357 assert!(!contents.contains("Line 18 modified"));
5358
5359 let _ = std::fs::remove_dir_all(&temp_path);
5361 }
5362
5363 #[test]
5364 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5365 fn test_stage_unstage_discard_line() {
5366 let mut temp_path = std::env::temp_dir();
5367 temp_path.push(format!(
5368 "twig_test_line_{}",
5369 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5370 ));
5371 std::fs::create_dir_all(&temp_path).unwrap();
5372
5373 let repo = Repository::init(&temp_path).unwrap();
5374 let mut config = repo.config().unwrap();
5375 config.set_str("user.name", "Test User").unwrap();
5376 config.set_str("user.email", "test@example.com").unwrap();
5377
5378 let file_path = temp_path.join("line_test.txt");
5380 let mut file = File::create(&file_path).unwrap();
5381 writeln!(file, "line A").unwrap();
5382 writeln!(file, "line B").unwrap();
5383 writeln!(file, "line C").unwrap();
5384 drop(file);
5385
5386 stage_file(&temp_path, "line_test.txt").unwrap();
5387 commit_changes(&temp_path, "initial").unwrap();
5388
5389 let mut file = File::create(&file_path).unwrap();
5391 writeln!(file, "line A modified").unwrap();
5392 writeln!(file, "line B").unwrap();
5393 writeln!(file, "line C modified").unwrap();
5394 drop(file);
5395
5396 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5397 let mut hunk_ranges = Vec::new();
5398 let mut current_start = None;
5399 for (i, line) in diff_lines.iter().enumerate() {
5400 if line.kind == DiffLineKind::Header {
5401 if let Some(start) = current_start {
5402 hunk_ranges.push(start..i);
5403 }
5404 current_start = Some(i);
5405 }
5406 }
5407 if let Some(start) = current_start {
5408 hunk_ranges.push(start..diff_lines.len());
5409 }
5410
5411 assert_eq!(hunk_ranges.len(), 1);
5412 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
5413
5414 assert_eq!(hunk0[2].content, "line A modified");
5415 assert_eq!(hunk0[5].content, "line C modified");
5416
5417 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
5419
5420 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
5422 assert!(
5423 staged_diff
5424 .iter()
5425 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
5426 );
5427 assert!(
5428 !staged_diff
5429 .iter()
5430 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
5431 );
5432
5433 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5435 assert!(
5436 !unstaged_diff
5437 .iter()
5438 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
5439 );
5440 assert!(
5441 unstaged_diff
5442 .iter()
5443 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
5444 );
5445
5446 assert_eq!(staged_diff[2].content, "line A modified");
5448 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
5449
5450 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
5452
5453 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5455 assert_eq!(unstaged_diff2[5].content, "line C modified");
5456 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
5457
5458 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
5459 let remove_idx = unstaged_diff3
5460 .iter()
5461 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
5462 .unwrap();
5463 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
5464
5465 let contents = std::fs::read_to_string(&file_path).unwrap();
5467 assert!(contents.contains("line A modified"));
5468 assert!(contents.contains("line B"));
5469 assert!(contents.contains("line C\n"));
5470 assert!(!contents.contains("line C modified"));
5471
5472 let _ = std::fs::remove_dir_all(&temp_path);
5474 }
5475
5476 #[test]
5477 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
5478 fn test_stage_unstage_discard_all_changes() {
5479 let mut temp_path = std::env::temp_dir();
5480 temp_path.push(format!(
5481 "twig_test_all_{}",
5482 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5483 ));
5484 std::fs::create_dir_all(&temp_path).unwrap();
5485
5486 let repo = Repository::init(&temp_path).unwrap();
5488
5489 let mut config = repo.config().unwrap();
5491 config.set_str("user.name", "Test User").unwrap();
5492 config.set_str("user.email", "test@example.com").unwrap();
5493
5494 let file_path = temp_path.join("tracked.txt");
5496 std::fs::write(&file_path, "original content\n").unwrap();
5497 stage_file(&temp_path, "tracked.txt").unwrap();
5498 commit_changes(&temp_path, "initial").unwrap();
5499
5500 std::fs::write(&file_path, "modified content\n").unwrap();
5502 let untracked_path = temp_path.join("untracked.txt");
5503 std::fs::write(&untracked_path, "untracked content\n").unwrap();
5504
5505 let status = repo.statuses(None).unwrap();
5507 assert_eq!(status.len(), 2);
5508
5509 stage_all_changes(&temp_path).unwrap();
5511
5512 let status = repo.statuses(None).unwrap();
5514 for entry in status.iter() {
5515 assert!(
5516 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
5517 );
5518 }
5519
5520 unstage_all_changes(&temp_path).unwrap();
5522
5523 let status = repo.statuses(None).unwrap();
5525 for entry in status.iter() {
5526 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
5527 }
5528
5529 discard_all_changes(&temp_path).unwrap();
5531
5532 let status = repo.statuses(None).unwrap();
5534 assert_eq!(status.len(), 0);
5535
5536 let contents = std::fs::read_to_string(&file_path).unwrap();
5538 assert_eq!(contents, "original content\n");
5539 assert!(!untracked_path.exists());
5540
5541 let _ = std::fs::remove_dir_all(&temp_path);
5543 }
5544
5545 #[test]
5546 fn test_merge_conflicts_flow() {
5547 let mut temp_path = std::env::temp_dir();
5548 temp_path.push(format!(
5549 "twig_test_conflict_{}",
5550 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5551 ));
5552 std::fs::create_dir_all(&temp_path).unwrap();
5553
5554 let repo = Repository::init(&temp_path).unwrap();
5556
5557 let mut config = repo.config().unwrap();
5559 config.set_str("user.name", "Test User").unwrap();
5560 config.set_str("user.email", "test@example.com").unwrap();
5561
5562 let file_path = temp_path.join("conflict.txt");
5564 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
5565 stage_file(&temp_path, "conflict.txt").unwrap();
5566 commit_changes(&temp_path, "initial commit").unwrap();
5567
5568 let output = git_command()
5570 .args(["symbolic-ref", "--short", "HEAD"])
5571 .current_dir(&temp_path)
5572 .output()
5573 .unwrap();
5574 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5575
5576 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5578
5579 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
5580 stage_file(&temp_path, "conflict.txt").unwrap();
5581 commit_changes(&temp_path, "feature commit").unwrap();
5582
5583 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5585
5586 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
5587 stage_file(&temp_path, "conflict.txt").unwrap();
5588 commit_changes(&temp_path, "main commit").unwrap();
5589
5590 assert!(!is_merging(&temp_path));
5592 let merge_output =
5593 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5594
5595 assert!(!merge_output.status.success());
5596 assert!(is_merging(&temp_path));
5597
5598 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
5600 assert!(!diff.is_empty());
5601 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
5602 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
5603 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
5604 assert!(has_separator);
5605 assert!(has_ours);
5606 assert!(has_theirs);
5607
5608 abort_merge(&temp_path).unwrap();
5610 assert!(!is_merging(&temp_path));
5611
5612 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5614 assert!(is_merging(&temp_path));
5615
5616 resolve_ours(&temp_path, "conflict.txt").unwrap();
5618 let contents = std::fs::read_to_string(&file_path).unwrap();
5619 assert!(contents.contains("line 2 on main"));
5620 assert!(!contents.contains("<<<<<<<"));
5621
5622 continue_merge(&temp_path).unwrap();
5624 assert!(!is_merging(&temp_path));
5625
5626 git_command().args(["reset", "--hard", "HEAD~1"]).current_dir(&temp_path).output().unwrap();
5628
5629 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5630 assert!(is_merging(&temp_path));
5631
5632 resolve_theirs(&temp_path, "conflict.txt").unwrap();
5633 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
5634 assert!(contents_theirs.contains("line 2 on feature"));
5635 assert!(!contents_theirs.contains("<<<<<<<"));
5636
5637 continue_merge(&temp_path).unwrap();
5638 assert!(!is_merging(&temp_path));
5639
5640 let _ = std::fs::remove_dir_all(&temp_path);
5642 }
5643
5644 #[test]
5645 fn test_resolve_conflict_hunk() {
5646 let mut temp_path = std::env::temp_dir();
5647 temp_path.push(format!(
5648 "twig_test_hunk_conflict_{}",
5649 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5650 ));
5651 std::fs::create_dir_all(&temp_path).unwrap();
5652
5653 let repo = Repository::init(&temp_path).unwrap();
5655
5656 let mut config = repo.config().unwrap();
5658 config.set_str("user.name", "Test User").unwrap();
5659 config.set_str("user.email", "test@example.com").unwrap();
5660
5661 let file_path = temp_path.join("conflict.txt");
5663 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";
5664 std::fs::write(&file_path, initial_lines).unwrap();
5665 stage_file(&temp_path, "conflict.txt").unwrap();
5666 commit_changes(&temp_path, "initial commit").unwrap();
5667
5668 let output = git_command()
5670 .args(["symbolic-ref", "--short", "HEAD"])
5671 .current_dir(&temp_path)
5672 .output()
5673 .unwrap();
5674 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5675
5676 git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5678 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";
5679 std::fs::write(&file_path, feature_lines).unwrap();
5680 stage_file(&temp_path, "conflict.txt").unwrap();
5681 commit_changes(&temp_path, "feature commit").unwrap();
5682
5683 git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5685 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";
5686 std::fs::write(&file_path, main_lines).unwrap();
5687 stage_file(&temp_path, "conflict.txt").unwrap();
5688 commit_changes(&temp_path, "main commit").unwrap();
5689
5690 let merge_output =
5692 git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5693 assert!(!merge_output.status.success());
5694 assert!(is_merging(&temp_path));
5695
5696 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
5698 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
5699 assert!(contents_after_first.contains("line 2 on main"));
5701 assert!(!contents_after_first.contains("line 2 on feature"));
5702 assert!(contents_after_first.contains("<<<<<<<"));
5704 assert!(contents_after_first.contains("line 11 on main"));
5705 assert!(contents_after_first.contains("line 11 on feature"));
5706
5707 assert!(is_merging(&temp_path));
5709
5710 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
5715 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
5716 assert!(contents_after_second.contains("line 2 on main"));
5718 assert!(contents_after_second.contains("line 11 on feature"));
5719 assert!(!contents_after_second.contains("<<<<<<<"));
5720
5721 let status = repo.statuses(None).unwrap();
5723 assert_eq!(status.len(), 1);
5724 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
5725
5726 continue_merge(&temp_path).unwrap();
5728 assert!(!is_merging(&temp_path));
5729
5730 let _ = std::fs::remove_dir_all(&temp_path);
5732 }
5733
5734 #[test]
5735 fn test_branch_and_commit_helpers() {
5736 let mut temp_path = std::env::temp_dir();
5737 temp_path.push(format!(
5738 "twig_test_helpers_{}",
5739 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5740 ));
5741 let _ = std::fs::remove_dir_all(&temp_path);
5742 std::fs::create_dir_all(&temp_path).unwrap();
5743
5744 let repo = Repository::init(&temp_path).unwrap();
5746
5747 let mut config = repo.config().unwrap();
5749 config.set_str("user.name", "Test User").unwrap();
5750 config.set_str("user.email", "test@example.com").unwrap();
5751
5752 let file_path = temp_path.join("test.txt");
5754 std::fs::write(&file_path, "root content").unwrap();
5755 stage_file(&temp_path, "test.txt").unwrap();
5756 commit_changes(&temp_path, "root commit").unwrap();
5757
5758 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5759 assert!(is_root_commit(&temp_path, &head_oid));
5760
5761 std::fs::write(&file_path, "second content").unwrap();
5763 stage_file(&temp_path, "test.txt").unwrap();
5764 commit_changes(&temp_path, "second commit").unwrap();
5765
5766 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
5767 assert!(!is_root_commit(&temp_path, &new_head_oid));
5768
5769 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
5774 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
5775 "master"
5776 } else {
5777 "main"
5778 };
5779 let target = get_branch_push_target(&temp_path, active_branch);
5780 assert!(target.is_some());
5781 let (remote_name, set_upstream) = target.unwrap();
5782 assert_eq!(remote_name, "origin");
5783 assert!(set_upstream);
5784
5785 let mut git_config = repo.config().unwrap();
5787 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5788 git_config
5789 .set_str(
5790 &format!("branch.{}.merge", active_branch),
5791 &format!("refs/heads/{}", active_branch),
5792 )
5793 .unwrap();
5794
5795 let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5797 repo.reference(
5798 &format!("refs/remotes/origin/{}", active_branch),
5799 commit_obj.id(),
5800 true,
5801 "create mock remote ref",
5802 )
5803 .unwrap();
5804
5805 assert!(has_upstream_remote(&temp_path, active_branch));
5807 assert_eq!(
5808 get_branch_upstream_remote(&temp_path, active_branch),
5809 Some("origin".to_string())
5810 );
5811
5812 let target_tracking = get_branch_push_target(&temp_path, active_branch);
5814 assert!(target_tracking.is_some());
5815 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5816 assert_eq!(remote_name_tr, "origin");
5817 assert!(!set_upstream_tr);
5818
5819 let _ = std::fs::remove_dir_all(&temp_path);
5821 }
5822
5823 #[test]
5824 fn test_core_helpers_comprehensive() {
5825 assert_eq!(format_relative_time(0), "unknown");
5827 assert_eq!(format_relative_time(-100), "unknown");
5828
5829 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5830 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5831 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5832 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5833 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5834 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5835 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5836
5837 assert_eq!(format_utc_date(0), "unknown");
5839 assert_eq!(format_utc_date(-1), "unknown");
5840 let date_str = format_utc_date(1700000000);
5841 assert!(date_str.contains("2023-11-14"));
5842
5843 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5845 let tilde_path = expand_tilde("~/path");
5846 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5847
5848 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5850 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5851 assert_eq!(sanitize_text("M4 — UAS"), "M4 — UAS");
5852 assert_eq!(
5853 sanitize_text("feat: support utf-8 — – … 🚀 日本語"),
5854 "feat: support utf-8 — – … 🚀 日本語"
5855 );
5856
5857 assert_eq!(safe_sha_slice("12345", 10), "12345");
5859 assert_eq!(safe_sha_slice("12345", 3), "123");
5860
5861 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
5863 assert!(safe_ref("-invalid").is_err());
5864 assert!(safe_ref(" ").is_err());
5865
5866 let mut status = RepoSummary::default();
5868 assert!(status.is_clean());
5869 assert!(status.is_synced());
5870 assert!(status.unchanged());
5871
5872 status.modified = 1;
5873 assert!(!status.is_clean());
5874 assert!(!status.unchanged());
5875
5876 status.modified = 0;
5877 status.ahead = 1;
5878 assert!(!status.is_synced());
5879 assert!(!status.unchanged());
5880
5881 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5883 assert!(data_not_loaded.is_not_loaded());
5884 assert!(!data_not_loaded.is_loading());
5885 assert!(!data_not_loaded.is_loaded());
5886 assert_eq!(data_not_loaded.len(), 0);
5887 assert!(data_not_loaded.is_empty());
5888 assert!(data_not_loaded.as_ref().is_none());
5889 assert!(data_not_loaded.first().is_none());
5890 assert!(data_not_loaded.get(0).is_none());
5891 assert_eq!(data_not_loaded.iter().count(), 0);
5892 assert_eq!(data_not_loaded.as_slice().len(), 0);
5893
5894 let data_loading: TabData<Vec<i32>> = TabData::Loading;
5895 assert!(!data_loading.is_not_loaded());
5896 assert!(data_loading.is_loading());
5897
5898 let data_loaded = TabData::Loaded(vec![10, 20]);
5899 assert!(!data_loaded.is_not_loaded());
5900 assert!(!data_loaded.is_loading());
5901 assert!(data_loaded.is_loaded());
5902 assert_eq!(data_loaded.len(), 2);
5903 assert!(!data_loaded.is_empty());
5904 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
5905 assert_eq!(data_loaded.first().unwrap(), &10);
5906 assert_eq!(data_loaded.get(1).unwrap(), &20);
5907 assert_eq!(data_loaded.iter().count(), 2);
5908 assert_eq!(data_loaded.as_slice(), &[10, 20]);
5909 }
5910
5911 #[test]
5912 fn test_comprehensive_gitwig_core_coverage_booster() {
5913 let mut temp_path = std::env::temp_dir();
5915 temp_path.push(format!(
5916 "twig_test_boost_{}",
5917 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5918 ));
5919 std::fs::create_dir_all(&temp_path).unwrap();
5920
5921 let repo = Repository::init(&temp_path).unwrap();
5922
5923 {
5925 let mut config = repo.config().unwrap();
5926 config.set_str("user.name", "User A").unwrap();
5927 config.set_str("user.email", "a@example.com").unwrap();
5928 std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
5929 stage_file(&temp_path, "file1.txt").unwrap();
5930 commit_changes(&temp_path, "commit A").unwrap();
5931 }
5932
5933 {
5935 let mut config = repo.config().unwrap();
5936 config.set_str("user.name", "User B").unwrap();
5937 config.set_str("user.email", "b@example.com").unwrap();
5938 std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
5939 stage_file(&temp_path, "file2.txt").unwrap();
5940 commit_changes(&temp_path, "commit B").unwrap();
5941 }
5942
5943 let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
5945 assert_eq!(stats.len(), 2);
5946 assert_eq!(stats[0].count, 1);
5947 assert_eq!(stats[1].count, 1);
5948
5949 std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
5952 stage_file(&temp_path, "file1.txt").unwrap();
5953 commit_changes(&temp_path, "deleted file1").unwrap();
5954 let head_oid = repo.head().unwrap().target().unwrap().to_string();
5955 let files = get_commit_files(&temp_path, &head_oid).unwrap();
5956 assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
5957
5958 let path_tilde = expand_tilde("~");
5960 assert!(path_tilde.is_absolute());
5961
5962 let lfs_dir = temp_path.join(".git").join("lfs");
5964 std::fs::create_dir_all(&lfs_dir).unwrap();
5965 let nested_dir = lfs_dir.join("objects").join("xx");
5966 std::fs::create_dir_all(&nested_dir).unwrap();
5967 std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
5968 let lfs_size = get_lfs_storage_size(&temp_path);
5969 assert_eq!(lfs_size, Some(5));
5970
5971 let mut sub = repo
5973 .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
5974 .unwrap();
5975 let _ = sub.init(false);
5976 let subs = load_tab_submodules(&temp_path).unwrap();
5977 assert!(!subs.is_empty());
5978
5979 let wt_path = temp_path.join("wt-booster");
5981 let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
5982 let _ = worktree_lock(&temp_path, &wt_path, "lock reason");
5983 let _ = worktree_unlock(&temp_path, &wt_path);
5984 let _ = worktree_prune(&temp_path);
5985 let _ = std::fs::remove_dir_all(&wt_path);
5986
5987 open_browser("");
5989
5990 let nonexistent = Path::new("/nonexistent_directory_twig_test");
5992 let _ = stage_file(nonexistent, "test.txt");
5993 let _ = unstage_file(nonexistent, "test.txt");
5994 let _ = discard_all_changes(nonexistent);
5995 let _ = discard_file_changes(nonexistent, "test.txt", false);
5996 let _ = commit_changes(nonexistent, "msg");
5997 let _ = remote_add(nonexistent, "origin", "url");
5998 let _ = get_file_history(nonexistent, "test.txt");
5999 let _ = get_file_blame(nonexistent, "test.txt");
6000 let _ = get_commit_files(nonexistent, "sha");
6001 let _ = load_tab_reflog(nonexistent);
6002 let _ = load_tab_files(nonexistent);
6003 let _ = load_tab_branches(nonexistent);
6004 let _ = load_tab_tags(nonexistent);
6005 let _ = load_tab_remotes(nonexistent);
6006 let _ = load_tab_stashes(nonexistent);
6007 let _ = load_tab_overview(nonexistent, 10);
6008 let _ = load_tab_submodules(nonexistent);
6009 let _ = checkout_remote_branch(nonexistent, "origin/branch");
6010 let _ = checkout_local_branch(nonexistent, "branch");
6011 let _ = checkout_tag(nonexistent, "tag");
6012 let _ = delete_local_branch(nonexistent, "branch");
6013 let _ = delete_remote_branch(nonexistent, "origin/branch");
6014 let _ = create_tag(nonexistent, "tag", "sha", None, false);
6015 let _ = delete_remote_tag(nonexistent, "origin", "tag", Duration::from_secs(5));
6016 let _ = get_remote_tags(nonexistent, "origin", Duration::from_secs(5));
6017 let _ = apply_stash(nonexistent, 0);
6018 let _ = delete_stash(nonexistent, 0);
6019 let _ = save_stash(nonexistent, "msg", false, false);
6020 let _ = commit_amend(nonexistent, "msg");
6021 let _ = resolve_ours(nonexistent, "file");
6022 let _ = resolve_theirs(nonexistent, "file");
6023 let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
6024 let _ = abort_merge(nonexistent);
6025 let _ = continue_merge(nonexistent);
6026 let _ = get_branch_upstream_remote(nonexistent, "branch");
6027 let _ = load_tab_worktrees(nonexistent);
6028 let _ = worktree_add(nonexistent, "branch", Path::new("path"));
6029 let _ = worktree_lock(nonexistent, Path::new("path"), "reason");
6030 let _ = worktree_unlock(nonexistent, Path::new("path"));
6031 let _ = worktree_remove(nonexistent, Path::new("path"), true);
6032 let _ = submodule_remove(nonexistent, "name", Path::new("path"));
6033 let _ = worktree_prune(nonexistent);
6034 let _ = load_tab_forge_issues(nonexistent, true);
6035 let _ = load_tab_forge_prs(nonexistent);
6036 let _ = load_pr_comments(nonexistent, 1);
6037 let _ = checkout_pr_branch(nonexistent, 1);
6038 let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
6039 let _ = get_remote_url(nonexistent, "origin");
6040 let _ = get_default_remote_url(nonexistent);
6041 let _ = get_latest_change_time("/nonexistent");
6042
6043 let dummy_hunk = vec![
6044 DiffLine {
6045 kind: DiffLineKind::Header,
6046 content: "@@ -1,1 +1,1 @@".to_string(),
6047 old_lineno: None,
6048 new_lineno: None,
6049 hunk_idx: None,
6050 },
6051 DiffLine {
6052 kind: DiffLineKind::Added,
6053 content: "line".to_string(),
6054 old_lineno: None,
6055 new_lineno: None,
6056 hunk_idx: None,
6057 },
6058 ];
6059 let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
6060 let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
6061 let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
6062 let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
6063 let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
6064 let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
6065
6066 let _ = std::fs::remove_dir_all(&temp_path);
6068 }
6069}