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