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 Ok(info)
1170}
1171
1172pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1173 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1174 let mut files = Vec::new();
1175 if let Ok(index) = repo.index() {
1176 for entry in index.iter() {
1177 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1178 files.push(path_str.to_string());
1179 }
1180 }
1181 }
1182 Ok(files)
1183}
1184
1185pub fn load_tab_graph_stream(
1186 repo_path: &Path,
1187 graph_max_commits: usize,
1188 repo_resolved_path: String,
1189 tab_idx: usize,
1190 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1191) -> Result<Vec<GraphLine>, String> {
1192 let mut graph_lines = Vec::new();
1193 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1194
1195 let mut args = vec![
1196 "log".to_string(),
1197 "--graph".to_string(),
1198 "--all".to_string(),
1199 "--date=relative".to_string(),
1200 ];
1201 if graph_max_commits > 0 {
1202 args.push(format!("--max-count={}", graph_max_commits));
1203 }
1204 args.push(format!("--pretty=format:{}", format_str));
1205 args.push("--color=never".to_string());
1206
1207 let mut child = std::process::Command::new("git")
1208 .env("GIT_TERMINAL_PROMPT", "0")
1209 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1210 .args(&args)
1211 .current_dir(repo_path)
1212 .stdout(std::process::Stdio::piped())
1213 .spawn()
1214 .map_err(|e| e.to_string())?;
1215
1216 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1217 let reader = std::io::BufReader::new(stdout);
1218 use std::io::BufRead;
1219
1220 for (idx, line_res) in reader.lines().enumerate() {
1221 let line = line_res.map_err(|e| e.to_string())?;
1222 let parsed = parse_graph_line(&line);
1223 graph_lines.push(parsed);
1224
1225 if (idx + 1) % 200 == 0 {
1227 let _ = tx.send((
1228 repo_resolved_path.clone(),
1229 tab_idx,
1230 TabPayload::Graph(Ok(graph_lines.clone())),
1231 ));
1232 }
1233 }
1234
1235 let status = child.wait().map_err(|e| e.to_string())?;
1237 if !status.success() && graph_lines.is_empty() {
1238 return Err("git log failed".to_string());
1239 }
1240
1241 Ok(graph_lines)
1242}
1243
1244pub fn load_tab_branches(
1245 repo_path: &Path,
1246) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1247 let repo = match Repository::open(repo_path) {
1248 Ok(r) => r,
1249 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1250 };
1251
1252 let mut local_branches = Vec::new();
1253 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1254 for (branch, _) in branches.flatten() {
1255 if let Ok(Some(name)) = branch.name() {
1256 let is_head = branch.is_head();
1257 let mut short_sha = String::new();
1258 let mut short_message = String::new();
1259 if let Ok(target) = branch.get().peel_to_commit() {
1260 let id = target.id();
1261 short_sha = id.to_string()[..7.min(id.to_string().len())].to_string();
1262 if let Ok(Some(summary)) = target.summary() {
1263 short_message = summary.to_string();
1264 }
1265 }
1266 local_branches.push(BranchInfo {
1267 name: name.to_string(),
1268 is_head,
1269 short_sha,
1270 short_message,
1271 });
1272 }
1273 }
1274 }
1275 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1276
1277 let mut remote_branches = Vec::new();
1278 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1279 for (branch, _) in branches.flatten() {
1280 if let Ok(Some(name)) = branch.name() {
1281 if !name.ends_with("/HEAD") {
1282 let is_head = branch.is_head();
1283 let mut short_sha = String::new();
1284 let mut short_message = String::new();
1285 if let Ok(target) = branch.get().peel_to_commit() {
1286 let id = target.id();
1287 short_sha = id.to_string()[..7.min(id.to_string().len())].to_string();
1288 if let Ok(Some(summary)) = target.summary() {
1289 short_message = summary.to_string();
1290 }
1291 }
1292 remote_branches.push(BranchInfo {
1293 name: name.to_string(),
1294 is_head,
1295 short_sha,
1296 short_message,
1297 });
1298 }
1299 }
1300 }
1301 }
1302 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1303
1304 (Ok(local_branches), Ok(remote_branches))
1305}
1306
1307pub fn load_tab_tags(
1308 repo_path: &Path,
1309) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1310 let repo = match Repository::open(repo_path) {
1311 Ok(r) => r,
1312 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1313 };
1314
1315 let mut local_tags = Vec::new();
1316 if let Ok(tags) = repo.tag_names(None) {
1317 for tag_opt in tags.iter() {
1318 if let Ok(Some(tag)) = tag_opt {
1319 let mut short_sha = String::new();
1320 let mut short_message = String::new();
1321 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1322 if let Ok(target) = reference.peel_to_commit() {
1323 let id = target.id();
1324 short_sha = id.to_string()[..7.min(id.to_string().len())].to_string();
1325 if let Ok(Some(summary)) = target.summary() {
1326 short_message = summary.to_string();
1327 }
1328 }
1329 }
1330 local_tags.push(BranchInfo {
1331 name: tag.to_string(),
1332 is_head: false,
1333 short_sha,
1334 short_message,
1335 });
1336 }
1337 }
1338 }
1339 local_tags.sort_by(|a, b| a.name.cmp(&b.name));
1340
1341 (Ok(local_tags), Ok(Vec::new()))
1342}
1343
1344pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1345 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1346 let mut remotes_list = Vec::new();
1347 if let Ok(remotes) = repo.remotes() {
1348 for name in remotes.iter() {
1349 let Ok(Some(name)) = name else { continue };
1350 if let Ok(remote) = repo.find_remote(name) {
1351 let push_url = remote.pushurl().ok().flatten().map(String::from);
1352 let mut refspecs = Vec::new();
1353 for r in remote.refspecs() {
1354 if let Ok(s) = r.str() {
1355 refspecs.push(s.to_string());
1356 }
1357 }
1358 remotes_list.push(RemoteInfo {
1359 name: name.to_string(),
1360 url: remote.url().unwrap_or("(no url)").to_string(),
1361 push_url,
1362 refspecs,
1363 });
1364 }
1365 }
1366 }
1367 Ok(remotes_list)
1368}
1369
1370pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1371 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1372 let mut temp_stashes = Vec::new();
1373 let _ = repo.stash_foreach(|index, message, oid| {
1374 temp_stashes.push((index, message.to_string(), *oid));
1375 true
1376 });
1377
1378 let mut stashes = Vec::new();
1379 for (index, message, oid) in temp_stashes {
1380 let mut files = Vec::new();
1381 if let Ok(commit) = repo.find_commit(oid) {
1382 files = commit_changed_files(&repo, &commit);
1383 }
1384 stashes.push(StashInfo { index, message, commit_id: oid.to_string(), files });
1385 }
1386 Ok(stashes)
1387}
1388
1389pub fn load_tab_overview(
1390 repo_path: &Path,
1391 commit_limit: usize,
1392) -> Result<(Vec<CommitterStat>, bool), String> {
1393 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1394 let stats_limit = if commit_limit > 0 { commit_limit.min(10000) } else { 10000 };
1395 let (stats, limit_reached) =
1396 collect_committer_stats(&repo, stats_limit).map_err(|e| e.to_string())?;
1397 Ok((stats, limit_reached))
1398}
1399
1400fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1405 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1406 std::collections::HashMap::new();
1407
1408 if let Ok(refs) = repo.references() {
1409 for reference in refs.flatten() {
1410 let Ok(target) = reference.peel_to_commit() else {
1412 continue;
1413 };
1414 let oid = target.id();
1415
1416 let Ok(full_name) = reference.name() else {
1417 continue;
1418 };
1419
1420 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1421 branch.to_string()
1422 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1423 format!("tag:{}", tag)
1424 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1425 if remote.ends_with("/HEAD") {
1427 continue;
1428 }
1429 format!("remote:{}", remote)
1430 } else {
1431 continue;
1432 };
1433
1434 map.entry(oid).or_default().push(label);
1435 }
1436 }
1437 map
1438}
1439
1440#[allow(clippy::type_complexity)]
1441static REF_MAP_CACHE: std::sync::OnceLock<
1442 std::sync::Mutex<
1443 std::collections::HashMap<
1444 String,
1445 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1446 >,
1447 >,
1448> = std::sync::OnceLock::new();
1449
1450fn get_cached_ref_map(
1451 repo: &Repository,
1452 repo_path: &Path,
1453) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1454 let cache_lock =
1455 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1456 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1457 let path_key = repo_path.to_string_lossy().to_string();
1458
1459 if let Some((map, loaded_at)) = cache.get(&path_key) {
1460 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1461 return map.clone();
1462 }
1463 }
1464
1465 let map = build_ref_map(repo);
1466 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1467 map
1468}
1469
1470pub fn invalidate_ref_map_cache(repo_path: &Path) {
1471 if let Some(cache_lock) = REF_MAP_CACHE.get() {
1472 if let Ok(mut cache) = cache_lock.lock() {
1473 cache.remove(&repo_path.to_string_lossy().to_string());
1474 }
1475 }
1476}
1477
1478const MAX_FILES_PER_SECTION: usize = 100;
1481
1482fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1484 let mut opts = StatusOptions::new();
1485 opts.include_untracked(true)
1486 .renames_head_to_index(true)
1487 .recurse_untracked_dirs(true)
1488 .show(StatusShow::IndexAndWorkdir);
1489 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1490 return;
1491 };
1492 for entry in statuses.iter() {
1493 let path = entry.path().unwrap_or("(unknown)").to_string();
1494 let flags = entry.status();
1495
1496 if flags.is_conflicted() {
1498 info.summary.conflicted += 1;
1499 } else {
1500 if flags.is_wt_new() {
1501 info.summary.untracked += 1;
1502 }
1503 if flags.is_wt_modified()
1504 || flags.is_wt_deleted()
1505 || flags.is_wt_renamed()
1506 || flags.is_wt_typechange()
1507 {
1508 info.summary.modified += 1;
1509 }
1510 if flags.is_index_new()
1511 || flags.is_index_modified()
1512 || flags.is_index_deleted()
1513 || flags.is_index_renamed()
1514 || flags.is_index_typechange()
1515 {
1516 info.summary.staged += 1;
1517 }
1518 }
1519
1520 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
1523 if path_buf.is_dir() {
1524 continue;
1525 }
1526
1527 if flags.is_conflicted() {
1528 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
1529 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
1530 }
1531 continue;
1532 }
1533
1534 if (flags.is_index_new()
1536 || flags.is_index_modified()
1537 || flags.is_index_deleted()
1538 || flags.is_index_renamed()
1539 || flags.is_index_typechange())
1540 && info.changes.staged.len() < MAX_FILES_PER_SECTION
1541 {
1542 let label = if flags.is_index_new() {
1543 "N"
1544 } else if flags.is_index_deleted() {
1545 "D"
1546 } else if flags.is_index_renamed() {
1547 "R"
1548 } else if flags.is_index_typechange() {
1549 "T"
1550 } else {
1551 "M"
1552 };
1553 info.changes.staged.push(FileEntry { path: path.clone(), label });
1554 }
1555
1556 if flags.is_wt_new() {
1558 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
1559 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
1560 }
1561 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
1562 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
1563 }
1564 } else if (flags.is_wt_modified()
1565 || flags.is_wt_deleted()
1566 || flags.is_wt_renamed()
1567 || flags.is_wt_typechange())
1568 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
1569 {
1570 let label = if flags.is_wt_deleted() {
1571 "D"
1572 } else if flags.is_wt_renamed() {
1573 "R"
1574 } else if flags.is_wt_typechange() {
1575 "T"
1576 } else {
1577 "M"
1578 };
1579 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
1580 }
1581 }
1582}
1583
1584fn collect_summary(repo: &Repository) -> RepoSummary {
1588 let mut s = RepoSummary::default();
1589 if let Ok(head) = repo.head() {
1591 s.branch = head.shorthand().ok().map(String::from);
1592 }
1593 populate_worktree(repo, &mut s);
1594 populate_ahead_behind(repo, &mut s);
1595 s
1596}
1597
1598fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
1599 let mut opts = StatusOptions::new();
1600 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
1601 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1602 return;
1603 };
1604 for entry in statuses.iter() {
1605 let flags = entry.status();
1606 if flags.is_conflicted() {
1607 s.conflicted += 1;
1608 continue;
1609 }
1610 if flags.is_wt_new() {
1611 s.untracked += 1;
1612 }
1613 if flags.is_wt_modified()
1614 || flags.is_wt_deleted()
1615 || flags.is_wt_renamed()
1616 || flags.is_wt_typechange()
1617 {
1618 s.modified += 1;
1619 }
1620 if flags.is_index_new()
1621 || flags.is_index_modified()
1622 || flags.is_index_deleted()
1623 || flags.is_index_renamed()
1624 || flags.is_index_typechange()
1625 {
1626 s.staged += 1;
1627 }
1628 }
1629}
1630
1631fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
1635 let Ok(head) = repo.head() else { return };
1636 let Some(local_oid) = head.target() else {
1637 return;
1638 };
1639 let Ok(head_name) = head.name() else { return };
1640 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
1641 return;
1642 };
1643 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
1644 return;
1645 };
1646 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
1647 return;
1648 };
1649 let Some(upstream_oid) = upstream_ref.target() else {
1650 return;
1651 };
1652 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
1653 s.ahead = ahead;
1654 s.behind = behind;
1655 }
1656}
1657
1658fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
1660 let buf = repo.branch_upstream_name(head_name).ok()?;
1661 let raw = std::str::from_utf8(&buf).ok()?;
1662 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
1663}
1664
1665fn format_relative_time(secs: i64) -> String {
1667 if secs <= 0 {
1668 return "unknown".to_string();
1669 }
1670 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
1671 let now = SystemTime::now();
1672 let Ok(elapsed) = now.duration_since(then) else {
1673 return "in the future".to_string();
1674 };
1675 let secs = elapsed.as_secs();
1676 let (n, unit) = if secs < 60 {
1677 (secs, "second")
1678 } else if secs < 3600 {
1679 (secs / 60, "minute")
1680 } else if secs < 86_400 {
1681 (secs / 3600, "hour")
1682 } else if secs < 86_400 * 30 {
1683 (secs / 86_400, "day")
1684 } else if secs < 86_400 * 365 {
1685 (secs / (86_400 * 30), "month")
1686 } else {
1687 (secs / (86_400 * 365), "year")
1688 };
1689 let plural = if n == 1 { "" } else { "s" };
1690 format!("{} {}{} ago", n, unit, plural)
1691}
1692
1693fn format_utc_date(secs: i64) -> String {
1695 if secs <= 0 {
1696 return "unknown".to_string();
1697 }
1698 let seconds_in_day = 86400;
1699 let day_number = secs / seconds_in_day;
1700 let time_of_day = secs % seconds_in_day;
1701
1702 let mut hour = time_of_day / 3600;
1703 let mut minute = (time_of_day % 3600) / 60;
1704 let mut second = time_of_day % 60;
1705 if hour < 0 {
1706 hour += 24;
1707 }
1708 if minute < 0 {
1709 minute += 60;
1710 }
1711 if second < 0 {
1712 second += 60;
1713 }
1714
1715 let z = day_number + 719468;
1717 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
1718 let doe = (z - era * 146097) as u32;
1719 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1720 let y = (yoe as i32) + (era as i32) * 400;
1721 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1722 let mp = (5 * doy + 2) / 153;
1723 let d = doy - (153 * mp + 2) / 5 + 1;
1724 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1725 let y = y + if m <= 2 { 1 } else { 0 };
1726
1727 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
1728}
1729
1730fn get_file_diff_inner(
1733 repo_path: &Path,
1734 commit_oid: &str,
1735 file_path: &str,
1736) -> Option<Vec<DiffLine>> {
1737 let repo = Repository::open(repo_path).ok()?;
1738 let oid = git2::Oid::from_str(commit_oid).ok()?;
1739 let commit = repo.find_commit(oid).ok()?;
1740
1741 let commit_tree = commit.tree().ok()?;
1742 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1744
1745 let mut opts = git2::DiffOptions::new();
1746 opts.pathspec(file_path);
1747
1748 let diff =
1749 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
1750
1751 collect_diff_lines(&diff)
1752}
1753
1754fn get_worktree_diff_inner(
1759 repo_path: &Path,
1760 file_path: &str,
1761 staged: bool,
1762) -> Option<Vec<DiffLine>> {
1763 let repo = Repository::open(repo_path).ok()?;
1764 let mut opts = git2::DiffOptions::new();
1765 opts.pathspec(file_path);
1766 opts.include_untracked(true);
1767 opts.recurse_untracked_dirs(true);
1768
1769 let diff = if staged {
1770 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
1772 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
1773 } else {
1774 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
1776 };
1777
1778 collect_diff_lines(&diff)
1779}
1780
1781fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
1783 let mut lines: Vec<DiffLine> = Vec::new();
1784 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
1785 let kind = match line.origin() {
1786 '+' => DiffLineKind::Added,
1787 '-' => DiffLineKind::Removed,
1788 'H' => DiffLineKind::Header,
1789 ' ' => DiffLineKind::Context,
1790 _ => return true, };
1792 let content = String::from_utf8_lossy(line.content())
1793 .trim_end_matches('\n')
1794 .trim_end_matches('\r')
1795 .to_string();
1796 lines.push(DiffLine { kind, content });
1797 true
1798 })
1799 .ok()?;
1800 Some(lines)
1801}
1802
1803fn parse_graph_line(line: &str) -> GraphLine {
1804 if line.contains("__TWIG_SEP__") {
1805 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
1806 if parts.len() >= 5 {
1807 let graph_and_hash = parts[0];
1808 let decoration = parts[1].trim().to_string();
1809 let summary = parts[2].trim().to_string();
1810 let author = parts[3].trim().to_string();
1811 let date = parts[4].trim().to_string();
1812 let signature_status =
1813 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
1814
1815 let char_count = graph_and_hash.chars().count();
1816 if char_count >= 40 {
1817 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
1818 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
1819 GraphLine {
1820 graph,
1821 commit: Some(GraphCommit {
1822 oid,
1823 decoration,
1824 summary,
1825 author,
1826 date,
1827 signature_status,
1828 }),
1829 }
1830 } else {
1831 GraphLine { graph: graph_and_hash.to_string(), commit: None }
1832 }
1833 } else {
1834 GraphLine { graph: line.to_string(), commit: None }
1835 }
1836 } else {
1837 GraphLine { graph: line.to_string(), commit: None }
1838 }
1839}
1840
1841#[allow(dead_code)]
1842fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
1843 let mut graph_lines = Vec::new();
1844 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1845
1846 let mut args = vec![
1847 "log".to_string(),
1848 "--graph".to_string(),
1849 "--all".to_string(),
1850 "--date=relative".to_string(),
1851 ];
1852 if graph_max_commits > 0 {
1853 args.push(format!("--max-count={}", graph_max_commits));
1854 }
1855 args.push(format!("--pretty=format:{}", format_str));
1856 args.push("--color=never".to_string());
1857
1858 let output = std::process::Command::new("git")
1859 .env("GIT_TERMINAL_PROMPT", "0")
1860 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1861 .args(&args)
1862 .current_dir(repo_path)
1863 .output();
1864
1865 if let Ok(out) = output {
1866 if out.status.success() {
1867 let stdout_str = String::from_utf8_lossy(&out.stdout);
1868 for line in stdout_str.lines() {
1869 graph_lines.push(parse_graph_line(line));
1870 }
1871 }
1872 }
1873 graph_lines
1874}
1875
1876pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
1877 let output = std::process::Command::new("git")
1878 .env("GIT_TERMINAL_PROMPT", "0")
1879 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1880 .arg("checkout")
1881 .arg(branch_name)
1882 .current_dir(repo_path)
1883 .output()
1884 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1885
1886 if !output.status.success() {
1887 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1888 return Err(git2::Error::from_str(&err));
1889 }
1890 Ok(())
1891}
1892
1893pub fn checkout_remote_branch(
1894 repo_path: &Path,
1895 remote_branch_name: &str,
1896) -> Result<String, git2::Error> {
1897 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
1898 if parts.len() < 2 {
1899 return Err(git2::Error::from_str("Invalid remote branch name"));
1900 }
1901 let local_name = parts[1];
1902
1903 let output = std::process::Command::new("git")
1904 .env("GIT_TERMINAL_PROMPT", "0")
1905 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1906 .arg("checkout")
1907 .arg(local_name)
1908 .current_dir(repo_path)
1909 .output()
1910 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1911
1912 if output.status.success() {
1913 return Ok(format!("Switched to existing branch '{}'", local_name));
1914 }
1915
1916 let output = std::process::Command::new("git")
1917 .env("GIT_TERMINAL_PROMPT", "0")
1918 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1919 .arg("checkout")
1920 .arg("--track")
1921 .arg(remote_branch_name)
1922 .current_dir(repo_path)
1923 .output()
1924 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1925
1926 if !output.status.success() {
1927 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1928 return Err(git2::Error::from_str(&err));
1929 }
1930
1931 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
1932}
1933
1934pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
1936 let repo = Repository::open(repo_path)?;
1937 let head = repo.head()?;
1938 let target_commit = head.peel_to_commit()?;
1939 repo.branch(branch_name, &target_commit, false)?;
1940 Ok(())
1941}
1942
1943pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
1945 let repo = Repository::open(repo_path)?;
1946 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
1947 branch.delete()?;
1948 Ok(())
1949}
1950
1951pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
1953 let repo = Repository::open(repo_path)?;
1954 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
1955 branch.delete()?;
1956 Ok(())
1957}
1958
1959pub fn create_tag(
1961 repo_path: &Path,
1962 tag_name: &str,
1963 commit_oid_str: &str,
1964) -> Result<(), git2::Error> {
1965 let repo = Repository::open(repo_path)?;
1966 let oid = git2::Oid::from_str(commit_oid_str)?;
1967 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
1968 repo.tag_lightweight(tag_name, &target_object, false)?;
1969 Ok(())
1970}
1971
1972pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
1974 let repo = Repository::open(repo_path)?;
1975 repo.tag_delete(tag_name)?;
1976 Ok(())
1977}
1978
1979pub fn delete_remote_tag(
1981 repo_path: &Path,
1982 remote_name: &str,
1983 tag_name: &str,
1984) -> Result<(), Box<dyn std::error::Error>> {
1985 let output = std::process::Command::new("git")
1986 .env("GIT_TERMINAL_PROMPT", "0")
1987 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
1988 .arg("push")
1989 .arg(remote_name)
1990 .arg("--delete")
1991 .arg(tag_name)
1992 .current_dir(repo_path)
1993 .output()?;
1994 if !output.status.success() {
1995 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1996 return Err(err.into());
1997 }
1998 Ok(())
1999}
2000
2001pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2002 let output = std::process::Command::new("git")
2003 .env("GIT_TERMINAL_PROMPT", "0")
2004 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2005 .arg("checkout")
2006 .arg(tag_name)
2007 .current_dir(repo_path)
2008 .output()
2009 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2010
2011 if !output.status.success() {
2012 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2013 return Err(git2::Error::from_str(&err));
2014 }
2015 Ok(())
2016}
2017
2018pub fn get_remote_tags(
2020 repo_path: &Path,
2021 remote_name: &str,
2022) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2023 let output = std::process::Command::new("git")
2024 .env("GIT_TERMINAL_PROMPT", "0")
2025 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2026 .arg("ls-remote")
2027 .arg("--tags")
2028 .arg(remote_name)
2029 .current_dir(repo_path)
2030 .output()?;
2031
2032 if !output.status.success() {
2033 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2034 return Err(err.into());
2035 }
2036
2037 let stdout = String::from_utf8_lossy(&output.stdout);
2038 let repo = git2::Repository::open(repo_path)?;
2039 let mut tags_map = std::collections::HashMap::new();
2040
2041 for line in stdout.lines() {
2042 let parts: Vec<&str> = line.split_whitespace().collect();
2043 if parts.len() >= 2 {
2044 let sha = parts[0];
2045 let ref_name = parts[1];
2046 if ref_name.starts_with("refs/tags/") {
2047 let is_peeled = ref_name.ends_with("^{}");
2048 let clean_ref = if is_peeled { &ref_name[..ref_name.len() - 3] } else { ref_name };
2049 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2050 let short_sha = if sha.len() >= 7 { &sha[..7] } else { sha };
2051
2052 let mut short_message = String::new();
2054 if let Ok(oid) = git2::Oid::from_str(sha) {
2055 if let Ok(commit) = repo.find_commit(oid) {
2056 if let Ok(Some(summary)) = commit.summary() {
2057 short_message = summary.to_string();
2058 }
2059 }
2060 }
2061 if short_message.is_empty() {
2062 short_message = "(not fetched)".to_string();
2063 }
2064
2065 if is_peeled {
2066 tags_map.insert(tag_name.to_string(), (short_sha.to_string(), short_message));
2067 } else {
2068 tags_map
2069 .entry(tag_name.to_string())
2070 .or_insert_with(|| (short_sha.to_string(), short_message));
2071 }
2072 }
2073 }
2074 }
2075
2076 let mut tags = Vec::new();
2077 for (name, (short_sha, short_message)) in tags_map {
2078 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2079 }
2080 tags.sort_by(|a, b| a.name.cmp(&b.name));
2081 Ok(tags)
2082}
2083
2084pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2085 let mut s = String::new();
2086 for tag in tags {
2087 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2088 }
2089 s
2090}
2091
2092pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2093 let mut tags = Vec::new();
2094 for line in s.lines() {
2095 let parts: Vec<&str> = line.split('|').collect();
2096 if parts.len() >= 3 {
2097 tags.push(BranchInfo {
2098 name: parts[0].to_string(),
2099 is_head: false,
2100 short_sha: parts[1].to_string(),
2101 short_message: parts[2].to_string(),
2102 });
2103 }
2104 }
2105 tags
2106}
2107
2108pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2109 let mut repo = Repository::open(repo_path)?;
2110 repo.stash_drop(index)?;
2111 Ok(())
2112}
2113
2114pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2115 let stash_ref = format!("stash@{{{}}}", index);
2116 let output = std::process::Command::new("git")
2117 .env("GIT_TERMINAL_PROMPT", "0")
2118 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2119 .arg("stash")
2120 .arg("apply")
2121 .arg(&stash_ref)
2122 .current_dir(repo_path)
2123 .output()
2124 .map_err(|e| e.to_string())?;
2125
2126 if !output.status.success() {
2127 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2128 return Err(err_msg);
2129 }
2130 Ok(())
2131}
2132
2133pub fn save_stash(repo_path: &Path, message: &str) -> Result<(), String> {
2134 let output = std::process::Command::new("git")
2135 .env("GIT_TERMINAL_PROMPT", "0")
2136 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2137 .arg("stash")
2138 .arg("push")
2139 .arg("-m")
2140 .arg(message)
2141 .current_dir(repo_path)
2142 .output()
2143 .map_err(|e| e.to_string())?;
2144
2145 if !output.status.success() {
2146 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2147 return Err(err_msg);
2148 }
2149 Ok(())
2150}
2151
2152pub fn get_latest_change_time(item: &str) -> u64 {
2153 let path = expand_tilde(item);
2154 if !path.exists() {
2155 return 0;
2156 }
2157
2158 if path.join(".git").exists() {
2159 if let Ok(repo) = Repository::open(&path) {
2160 if let Ok(head) = repo.head() {
2161 if let Ok(commit) = head.peel_to_commit() {
2162 return commit.time().seconds() as u64;
2163 }
2164 }
2165 }
2166 }
2167
2168 if let Ok(meta) = std::fs::metadata(&path) {
2169 if let Ok(modified) = meta.modified() {
2170 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2171 return duration.as_secs();
2172 }
2173 }
2174 }
2175 0
2176}
2177
2178pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2179 if let Ok(repo) = Repository::open(repo_path) {
2180 if let Ok(head) = repo.head() {
2181 if let Ok(commit) = head.peel_to_commit() {
2182 if let Ok(msg) = commit.message() {
2183 return Some(msg.to_string());
2184 }
2185 }
2186 }
2187 }
2188 None
2189}
2190
2191pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2192 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2193 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2194 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2195
2196 let mut index = repo.index().map_err(|e| e.to_string())?;
2197 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2198 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2199
2200 let signature = repo
2201 .signature()
2202 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2203
2204 head_commit
2205 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2206 .map_err(|e| e.to_string())?;
2207
2208 Ok(())
2209}
2210
2211pub fn is_merging(repo_path: &Path) -> bool {
2216 repo_path.join(".git/MERGE_HEAD").exists()
2217}
2218
2219pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2222 let full_path = repo_path.join(file_path);
2223 let content = match std::fs::read_to_string(&full_path) {
2224 Ok(s) => s,
2225 Err(_) => return Vec::new(),
2226 };
2227
2228 let mut lines = Vec::new();
2229 let mut in_ours = false;
2230 let mut in_theirs = false;
2231
2232 for line in content.lines() {
2233 if line.starts_with("<<<<<<<") {
2234 in_ours = true;
2235 in_theirs = false;
2236 lines.push(DiffLine {
2237 kind: DiffLineKind::ConflictSeparator,
2238 content: line.to_string(),
2239 });
2240 } else if line.starts_with("=======") {
2241 in_ours = false;
2242 in_theirs = true;
2243 lines.push(DiffLine {
2244 kind: DiffLineKind::ConflictSeparator,
2245 content: line.to_string(),
2246 });
2247 } else if line.starts_with(">>>>>>>") {
2248 in_ours = false;
2249 in_theirs = false;
2250 lines.push(DiffLine {
2251 kind: DiffLineKind::ConflictSeparator,
2252 content: line.to_string(),
2253 });
2254 } else if in_ours {
2255 lines.push(DiffLine { kind: DiffLineKind::ConflictOurs, content: line.to_string() });
2256 } else if in_theirs {
2257 lines.push(DiffLine { kind: DiffLineKind::ConflictTheirs, content: line.to_string() });
2258 } else {
2259 lines.push(DiffLine { kind: DiffLineKind::Context, content: line.to_string() });
2260 }
2261 }
2262 lines
2263}
2264
2265pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2268 let output1 = std::process::Command::new("git")
2269 .env("GIT_TERMINAL_PROMPT", "0")
2270 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2271 .args(["checkout", "--ours", file_path])
2272 .current_dir(repo_path)
2273 .output()
2274 .map_err(|e| e.to_string())?;
2275 if !output1.status.success() {
2276 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2277 }
2278 stage_file(repo_path, file_path)?;
2279 Ok(())
2280}
2281
2282pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2285 let output1 = std::process::Command::new("git")
2286 .env("GIT_TERMINAL_PROMPT", "0")
2287 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2288 .args(["checkout", "--theirs", file_path])
2289 .current_dir(repo_path)
2290 .output()
2291 .map_err(|e| e.to_string())?;
2292 if !output1.status.success() {
2293 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2294 }
2295 stage_file(repo_path, file_path)?;
2296 Ok(())
2297}
2298
2299pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2301 stage_file(repo_path, file_path)
2302}
2303
2304pub fn resolve_conflict_hunk(
2308 repo_path: &Path,
2309 file_path: &str,
2310 hunk_idx: usize,
2311 accept_ours: bool,
2312) -> Result<(), String> {
2313 let full_path = repo_path.join(file_path);
2314 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2315
2316 let mut new_lines = Vec::new();
2317 let mut lines_iter = content.lines().peekable();
2318 let mut current_hunk_idx = 0;
2319
2320 while let Some(line) = lines_iter.next() {
2321 if line.starts_with("<<<<<<<") {
2322 let mut ours_block = Vec::new();
2323 let mut theirs_block = Vec::new();
2324
2325 let mut found_separator = false;
2327 while let Some(&next_line) = lines_iter.peek() {
2328 if next_line.starts_with("=======") {
2329 lines_iter.next(); found_separator = true;
2331 break;
2332 }
2333 if let Some(line) = lines_iter.next() {
2334 ours_block.push(line.to_string());
2335 }
2336 }
2337
2338 let mut found_end = false;
2340 let mut end_line_marker = ">>>>>>>".to_string();
2341 while let Some(&next_line) = lines_iter.peek() {
2342 if next_line.starts_with(">>>>>>>") {
2343 if let Some(marker) = lines_iter.next() {
2344 end_line_marker = marker.to_string(); }
2346 found_end = true;
2347 break;
2348 }
2349 if let Some(line) = lines_iter.next() {
2350 theirs_block.push(line.to_string());
2351 }
2352 }
2353
2354 if current_hunk_idx == hunk_idx {
2355 if accept_ours {
2356 new_lines.extend(ours_block);
2357 } else {
2358 new_lines.extend(theirs_block);
2359 }
2360 } else {
2361 new_lines.push(line.to_string());
2362 new_lines.extend(ours_block);
2363 if found_separator {
2364 new_lines.push("=======".to_string());
2365 }
2366 new_lines.extend(theirs_block);
2367 if found_end {
2368 new_lines.push(end_line_marker);
2369 }
2370 }
2371
2372 current_hunk_idx += 1;
2373 } else {
2374 new_lines.push(line.to_string());
2375 }
2376 }
2377
2378 let mut new_content = new_lines.join("\n");
2379 if content.ends_with('\n') && !new_content.ends_with('\n') {
2380 new_content.push('\n');
2381 }
2382 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2383
2384 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2386 let has_conflict_markers = updated_content
2387 .lines()
2388 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2389
2390 if !has_conflict_markers {
2391 stage_file(repo_path, file_path)?;
2392 }
2393
2394 Ok(())
2395}
2396
2397pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2399 let output = std::process::Command::new("git")
2400 .env("GIT_TERMINAL_PROMPT", "0")
2401 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2402 .args(["merge", "--abort"])
2403 .current_dir(repo_path)
2404 .output()
2405 .map_err(|e| e.to_string())?;
2406 if !output.status.success() {
2407 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2408 }
2409 Ok(())
2410}
2411
2412pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2414 let output = std::process::Command::new("git")
2415 .env("GIT_TERMINAL_PROMPT", "0")
2416 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
2417 .args(["merge", "--continue"])
2418 .env("GIT_EDITOR", "true")
2419 .current_dir(repo_path)
2420 .output()
2421 .map_err(|e| e.to_string())?;
2422 if !output.status.success() {
2423 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2424 }
2425 Ok(())
2426}
2427
2428pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2430 let repo = Repository::open(repo_path).ok()?;
2431 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2432 let upstream = branch.upstream().ok()?;
2433 let upstream_ref = upstream.get().name().ok()?;
2434 let remote_buf = repo.branch_upstream_remote(upstream_ref).ok()?;
2435 remote_buf.as_str().ok().map(|s| s.to_string())
2436}
2437
2438pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2440 get_branch_upstream_remote(repo_path, branch_name).is_some()
2441}
2442
2443pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
2445 let repo = Repository::open(repo_path).ok()?;
2446 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2447 if let Ok(upstream) = branch.upstream() {
2448 if let Ok(upstream_ref) = upstream.get().name() {
2449 if let Ok(remote_buf) = repo.branch_upstream_remote(upstream_ref) {
2450 if let Ok(name) = remote_buf.as_str() {
2451 return Some((name.to_string(), false));
2452 }
2453 }
2454 }
2455 }
2456 let remotes = repo.remotes().ok()?;
2457 let first_remote = remotes.iter().next()?.ok()??.to_string();
2458 Some((first_remote, true))
2459}
2460
2461pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
2463 if let Ok(repo) = Repository::open(repo_path) {
2464 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
2465 if let Ok(commit) = repo.find_commit(oid) {
2466 return commit.parent_count() == 0;
2467 }
2468 }
2469 }
2470 false
2471}
2472
2473#[cfg(test)]
2474#[allow(clippy::unwrap_used, clippy::panic)]
2475mod tests {
2476 use super::*;
2477 use std::fs::File;
2478 use std::io::Write;
2479
2480 #[test]
2481 fn test_commit_amend() {
2482 let mut temp_path = std::env::temp_dir();
2483 temp_path.push(format!(
2484 "twig_test_{}",
2485 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2486 ));
2487 std::fs::create_dir_all(&temp_path).unwrap();
2488
2489 let repo = Repository::init(&temp_path).unwrap();
2491
2492 let mut config = repo.config().unwrap();
2494 config.set_str("user.name", "Test User").unwrap();
2495 config.set_str("user.email", "test@example.com").unwrap();
2496
2497 let file_path = temp_path.join("test.txt");
2499 let mut file = File::create(&file_path).unwrap();
2500 writeln!(file, "initial content").unwrap();
2501
2502 stage_file(&temp_path, "test.txt").unwrap();
2504 commit_changes(&temp_path, "initial commit").unwrap();
2505
2506 let msg = get_last_commit_message(&temp_path).unwrap();
2508 assert_eq!(msg, "initial commit");
2509
2510 commit_amend(&temp_path, "amended commit").unwrap();
2512
2513 let amended_msg = get_last_commit_message(&temp_path).unwrap();
2515 assert_eq!(amended_msg, "amended commit");
2516
2517 let _ = std::fs::remove_dir_all(&temp_path);
2519 }
2520
2521 #[test]
2522 fn test_commit_signatures_collection() {
2523 let mut temp_path = std::env::temp_dir();
2524 temp_path.push(format!(
2525 "twig_test_sig_{}",
2526 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2527 ));
2528 std::fs::create_dir_all(&temp_path).unwrap();
2529
2530 let repo = Repository::init(&temp_path).unwrap();
2532
2533 let mut config = repo.config().unwrap();
2535 config.set_str("user.name", "Test User").unwrap();
2536 config.set_str("user.email", "test@example.com").unwrap();
2537
2538 let file_path = temp_path.join("test.txt");
2540 let mut file = File::create(&file_path).unwrap();
2541 writeln!(file, "initial content").unwrap();
2542
2543 stage_file(&temp_path, "test.txt").unwrap();
2545 commit_changes(&temp_path, "initial commit").unwrap();
2546
2547 let sigs = collect_signatures(&temp_path, 0);
2549 assert_eq!(sigs.len(), 1);
2550 let head_oid = repo.head().unwrap().target().unwrap().to_string();
2551 let sig_status = sigs.get(&head_oid).unwrap();
2552 assert_eq!(sig_status, "N");
2553
2554 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
2556 assert_eq!(commits.len(), 1);
2557 assert_eq!(commits[0].signature_status, "N");
2558 assert!(commits[0].files.is_empty());
2559
2560 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
2562 assert_eq!(files.len(), 1);
2563 assert_eq!(files[0].path, "test.txt");
2564 assert_eq!(files[0].label, "N");
2565
2566 let graph = collect_graph_lines(&temp_path, 1000);
2568 assert_eq!(graph.len(), 1);
2569 assert!(graph[0].commit.is_some());
2570 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
2571
2572 let _ = std::fs::remove_dir_all(&temp_path);
2574 }
2575
2576 #[test]
2577 fn test_ref_map_cache_behavior() {
2578 let temp_dir = std::env::temp_dir();
2579 let repo_path = temp_dir.join("test_ref_map_repo");
2580 let _ = std::fs::remove_dir_all(&repo_path);
2581 std::fs::create_dir_all(&repo_path).unwrap();
2582
2583 let repo = Repository::init(&repo_path).unwrap();
2584
2585 let map1 = get_cached_ref_map(&repo, &repo_path);
2587
2588 let map2 = get_cached_ref_map(&repo, &repo_path);
2590 assert_eq!(map1.len(), map2.len());
2591
2592 invalidate_ref_map_cache(&repo_path);
2594
2595 let _ = std::fs::remove_dir_all(&repo_path);
2597 }
2598
2599 #[test]
2600 fn test_get_latest_change_time() {
2601 let mut temp_path = std::env::temp_dir();
2602 temp_path.push(format!(
2603 "twig_test_{}",
2604 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2605 ));
2606 std::fs::create_dir_all(&temp_path).unwrap();
2607
2608 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
2609 assert!(change_time > 0);
2610
2611 let _ = std::fs::remove_dir_all(&temp_path);
2612 }
2613
2614 #[test]
2615 fn test_committer_stats() {
2616 let mut temp_path = std::env::temp_dir();
2617 temp_path.push(format!(
2618 "twig_test_{}",
2619 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2620 ));
2621 std::fs::create_dir_all(&temp_path).unwrap();
2622
2623 let repo = Repository::init(&temp_path).unwrap();
2625
2626 let mut config = repo.config().unwrap();
2628 config.set_str("user.name", "Test User").unwrap();
2629 config.set_str("user.email", "test@example.com").unwrap();
2630
2631 let file_path = temp_path.join("test.txt");
2633 let mut file = File::create(&file_path).unwrap();
2634 writeln!(file, "initial content").unwrap();
2635
2636 stage_file(&temp_path, "test.txt").unwrap();
2638 commit_changes(&temp_path, "initial commit").unwrap();
2639
2640 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
2642 assert_eq!(stats.len(), 1);
2643 assert_eq!(stats[0].name, "Test User");
2644 assert_eq!(stats[0].email, "test@example.com");
2645 assert_eq!(stats[0].count, 1);
2646 assert!(!limit_reached);
2647
2648 let _ = std::fs::remove_dir_all(&temp_path);
2650 }
2651
2652 #[test]
2653 fn test_untracked_files_in_unstaged() {
2654 let mut temp_path = std::env::temp_dir();
2655 temp_path.push(format!(
2656 "twig_test_{}",
2657 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2658 ));
2659 std::fs::create_dir_all(&temp_path).unwrap();
2660
2661 let _repo = Repository::init(&temp_path).unwrap();
2663
2664 let file_path = temp_path.join("untracked.txt");
2666 let mut file = File::create(&file_path).unwrap();
2667 writeln!(file, "hello untracked").unwrap();
2668
2669 let untracked_dir = temp_path.join("untracked_dir");
2671 std::fs::create_dir_all(&untracked_dir).unwrap();
2672 let nested_file_path = untracked_dir.join("nested.txt");
2673 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
2674
2675 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
2677 match detail {
2678 ItemDetail::Repo { info, .. } => {
2679 let unstaged_paths: Vec<String> =
2681 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
2682 let untracked_paths: Vec<String> =
2683 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
2684
2685 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
2687 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
2688 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
2689 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
2690
2691 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
2693 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
2694 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
2695 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
2696 }
2697 _ => panic!("Expected ItemDetail::Repo"),
2698 }
2699
2700 let _ = std::fs::remove_dir_all(&temp_path);
2702 }
2703
2704 #[test]
2705 fn test_stage_new_and_deleted_files() {
2706 let mut temp_path = std::env::temp_dir();
2707 temp_path.push(format!(
2708 "twig_test_{}",
2709 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2710 ));
2711 std::fs::create_dir_all(&temp_path).unwrap();
2712
2713 let repo = Repository::init(&temp_path).unwrap();
2715
2716 let mut config = repo.config().unwrap();
2718 config.set_str("user.name", "Test User").unwrap();
2719 config.set_str("user.email", "test@example.com").unwrap();
2720
2721 let init_file = temp_path.join("init.txt");
2723 std::fs::write(&init_file, "initial").unwrap();
2724 stage_file(&temp_path, "init.txt").unwrap();
2725 commit_changes(&temp_path, "initial commit").unwrap();
2726
2727 let untracked_file = temp_path.join("untracked.txt");
2729 std::fs::write(&untracked_file, "new file content").unwrap();
2730
2731 stage_file(&temp_path, "untracked.txt").unwrap();
2733
2734 std::fs::remove_file(&init_file).unwrap();
2736
2737 stage_file(&temp_path, "init.txt").unwrap();
2739
2740 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
2742 match detail {
2743 ItemDetail::Repo { info, .. } => {
2744 assert_eq!(info.changes.staged.len(), 2);
2746 let paths: Vec<String> =
2747 info.changes.staged.iter().map(|f| f.path.clone()).collect();
2748 assert!(paths.contains(&"untracked.txt".to_string()));
2749 assert!(paths.contains(&"init.txt".to_string()));
2750 }
2751 _ => panic!("Expected ItemDetail::Repo"),
2752 }
2753
2754 let _ = std::fs::remove_dir_all(&temp_path);
2756 }
2757
2758 #[test]
2759 fn test_discard_file_changes_all_cases() {
2760 let mut temp_path = std::env::temp_dir();
2761 temp_path.push(format!(
2762 "twig_test_{}",
2763 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2764 ));
2765 std::fs::create_dir_all(&temp_path).unwrap();
2766
2767 let repo = Repository::init(&temp_path).unwrap();
2769
2770 let mut config = repo.config().unwrap();
2772 config.set_str("user.name", "Test User").unwrap();
2773 config.set_str("user.email", "test@example.com").unwrap();
2774
2775 let file_tracked = temp_path.join("tracked.txt");
2777 std::fs::write(&file_tracked, "original content\n").unwrap();
2778 stage_file(&temp_path, "tracked.txt").unwrap();
2779 commit_changes(&temp_path, "initial commit").unwrap();
2780
2781 let file_untracked = temp_path.join("untracked.txt");
2783 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
2784 assert!(file_untracked.exists());
2785 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
2786 assert!(!file_untracked.exists());
2787
2788 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
2790 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
2791 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
2792
2793 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
2795 stage_file(&temp_path, "tracked.txt").unwrap();
2796 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
2798 match detail {
2799 ItemDetail::Repo { info, .. } => {
2800 assert!(!info.changes.staged.is_empty());
2801 }
2802 _ => panic!("Expected ItemDetail::Repo"),
2803 }
2804 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
2805 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
2806 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
2808 match detail {
2809 ItemDetail::Repo { info, .. } => {
2810 assert!(info.changes.staged.is_empty());
2811 assert!(info.changes.unstaged.is_empty());
2812 }
2813 _ => panic!("Expected ItemDetail::Repo"),
2814 }
2815
2816 std::fs::remove_file(&file_tracked).unwrap();
2818 assert!(!file_tracked.exists());
2819 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
2820 assert!(file_tracked.exists());
2821 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
2822
2823 let _ = std::fs::remove_dir_all(&temp_path);
2825 }
2826
2827 #[test]
2828 fn test_stage_unstage_by_hunk() {
2829 let mut temp_path = std::env::temp_dir();
2830 temp_path.push(format!(
2831 "twig_test_{}",
2832 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2833 ));
2834 std::fs::create_dir_all(&temp_path).unwrap();
2835
2836 let repo = Repository::init(&temp_path).unwrap();
2838
2839 let mut config = repo.config().unwrap();
2841 config.set_str("user.name", "Test User").unwrap();
2842 config.set_str("user.email", "test@example.com").unwrap();
2843
2844 let file_path = temp_path.join("multihunk.txt");
2846 let mut file = File::create(&file_path).unwrap();
2847 for i in 1..=20 {
2848 writeln!(file, "Line {}", i).unwrap();
2849 }
2850 drop(file);
2851
2852 stage_file(&temp_path, "multihunk.txt").unwrap();
2854 commit_changes(&temp_path, "initial commit").unwrap();
2855
2856 let mut file = File::create(&file_path).unwrap();
2858 for i in 1..=20 {
2859 if i == 2 || i == 18 {
2860 writeln!(file, "Line {} modified", i).unwrap();
2861 } else {
2862 writeln!(file, "Line {}", i).unwrap();
2863 }
2864 }
2865 drop(file);
2866
2867 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
2869 let mut hunk_ranges = Vec::new();
2871 let mut current_start = None;
2872 for (i, line) in diff_lines.iter().enumerate() {
2873 if line.kind == DiffLineKind::Header {
2874 if let Some(start) = current_start {
2875 hunk_ranges.push(start..i);
2876 }
2877 current_start = Some(i);
2878 }
2879 }
2880 if let Some(start) = current_start {
2881 hunk_ranges.push(start..diff_lines.len());
2882 }
2883
2884 assert_eq!(hunk_ranges.len(), 2);
2886
2887 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
2889 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
2890
2891 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
2893 let staged_content: String =
2894 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
2895 assert!(staged_content.contains("Line 18 modified"));
2896 assert!(!staged_content.contains("Line 2 modified"));
2897
2898 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
2900 let unstaged_content: String =
2901 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
2902 assert!(unstaged_content.contains("Line 2 modified"));
2903 assert!(!unstaged_content.contains("Line 18 modified"));
2904
2905 let staged_hunk_ranges = {
2907 let mut ranges = Vec::new();
2908 let mut current_start = None;
2909 for (i, line) in staged_diff.iter().enumerate() {
2910 if line.kind == DiffLineKind::Header {
2911 if let Some(start) = current_start {
2912 ranges.push(start..i);
2913 }
2914 current_start = Some(i);
2915 }
2916 }
2917 if let Some(start) = current_start {
2918 ranges.push(start..staged_diff.len());
2919 }
2920 ranges
2921 };
2922 assert_eq!(staged_hunk_ranges.len(), 1);
2923 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
2924 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
2925
2926 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
2928 assert!(staged_diff_after.is_empty());
2929
2930 let _ = std::fs::remove_dir_all(&temp_path);
2932 }
2933
2934 #[test]
2935 fn test_discard_hunk() {
2936 let mut temp_path = std::env::temp_dir();
2937 temp_path.push(format!(
2938 "twig_test_{}",
2939 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2940 ));
2941 std::fs::create_dir_all(&temp_path).unwrap();
2942
2943 let repo = Repository::init(&temp_path).unwrap();
2945
2946 let mut config = repo.config().unwrap();
2948 config.set_str("user.name", "Test User").unwrap();
2949 config.set_str("user.email", "test@example.com").unwrap();
2950
2951 let file_path = temp_path.join("discardhunk.txt");
2953 let mut file = File::create(&file_path).unwrap();
2954 for i in 1..=20 {
2955 writeln!(file, "Line {}", i).unwrap();
2956 }
2957 drop(file);
2958
2959 stage_file(&temp_path, "discardhunk.txt").unwrap();
2961 commit_changes(&temp_path, "initial commit").unwrap();
2962
2963 let mut file = File::create(&file_path).unwrap();
2965 for i in 1..=20 {
2966 if i == 2 || i == 18 {
2967 writeln!(file, "Line {} modified", i).unwrap();
2968 } else {
2969 writeln!(file, "Line {}", i).unwrap();
2970 }
2971 }
2972 drop(file);
2973
2974 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
2976 let mut hunk_ranges = Vec::new();
2978 let mut current_start = None;
2979 for (i, line) in diff_lines.iter().enumerate() {
2980 if line.kind == DiffLineKind::Header {
2981 if let Some(start) = current_start {
2982 hunk_ranges.push(start..i);
2983 }
2984 current_start = Some(i);
2985 }
2986 }
2987 if let Some(start) = current_start {
2988 hunk_ranges.push(start..diff_lines.len());
2989 }
2990
2991 assert_eq!(hunk_ranges.len(), 2);
2993
2994 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
2996 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
2997
2998 let contents = std::fs::read_to_string(&file_path).unwrap();
3000 assert!(contents.contains("Line 2 modified"));
3001 assert!(contents.contains("Line 18\n"));
3002 assert!(!contents.contains("Line 18 modified"));
3003
3004 let _ = std::fs::remove_dir_all(&temp_path);
3006 }
3007
3008 #[test]
3009 fn test_stage_unstage_discard_line() {
3010 let mut temp_path = std::env::temp_dir();
3011 temp_path.push(format!(
3012 "twig_test_{}",
3013 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3014 ));
3015 std::fs::create_dir_all(&temp_path).unwrap();
3016
3017 let repo = Repository::init(&temp_path).unwrap();
3018 let mut config = repo.config().unwrap();
3019 config.set_str("user.name", "Test User").unwrap();
3020 config.set_str("user.email", "test@example.com").unwrap();
3021
3022 let file_path = temp_path.join("line_test.txt");
3024 let mut file = File::create(&file_path).unwrap();
3025 writeln!(file, "line A").unwrap();
3026 writeln!(file, "line B").unwrap();
3027 writeln!(file, "line C").unwrap();
3028 drop(file);
3029
3030 stage_file(&temp_path, "line_test.txt").unwrap();
3031 commit_changes(&temp_path, "initial").unwrap();
3032
3033 let mut file = File::create(&file_path).unwrap();
3035 writeln!(file, "line A modified").unwrap();
3036 writeln!(file, "line B").unwrap();
3037 writeln!(file, "line C modified").unwrap();
3038 drop(file);
3039
3040 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3041 let mut hunk_ranges = Vec::new();
3042 let mut current_start = None;
3043 for (i, line) in diff_lines.iter().enumerate() {
3044 if line.kind == DiffLineKind::Header {
3045 if let Some(start) = current_start {
3046 hunk_ranges.push(start..i);
3047 }
3048 current_start = Some(i);
3049 }
3050 }
3051 if let Some(start) = current_start {
3052 hunk_ranges.push(start..diff_lines.len());
3053 }
3054
3055 assert_eq!(hunk_ranges.len(), 1);
3056 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3057
3058 assert_eq!(hunk0[2].content, "line A modified");
3059 assert_eq!(hunk0[5].content, "line C modified");
3060
3061 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3063
3064 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3066 assert!(
3067 staged_diff
3068 .iter()
3069 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3070 );
3071 assert!(
3072 !staged_diff
3073 .iter()
3074 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3075 );
3076
3077 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3079 assert!(
3080 !unstaged_diff
3081 .iter()
3082 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3083 );
3084 assert!(
3085 unstaged_diff
3086 .iter()
3087 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3088 );
3089
3090 assert_eq!(staged_diff[2].content, "line A modified");
3092 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3093
3094 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3096
3097 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3099 assert_eq!(unstaged_diff2[5].content, "line C modified");
3100 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3101
3102 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3103 let remove_idx = unstaged_diff3
3104 .iter()
3105 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3106 .unwrap();
3107 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3108
3109 let contents = std::fs::read_to_string(&file_path).unwrap();
3111 assert!(contents.contains("line A modified"));
3112 assert!(contents.contains("line B"));
3113 assert!(contents.contains("line C\n"));
3114 assert!(!contents.contains("line C modified"));
3115
3116 let _ = std::fs::remove_dir_all(&temp_path);
3118 }
3119
3120 #[test]
3121 fn test_stage_unstage_discard_all_changes() {
3122 let mut temp_path = std::env::temp_dir();
3123 temp_path.push(format!(
3124 "twig_test_all_{}",
3125 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3126 ));
3127 std::fs::create_dir_all(&temp_path).unwrap();
3128
3129 let repo = Repository::init(&temp_path).unwrap();
3131
3132 let mut config = repo.config().unwrap();
3134 config.set_str("user.name", "Test User").unwrap();
3135 config.set_str("user.email", "test@example.com").unwrap();
3136
3137 let file_path = temp_path.join("tracked.txt");
3139 std::fs::write(&file_path, "original content\n").unwrap();
3140 stage_file(&temp_path, "tracked.txt").unwrap();
3141 commit_changes(&temp_path, "initial").unwrap();
3142
3143 std::fs::write(&file_path, "modified content\n").unwrap();
3145 let untracked_path = temp_path.join("untracked.txt");
3146 std::fs::write(&untracked_path, "untracked content\n").unwrap();
3147
3148 let status = repo.statuses(None).unwrap();
3150 assert_eq!(status.len(), 2);
3151
3152 stage_all_changes(&temp_path).unwrap();
3154
3155 let status = repo.statuses(None).unwrap();
3157 for entry in status.iter() {
3158 assert!(
3159 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
3160 );
3161 }
3162
3163 unstage_all_changes(&temp_path).unwrap();
3165
3166 let status = repo.statuses(None).unwrap();
3168 for entry in status.iter() {
3169 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
3170 }
3171
3172 discard_all_changes(&temp_path).unwrap();
3174
3175 let status = repo.statuses(None).unwrap();
3177 assert_eq!(status.len(), 0);
3178
3179 let contents = std::fs::read_to_string(&file_path).unwrap();
3181 assert_eq!(contents, "original content\n");
3182 assert!(!untracked_path.exists());
3183
3184 let _ = std::fs::remove_dir_all(&temp_path);
3186 }
3187
3188 #[test]
3189 fn test_merge_conflicts_flow() {
3190 let mut temp_path = std::env::temp_dir();
3191 temp_path.push(format!(
3192 "twig_test_conflict_{}",
3193 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3194 ));
3195 std::fs::create_dir_all(&temp_path).unwrap();
3196
3197 let repo = Repository::init(&temp_path).unwrap();
3199
3200 let mut config = repo.config().unwrap();
3202 config.set_str("user.name", "Test User").unwrap();
3203 config.set_str("user.email", "test@example.com").unwrap();
3204
3205 let file_path = temp_path.join("conflict.txt");
3207 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3208 stage_file(&temp_path, "conflict.txt").unwrap();
3209 commit_changes(&temp_path, "initial commit").unwrap();
3210
3211 let output = std::process::Command::new("git")
3213 .env("GIT_TERMINAL_PROMPT", "0")
3214 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3215 .args(["symbolic-ref", "--short", "HEAD"])
3216 .current_dir(&temp_path)
3217 .output()
3218 .unwrap();
3219 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3220
3221 std::process::Command::new("git")
3223 .env("GIT_TERMINAL_PROMPT", "0")
3224 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3225 .args(["checkout", "-b", "feature"])
3226 .current_dir(&temp_path)
3227 .output()
3228 .unwrap();
3229
3230 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
3231 stage_file(&temp_path, "conflict.txt").unwrap();
3232 commit_changes(&temp_path, "feature commit").unwrap();
3233
3234 std::process::Command::new("git")
3236 .env("GIT_TERMINAL_PROMPT", "0")
3237 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3238 .args(["checkout", &main_branch])
3239 .current_dir(&temp_path)
3240 .output()
3241 .unwrap();
3242
3243 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
3244 stage_file(&temp_path, "conflict.txt").unwrap();
3245 commit_changes(&temp_path, "main commit").unwrap();
3246
3247 assert!(!is_merging(&temp_path));
3249 let merge_output = std::process::Command::new("git")
3250 .env("GIT_TERMINAL_PROMPT", "0")
3251 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3252 .args(["merge", "feature"])
3253 .current_dir(&temp_path)
3254 .output()
3255 .unwrap();
3256
3257 assert!(!merge_output.status.success());
3258 assert!(is_merging(&temp_path));
3259
3260 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
3262 assert!(!diff.is_empty());
3263 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
3264 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
3265 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
3266 assert!(has_separator);
3267 assert!(has_ours);
3268 assert!(has_theirs);
3269
3270 abort_merge(&temp_path).unwrap();
3272 assert!(!is_merging(&temp_path));
3273
3274 std::process::Command::new("git")
3276 .env("GIT_TERMINAL_PROMPT", "0")
3277 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3278 .args(["merge", "feature"])
3279 .current_dir(&temp_path)
3280 .output()
3281 .unwrap();
3282 assert!(is_merging(&temp_path));
3283
3284 resolve_ours(&temp_path, "conflict.txt").unwrap();
3286 let contents = std::fs::read_to_string(&file_path).unwrap();
3287 assert!(contents.contains("line 2 on main"));
3288 assert!(!contents.contains("<<<<<<<"));
3289
3290 continue_merge(&temp_path).unwrap();
3292 assert!(!is_merging(&temp_path));
3293
3294 std::process::Command::new("git")
3296 .env("GIT_TERMINAL_PROMPT", "0")
3297 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3298 .args(["reset", "--hard", "HEAD~1"])
3299 .current_dir(&temp_path)
3300 .output()
3301 .unwrap();
3302
3303 std::process::Command::new("git")
3304 .env("GIT_TERMINAL_PROMPT", "0")
3305 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3306 .args(["merge", "feature"])
3307 .current_dir(&temp_path)
3308 .output()
3309 .unwrap();
3310 assert!(is_merging(&temp_path));
3311
3312 resolve_theirs(&temp_path, "conflict.txt").unwrap();
3313 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
3314 assert!(contents_theirs.contains("line 2 on feature"));
3315 assert!(!contents_theirs.contains("<<<<<<<"));
3316
3317 continue_merge(&temp_path).unwrap();
3318 assert!(!is_merging(&temp_path));
3319
3320 let _ = std::fs::remove_dir_all(&temp_path);
3322 }
3323
3324 #[test]
3325 fn test_resolve_conflict_hunk() {
3326 let mut temp_path = std::env::temp_dir();
3327 temp_path.push(format!(
3328 "twig_test_hunk_conflict_{}",
3329 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3330 ));
3331 std::fs::create_dir_all(&temp_path).unwrap();
3332
3333 let repo = Repository::init(&temp_path).unwrap();
3335
3336 let mut config = repo.config().unwrap();
3338 config.set_str("user.name", "Test User").unwrap();
3339 config.set_str("user.email", "test@example.com").unwrap();
3340
3341 let file_path = temp_path.join("conflict.txt");
3343 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";
3344 std::fs::write(&file_path, initial_lines).unwrap();
3345 stage_file(&temp_path, "conflict.txt").unwrap();
3346 commit_changes(&temp_path, "initial commit").unwrap();
3347
3348 let output = std::process::Command::new("git")
3350 .env("GIT_TERMINAL_PROMPT", "0")
3351 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3352 .args(["symbolic-ref", "--short", "HEAD"])
3353 .current_dir(&temp_path)
3354 .output()
3355 .unwrap();
3356 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3357
3358 std::process::Command::new("git")
3360 .env("GIT_TERMINAL_PROMPT", "0")
3361 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3362 .args(["checkout", "-b", "feature"])
3363 .current_dir(&temp_path)
3364 .output()
3365 .unwrap();
3366 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";
3367 std::fs::write(&file_path, feature_lines).unwrap();
3368 stage_file(&temp_path, "conflict.txt").unwrap();
3369 commit_changes(&temp_path, "feature commit").unwrap();
3370
3371 std::process::Command::new("git")
3373 .env("GIT_TERMINAL_PROMPT", "0")
3374 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3375 .args(["checkout", &main_branch])
3376 .current_dir(&temp_path)
3377 .output()
3378 .unwrap();
3379 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";
3380 std::fs::write(&file_path, main_lines).unwrap();
3381 stage_file(&temp_path, "conflict.txt").unwrap();
3382 commit_changes(&temp_path, "main commit").unwrap();
3383
3384 let merge_output = std::process::Command::new("git")
3386 .env("GIT_TERMINAL_PROMPT", "0")
3387 .env("GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new")
3388 .args(["merge", "feature"])
3389 .current_dir(&temp_path)
3390 .output()
3391 .unwrap();
3392 assert!(!merge_output.status.success());
3393 assert!(is_merging(&temp_path));
3394
3395 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
3397 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
3398 assert!(contents_after_first.contains("line 2 on main"));
3400 assert!(!contents_after_first.contains("line 2 on feature"));
3401 assert!(contents_after_first.contains("<<<<<<<"));
3403 assert!(contents_after_first.contains("line 11 on main"));
3404 assert!(contents_after_first.contains("line 11 on feature"));
3405
3406 assert!(is_merging(&temp_path));
3408
3409 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
3414 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
3415 assert!(contents_after_second.contains("line 2 on main"));
3417 assert!(contents_after_second.contains("line 11 on feature"));
3418 assert!(!contents_after_second.contains("<<<<<<<"));
3419
3420 let status = repo.statuses(None).unwrap();
3422 assert_eq!(status.len(), 1);
3423 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
3424
3425 continue_merge(&temp_path).unwrap();
3427 assert!(!is_merging(&temp_path));
3428
3429 let _ = std::fs::remove_dir_all(&temp_path);
3431 }
3432
3433 #[test]
3434 fn test_branch_and_commit_helpers() {
3435 let mut temp_path = std::env::temp_dir();
3436 temp_path.push(format!(
3437 "twig_test_helpers_{}",
3438 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3439 ));
3440 let _ = std::fs::remove_dir_all(&temp_path);
3441 std::fs::create_dir_all(&temp_path).unwrap();
3442
3443 let repo = Repository::init(&temp_path).unwrap();
3445
3446 let mut config = repo.config().unwrap();
3448 config.set_str("user.name", "Test User").unwrap();
3449 config.set_str("user.email", "test@example.com").unwrap();
3450
3451 let file_path = temp_path.join("test.txt");
3453 std::fs::write(&file_path, "root content").unwrap();
3454 stage_file(&temp_path, "test.txt").unwrap();
3455 commit_changes(&temp_path, "root commit").unwrap();
3456
3457 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3458 assert!(is_root_commit(&temp_path, &head_oid));
3459
3460 std::fs::write(&file_path, "second content").unwrap();
3462 stage_file(&temp_path, "test.txt").unwrap();
3463 commit_changes(&temp_path, "second commit").unwrap();
3464
3465 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
3466 assert!(!is_root_commit(&temp_path, &new_head_oid));
3467
3468 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
3473 let target = get_branch_push_target(&temp_path, "master")
3474 .or_else(|| get_branch_push_target(&temp_path, "main"));
3475 assert!(target.is_some());
3476 let (remote_name, set_upstream) = target.unwrap();
3477 assert_eq!(remote_name, "origin");
3478 assert!(set_upstream);
3479
3480 let _ = std::fs::remove_dir_all(&temp_path);
3482 }
3483}