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