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