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