1#![deny(unsafe_code)]
12#![deny(unused_imports, unused_must_use, dead_code, unused_assignments)]
13#![deny(clippy::all, clippy::perf)]
14#![allow(clippy::collapsible_if, clippy::collapsible_else_if)]
15#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::panic))]
16
17use std::path::{Path, PathBuf};
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use git2::{Repository, StatusOptions, StatusShow};
21
22#[derive(Debug, Clone)]
29pub enum ItemStatus {
30 Missing,
31 Directory,
32 GitRepo(Option<RepoSummary>),
33}
34
35#[derive(Debug, Default, Clone)]
38pub struct RepoSummary {
39 pub branch: Option<String>,
42 pub staged: usize,
43 pub modified: usize,
44 pub untracked: usize,
45 pub conflicted: usize,
46 pub ahead: usize,
47 pub behind: usize,
48}
49
50impl RepoSummary {
51 pub fn is_clean(&self) -> bool {
52 self.staged + self.modified + self.untracked + self.conflicted == 0
53 }
54 pub fn is_synced(&self) -> bool {
55 self.ahead + self.behind == 0
56 }
57 pub fn unchanged(&self) -> bool {
58 self.is_clean() && self.is_synced()
59 }
60}
61
62#[derive(Debug, Clone)]
65pub enum ItemDetail {
66 Missing { resolved: PathBuf },
67 Directory { resolved: PathBuf },
68 Repo { resolved: PathBuf, info: Box<RepoInfo> },
69 Error { resolved: PathBuf, message: String },
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct BranchInfo {
74 pub name: String,
75 pub is_head: bool,
76 pub short_sha: String,
77 pub short_message: String,
78}
79
80#[derive(Debug, Clone, Default)]
81pub struct FileRevision {
82 pub commit_oid: String,
83 pub author: String,
84 pub date: String,
85 pub when: String,
86 pub summary: String,
87}
88
89#[derive(Debug, Clone, Default)]
90pub struct StashInfo {
91 pub index: usize,
92 pub message: String,
93 pub commit_id: String,
94 pub files: Vec<FileEntry>,
95}
96
97#[derive(Debug, Clone, Default)]
98pub struct CommitterStat {
99 pub name: String,
100 pub email: String,
101 pub count: usize,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub enum TabData<T> {
106 #[default]
107 NotLoaded,
108 Loading,
109 Loaded(T),
110 Error(String),
111}
112
113impl<T> TabData<T> {
114 pub fn is_not_loaded(&self) -> bool {
115 matches!(self, TabData::NotLoaded)
116 }
117 pub fn is_loading(&self) -> bool {
118 matches!(self, TabData::Loading)
119 }
120 #[allow(dead_code)]
121 pub fn is_loaded(&self) -> bool {
122 matches!(self, TabData::Loaded(_))
123 }
124 pub fn as_ref(&self) -> Option<&T> {
125 match self {
126 TabData::Loaded(val) => Some(val),
127 _ => None,
128 }
129 }
130}
131
132impl<T> TabData<Vec<T>> {
133 pub fn len(&self) -> usize {
134 self.as_ref().map(|v| v.len()).unwrap_or(0)
135 }
136 pub fn is_empty(&self) -> bool {
137 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
138 }
139 pub fn first(&self) -> Option<&T> {
140 self.as_ref().and_then(|v| v.first())
141 }
142 pub fn get(&self, index: usize) -> Option<&T> {
143 self.as_ref().and_then(|v| v.get(index))
144 }
145 pub fn iter(&self) -> std::slice::Iter<'_, T> {
146 match self {
147 TabData::Loaded(v) => v.iter(),
148 _ => [].iter(),
149 }
150 }
151 pub fn as_slice(&self) -> &[T] {
152 match self {
153 TabData::Loaded(v) => v.as_slice(),
154 _ => &[],
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq)]
160pub struct WorktreeInfo {
161 pub name: String,
162 pub path: PathBuf,
163 pub branch: Option<String>,
164 pub is_locked: bool,
165 pub lock_reason: Option<String>,
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq)]
169pub struct SubmoduleInfo {
170 pub name: String,
171 pub path: PathBuf,
172 pub url: String,
173 pub commit_id: Option<String>,
174 pub head_id: Option<String>,
175 pub is_initialized: bool,
176 pub is_dirty: bool,
177}
178
179#[derive(Debug, Clone)]
180pub enum TabPayload {
181 Files(Result<Vec<String>, String>),
182 Graph(Result<Vec<GraphLine>, String>),
183 Branches { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
184 Tags { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
185 Remotes(Result<Vec<RemoteInfo>, String>),
186 Stashes(Result<Vec<StashInfo>, String>),
187 Overview(Result<(Vec<CommitterStat>, bool), String>),
188 Worktrees(Result<Vec<WorktreeInfo>, String>),
189 Submodules(Result<Vec<SubmoduleInfo>, String>),
190}
191
192#[derive(Debug, Default, Clone)]
193pub struct RepoInfo {
194 pub branch: Option<String>,
195 pub head: Option<HeadInfo>,
196 pub remotes: TabData<Vec<RemoteInfo>>,
197 pub upstream: Option<String>,
199 pub summary: RepoSummary,
200 pub changes: WorktreeChanges,
202 pub commits: Vec<CommitEntry>,
204 pub graph_lines: TabData<Vec<GraphLine>>,
206 pub local_branches: TabData<Vec<BranchInfo>>,
208 pub remote_branches: TabData<Vec<BranchInfo>>,
210 pub local_tags: TabData<Vec<BranchInfo>>,
212 pub remote_tags: TabData<Vec<BranchInfo>>,
214 pub remote_tags_loaded: bool,
216 pub remote_tags_attempted: bool,
218 pub files: TabData<Vec<String>>,
220 pub stashes: TabData<Vec<StashInfo>>,
222 pub worktrees: TabData<Vec<WorktreeInfo>>,
224 pub submodules: TabData<Vec<SubmoduleInfo>>,
226 pub committer_stats: TabData<Vec<CommitterStat>>,
228 pub committer_stats_limit_reached: bool,
230 pub tab_loaded_at: [Option<std::time::Instant>; 9],
232 pub tab_loading: [bool; 9],
234}
235
236#[derive(Debug, Clone)]
237pub struct HeadInfo {
238 pub short_id: String,
239 pub summary: String,
240 pub author: String,
241 pub when: String,
242}
243
244#[derive(Debug, Clone)]
245pub struct RemoteInfo {
246 pub name: String,
247 pub url: String,
248 pub push_url: Option<String>,
249 pub refspecs: Vec<String>,
250}
251
252#[derive(Debug, Clone)]
253pub struct CommitEntry {
254 pub id: String,
256 pub oid: String,
258 pub author: String,
259 pub when: String,
260 pub date: String,
261 pub summary: String,
262 pub message: String,
263 pub refs: Vec<String>,
266 pub files: Vec<FileEntry>,
268 pub signature_status: String,
270}
271
272#[derive(Debug, Clone)]
273pub struct GraphLine {
274 pub graph: String,
275 pub commit: Option<GraphCommit>,
276}
277
278#[derive(Debug, Clone)]
279pub struct GraphCommit {
280 pub oid: String,
281 pub decoration: String,
282 pub summary: String,
283 pub author: String,
284 pub date: String,
285 pub signature_status: String,
287}
288
289#[derive(Debug, Clone)]
291pub struct FileEntry {
292 pub path: String,
294 pub label: &'static str,
296}
297
298#[derive(Debug, Default, Clone)]
301pub struct WorktreeChanges {
302 pub staged: Vec<FileEntry>,
303 pub unstaged: Vec<FileEntry>,
304 pub untracked: Vec<FileEntry>,
305 pub conflicted: Vec<FileEntry>,
306}
307
308#[derive(Debug, Clone, PartialEq)]
312pub enum DiffLineKind {
313 Header,
315 Added,
317 Removed,
319 Context,
321 ConflictOurs,
323 ConflictTheirs,
325 ConflictSeparator,
327}
328
329#[derive(Debug, Clone)]
331pub struct DiffLine {
332 pub kind: DiffLineKind,
333 pub content: String,
335}
336
337pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
341 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
342}
343
344pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
351 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
352}
353
354pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
357 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
358 let mut index = repo.index().map_err(|e| e.to_string())?;
359 let full_path = repo_path.join(file_path);
360 if full_path.exists() {
361 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
362 } else {
363 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
364 }
365 index.write().map_err(|e| e.to_string())?;
366 Ok(())
367}
368
369pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
374 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
375 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
377 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
378 .map_err(|e| e.to_string())?;
379 } else {
380 let mut index = repo.index().map_err(|e| e.to_string())?;
382 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
383 index.write().map_err(|e| e.to_string())?;
384 }
385 Ok(())
386}
387
388pub fn sanitize_text(s: &str) -> String {
389 let mut result = String::with_capacity(s.len());
390 let bytes = s.as_bytes();
391 let mut i = 0;
392 while i < bytes.len() {
393 if bytes[i] == 0x1B {
394 i += 1;
396 if i < bytes.len() && bytes[i] == b'[' {
397 i += 1;
398 while i < bytes.len() {
399 let c = bytes[i];
400 i += 1;
401 if c.is_ascii_alphabetic() {
403 break;
404 }
405 }
406 } else {
407 while i < bytes.len() {
409 let c = bytes[i];
410 i += 1;
411 if c == b' ' || c.is_ascii_alphabetic() {
412 break;
413 }
414 }
415 }
416 } else {
417 let c = bytes[i];
418 if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
420 result.push(' ');
421 } else {
422 result.push(c as char);
423 }
424 i += 1;
425 }
426 }
427 result
428}
429
430pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
431 let mut end = len.min(sha.len());
432 while end > 0 && !sha.is_char_boundary(end) {
433 end -= 1;
434 }
435 &sha[..end]
436}
437
438fn ssh_command_val() -> &'static str {
439 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
440 "ssh -o StrictHostKeyChecking=yes"
441 } else {
442 "ssh -o StrictHostKeyChecking=accept-new"
443 }
444}
445
446fn git_command() -> std::process::Command {
447 let mut cmd = std::process::Command::new("git");
448 cmd.env("GIT_TERMINAL_PROMPT", "0");
449 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
450 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
451 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
452 cmd
453}
454
455pub fn safe_ref(r: &str) -> Result<&str, String> {
456 let trimmed = r.trim();
457 if trimmed.starts_with('-') {
458 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
459 }
460 if trimmed.is_empty() {
461 return Err("Ref name cannot be empty".to_string());
462 }
463 Ok(trimmed)
464}
465
466pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
468 let output = git_command()
469 .arg("add")
470 .arg("-A")
471 .current_dir(repo_path)
472 .output()
473 .map_err(|e| e.to_string())?;
474
475 if output.status.success() {
476 Ok(())
477 } else {
478 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
479 }
480}
481
482pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
484 let output =
485 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
486
487 if output.status.success() {
488 Ok(())
489 } else {
490 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
491 }
492}
493
494pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
496 let _ = git_command().arg("reset").current_dir(repo_path).output();
498
499 let checkout_out = git_command()
501 .arg("checkout")
502 .arg("--")
503 .arg(".")
504 .current_dir(repo_path)
505 .output()
506 .map_err(|e| e.to_string())?;
507
508 if !checkout_out.status.success() {
509 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
510 }
511
512 let clean_out = git_command()
514 .arg("clean")
515 .arg("-fd")
516 .current_dir(repo_path)
517 .output()
518 .map_err(|e| e.to_string())?;
519
520 if !clean_out.status.success() {
521 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
522 }
523
524 Ok(())
525}
526
527pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
529 apply_hunk_patch(repo_path, file_path, hunk, false, true)
530}
531
532pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
534 apply_hunk_patch(repo_path, file_path, hunk, true, true)
535}
536
537pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
539 apply_hunk_patch(repo_path, file_path, hunk, true, false)
540}
541
542pub fn stage_line(
544 repo_path: &Path,
545 file_path: &str,
546 hunk: &[DiffLine],
547 selected_line_idx: usize,
548) -> Result<(), String> {
549 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
550}
551
552pub fn unstage_line(
554 repo_path: &Path,
555 file_path: &str,
556 hunk: &[DiffLine],
557 selected_line_idx: usize,
558) -> Result<(), String> {
559 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
560}
561
562pub fn discard_line(
564 repo_path: &Path,
565 file_path: &str,
566 hunk: &[DiffLine],
567 selected_line_idx: usize,
568) -> Result<(), String> {
569 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
570}
571
572fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
573 if !header.starts_with("@@") {
574 return None;
575 }
576 let parts: Vec<&str> = header.split("@@").collect();
577 if parts.len() < 3 {
578 return None;
579 }
580 let meta = parts[1].trim();
581 let subparts: Vec<&str> = meta.split_whitespace().collect();
582 if subparts.len() < 2 {
583 return None;
584 }
585
586 let parse_part = |p: &str| -> (usize, usize) {
587 let s = p.trim_start_matches(['-', '+']);
588 let comps: Vec<&str> = s.split(',').collect();
589 let start = comps[0].parse::<usize>().unwrap_or(0);
590 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
591 (start, count)
592 };
593
594 let (old_start, old_count) = parse_part(subparts[0]);
595 let (new_start, new_count) = parse_part(subparts[1]);
596 Some((old_start, old_count, new_start, new_count))
597}
598
599fn apply_line_patch_inner(
600 repo_path: &Path,
601 file_path: &str,
602 hunk: &[DiffLine],
603 selected_line_idx_in_hunk: usize,
604 revert: bool,
605 target_has_modification: bool,
606 cached: bool,
607) -> Result<(), String> {
608 use std::io::Write;
609 use std::process::{Command, Stdio};
610
611 let path_check = std::path::Path::new(file_path);
612 if path_check.is_absolute()
613 || path_check.components().any(|c| c == std::path::Component::ParentDir)
614 {
615 return Err("Path escapes repository root".to_string());
616 }
617
618 if hunk.is_empty() {
619 return Err("Empty hunk".to_string());
620 }
621
622 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
623 Some(line) => line,
624 None => return Err("Invalid line index".to_string()),
625 };
626
627 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
628 return Err("Selected line is not a modification (must be + or -)".to_string());
629 }
630
631 let header_line = &hunk[0];
632 let (old_start, _old_count, new_start, _new_count) =
633 match parse_hunk_header(&header_line.content) {
634 Some(coords) => coords,
635 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
636 };
637
638 let mut patch_lines = Vec::new();
639 let mut new_old_count = 0;
640 let mut new_new_count = 0;
641
642 for (i, line) in hunk.iter().enumerate() {
643 if i == 0 {
644 continue;
645 }
646
647 if i == selected_line_idx_in_hunk {
648 if revert {
649 match line.kind {
650 DiffLineKind::Added => {
651 patch_lines.push(DiffLine {
652 kind: DiffLineKind::Removed,
653 content: line.content.clone(),
654 });
655 new_old_count += 1;
656 }
657 DiffLineKind::Removed => {
658 patch_lines.push(DiffLine {
659 kind: DiffLineKind::Added,
660 content: line.content.clone(),
661 });
662 new_new_count += 1;
663 }
664 _ => {}
665 }
666 } else {
667 match line.kind {
668 DiffLineKind::Added => {
669 patch_lines.push(DiffLine {
670 kind: DiffLineKind::Added,
671 content: line.content.clone(),
672 });
673 new_new_count += 1;
674 }
675 DiffLineKind::Removed => {
676 patch_lines.push(DiffLine {
677 kind: DiffLineKind::Removed,
678 content: line.content.clone(),
679 });
680 new_old_count += 1;
681 }
682 _ => {}
683 }
684 }
685 } else {
686 match line.kind {
687 DiffLineKind::Context => {
688 patch_lines.push(line.clone());
689 new_old_count += 1;
690 new_new_count += 1;
691 }
692 DiffLineKind::Added => {
693 if target_has_modification {
694 patch_lines.push(DiffLine {
695 kind: DiffLineKind::Context,
696 content: line.content.clone(),
697 });
698 new_old_count += 1;
699 new_new_count += 1;
700 } else {
701 }
703 }
704 DiffLineKind::Removed => {
705 if target_has_modification {
706 } else {
708 patch_lines.push(DiffLine {
709 kind: DiffLineKind::Context,
710 content: line.content.clone(),
711 });
712 new_old_count += 1;
713 new_new_count += 1;
714 }
715 }
716 _ => {}
717 }
718 }
719 }
720
721 let mut patch = String::new();
722 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
723 patch.push_str(&format!("--- a/{}\n", file_path));
724 patch.push_str(&format!("+++ b/{}\n", file_path));
725 patch.push_str(&format!(
726 "@@ -{},{} +{},{} @@\n",
727 old_start, new_old_count, new_start, new_new_count
728 ));
729
730 for line in patch_lines {
731 let prefix = match line.kind {
732 DiffLineKind::Added => "+",
733 DiffLineKind::Removed => "-",
734 DiffLineKind::Context => " ",
735 DiffLineKind::Header => "",
736 _ => "",
737 };
738 patch.push_str(prefix);
739 patch.push_str(&line.content);
740 patch.push('\n');
741 }
742
743 let mut args = vec!["apply"];
744 if cached {
745 args.push("--cached");
746 }
747 args.push("-");
748
749 let mut cmd = Command::new("git");
750 let mut child = cmd
751 .env("GIT_TERMINAL_PROMPT", "0")
752 .env("GIT_SSH_COMMAND", ssh_command_val())
753 .args(&args)
754 .current_dir(repo_path)
755 .stdin(Stdio::piped())
756 .stdout(Stdio::piped())
757 .stderr(Stdio::piped())
758 .spawn()
759 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
760
761 if let Some(mut stdin) = child.stdin.take() {
762 stdin
763 .write_all(patch.as_bytes())
764 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
765 }
766
767 let output =
768 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
769
770 if !output.status.success() {
771 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
772 return Err(format!("git apply failed: {}", err_msg.trim()));
773 }
774
775 Ok(())
776}
777
778fn apply_hunk_patch(
779 repo_path: &Path,
780 file_path: &str,
781 hunk: &[DiffLine],
782 reverse: bool,
783 cached: bool,
784) -> Result<(), String> {
785 use std::io::Write;
786 use std::process::{Command, Stdio};
787
788 let path_check = std::path::Path::new(file_path);
789 if path_check.is_absolute()
790 || path_check.components().any(|c| c == std::path::Component::ParentDir)
791 {
792 return Err("Path escapes repository root".to_string());
793 }
794
795 let mut patch = String::new();
796 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
797 patch.push_str(&format!("--- a/{}\n", file_path));
798 patch.push_str(&format!("+++ b/{}\n", file_path));
799 for line in hunk {
800 let prefix = match line.kind {
801 DiffLineKind::Added => "+",
802 DiffLineKind::Removed => "-",
803 DiffLineKind::Context => " ",
804 DiffLineKind::Header => "",
805 _ => "",
806 };
807 patch.push_str(prefix);
808 patch.push_str(&line.content);
809 patch.push('\n');
810 }
811
812 let mut args = vec!["apply"];
813 if cached {
814 args.push("--cached");
815 }
816 if reverse {
817 args.push("--reverse");
818 }
819 args.push("-");
820
821 let mut cmd = Command::new("git");
822 let mut child = cmd
823 .env("GIT_TERMINAL_PROMPT", "0")
824 .env("GIT_SSH_COMMAND", ssh_command_val())
825 .args(&args)
826 .current_dir(repo_path)
827 .stdin(Stdio::piped())
828 .stdout(Stdio::piped())
829 .stderr(Stdio::piped())
830 .spawn()
831 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
832
833 if let Some(mut stdin) = child.stdin.take() {
834 stdin
835 .write_all(patch.as_bytes())
836 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
837 }
838
839 let output =
840 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
841
842 if !output.status.success() {
843 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
844 return Err(format!("git apply failed: {}", err_msg.trim()));
845 }
846
847 Ok(())
848}
849
850pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
855 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
856
857 if staged {
858 unstage_file(repo_path, file_path)?;
860 }
861
862 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
864 status.contains(git2::Status::WT_NEW)
865 } else {
866 false
867 };
868
869 if is_untracked {
870 let full_path = repo_path.join(file_path);
871 if full_path.exists() {
872 if full_path.is_file() {
873 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
874 } else if full_path.is_dir() {
875 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
876 }
877 }
878 } else {
879 let mut checkout_opts = git2::build::CheckoutBuilder::new();
881 checkout_opts.path(Path::new(file_path));
882 checkout_opts.force();
883 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
884 }
885
886 Ok(())
887}
888
889pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
892 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
893 let mut index = repo.index().map_err(|e| e.to_string())?;
894 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
895 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
896
897 let signature = repo
898 .signature()
899 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
900
901 let mut parents = Vec::new();
903 let mut has_head = false;
904 if let Ok(head) = repo.head() {
905 if let Ok(parent_commit) = head.peel_to_commit() {
906 has_head = true;
907 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
909 if parent_tree.id() == tree_id {
910 return Err("No staged changes to commit".to_string());
911 }
912 parents.push(parent_commit);
913 }
914 }
915
916 if !has_head && index.is_empty() {
917 return Err("No staged changes to commit (index is empty)".to_string());
918 }
919
920 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
921
922 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
923 .map_err(|e| e.to_string())?;
924
925 Ok(())
926}
927
928pub fn expand_tilde(s: &str) -> PathBuf {
934 if s == "~" {
935 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
936 }
937 if let Some(stripped) = s.strip_prefix("~/")
938 && let Some(home) = dirs::home_dir()
939 {
940 return home.join(stripped);
941 }
942 PathBuf::from(s)
943}
944
945pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
947 let repo = Repository::open(repo_path)?;
948 repo.remote(name, url)?;
949 Ok(())
950}
951
952pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
954 let repo = Repository::open(repo_path)?;
955 repo.remote_delete(name)?;
956 Ok(())
957}
958
959pub fn inspect_summary(item: &str) -> ItemStatus {
961 let path = expand_tilde(item);
962 if !path.is_dir() {
963 return ItemStatus::Missing;
964 }
965 if !path.join(".git").exists() {
966 return ItemStatus::Directory;
967 }
968 match Repository::open(&path) {
969 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
970 Err(_) => ItemStatus::GitRepo(None),
971 }
972}
973
974pub fn inspect_detail(
976 item: &str,
977 commit_limit: usize,
978 graph_max_commits: usize,
979 enable_commit_signatures: bool,
980) -> ItemDetail {
981 let resolved = expand_tilde(item);
982 if !resolved.is_dir() {
983 return ItemDetail::Missing { resolved };
984 }
985 if !resolved.join(".git").exists() {
986 return ItemDetail::Directory { resolved };
987 }
988 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
989 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
990 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
991 }
992}
993
994fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
995 let mut sigs = std::collections::HashMap::new();
996 let mut cmd = std::process::Command::new("git");
997 cmd.env("GIT_TERMINAL_PROMPT", "0")
998 .env("GIT_SSH_COMMAND", ssh_command_val())
999 .arg("log")
1000 .arg("--all");
1001
1002 if limit > 0 {
1003 cmd.arg(format!("-n{}", limit));
1004 }
1005
1006 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1007
1008 if let Ok(out) = cmd.output() {
1009 if out.status.success() {
1010 let stdout_str = String::from_utf8_lossy(&out.stdout);
1011 for line in stdout_str.lines() {
1012 let parts: Vec<&str> = line.split_whitespace().collect();
1013 if parts.len() == 2 {
1014 sigs.insert(parts[0].to_string(), parts[1].to_string());
1015 } else if parts.len() == 1 {
1016 sigs.insert(parts[0].to_string(), "N".to_string());
1017 }
1018 }
1019 }
1020 }
1021 sigs
1022}
1023
1024#[derive(serde::Serialize, serde::Deserialize, Clone)]
1025struct CachedCommit {
1026 id: String,
1027 author: String,
1028 date: String,
1029 summary: String,
1030 message: String,
1031 time: i64,
1032}
1033
1034fn hash_path(path: &Path) -> String {
1035 use std::collections::hash_map::DefaultHasher;
1036 use std::hash::{Hash, Hasher};
1037 let mut hasher = DefaultHasher::new();
1038 path.hash(&mut hasher);
1039 format!("{:x}", hasher.finish())
1040}
1041
1042fn collect_commits(
1043 repo: &Repository,
1044 limit: usize,
1045 repo_path: &Path,
1046 enable_commit_signatures: bool,
1047) -> Result<Vec<CommitEntry>, git2::Error> {
1048 let mut walk = repo.revwalk()?;
1049 if walk.push_head().is_err() {
1050 return Ok(Vec::new());
1051 }
1052 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1053
1054 let mut commits = Vec::new();
1055 let oids: Vec<Result<git2::Oid, git2::Error>> =
1056 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1057
1058 let sig_map = if enable_commit_signatures {
1059 collect_signatures(repo_path, limit)
1060 } else {
1061 std::collections::HashMap::new()
1062 };
1063 let ref_map = get_cached_ref_map(repo, repo_path);
1064
1065 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1067 if let Some(ref dir) = cache_dir {
1068 let _ = std::fs::create_dir_all(dir);
1069 }
1070 let hash = hash_path(repo_path);
1071 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1072 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1073 .as_ref()
1074 .and_then(|f| std::fs::read_to_string(f).ok())
1075 .and_then(|s| serde_json::from_str(&s).ok())
1076 .unwrap_or_default();
1077
1078 let mut cache_updated = false;
1079
1080 for id in oids {
1081 let oid = id?;
1082 let oid_str = oid.to_string();
1083 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1084 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1085 let files = Vec::new();
1086
1087 if let Some(cached) = cache.get(&oid_str) {
1088 let when = format_relative_time(cached.time);
1089 commits.push(CommitEntry {
1090 id: cached.id.clone(),
1091 oid: oid_str,
1092 author: sanitize_text(&cached.author),
1093 when,
1094 date: cached.date.clone(),
1095 summary: sanitize_text(&cached.summary),
1096 message: sanitize_text(&cached.message),
1097 refs,
1098 files,
1099 signature_status: sig_status,
1100 });
1101 } else if let Ok(commit) = repo.find_commit(oid) {
1102 let short_id = format!("{:.7}", commit.id());
1103 let summary =
1104 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1105 let author = commit.author();
1106 let author_name = author.name().unwrap_or("?");
1107 let author_email = author.email().unwrap_or("?");
1108 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1109 let time_secs = commit.time().seconds();
1110 let when = format_relative_time(time_secs);
1111 let date = format_utc_date(time_secs);
1112 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1113
1114 let cached = CachedCommit {
1115 id: short_id.clone(),
1116 author: author_str.clone(),
1117 date: date.clone(),
1118 summary: summary.clone(),
1119 message: message.clone(),
1120 time: time_secs,
1121 };
1122 cache.insert(oid_str.clone(), cached);
1123 cache_updated = true;
1124
1125 commits.push(CommitEntry {
1126 id: short_id,
1127 oid: oid_str,
1128 author: author_str,
1129 when,
1130 date,
1131 summary,
1132 message,
1133 refs,
1134 files,
1135 signature_status: sig_status,
1136 });
1137 }
1138 }
1139
1140 if cache_updated {
1141 if let Some(ref f) = cache_file {
1142 if let Ok(json) = serde_json::to_string(&cache) {
1143 let _ = std::fs::write(f, json);
1144 }
1145 }
1146 }
1147
1148 Ok(commits)
1149}
1150
1151pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1152 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1153 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1154 if walk.push_head().is_err() {
1155 return Ok(Vec::new());
1156 }
1157 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1158
1159 let mut revisions = Vec::new();
1160
1161 for oid_res in walk {
1162 let oid = oid_res.map_err(|e| e.to_string())?;
1163 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1164
1165 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1166 let mut modified = false;
1167
1168 if commit.parent_count() > 0 {
1169 for i in 0..commit.parent_count() {
1170 if let Ok(parent) = commit.parent(i) {
1171 if let Ok(parent_tree) = parent.tree() {
1172 let mut diff_opts = git2::DiffOptions::new();
1173 diff_opts.pathspec(file_path);
1174 if let Ok(diff) = repo.diff_tree_to_tree(
1175 Some(&parent_tree),
1176 Some(&commit_tree),
1177 Some(&mut diff_opts),
1178 ) {
1179 if diff.deltas().len() > 0 {
1180 modified = true;
1181 break;
1182 }
1183 }
1184 }
1185 }
1186 }
1187 } else {
1188 let mut diff_opts = git2::DiffOptions::new();
1189 diff_opts.pathspec(file_path);
1190 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1191 {
1192 if diff.deltas().len() > 0 {
1193 modified = true;
1194 }
1195 }
1196 }
1197
1198 if modified {
1199 let author = commit.author();
1200 let author_name = author.name().unwrap_or("?");
1201 let author_email = author.email().unwrap_or("?");
1202 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1203 let time_secs = commit.time().seconds();
1204 let when = format_relative_time(time_secs);
1205 let date = format_utc_date(time_secs);
1206 let summary =
1207 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1208
1209 revisions.push(FileRevision {
1210 commit_oid: oid.to_string(),
1211 author: author_str,
1212 date,
1213 when,
1214 summary,
1215 });
1216 }
1217 }
1218
1219 Ok(revisions)
1220}
1221
1222fn collect_committer_stats(
1223 repo: &Repository,
1224 limit: usize,
1225) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1226 let mut walk = repo.revwalk()?;
1227 if walk.push_head().is_err() {
1228 return Ok((Vec::new(), false));
1229 }
1230 let mut counts = std::collections::HashMap::new();
1231 let mut count = 0;
1232 let mut limit_reached = false;
1233 for id in walk {
1234 let oid = id?;
1235 if let Ok(commit) = repo.find_commit(oid) {
1236 let author = commit.author();
1237 let name = author.name().unwrap_or("?").to_string();
1238 let email = author.email().unwrap_or("?").to_string();
1239 let key = (name, email);
1240 *counts.entry(key).or_insert(0) += 1;
1241 count += 1;
1242 if count >= limit {
1243 limit_reached = true;
1244 break;
1245 }
1246 }
1247 }
1248
1249 let mut stats: Vec<CommitterStat> = counts
1250 .into_iter()
1251 .map(|((name, email), count)| CommitterStat { name, email, count })
1252 .collect();
1253
1254 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1255
1256 Ok((stats, limit_reached))
1257}
1258
1259fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1263 let commit_tree = match commit.tree() {
1264 Ok(t) => t,
1265 Err(_) => return Vec::new(),
1266 };
1267 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1270
1271 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1272 Ok(d) => d,
1273 Err(_) => return Vec::new(),
1274 };
1275
1276 let mut files = Vec::new();
1277 for delta in diff.deltas() {
1278 if files.len() >= MAX_FILES_PER_SECTION {
1279 break;
1280 }
1281 let path = delta
1282 .new_file()
1283 .path()
1284 .or_else(|| delta.old_file().path())
1285 .map(|p| p.to_string_lossy().into_owned())
1286 .unwrap_or_else(|| "(unknown)".to_string());
1287
1288 let label: &'static str = match delta.status() {
1289 git2::Delta::Added => "N",
1290 git2::Delta::Deleted => "D",
1291 git2::Delta::Modified => "M",
1292 git2::Delta::Renamed => "R",
1293 git2::Delta::Typechange => "T",
1294 _ => "M",
1295 };
1296 files.push(FileEntry { path, label });
1297 }
1298 files
1299}
1300
1301pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1302 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1303 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1304 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1305 Ok(commit_changed_files(&repo, &commit))
1306}
1307
1308fn collect_info(
1311 path: &Path,
1312 commit_limit: usize,
1313 _graph_max_commits: usize,
1314 enable_commit_signatures: bool,
1315) -> Result<RepoInfo, git2::Error> {
1316 let repo = Repository::open(path)?;
1317 let mut summary = RepoSummary::default();
1318 if let Ok(head) = repo.head() {
1319 summary.branch = head.shorthand().ok().map(String::from);
1320 }
1321 populate_ahead_behind(&repo, &mut summary);
1322
1323 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1324
1325 if let Ok(head) = repo.head() {
1326 info.branch = head.shorthand().ok().map(String::from);
1327
1328 if let Ok(commit) = head.peel_to_commit() {
1329 let short_id = format!("{:.7}", commit.id());
1330 let summary_text =
1331 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1332 let author = commit.author();
1333 let author_str = sanitize_text(&format!(
1334 "{} <{}>",
1335 author.name().unwrap_or("?"),
1336 author.email().unwrap_or("?")
1337 ));
1338 let when = format_relative_time(commit.time().seconds());
1339 info.head =
1340 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1341 }
1342
1343 if let Ok(head_name) = head.name() {
1344 info.upstream = upstream_short_name(&repo, head_name);
1345 }
1346 }
1347
1348 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1349 info.commits = commits;
1350 }
1351
1352 populate_summary_and_file_changes(&repo, &mut info);
1353
1354 if let Ok(remotes) = load_tab_remotes(path) {
1355 info.remotes = TabData::Loaded(remotes);
1356 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1357 }
1358
1359 Ok(info)
1360}
1361
1362pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1363 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1364 let mut files = Vec::new();
1365 if let Ok(index) = repo.index() {
1366 for entry in index.iter() {
1367 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1368 files.push(path_str.to_string());
1369 }
1370 }
1371 }
1372 Ok(files)
1373}
1374
1375pub fn load_tab_graph_stream(
1376 repo_path: &Path,
1377 graph_max_commits: usize,
1378 repo_resolved_path: String,
1379 tab_idx: usize,
1380 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1381) -> Result<Vec<GraphLine>, String> {
1382 let mut graph_lines = Vec::new();
1383 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1384
1385 let mut args = vec![
1386 "log".to_string(),
1387 "--graph".to_string(),
1388 "--all".to_string(),
1389 "--date=relative".to_string(),
1390 ];
1391 if graph_max_commits > 0 {
1392 args.push(format!("--max-count={}", graph_max_commits));
1393 }
1394 args.push(format!("--pretty=format:{}", format_str));
1395 args.push("--color=never".to_string());
1396
1397 let mut child = std::process::Command::new("git")
1398 .env("GIT_TERMINAL_PROMPT", "0")
1399 .env("GIT_SSH_COMMAND", ssh_command_val())
1400 .args(&args)
1401 .current_dir(repo_path)
1402 .stdout(std::process::Stdio::piped())
1403 .spawn()
1404 .map_err(|e| e.to_string())?;
1405
1406 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1407 let reader = std::io::BufReader::new(stdout);
1408 use std::io::BufRead;
1409
1410 for (idx, line_res) in reader.lines().enumerate() {
1411 let line = line_res.map_err(|e| e.to_string())?;
1412 let parsed = parse_graph_line(&line);
1413 graph_lines.push(parsed);
1414
1415 if (idx + 1) % 200 == 0 {
1417 let _ = tx.send((
1418 repo_resolved_path.clone(),
1419 tab_idx,
1420 TabPayload::Graph(Ok(graph_lines.clone())),
1421 ));
1422 }
1423 }
1424
1425 let status = child.wait().map_err(|e| e.to_string())?;
1427 if !status.success() && graph_lines.is_empty() {
1428 return Err("git log failed".to_string());
1429 }
1430
1431 Ok(graph_lines)
1432}
1433
1434pub fn load_tab_branches(
1435 repo_path: &Path,
1436) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1437 let repo = match Repository::open(repo_path) {
1438 Ok(r) => r,
1439 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1440 };
1441
1442 let mut local_branches = Vec::new();
1443 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1444 for (branch, _) in branches.flatten() {
1445 if let Ok(Some(name)) = branch.name() {
1446 let is_head = branch.is_head();
1447 let mut short_sha = String::new();
1448 let mut short_message = String::new();
1449 if let Ok(target) = branch.get().peel_to_commit() {
1450 let id = target.id();
1451 let id_str = id.to_string();
1452 short_sha = safe_sha_slice(&id_str, 7).to_string();
1453 if let Ok(Some(summary)) = target.summary() {
1454 short_message = summary.to_string();
1455 }
1456 }
1457 local_branches.push(BranchInfo {
1458 name: sanitize_text(name),
1459 is_head,
1460 short_sha,
1461 short_message: sanitize_text(&short_message),
1462 });
1463 }
1464 }
1465 }
1466 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1467
1468 let mut remote_branches = Vec::new();
1469 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1470 for (branch, _) in branches.flatten() {
1471 if let Ok(Some(name)) = branch.name() {
1472 if !name.ends_with("/HEAD") {
1473 let is_head = branch.is_head();
1474 let mut short_sha = String::new();
1475 let mut short_message = String::new();
1476 if let Ok(target) = branch.get().peel_to_commit() {
1477 let id = target.id();
1478 let id_str = id.to_string();
1479 short_sha = safe_sha_slice(&id_str, 7).to_string();
1480 if let Ok(Some(summary)) = target.summary() {
1481 short_message = summary.to_string();
1482 }
1483 }
1484 remote_branches.push(BranchInfo {
1485 name: sanitize_text(name),
1486 is_head,
1487 short_sha,
1488 short_message: sanitize_text(&short_message),
1489 });
1490 }
1491 }
1492 }
1493 }
1494 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1495
1496 (Ok(local_branches), Ok(remote_branches))
1497}
1498
1499pub fn load_tab_tags(
1500 repo_path: &Path,
1501) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1502 let repo = match Repository::open(repo_path) {
1503 Ok(r) => r,
1504 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1505 };
1506
1507 let mut local_tags = Vec::new();
1508 if let Ok(tags) = repo.tag_names(None) {
1509 for tag_opt in tags.iter() {
1510 if let Ok(Some(tag)) = tag_opt {
1511 let mut short_sha = String::new();
1512 let mut short_message = String::new();
1513 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1514 if let Ok(target) = reference.peel_to_commit() {
1515 let id = target.id();
1516 let id_str = id.to_string();
1517 short_sha = safe_sha_slice(&id_str, 7).to_string();
1518 if let Ok(Some(summary)) = target.summary() {
1519 short_message = summary.to_string();
1520 }
1521 }
1522 }
1523 local_tags.push(BranchInfo {
1524 name: sanitize_text(tag),
1525 is_head: false,
1526 short_sha,
1527 short_message: sanitize_text(&short_message),
1528 });
1529 }
1530 }
1531 }
1532 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1533
1534 (Ok(local_tags), Ok(Vec::new()))
1535}
1536
1537pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1538 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1539 let mut remotes_list = Vec::new();
1540 if let Ok(remotes) = repo.remotes() {
1541 for name in remotes.iter() {
1542 let Ok(Some(name)) = name else { continue };
1543 if let Ok(remote) = repo.find_remote(name) {
1544 let push_url = remote.pushurl().ok().flatten().map(String::from);
1545 let mut refspecs = Vec::new();
1546 for r in remote.refspecs() {
1547 if let Ok(s) = r.str() {
1548 refspecs.push(s.to_string());
1549 }
1550 }
1551 remotes_list.push(RemoteInfo {
1552 name: name.to_string(),
1553 url: remote.url().unwrap_or("(no url)").to_string(),
1554 push_url,
1555 refspecs,
1556 });
1557 }
1558 }
1559 }
1560 Ok(remotes_list)
1561}
1562
1563pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1564 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1565 let mut temp_stashes = Vec::new();
1566 let _ = repo.stash_foreach(|index, message, oid| {
1567 temp_stashes.push((index, message.to_string(), *oid));
1568 true
1569 });
1570
1571 let mut stashes = Vec::new();
1572 for (index, message, oid) in temp_stashes {
1573 let mut files = Vec::new();
1574 if let Ok(commit) = repo.find_commit(oid) {
1575 files = commit_changed_files(&repo, &commit);
1576 }
1577 stashes.push(StashInfo {
1578 index,
1579 message: sanitize_text(&message),
1580 commit_id: oid.to_string(),
1581 files,
1582 });
1583 }
1584 Ok(stashes)
1585}
1586
1587pub fn load_tab_overview(
1588 repo_path: &Path,
1589 commit_limit: usize,
1590) -> Result<(Vec<CommitterStat>, bool), String> {
1591 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1592 let stats_limit = if commit_limit > 0 { commit_limit.min(10000) } else { 10000 };
1593 let (stats, limit_reached) =
1594 collect_committer_stats(&repo, stats_limit).map_err(|e| e.to_string())?;
1595 Ok((stats, limit_reached))
1596}
1597
1598pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1599 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1600 let submodules = repo.submodules().map_err(|e| e.to_string())?;
1601 let mut list = Vec::new();
1602 for sub in submodules {
1603 let name = sub.name().unwrap_or("").to_string();
1604 let path = sub.path().to_path_buf();
1605 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
1606 let commit_id = sub.index_id().map(|id| id.to_string());
1607 let head_id = sub.head_id().map(|id| id.to_string());
1608
1609 let mut is_initialized = true;
1610 let mut is_dirty = false;
1611
1612 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
1613 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
1614 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
1615 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
1616 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
1617 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
1618 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
1619 }
1620
1621 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
1622 }
1623 Ok(list)
1624}
1625
1626fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1631 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1632 std::collections::HashMap::new();
1633
1634 if let Ok(refs) = repo.references() {
1635 for reference in refs.flatten() {
1636 let Ok(target) = reference.peel_to_commit() else {
1638 continue;
1639 };
1640 let oid = target.id();
1641
1642 let Ok(full_name) = reference.name() else {
1643 continue;
1644 };
1645
1646 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1647 branch.to_string()
1648 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1649 format!("tag:{}", tag)
1650 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1651 if remote.ends_with("/HEAD") {
1653 continue;
1654 }
1655 format!("remote:{}", remote)
1656 } else {
1657 continue;
1658 };
1659
1660 map.entry(oid).or_default().push(label);
1661 }
1662 }
1663 map
1664}
1665
1666#[allow(clippy::type_complexity)]
1667static REF_MAP_CACHE: std::sync::OnceLock<
1668 std::sync::Mutex<
1669 std::collections::HashMap<
1670 String,
1671 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1672 >,
1673 >,
1674> = std::sync::OnceLock::new();
1675
1676fn get_cached_ref_map(
1677 repo: &Repository,
1678 repo_path: &Path,
1679) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1680 let cache_lock =
1681 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1682 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1683 let path_key = repo_path.to_string_lossy().to_string();
1684
1685 if let Some((map, loaded_at)) = cache.get(&path_key) {
1686 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1687 return map.clone();
1688 }
1689 }
1690
1691 let map = build_ref_map(repo);
1692 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1693 map
1694}
1695
1696pub fn invalidate_ref_map_cache(repo_path: &Path) {
1697 if let Some(cache_lock) = REF_MAP_CACHE.get() {
1698 if let Ok(mut cache) = cache_lock.lock() {
1699 cache.remove(&repo_path.to_string_lossy().to_string());
1700 }
1701 }
1702}
1703
1704const MAX_FILES_PER_SECTION: usize = 100;
1707
1708fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1710 let mut opts = StatusOptions::new();
1711 opts.include_untracked(true)
1712 .renames_head_to_index(true)
1713 .recurse_untracked_dirs(true)
1714 .show(StatusShow::IndexAndWorkdir);
1715 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1716 return;
1717 };
1718 for entry in statuses.iter() {
1719 let path = entry.path().unwrap_or("(unknown)").to_string();
1720 let flags = entry.status();
1721
1722 if flags.is_conflicted() {
1724 info.summary.conflicted += 1;
1725 } else {
1726 if flags.is_wt_new() {
1727 info.summary.untracked += 1;
1728 }
1729 if flags.is_wt_modified()
1730 || flags.is_wt_deleted()
1731 || flags.is_wt_renamed()
1732 || flags.is_wt_typechange()
1733 {
1734 info.summary.modified += 1;
1735 }
1736 if flags.is_index_new()
1737 || flags.is_index_modified()
1738 || flags.is_index_deleted()
1739 || flags.is_index_renamed()
1740 || flags.is_index_typechange()
1741 {
1742 info.summary.staged += 1;
1743 }
1744 }
1745
1746 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
1749 if path_buf.is_dir() {
1750 continue;
1751 }
1752
1753 if flags.is_conflicted() {
1754 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
1755 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
1756 }
1757 continue;
1758 }
1759
1760 if (flags.is_index_new()
1762 || flags.is_index_modified()
1763 || flags.is_index_deleted()
1764 || flags.is_index_renamed()
1765 || flags.is_index_typechange())
1766 && info.changes.staged.len() < MAX_FILES_PER_SECTION
1767 {
1768 let label = if flags.is_index_new() {
1769 "N"
1770 } else if flags.is_index_deleted() {
1771 "D"
1772 } else if flags.is_index_renamed() {
1773 "R"
1774 } else if flags.is_index_typechange() {
1775 "T"
1776 } else {
1777 "M"
1778 };
1779 info.changes.staged.push(FileEntry { path: path.clone(), label });
1780 }
1781
1782 if flags.is_wt_new() {
1784 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
1785 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
1786 }
1787 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
1788 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
1789 }
1790 } else if (flags.is_wt_modified()
1791 || flags.is_wt_deleted()
1792 || flags.is_wt_renamed()
1793 || flags.is_wt_typechange())
1794 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
1795 {
1796 let label = if flags.is_wt_deleted() {
1797 "D"
1798 } else if flags.is_wt_renamed() {
1799 "R"
1800 } else if flags.is_wt_typechange() {
1801 "T"
1802 } else {
1803 "M"
1804 };
1805 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
1806 }
1807 }
1808}
1809
1810fn collect_summary(repo: &Repository) -> RepoSummary {
1814 let mut s = RepoSummary::default();
1815 if let Ok(head) = repo.head() {
1817 s.branch = head.shorthand().ok().map(String::from);
1818 }
1819 populate_worktree(repo, &mut s);
1820 populate_ahead_behind(repo, &mut s);
1821 s
1822}
1823
1824fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
1825 let mut opts = StatusOptions::new();
1826 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
1827 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1828 return;
1829 };
1830 for entry in statuses.iter() {
1831 let flags = entry.status();
1832 if flags.is_conflicted() {
1833 s.conflicted += 1;
1834 continue;
1835 }
1836 if flags.is_wt_new() {
1837 s.untracked += 1;
1838 }
1839 if flags.is_wt_modified()
1840 || flags.is_wt_deleted()
1841 || flags.is_wt_renamed()
1842 || flags.is_wt_typechange()
1843 {
1844 s.modified += 1;
1845 }
1846 if flags.is_index_new()
1847 || flags.is_index_modified()
1848 || flags.is_index_deleted()
1849 || flags.is_index_renamed()
1850 || flags.is_index_typechange()
1851 {
1852 s.staged += 1;
1853 }
1854 }
1855}
1856
1857fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
1861 let Ok(head) = repo.head() else { return };
1862 let Some(local_oid) = head.target() else {
1863 return;
1864 };
1865 let Ok(head_name) = head.name() else { return };
1866 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
1867 return;
1868 };
1869 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
1870 return;
1871 };
1872 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
1873 return;
1874 };
1875 let Some(upstream_oid) = upstream_ref.target() else {
1876 return;
1877 };
1878 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
1879 s.ahead = ahead;
1880 s.behind = behind;
1881 }
1882}
1883
1884fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
1886 let buf = repo.branch_upstream_name(head_name).ok()?;
1887 let raw = std::str::from_utf8(&buf).ok()?;
1888 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
1889}
1890
1891fn format_relative_time(secs: i64) -> String {
1893 if secs <= 0 {
1894 return "unknown".to_string();
1895 }
1896 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
1897 let now = SystemTime::now();
1898 let Ok(elapsed) = now.duration_since(then) else {
1899 return "in the future".to_string();
1900 };
1901 let secs = elapsed.as_secs();
1902 let (n, unit) = if secs < 60 {
1903 (secs, "second")
1904 } else if secs < 3600 {
1905 (secs / 60, "minute")
1906 } else if secs < 86_400 {
1907 (secs / 3600, "hour")
1908 } else if secs < 86_400 * 30 {
1909 (secs / 86_400, "day")
1910 } else if secs < 86_400 * 365 {
1911 (secs / (86_400 * 30), "month")
1912 } else {
1913 (secs / (86_400 * 365), "year")
1914 };
1915 let plural = if n == 1 { "" } else { "s" };
1916 format!("{} {}{} ago", n, unit, plural)
1917}
1918
1919fn format_utc_date(secs: i64) -> String {
1921 if secs <= 0 {
1922 return "unknown".to_string();
1923 }
1924 let seconds_in_day = 86400;
1925 let day_number = secs / seconds_in_day;
1926 let time_of_day = secs % seconds_in_day;
1927
1928 let mut hour = time_of_day / 3600;
1929 let mut minute = (time_of_day % 3600) / 60;
1930 let mut second = time_of_day % 60;
1931 if hour < 0 {
1932 hour += 24;
1933 }
1934 if minute < 0 {
1935 minute += 60;
1936 }
1937 if second < 0 {
1938 second += 60;
1939 }
1940
1941 let z = day_number + 719468;
1943 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
1944 let doe = (z - era * 146097) as u32;
1945 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1946 let y = (yoe as i32) + (era as i32) * 400;
1947 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1948 let mp = (5 * doy + 2) / 153;
1949 let d = doy - (153 * mp + 2) / 5 + 1;
1950 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1951 let y = y + if m <= 2 { 1 } else { 0 };
1952
1953 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
1954}
1955
1956fn get_file_diff_inner(
1959 repo_path: &Path,
1960 commit_oid: &str,
1961 file_path: &str,
1962) -> Option<Vec<DiffLine>> {
1963 let repo = Repository::open(repo_path).ok()?;
1964 let oid = git2::Oid::from_str(commit_oid).ok()?;
1965 let commit = repo.find_commit(oid).ok()?;
1966
1967 let commit_tree = commit.tree().ok()?;
1968 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1970
1971 let mut opts = git2::DiffOptions::new();
1972 opts.pathspec(file_path);
1973
1974 let diff =
1975 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
1976
1977 collect_diff_lines(&diff)
1978}
1979
1980fn get_worktree_diff_inner(
1985 repo_path: &Path,
1986 file_path: &str,
1987 staged: bool,
1988) -> Option<Vec<DiffLine>> {
1989 let repo = Repository::open(repo_path).ok()?;
1990 let mut opts = git2::DiffOptions::new();
1991 opts.pathspec(file_path);
1992 opts.include_untracked(true);
1993 opts.recurse_untracked_dirs(true);
1994
1995 let diff = if staged {
1996 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
1998 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
1999 } else {
2000 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2002 };
2003
2004 collect_diff_lines(&diff)
2005}
2006
2007fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2009 let mut lines: Vec<DiffLine> = Vec::new();
2010 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
2011 let kind = match line.origin() {
2012 '+' => DiffLineKind::Added,
2013 '-' => DiffLineKind::Removed,
2014 'H' => DiffLineKind::Header,
2015 ' ' => DiffLineKind::Context,
2016 _ => return true, };
2018 let content = String::from_utf8_lossy(line.content())
2019 .trim_end_matches('\n')
2020 .trim_end_matches('\r')
2021 .to_string();
2022 lines.push(DiffLine { kind, content });
2023 true
2024 })
2025 .ok()?;
2026 Some(lines)
2027}
2028
2029fn parse_graph_line(line: &str) -> GraphLine {
2030 if line.contains("__TWIG_SEP__") {
2031 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2032 if parts.len() >= 5 {
2033 let graph_and_hash = parts[0];
2034 let decoration = parts[1].trim().to_string();
2035 let summary = parts[2].trim().to_string();
2036 let author = parts[3].trim().to_string();
2037 let date = parts[4].trim().to_string();
2038 let signature_status =
2039 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2040
2041 let char_count = graph_and_hash.chars().count();
2042 if char_count >= 40 {
2043 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2044 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2045 GraphLine {
2046 graph,
2047 commit: Some(GraphCommit {
2048 oid,
2049 decoration,
2050 summary,
2051 author,
2052 date,
2053 signature_status,
2054 }),
2055 }
2056 } else {
2057 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2058 }
2059 } else {
2060 GraphLine { graph: line.to_string(), commit: None }
2061 }
2062 } else {
2063 GraphLine { graph: line.to_string(), commit: None }
2064 }
2065}
2066
2067#[allow(dead_code)]
2068fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2069 let mut graph_lines = Vec::new();
2070 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2071
2072 let mut args = vec![
2073 "log".to_string(),
2074 "--graph".to_string(),
2075 "--all".to_string(),
2076 "--date=relative".to_string(),
2077 ];
2078 if graph_max_commits > 0 {
2079 args.push(format!("--max-count={}", graph_max_commits));
2080 }
2081 args.push(format!("--pretty=format:{}", format_str));
2082 args.push("--color=never".to_string());
2083
2084 let output = std::process::Command::new("git")
2085 .env("GIT_TERMINAL_PROMPT", "0")
2086 .env("GIT_SSH_COMMAND", ssh_command_val())
2087 .args(&args)
2088 .current_dir(repo_path)
2089 .output();
2090
2091 if let Ok(out) = output {
2092 if out.status.success() {
2093 let stdout_str = String::from_utf8_lossy(&out.stdout);
2094 for line in stdout_str.lines() {
2095 graph_lines.push(parse_graph_line(line));
2096 }
2097 }
2098 }
2099 graph_lines
2100}
2101
2102pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2103 let output = std::process::Command::new("git")
2104 .env("GIT_TERMINAL_PROMPT", "0")
2105 .env("GIT_SSH_COMMAND", ssh_command_val())
2106 .arg("checkout")
2107 .arg(branch_name)
2108 .current_dir(repo_path)
2109 .output()
2110 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2111
2112 if !output.status.success() {
2113 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2114 return Err(git2::Error::from_str(&err));
2115 }
2116 Ok(())
2117}
2118
2119pub fn checkout_remote_branch(
2120 repo_path: &Path,
2121 remote_branch_name: &str,
2122) -> Result<String, git2::Error> {
2123 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2124 if parts.len() < 2 {
2125 return Err(git2::Error::from_str("Invalid remote branch name"));
2126 }
2127 let local_name = parts[1];
2128
2129 let output = std::process::Command::new("git")
2130 .env("GIT_TERMINAL_PROMPT", "0")
2131 .env("GIT_SSH_COMMAND", ssh_command_val())
2132 .arg("checkout")
2133 .arg(local_name)
2134 .current_dir(repo_path)
2135 .output()
2136 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2137
2138 if output.status.success() {
2139 return Ok(format!("Switched to existing branch '{}'", local_name));
2140 }
2141
2142 let output = std::process::Command::new("git")
2143 .env("GIT_TERMINAL_PROMPT", "0")
2144 .env("GIT_SSH_COMMAND", ssh_command_val())
2145 .arg("checkout")
2146 .arg("--track")
2147 .arg(remote_branch_name)
2148 .current_dir(repo_path)
2149 .output()
2150 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2151
2152 if !output.status.success() {
2153 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2154 return Err(git2::Error::from_str(&err));
2155 }
2156
2157 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2158}
2159
2160pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2162 let repo = Repository::open(repo_path)?;
2163 let head = repo.head()?;
2164 let target_commit = head.peel_to_commit()?;
2165 repo.branch(branch_name, &target_commit, false)?;
2166 Ok(())
2167}
2168
2169pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2171 let repo = Repository::open(repo_path)?;
2172 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2173 branch.delete()?;
2174 Ok(())
2175}
2176
2177pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2179 let repo = Repository::open(repo_path)?;
2180 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2181 branch.delete()?;
2182 Ok(())
2183}
2184
2185pub fn create_tag(
2187 repo_path: &Path,
2188 tag_name: &str,
2189 commit_oid_str: &str,
2190) -> Result<(), git2::Error> {
2191 let repo = Repository::open(repo_path)?;
2192 let oid = git2::Oid::from_str(commit_oid_str)?;
2193 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2194 repo.tag_lightweight(tag_name, &target_object, false)?;
2195 Ok(())
2196}
2197
2198pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2200 let repo = Repository::open(repo_path)?;
2201 repo.tag_delete(tag_name)?;
2202 Ok(())
2203}
2204
2205pub fn delete_remote_tag(
2207 repo_path: &Path,
2208 remote_name: &str,
2209 tag_name: &str,
2210) -> Result<(), Box<dyn std::error::Error>> {
2211 let safe_remote = safe_ref(remote_name)?;
2212 let safe_tag = safe_ref(tag_name)?;
2213 let output = git_command()
2214 .arg("push")
2215 .arg(safe_remote)
2216 .arg("--delete")
2217 .arg(safe_tag)
2218 .current_dir(repo_path)
2219 .output()?;
2220 if !output.status.success() {
2221 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2222 return Err(err.into());
2223 }
2224 Ok(())
2225}
2226
2227pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2228 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2229 let output = git_command()
2230 .arg("checkout")
2231 .arg(safe_tag)
2232 .current_dir(repo_path)
2233 .output()
2234 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2235
2236 if !output.status.success() {
2237 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2238 return Err(git2::Error::from_str(&err));
2239 }
2240 Ok(())
2241}
2242
2243pub fn get_remote_tags(
2245 repo_path: &Path,
2246 remote_name: &str,
2247) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2248 let safe_remote = safe_ref(remote_name)?;
2249 let output = git_command()
2250 .arg("ls-remote")
2251 .arg("--tags")
2252 .arg(safe_remote)
2253 .current_dir(repo_path)
2254 .output()?;
2255
2256 if !output.status.success() {
2257 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2258 return Err(err.into());
2259 }
2260
2261 let stdout = String::from_utf8_lossy(&output.stdout);
2262 let repo = git2::Repository::open(repo_path)?;
2263 let mut tags_map = std::collections::HashMap::new();
2264
2265 for line in stdout.lines() {
2266 let parts: Vec<&str> = line.split_whitespace().collect();
2267 if parts.len() >= 2 {
2268 let sha = parts[0];
2269 let ref_name = parts[1];
2270 if ref_name.starts_with("refs/tags/") {
2271 let is_peeled = ref_name.ends_with("^{}");
2272 let clean_ref = if is_peeled {
2273 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2274 } else {
2275 ref_name
2276 };
2277 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2278 let short_sha = safe_sha_slice(sha, 7);
2279
2280 let mut short_message = String::new();
2282 if let Ok(oid) = git2::Oid::from_str(sha) {
2283 if let Ok(commit) = repo.find_commit(oid) {
2284 if let Ok(Some(summary)) = commit.summary() {
2285 short_message = summary.to_string();
2286 }
2287 }
2288 }
2289 if short_message.is_empty() {
2290 short_message = "(not fetched)".to_string();
2291 }
2292
2293 let sanitized_tag = sanitize_text(tag_name);
2294 let sanitized_msg = sanitize_text(&short_message);
2295
2296 if is_peeled {
2297 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2298 } else {
2299 tags_map
2300 .entry(sanitized_tag)
2301 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2302 }
2303 }
2304 }
2305 }
2306
2307 let mut tags = Vec::new();
2308 for (name, (short_sha, short_message)) in tags_map {
2309 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2310 }
2311 tags.sort_by(|a, b| b.name.cmp(&a.name));
2312 Ok(tags)
2313}
2314
2315pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2316 let mut s = String::new();
2317 for tag in tags {
2318 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2319 }
2320 s
2321}
2322
2323pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2324 let mut tags = Vec::new();
2325 for line in s.lines() {
2326 let parts: Vec<&str> = line.split('|').collect();
2327 if parts.len() >= 3 {
2328 tags.push(BranchInfo {
2329 name: parts[0].to_string(),
2330 is_head: false,
2331 short_sha: parts[1].to_string(),
2332 short_message: parts[2].to_string(),
2333 });
2334 }
2335 }
2336 tags
2337}
2338
2339pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2340 let mut repo = Repository::open(repo_path)?;
2341 repo.stash_drop(index)?;
2342 Ok(())
2343}
2344
2345pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2346 let stash_ref = format!("stash@{{{}}}", index);
2347 let output = std::process::Command::new("git")
2348 .env("GIT_TERMINAL_PROMPT", "0")
2349 .env("GIT_SSH_COMMAND", ssh_command_val())
2350 .arg("stash")
2351 .arg("apply")
2352 .arg(&stash_ref)
2353 .current_dir(repo_path)
2354 .output()
2355 .map_err(|e| e.to_string())?;
2356
2357 if !output.status.success() {
2358 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2359 return Err(err_msg);
2360 }
2361 Ok(())
2362}
2363
2364pub fn save_stash(
2365 repo_path: &Path,
2366 message: &str,
2367 include_untracked: bool,
2368 keep_index: bool,
2369) -> Result<(), String> {
2370 let mut cmd = std::process::Command::new("git");
2371 cmd.env("GIT_TERMINAL_PROMPT", "0")
2372 .env("GIT_SSH_COMMAND", ssh_command_val())
2373 .arg("stash")
2374 .arg("push");
2375
2376 if include_untracked {
2377 cmd.arg("--include-untracked");
2378 }
2379 if keep_index {
2380 cmd.arg("--keep-index");
2381 }
2382
2383 if !message.is_empty() {
2384 cmd.arg("-m").arg(message);
2385 }
2386
2387 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2388
2389 if !output.status.success() {
2390 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2391 return Err(err_msg);
2392 }
2393 Ok(())
2394}
2395
2396pub fn get_latest_change_time(item: &str) -> u64 {
2397 let path = expand_tilde(item);
2398 if !path.exists() {
2399 return 0;
2400 }
2401
2402 if path.join(".git").exists() {
2403 if let Ok(repo) = Repository::open(&path) {
2404 if let Ok(head) = repo.head() {
2405 if let Ok(commit) = head.peel_to_commit() {
2406 return commit.time().seconds() as u64;
2407 }
2408 }
2409 }
2410 }
2411
2412 if let Ok(meta) = std::fs::metadata(&path) {
2413 if let Ok(modified) = meta.modified() {
2414 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2415 return duration.as_secs();
2416 }
2417 }
2418 }
2419 0
2420}
2421
2422pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2423 if let Ok(repo) = Repository::open(repo_path) {
2424 if let Ok(head) = repo.head() {
2425 if let Ok(commit) = head.peel_to_commit() {
2426 if let Ok(msg) = commit.message() {
2427 return Some(msg.to_string());
2428 }
2429 }
2430 }
2431 }
2432 None
2433}
2434
2435pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2436 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2437 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2438 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2439
2440 let mut index = repo.index().map_err(|e| e.to_string())?;
2441 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2442 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2443
2444 let signature = repo
2445 .signature()
2446 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2447
2448 head_commit
2449 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2450 .map_err(|e| e.to_string())?;
2451
2452 Ok(())
2453}
2454
2455pub fn is_merging(repo_path: &Path) -> bool {
2460 repo_path.join(".git/MERGE_HEAD").exists()
2461}
2462
2463pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2466 let full_path = repo_path.join(file_path);
2467 let content = match std::fs::read_to_string(&full_path) {
2468 Ok(s) => s,
2469 Err(_) => return Vec::new(),
2470 };
2471
2472 let mut lines = Vec::new();
2473 let mut in_ours = false;
2474 let mut in_theirs = false;
2475
2476 for line in content.lines() {
2477 if line.starts_with("<<<<<<<") {
2478 in_ours = true;
2479 in_theirs = false;
2480 lines.push(DiffLine {
2481 kind: DiffLineKind::ConflictSeparator,
2482 content: line.to_string(),
2483 });
2484 } else if line.starts_with("=======") {
2485 in_ours = false;
2486 in_theirs = true;
2487 lines.push(DiffLine {
2488 kind: DiffLineKind::ConflictSeparator,
2489 content: line.to_string(),
2490 });
2491 } else if line.starts_with(">>>>>>>") {
2492 in_ours = false;
2493 in_theirs = false;
2494 lines.push(DiffLine {
2495 kind: DiffLineKind::ConflictSeparator,
2496 content: line.to_string(),
2497 });
2498 } else if in_ours {
2499 lines.push(DiffLine { kind: DiffLineKind::ConflictOurs, content: line.to_string() });
2500 } else if in_theirs {
2501 lines.push(DiffLine { kind: DiffLineKind::ConflictTheirs, content: line.to_string() });
2502 } else {
2503 lines.push(DiffLine { kind: DiffLineKind::Context, content: line.to_string() });
2504 }
2505 }
2506 lines
2507}
2508
2509pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2512 let output1 = std::process::Command::new("git")
2513 .env("GIT_TERMINAL_PROMPT", "0")
2514 .env("GIT_SSH_COMMAND", ssh_command_val())
2515 .args(["checkout", "--ours", file_path])
2516 .current_dir(repo_path)
2517 .output()
2518 .map_err(|e| e.to_string())?;
2519 if !output1.status.success() {
2520 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2521 }
2522 stage_file(repo_path, file_path)?;
2523 Ok(())
2524}
2525
2526pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2529 let output1 = std::process::Command::new("git")
2530 .env("GIT_TERMINAL_PROMPT", "0")
2531 .env("GIT_SSH_COMMAND", ssh_command_val())
2532 .args(["checkout", "--theirs", file_path])
2533 .current_dir(repo_path)
2534 .output()
2535 .map_err(|e| e.to_string())?;
2536 if !output1.status.success() {
2537 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2538 }
2539 stage_file(repo_path, file_path)?;
2540 Ok(())
2541}
2542
2543pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2545 stage_file(repo_path, file_path)
2546}
2547
2548pub fn resolve_conflict_hunk(
2552 repo_path: &Path,
2553 file_path: &str,
2554 hunk_idx: usize,
2555 accept_ours: bool,
2556) -> Result<(), String> {
2557 let full_path = repo_path.join(file_path);
2558 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2559
2560 let mut new_lines = Vec::new();
2561 let mut lines_iter = content.lines().peekable();
2562 let mut current_hunk_idx = 0;
2563
2564 while let Some(line) = lines_iter.next() {
2565 if line.starts_with("<<<<<<<") {
2566 let mut ours_block = Vec::new();
2567 let mut theirs_block = Vec::new();
2568
2569 let mut found_separator = false;
2571 while let Some(&next_line) = lines_iter.peek() {
2572 if next_line.starts_with("=======") {
2573 lines_iter.next(); found_separator = true;
2575 break;
2576 }
2577 if let Some(line) = lines_iter.next() {
2578 ours_block.push(line.to_string());
2579 }
2580 }
2581
2582 let mut found_end = false;
2584 let mut end_line_marker = ">>>>>>>".to_string();
2585 while let Some(&next_line) = lines_iter.peek() {
2586 if next_line.starts_with(">>>>>>>") {
2587 if let Some(marker) = lines_iter.next() {
2588 end_line_marker = marker.to_string(); }
2590 found_end = true;
2591 break;
2592 }
2593 if let Some(line) = lines_iter.next() {
2594 theirs_block.push(line.to_string());
2595 }
2596 }
2597
2598 if current_hunk_idx == hunk_idx {
2599 if accept_ours {
2600 new_lines.extend(ours_block);
2601 } else {
2602 new_lines.extend(theirs_block);
2603 }
2604 } else {
2605 new_lines.push(line.to_string());
2606 new_lines.extend(ours_block);
2607 if found_separator {
2608 new_lines.push("=======".to_string());
2609 }
2610 new_lines.extend(theirs_block);
2611 if found_end {
2612 new_lines.push(end_line_marker);
2613 }
2614 }
2615
2616 current_hunk_idx += 1;
2617 } else {
2618 new_lines.push(line.to_string());
2619 }
2620 }
2621
2622 let mut new_content = new_lines.join("\n");
2623 if content.ends_with('\n') && !new_content.ends_with('\n') {
2624 new_content.push('\n');
2625 }
2626 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2627
2628 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2630 let has_conflict_markers = updated_content
2631 .lines()
2632 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2633
2634 if !has_conflict_markers {
2635 stage_file(repo_path, file_path)?;
2636 }
2637
2638 Ok(())
2639}
2640
2641pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2643 let output = std::process::Command::new("git")
2644 .env("GIT_TERMINAL_PROMPT", "0")
2645 .env("GIT_SSH_COMMAND", ssh_command_val())
2646 .args(["merge", "--abort"])
2647 .current_dir(repo_path)
2648 .output()
2649 .map_err(|e| e.to_string())?;
2650 if !output.status.success() {
2651 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2652 }
2653 Ok(())
2654}
2655
2656pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2658 let output = std::process::Command::new("git")
2659 .env("GIT_TERMINAL_PROMPT", "0")
2660 .env("GIT_SSH_COMMAND", ssh_command_val())
2661 .args(["merge", "--continue"])
2662 .env("GIT_EDITOR", "true")
2663 .current_dir(repo_path)
2664 .output()
2665 .map_err(|e| e.to_string())?;
2666 if !output.status.success() {
2667 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2668 }
2669 Ok(())
2670}
2671
2672pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2674 let repo = Repository::open(repo_path).ok()?;
2675 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2676 let upstream = branch.upstream().ok()?;
2677 let upstream_ref = upstream.get().name().ok()?;
2678 let remote_buf = repo.branch_upstream_remote(upstream_ref).ok()?;
2679 remote_buf.as_str().ok().map(|s| s.to_string())
2680}
2681
2682pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2684 get_branch_upstream_remote(repo_path, branch_name).is_some()
2685}
2686
2687pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
2689 let repo = Repository::open(repo_path).ok()?;
2690 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2691 if let Ok(upstream) = branch.upstream() {
2692 if let Ok(upstream_ref) = upstream.get().name() {
2693 if let Ok(remote_buf) = repo.branch_upstream_remote(upstream_ref) {
2694 if let Ok(name) = remote_buf.as_str() {
2695 return Some((name.to_string(), false));
2696 }
2697 }
2698 }
2699 }
2700 let remotes = repo.remotes().ok()?;
2701 let first_remote = remotes.iter().next()?.ok()??.to_string();
2702 Some((first_remote, true))
2703}
2704
2705pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
2707 if let Ok(repo) = Repository::open(repo_path) {
2708 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
2709 if let Ok(commit) = repo.find_commit(oid) {
2710 return commit.parent_count() == 0;
2711 }
2712 }
2713 }
2714 false
2715}
2716
2717pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
2718 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2719 let mut worktrees_list = Vec::new();
2720 if let Ok(worktree_names) = repo.worktrees() {
2721 for name in worktree_names.iter() {
2722 let Ok(Some(wt_name)) = name else { continue };
2723 if let Ok(wt) = repo.find_worktree(wt_name) {
2724 let path = wt.path().to_path_buf();
2725 let mut branch = None;
2726 if let Ok(wt_repo) = Repository::open(&path) {
2727 if let Ok(head) = wt_repo.head() {
2728 branch = head.shorthand().map(String::from).ok();
2729 }
2730 }
2731
2732 let mut is_locked = false;
2733 let mut lock_reason = None;
2734 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
2735 is_locked = true;
2736 if let Some(reason) = reason_opt {
2737 if !reason.is_empty() {
2738 lock_reason = Some(reason);
2739 }
2740 }
2741 }
2742
2743 worktrees_list.push(WorktreeInfo {
2744 name: wt_name.to_string(),
2745 path,
2746 branch,
2747 is_locked,
2748 lock_reason,
2749 });
2750 }
2751 }
2752 }
2753 Ok(worktrees_list)
2754}
2755
2756pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
2757 let output = std::process::Command::new("git")
2758 .arg("worktree")
2759 .arg("add")
2760 .arg(wt_path)
2761 .arg(branch)
2762 .current_dir(repo_path)
2763 .output()
2764 .map_err(|e| e.to_string())?;
2765
2766 if !output.status.success() {
2767 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2768 }
2769 Ok(())
2770}
2771
2772pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
2773 let output = std::process::Command::new("git")
2774 .arg("worktree")
2775 .arg("lock")
2776 .arg("--reason")
2777 .arg(reason)
2778 .arg(name)
2779 .current_dir(repo_path)
2780 .output()
2781 .map_err(|e| e.to_string())?;
2782
2783 if !output.status.success() {
2784 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2785 }
2786 Ok(())
2787}
2788
2789pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
2790 let output = std::process::Command::new("git")
2791 .arg("worktree")
2792 .arg("unlock")
2793 .arg(name)
2794 .current_dir(repo_path)
2795 .output()
2796 .map_err(|e| e.to_string())?;
2797
2798 if !output.status.success() {
2799 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2800 }
2801 Ok(())
2802}
2803
2804pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
2805 let mut cmd = std::process::Command::new("git");
2806 cmd.arg("worktree").arg("remove");
2807 if force {
2808 cmd.arg("--force");
2809 }
2810 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
2811
2812 if !output.status.success() {
2813 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2814 }
2815 Ok(())
2816}
2817
2818pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
2819 let output = std::process::Command::new("git")
2820 .arg("worktree")
2821 .arg("prune")
2822 .current_dir(repo_path)
2823 .output()
2824 .map_err(|e| e.to_string())?;
2825
2826 if !output.status.success() {
2827 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2828 }
2829 Ok(())
2830}
2831
2832#[cfg(test)]
2833#[allow(clippy::unwrap_used, clippy::panic)]
2834mod tests {
2835 use super::*;
2836 use std::fs::File;
2837 use std::io::Write;
2838
2839 #[test]
2840 fn test_commit_amend() {
2841 let mut temp_path = std::env::temp_dir();
2842 temp_path.push(format!(
2843 "twig_test_{}",
2844 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2845 ));
2846 std::fs::create_dir_all(&temp_path).unwrap();
2847
2848 let repo = Repository::init(&temp_path).unwrap();
2850
2851 let mut config = repo.config().unwrap();
2853 config.set_str("user.name", "Test User").unwrap();
2854 config.set_str("user.email", "test@example.com").unwrap();
2855
2856 let file_path = temp_path.join("test.txt");
2858 let mut file = File::create(&file_path).unwrap();
2859 writeln!(file, "initial content").unwrap();
2860
2861 stage_file(&temp_path, "test.txt").unwrap();
2863 commit_changes(&temp_path, "initial commit").unwrap();
2864
2865 let msg = get_last_commit_message(&temp_path).unwrap();
2867 assert_eq!(msg, "initial commit");
2868
2869 commit_amend(&temp_path, "amended commit").unwrap();
2871
2872 let amended_msg = get_last_commit_message(&temp_path).unwrap();
2874 assert_eq!(amended_msg, "amended commit");
2875
2876 let _ = std::fs::remove_dir_all(&temp_path);
2878 }
2879
2880 #[test]
2881 fn test_commit_signatures_collection() {
2882 let mut temp_path = std::env::temp_dir();
2883 temp_path.push(format!(
2884 "twig_test_sig_{}",
2885 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2886 ));
2887 std::fs::create_dir_all(&temp_path).unwrap();
2888
2889 let repo = Repository::init(&temp_path).unwrap();
2891
2892 let mut config = repo.config().unwrap();
2894 config.set_str("user.name", "Test User").unwrap();
2895 config.set_str("user.email", "test@example.com").unwrap();
2896
2897 let file_path = temp_path.join("test.txt");
2899 let mut file = File::create(&file_path).unwrap();
2900 writeln!(file, "initial content").unwrap();
2901
2902 stage_file(&temp_path, "test.txt").unwrap();
2904 commit_changes(&temp_path, "initial commit").unwrap();
2905
2906 let sigs = collect_signatures(&temp_path, 0);
2908 assert_eq!(sigs.len(), 1);
2909 let head_oid = repo.head().unwrap().target().unwrap().to_string();
2910 let sig_status = sigs.get(&head_oid).unwrap();
2911 assert_eq!(sig_status, "N");
2912
2913 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
2915 assert_eq!(commits.len(), 1);
2916 assert_eq!(commits[0].signature_status, "N");
2917 assert!(commits[0].files.is_empty());
2918
2919 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
2921 assert_eq!(files.len(), 1);
2922 assert_eq!(files[0].path, "test.txt");
2923 assert_eq!(files[0].label, "N");
2924
2925 let graph = collect_graph_lines(&temp_path, 1000);
2927 assert_eq!(graph.len(), 1);
2928 assert!(graph[0].commit.is_some());
2929 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
2930
2931 let _ = std::fs::remove_dir_all(&temp_path);
2933 }
2934
2935 #[test]
2936 fn test_ref_map_cache_behavior() {
2937 let temp_dir = std::env::temp_dir();
2938 let repo_path = temp_dir.join("test_ref_map_repo");
2939 let _ = std::fs::remove_dir_all(&repo_path);
2940 std::fs::create_dir_all(&repo_path).unwrap();
2941
2942 let repo = Repository::init(&repo_path).unwrap();
2943
2944 let map1 = get_cached_ref_map(&repo, &repo_path);
2946
2947 let map2 = get_cached_ref_map(&repo, &repo_path);
2949 assert_eq!(map1.len(), map2.len());
2950
2951 invalidate_ref_map_cache(&repo_path);
2953
2954 let _ = std::fs::remove_dir_all(&repo_path);
2956 }
2957
2958 #[test]
2959 fn test_get_latest_change_time() {
2960 let mut temp_path = std::env::temp_dir();
2961 temp_path.push(format!(
2962 "twig_test_{}",
2963 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2964 ));
2965 std::fs::create_dir_all(&temp_path).unwrap();
2966
2967 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
2968 assert!(change_time > 0);
2969
2970 let _ = std::fs::remove_dir_all(&temp_path);
2971 }
2972
2973 #[test]
2974 fn test_committer_stats() {
2975 let mut temp_path = std::env::temp_dir();
2976 temp_path.push(format!(
2977 "twig_test_{}",
2978 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2979 ));
2980 std::fs::create_dir_all(&temp_path).unwrap();
2981
2982 let repo = Repository::init(&temp_path).unwrap();
2984
2985 let mut config = repo.config().unwrap();
2987 config.set_str("user.name", "Test User").unwrap();
2988 config.set_str("user.email", "test@example.com").unwrap();
2989
2990 let file_path = temp_path.join("test.txt");
2992 let mut file = File::create(&file_path).unwrap();
2993 writeln!(file, "initial content").unwrap();
2994
2995 stage_file(&temp_path, "test.txt").unwrap();
2997 commit_changes(&temp_path, "initial commit").unwrap();
2998
2999 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
3001 assert_eq!(stats.len(), 1);
3002 assert_eq!(stats[0].name, "Test User");
3003 assert_eq!(stats[0].email, "test@example.com");
3004 assert_eq!(stats[0].count, 1);
3005 assert!(!limit_reached);
3006
3007 let _ = std::fs::remove_dir_all(&temp_path);
3009 }
3010
3011 #[test]
3012 fn test_untracked_files_in_unstaged() {
3013 let mut temp_path = std::env::temp_dir();
3014 temp_path.push(format!(
3015 "twig_test_{}",
3016 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3017 ));
3018 std::fs::create_dir_all(&temp_path).unwrap();
3019
3020 let _repo = Repository::init(&temp_path).unwrap();
3022
3023 let file_path = temp_path.join("untracked.txt");
3025 let mut file = File::create(&file_path).unwrap();
3026 writeln!(file, "hello untracked").unwrap();
3027
3028 let untracked_dir = temp_path.join("untracked_dir");
3030 std::fs::create_dir_all(&untracked_dir).unwrap();
3031 let nested_file_path = untracked_dir.join("nested.txt");
3032 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
3033
3034 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3036 match detail {
3037 ItemDetail::Repo { info, .. } => {
3038 let unstaged_paths: Vec<String> =
3040 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
3041 let untracked_paths: Vec<String> =
3042 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
3043
3044 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
3046 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
3047 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
3048 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
3049
3050 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
3052 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
3053 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
3054 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
3055 }
3056 _ => panic!("Expected ItemDetail::Repo"),
3057 }
3058
3059 let _ = std::fs::remove_dir_all(&temp_path);
3061 }
3062
3063 #[test]
3064 fn test_stage_new_and_deleted_files() {
3065 let mut temp_path = std::env::temp_dir();
3066 temp_path.push(format!(
3067 "twig_test_{}",
3068 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3069 ));
3070 std::fs::create_dir_all(&temp_path).unwrap();
3071
3072 let repo = Repository::init(&temp_path).unwrap();
3074
3075 let mut config = repo.config().unwrap();
3077 config.set_str("user.name", "Test User").unwrap();
3078 config.set_str("user.email", "test@example.com").unwrap();
3079
3080 let init_file = temp_path.join("init.txt");
3082 std::fs::write(&init_file, "initial").unwrap();
3083 stage_file(&temp_path, "init.txt").unwrap();
3084 commit_changes(&temp_path, "initial commit").unwrap();
3085
3086 let untracked_file = temp_path.join("untracked.txt");
3088 std::fs::write(&untracked_file, "new file content").unwrap();
3089
3090 stage_file(&temp_path, "untracked.txt").unwrap();
3092
3093 std::fs::remove_file(&init_file).unwrap();
3095
3096 stage_file(&temp_path, "init.txt").unwrap();
3098
3099 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3101 match detail {
3102 ItemDetail::Repo { info, .. } => {
3103 assert_eq!(info.changes.staged.len(), 2);
3105 let paths: Vec<String> =
3106 info.changes.staged.iter().map(|f| f.path.clone()).collect();
3107 assert!(paths.contains(&"untracked.txt".to_string()));
3108 assert!(paths.contains(&"init.txt".to_string()));
3109 }
3110 _ => panic!("Expected ItemDetail::Repo"),
3111 }
3112
3113 let _ = std::fs::remove_dir_all(&temp_path);
3115 }
3116
3117 #[test]
3118 fn test_discard_file_changes_all_cases() {
3119 let mut temp_path = std::env::temp_dir();
3120 temp_path.push(format!(
3121 "twig_test_{}",
3122 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3123 ));
3124 std::fs::create_dir_all(&temp_path).unwrap();
3125
3126 let repo = Repository::init(&temp_path).unwrap();
3128
3129 let mut config = repo.config().unwrap();
3131 config.set_str("user.name", "Test User").unwrap();
3132 config.set_str("user.email", "test@example.com").unwrap();
3133
3134 let file_tracked = temp_path.join("tracked.txt");
3136 std::fs::write(&file_tracked, "original content\n").unwrap();
3137 stage_file(&temp_path, "tracked.txt").unwrap();
3138 commit_changes(&temp_path, "initial commit").unwrap();
3139
3140 let file_untracked = temp_path.join("untracked.txt");
3142 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
3143 assert!(file_untracked.exists());
3144 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
3145 assert!(!file_untracked.exists());
3146
3147 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
3149 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3150 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3151
3152 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
3154 stage_file(&temp_path, "tracked.txt").unwrap();
3155 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3157 match detail {
3158 ItemDetail::Repo { info, .. } => {
3159 assert!(!info.changes.staged.is_empty());
3160 }
3161 _ => panic!("Expected ItemDetail::Repo"),
3162 }
3163 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
3164 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3165 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3167 match detail {
3168 ItemDetail::Repo { info, .. } => {
3169 assert!(info.changes.staged.is_empty());
3170 assert!(info.changes.unstaged.is_empty());
3171 }
3172 _ => panic!("Expected ItemDetail::Repo"),
3173 }
3174
3175 std::fs::remove_file(&file_tracked).unwrap();
3177 assert!(!file_tracked.exists());
3178 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3179 assert!(file_tracked.exists());
3180 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3181
3182 let _ = std::fs::remove_dir_all(&temp_path);
3184 }
3185
3186 #[test]
3187 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3188 fn test_stage_unstage_by_hunk() {
3189 let mut temp_path = std::env::temp_dir();
3190 temp_path.push(format!(
3191 "twig_test_{}",
3192 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3193 ));
3194 std::fs::create_dir_all(&temp_path).unwrap();
3195
3196 let repo = Repository::init(&temp_path).unwrap();
3198
3199 let mut config = repo.config().unwrap();
3201 config.set_str("user.name", "Test User").unwrap();
3202 config.set_str("user.email", "test@example.com").unwrap();
3203
3204 let file_path = temp_path.join("multihunk.txt");
3206 let mut file = File::create(&file_path).unwrap();
3207 for i in 1..=20 {
3208 writeln!(file, "Line {}", i).unwrap();
3209 }
3210 drop(file);
3211
3212 stage_file(&temp_path, "multihunk.txt").unwrap();
3214 commit_changes(&temp_path, "initial commit").unwrap();
3215
3216 let mut file = File::create(&file_path).unwrap();
3218 for i in 1..=20 {
3219 if i == 2 || i == 18 {
3220 writeln!(file, "Line {} modified", i).unwrap();
3221 } else {
3222 writeln!(file, "Line {}", i).unwrap();
3223 }
3224 }
3225 drop(file);
3226
3227 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3229 let mut hunk_ranges = Vec::new();
3231 let mut current_start = None;
3232 for (i, line) in diff_lines.iter().enumerate() {
3233 if line.kind == DiffLineKind::Header {
3234 if let Some(start) = current_start {
3235 hunk_ranges.push(start..i);
3236 }
3237 current_start = Some(i);
3238 }
3239 }
3240 if let Some(start) = current_start {
3241 hunk_ranges.push(start..diff_lines.len());
3242 }
3243
3244 assert_eq!(hunk_ranges.len(), 2);
3246
3247 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3249 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
3250
3251 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3253 let staged_content: String =
3254 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3255 assert!(staged_content.contains("Line 18 modified"));
3256 assert!(!staged_content.contains("Line 2 modified"));
3257
3258 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3260 let unstaged_content: String =
3261 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3262 assert!(unstaged_content.contains("Line 2 modified"));
3263 assert!(!unstaged_content.contains("Line 18 modified"));
3264
3265 let staged_hunk_ranges = {
3267 let mut ranges = Vec::new();
3268 let mut current_start = None;
3269 for (i, line) in staged_diff.iter().enumerate() {
3270 if line.kind == DiffLineKind::Header {
3271 if let Some(start) = current_start {
3272 ranges.push(start..i);
3273 }
3274 current_start = Some(i);
3275 }
3276 }
3277 if let Some(start) = current_start {
3278 ranges.push(start..staged_diff.len());
3279 }
3280 ranges
3281 };
3282 assert_eq!(staged_hunk_ranges.len(), 1);
3283 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
3284 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
3285
3286 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3288 assert!(staged_diff_after.is_empty());
3289
3290 let _ = std::fs::remove_dir_all(&temp_path);
3292 }
3293
3294 #[test]
3295 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3296 fn test_discard_hunk() {
3297 let mut temp_path = std::env::temp_dir();
3298 temp_path.push(format!(
3299 "twig_test_{}",
3300 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3301 ));
3302 std::fs::create_dir_all(&temp_path).unwrap();
3303
3304 let repo = Repository::init(&temp_path).unwrap();
3306
3307 let mut config = repo.config().unwrap();
3309 config.set_str("user.name", "Test User").unwrap();
3310 config.set_str("user.email", "test@example.com").unwrap();
3311
3312 let file_path = temp_path.join("discardhunk.txt");
3314 let mut file = File::create(&file_path).unwrap();
3315 for i in 1..=20 {
3316 writeln!(file, "Line {}", i).unwrap();
3317 }
3318 drop(file);
3319
3320 stage_file(&temp_path, "discardhunk.txt").unwrap();
3322 commit_changes(&temp_path, "initial commit").unwrap();
3323
3324 let mut file = File::create(&file_path).unwrap();
3326 for i in 1..=20 {
3327 if i == 2 || i == 18 {
3328 writeln!(file, "Line {} modified", i).unwrap();
3329 } else {
3330 writeln!(file, "Line {}", i).unwrap();
3331 }
3332 }
3333 drop(file);
3334
3335 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
3337 let mut hunk_ranges = Vec::new();
3339 let mut current_start = None;
3340 for (i, line) in diff_lines.iter().enumerate() {
3341 if line.kind == DiffLineKind::Header {
3342 if let Some(start) = current_start {
3343 hunk_ranges.push(start..i);
3344 }
3345 current_start = Some(i);
3346 }
3347 }
3348 if let Some(start) = current_start {
3349 hunk_ranges.push(start..diff_lines.len());
3350 }
3351
3352 assert_eq!(hunk_ranges.len(), 2);
3354
3355 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3357 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
3358
3359 let contents = std::fs::read_to_string(&file_path).unwrap();
3361 assert!(contents.contains("Line 2 modified"));
3362 assert!(contents.contains("Line 18\n"));
3363 assert!(!contents.contains("Line 18 modified"));
3364
3365 let _ = std::fs::remove_dir_all(&temp_path);
3367 }
3368
3369 #[test]
3370 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3371 fn test_stage_unstage_discard_line() {
3372 let mut temp_path = std::env::temp_dir();
3373 temp_path.push(format!(
3374 "twig_test_{}",
3375 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3376 ));
3377 std::fs::create_dir_all(&temp_path).unwrap();
3378
3379 let repo = Repository::init(&temp_path).unwrap();
3380 let mut config = repo.config().unwrap();
3381 config.set_str("user.name", "Test User").unwrap();
3382 config.set_str("user.email", "test@example.com").unwrap();
3383
3384 let file_path = temp_path.join("line_test.txt");
3386 let mut file = File::create(&file_path).unwrap();
3387 writeln!(file, "line A").unwrap();
3388 writeln!(file, "line B").unwrap();
3389 writeln!(file, "line C").unwrap();
3390 drop(file);
3391
3392 stage_file(&temp_path, "line_test.txt").unwrap();
3393 commit_changes(&temp_path, "initial").unwrap();
3394
3395 let mut file = File::create(&file_path).unwrap();
3397 writeln!(file, "line A modified").unwrap();
3398 writeln!(file, "line B").unwrap();
3399 writeln!(file, "line C modified").unwrap();
3400 drop(file);
3401
3402 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3403 let mut hunk_ranges = Vec::new();
3404 let mut current_start = None;
3405 for (i, line) in diff_lines.iter().enumerate() {
3406 if line.kind == DiffLineKind::Header {
3407 if let Some(start) = current_start {
3408 hunk_ranges.push(start..i);
3409 }
3410 current_start = Some(i);
3411 }
3412 }
3413 if let Some(start) = current_start {
3414 hunk_ranges.push(start..diff_lines.len());
3415 }
3416
3417 assert_eq!(hunk_ranges.len(), 1);
3418 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3419
3420 assert_eq!(hunk0[2].content, "line A modified");
3421 assert_eq!(hunk0[5].content, "line C modified");
3422
3423 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3425
3426 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3428 assert!(
3429 staged_diff
3430 .iter()
3431 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3432 );
3433 assert!(
3434 !staged_diff
3435 .iter()
3436 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3437 );
3438
3439 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3441 assert!(
3442 !unstaged_diff
3443 .iter()
3444 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3445 );
3446 assert!(
3447 unstaged_diff
3448 .iter()
3449 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3450 );
3451
3452 assert_eq!(staged_diff[2].content, "line A modified");
3454 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3455
3456 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3458
3459 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3461 assert_eq!(unstaged_diff2[5].content, "line C modified");
3462 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3463
3464 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3465 let remove_idx = unstaged_diff3
3466 .iter()
3467 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3468 .unwrap();
3469 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3470
3471 let contents = std::fs::read_to_string(&file_path).unwrap();
3473 assert!(contents.contains("line A modified"));
3474 assert!(contents.contains("line B"));
3475 assert!(contents.contains("line C\n"));
3476 assert!(!contents.contains("line C modified"));
3477
3478 let _ = std::fs::remove_dir_all(&temp_path);
3480 }
3481
3482 #[test]
3483 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3484 fn test_stage_unstage_discard_all_changes() {
3485 let mut temp_path = std::env::temp_dir();
3486 temp_path.push(format!(
3487 "twig_test_all_{}",
3488 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3489 ));
3490 std::fs::create_dir_all(&temp_path).unwrap();
3491
3492 let repo = Repository::init(&temp_path).unwrap();
3494
3495 let mut config = repo.config().unwrap();
3497 config.set_str("user.name", "Test User").unwrap();
3498 config.set_str("user.email", "test@example.com").unwrap();
3499
3500 let file_path = temp_path.join("tracked.txt");
3502 std::fs::write(&file_path, "original content\n").unwrap();
3503 stage_file(&temp_path, "tracked.txt").unwrap();
3504 commit_changes(&temp_path, "initial").unwrap();
3505
3506 std::fs::write(&file_path, "modified content\n").unwrap();
3508 let untracked_path = temp_path.join("untracked.txt");
3509 std::fs::write(&untracked_path, "untracked content\n").unwrap();
3510
3511 let status = repo.statuses(None).unwrap();
3513 assert_eq!(status.len(), 2);
3514
3515 stage_all_changes(&temp_path).unwrap();
3517
3518 let status = repo.statuses(None).unwrap();
3520 for entry in status.iter() {
3521 assert!(
3522 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
3523 );
3524 }
3525
3526 unstage_all_changes(&temp_path).unwrap();
3528
3529 let status = repo.statuses(None).unwrap();
3531 for entry in status.iter() {
3532 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
3533 }
3534
3535 discard_all_changes(&temp_path).unwrap();
3537
3538 let status = repo.statuses(None).unwrap();
3540 assert_eq!(status.len(), 0);
3541
3542 let contents = std::fs::read_to_string(&file_path).unwrap();
3544 assert_eq!(contents, "original content\n");
3545 assert!(!untracked_path.exists());
3546
3547 let _ = std::fs::remove_dir_all(&temp_path);
3549 }
3550
3551 #[test]
3552 fn test_merge_conflicts_flow() {
3553 let mut temp_path = std::env::temp_dir();
3554 temp_path.push(format!(
3555 "twig_test_conflict_{}",
3556 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3557 ));
3558 std::fs::create_dir_all(&temp_path).unwrap();
3559
3560 let repo = Repository::init(&temp_path).unwrap();
3562
3563 let mut config = repo.config().unwrap();
3565 config.set_str("user.name", "Test User").unwrap();
3566 config.set_str("user.email", "test@example.com").unwrap();
3567
3568 let file_path = temp_path.join("conflict.txt");
3570 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3571 stage_file(&temp_path, "conflict.txt").unwrap();
3572 commit_changes(&temp_path, "initial commit").unwrap();
3573
3574 let output = std::process::Command::new("git")
3576 .env("GIT_TERMINAL_PROMPT", "0")
3577 .env("GIT_SSH_COMMAND", ssh_command_val())
3578 .args(["symbolic-ref", "--short", "HEAD"])
3579 .current_dir(&temp_path)
3580 .output()
3581 .unwrap();
3582 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3583
3584 std::process::Command::new("git")
3586 .env("GIT_TERMINAL_PROMPT", "0")
3587 .env("GIT_SSH_COMMAND", ssh_command_val())
3588 .args(["checkout", "-b", "feature"])
3589 .current_dir(&temp_path)
3590 .output()
3591 .unwrap();
3592
3593 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
3594 stage_file(&temp_path, "conflict.txt").unwrap();
3595 commit_changes(&temp_path, "feature commit").unwrap();
3596
3597 std::process::Command::new("git")
3599 .env("GIT_TERMINAL_PROMPT", "0")
3600 .env("GIT_SSH_COMMAND", ssh_command_val())
3601 .args(["checkout", &main_branch])
3602 .current_dir(&temp_path)
3603 .output()
3604 .unwrap();
3605
3606 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
3607 stage_file(&temp_path, "conflict.txt").unwrap();
3608 commit_changes(&temp_path, "main commit").unwrap();
3609
3610 assert!(!is_merging(&temp_path));
3612 let merge_output = std::process::Command::new("git")
3613 .env("GIT_TERMINAL_PROMPT", "0")
3614 .env("GIT_SSH_COMMAND", ssh_command_val())
3615 .args(["merge", "feature"])
3616 .current_dir(&temp_path)
3617 .output()
3618 .unwrap();
3619
3620 assert!(!merge_output.status.success());
3621 assert!(is_merging(&temp_path));
3622
3623 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
3625 assert!(!diff.is_empty());
3626 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
3627 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
3628 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
3629 assert!(has_separator);
3630 assert!(has_ours);
3631 assert!(has_theirs);
3632
3633 abort_merge(&temp_path).unwrap();
3635 assert!(!is_merging(&temp_path));
3636
3637 std::process::Command::new("git")
3639 .env("GIT_TERMINAL_PROMPT", "0")
3640 .env("GIT_SSH_COMMAND", ssh_command_val())
3641 .args(["merge", "feature"])
3642 .current_dir(&temp_path)
3643 .output()
3644 .unwrap();
3645 assert!(is_merging(&temp_path));
3646
3647 resolve_ours(&temp_path, "conflict.txt").unwrap();
3649 let contents = std::fs::read_to_string(&file_path).unwrap();
3650 assert!(contents.contains("line 2 on main"));
3651 assert!(!contents.contains("<<<<<<<"));
3652
3653 continue_merge(&temp_path).unwrap();
3655 assert!(!is_merging(&temp_path));
3656
3657 std::process::Command::new("git")
3659 .env("GIT_TERMINAL_PROMPT", "0")
3660 .env("GIT_SSH_COMMAND", ssh_command_val())
3661 .args(["reset", "--hard", "HEAD~1"])
3662 .current_dir(&temp_path)
3663 .output()
3664 .unwrap();
3665
3666 std::process::Command::new("git")
3667 .env("GIT_TERMINAL_PROMPT", "0")
3668 .env("GIT_SSH_COMMAND", ssh_command_val())
3669 .args(["merge", "feature"])
3670 .current_dir(&temp_path)
3671 .output()
3672 .unwrap();
3673 assert!(is_merging(&temp_path));
3674
3675 resolve_theirs(&temp_path, "conflict.txt").unwrap();
3676 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
3677 assert!(contents_theirs.contains("line 2 on feature"));
3678 assert!(!contents_theirs.contains("<<<<<<<"));
3679
3680 continue_merge(&temp_path).unwrap();
3681 assert!(!is_merging(&temp_path));
3682
3683 let _ = std::fs::remove_dir_all(&temp_path);
3685 }
3686
3687 #[test]
3688 fn test_resolve_conflict_hunk() {
3689 let mut temp_path = std::env::temp_dir();
3690 temp_path.push(format!(
3691 "twig_test_hunk_conflict_{}",
3692 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3693 ));
3694 std::fs::create_dir_all(&temp_path).unwrap();
3695
3696 let repo = Repository::init(&temp_path).unwrap();
3698
3699 let mut config = repo.config().unwrap();
3701 config.set_str("user.name", "Test User").unwrap();
3702 config.set_str("user.email", "test@example.com").unwrap();
3703
3704 let file_path = temp_path.join("conflict.txt");
3706 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";
3707 std::fs::write(&file_path, initial_lines).unwrap();
3708 stage_file(&temp_path, "conflict.txt").unwrap();
3709 commit_changes(&temp_path, "initial commit").unwrap();
3710
3711 let output = std::process::Command::new("git")
3713 .env("GIT_TERMINAL_PROMPT", "0")
3714 .env("GIT_SSH_COMMAND", ssh_command_val())
3715 .args(["symbolic-ref", "--short", "HEAD"])
3716 .current_dir(&temp_path)
3717 .output()
3718 .unwrap();
3719 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3720
3721 std::process::Command::new("git")
3723 .env("GIT_TERMINAL_PROMPT", "0")
3724 .env("GIT_SSH_COMMAND", ssh_command_val())
3725 .args(["checkout", "-b", "feature"])
3726 .current_dir(&temp_path)
3727 .output()
3728 .unwrap();
3729 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";
3730 std::fs::write(&file_path, feature_lines).unwrap();
3731 stage_file(&temp_path, "conflict.txt").unwrap();
3732 commit_changes(&temp_path, "feature commit").unwrap();
3733
3734 std::process::Command::new("git")
3736 .env("GIT_TERMINAL_PROMPT", "0")
3737 .env("GIT_SSH_COMMAND", ssh_command_val())
3738 .args(["checkout", &main_branch])
3739 .current_dir(&temp_path)
3740 .output()
3741 .unwrap();
3742 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";
3743 std::fs::write(&file_path, main_lines).unwrap();
3744 stage_file(&temp_path, "conflict.txt").unwrap();
3745 commit_changes(&temp_path, "main commit").unwrap();
3746
3747 let merge_output = std::process::Command::new("git")
3749 .env("GIT_TERMINAL_PROMPT", "0")
3750 .env("GIT_SSH_COMMAND", ssh_command_val())
3751 .args(["merge", "feature"])
3752 .current_dir(&temp_path)
3753 .output()
3754 .unwrap();
3755 assert!(!merge_output.status.success());
3756 assert!(is_merging(&temp_path));
3757
3758 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
3760 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
3761 assert!(contents_after_first.contains("line 2 on main"));
3763 assert!(!contents_after_first.contains("line 2 on feature"));
3764 assert!(contents_after_first.contains("<<<<<<<"));
3766 assert!(contents_after_first.contains("line 11 on main"));
3767 assert!(contents_after_first.contains("line 11 on feature"));
3768
3769 assert!(is_merging(&temp_path));
3771
3772 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
3777 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
3778 assert!(contents_after_second.contains("line 2 on main"));
3780 assert!(contents_after_second.contains("line 11 on feature"));
3781 assert!(!contents_after_second.contains("<<<<<<<"));
3782
3783 let status = repo.statuses(None).unwrap();
3785 assert_eq!(status.len(), 1);
3786 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
3787
3788 continue_merge(&temp_path).unwrap();
3790 assert!(!is_merging(&temp_path));
3791
3792 let _ = std::fs::remove_dir_all(&temp_path);
3794 }
3795
3796 #[test]
3797 fn test_branch_and_commit_helpers() {
3798 let mut temp_path = std::env::temp_dir();
3799 temp_path.push(format!(
3800 "twig_test_helpers_{}",
3801 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3802 ));
3803 let _ = std::fs::remove_dir_all(&temp_path);
3804 std::fs::create_dir_all(&temp_path).unwrap();
3805
3806 let repo = Repository::init(&temp_path).unwrap();
3808
3809 let mut config = repo.config().unwrap();
3811 config.set_str("user.name", "Test User").unwrap();
3812 config.set_str("user.email", "test@example.com").unwrap();
3813
3814 let file_path = temp_path.join("test.txt");
3816 std::fs::write(&file_path, "root content").unwrap();
3817 stage_file(&temp_path, "test.txt").unwrap();
3818 commit_changes(&temp_path, "root commit").unwrap();
3819
3820 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3821 assert!(is_root_commit(&temp_path, &head_oid));
3822
3823 std::fs::write(&file_path, "second content").unwrap();
3825 stage_file(&temp_path, "test.txt").unwrap();
3826 commit_changes(&temp_path, "second commit").unwrap();
3827
3828 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
3829 assert!(!is_root_commit(&temp_path, &new_head_oid));
3830
3831 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
3836 let target = get_branch_push_target(&temp_path, "master")
3837 .or_else(|| get_branch_push_target(&temp_path, "main"));
3838 assert!(target.is_some());
3839 let (remote_name, set_upstream) = target.unwrap();
3840 assert_eq!(remote_name, "origin");
3841 assert!(set_upstream);
3842
3843 let _ = std::fs::remove_dir_all(&temp_path);
3845 }
3846}