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