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, Clone, Copy, PartialEq, Eq, Default)]
36pub enum RepoState {
37 #[default]
38 Clean,
39 Merge,
40 Revert,
41 CherryPick,
42 Bisect,
43 Rebase,
44 ApplyMailbox,
45}
46
47#[derive(Debug, Default, Clone)]
50pub struct RepoSummary {
51 pub branch: Option<String>,
54 pub staged: usize,
55 pub modified: usize,
56 pub untracked: usize,
57 pub conflicted: usize,
58 pub ahead: usize,
59 pub behind: usize,
60 pub state: RepoState,
61 pub last_commit_time: Option<i64>,
62}
63
64impl RepoSummary {
65 pub fn is_clean(&self) -> bool {
66 self.staged + self.modified + self.untracked + self.conflicted == 0
67 }
68 pub fn is_synced(&self) -> bool {
69 self.ahead + self.behind == 0
70 }
71 pub fn unchanged(&self) -> bool {
72 self.is_clean() && self.is_synced()
73 }
74}
75
76#[derive(Debug, Clone)]
79pub enum ItemDetail {
80 Missing { resolved: PathBuf },
81 Directory { resolved: PathBuf },
82 Repo { resolved: PathBuf, info: Box<RepoInfo> },
83 Error { resolved: PathBuf, message: String },
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct BranchInfo {
88 pub name: String,
89 pub is_head: bool,
90 pub short_sha: String,
91 pub short_message: String,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct FileRevision {
96 pub commit_oid: String,
97 pub author: String,
98 pub date: String,
99 pub when: String,
100 pub summary: String,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct StashInfo {
105 pub index: usize,
106 pub message: String,
107 pub commit_id: String,
108 pub files: Vec<FileEntry>,
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct CommitterStat {
113 pub name: String,
114 pub email: String,
115 pub count: usize,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub enum TabData<T> {
120 #[default]
121 NotLoaded,
122 Loading,
123 Loaded(T),
124 Error(String),
125}
126
127impl<T> TabData<T> {
128 pub fn is_not_loaded(&self) -> bool {
129 matches!(self, TabData::NotLoaded)
130 }
131 pub fn is_loading(&self) -> bool {
132 matches!(self, TabData::Loading)
133 }
134 #[allow(dead_code)]
135 pub fn is_loaded(&self) -> bool {
136 matches!(self, TabData::Loaded(_))
137 }
138 pub fn as_ref(&self) -> Option<&T> {
139 match self {
140 TabData::Loaded(val) => Some(val),
141 _ => None,
142 }
143 }
144}
145
146impl<T> TabData<Vec<T>> {
147 pub fn len(&self) -> usize {
148 self.as_ref().map(|v| v.len()).unwrap_or(0)
149 }
150 pub fn is_empty(&self) -> bool {
151 self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
152 }
153 pub fn first(&self) -> Option<&T> {
154 self.as_ref().and_then(|v| v.first())
155 }
156 pub fn get(&self, index: usize) -> Option<&T> {
157 self.as_ref().and_then(|v| v.get(index))
158 }
159 pub fn iter(&self) -> std::slice::Iter<'_, T> {
160 match self {
161 TabData::Loaded(v) => v.iter(),
162 _ => [].iter(),
163 }
164 }
165 pub fn as_slice(&self) -> &[T] {
166 match self {
167 TabData::Loaded(v) => v.as_slice(),
168 _ => &[],
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct WorktreeInfo {
175 pub name: String,
176 pub path: PathBuf,
177 pub branch: Option<String>,
178 pub is_locked: bool,
179 pub lock_reason: Option<String>,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct SubmoduleInfo {
184 pub name: String,
185 pub path: PathBuf,
186 pub url: String,
187 pub commit_id: Option<String>,
188 pub head_id: Option<String>,
189 pub is_initialized: bool,
190 pub is_dirty: bool,
191}
192
193#[derive(Debug, Clone)]
194pub enum TabPayload {
195 Files(Result<Vec<String>, String>),
196 Graph(Result<Vec<GraphLine>, String>),
197 Branches { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
198 Tags { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
199 Remotes(Result<Vec<RemoteInfo>, String>),
200 Stashes(Result<Vec<StashInfo>, String>),
201 Overview(Result<(Vec<CommitterStat>, bool), String>),
202 Worktrees(Result<Vec<WorktreeInfo>, String>),
203 Submodules(Result<Vec<SubmoduleInfo>, String>),
204 Reflog(Result<Vec<ReflogEntry>, String>),
205}
206
207#[derive(Debug, Default, Clone)]
208pub struct RepoInfo {
209 pub branch: Option<String>,
210 pub head: Option<HeadInfo>,
211 pub remotes: TabData<Vec<RemoteInfo>>,
212 pub upstream: Option<String>,
214 pub summary: RepoSummary,
215 pub changes: WorktreeChanges,
217 pub commits: Vec<CommitEntry>,
219 pub graph_lines: TabData<Vec<GraphLine>>,
221 pub local_branches: TabData<Vec<BranchInfo>>,
223 pub remote_branches: TabData<Vec<BranchInfo>>,
225 pub local_tags: TabData<Vec<BranchInfo>>,
227 pub remote_tags: TabData<Vec<BranchInfo>>,
229 pub remote_tags_loaded: bool,
231 pub remote_tags_attempted: bool,
233 pub files: TabData<Vec<String>>,
235 pub stashes: TabData<Vec<StashInfo>>,
237 pub worktrees: TabData<Vec<WorktreeInfo>>,
239 pub submodules: TabData<Vec<SubmoduleInfo>>,
241 pub reflog: TabData<Vec<ReflogEntry>>,
243 pub committer_stats: TabData<Vec<CommitterStat>>,
245 pub committer_stats_limit_reached: bool,
247 pub tab_loaded_at: [Option<std::time::Instant>; 10],
249 pub tab_loading: [bool; 10],
251 pub lfs_files: std::collections::HashSet<String>,
253 pub lfs_installed: bool,
255 pub lfs_storage_size: Option<u64>,
257}
258
259#[derive(Debug, Clone)]
260pub struct HeadInfo {
261 pub short_id: String,
262 pub summary: String,
263 pub author: String,
264 pub when: String,
265}
266
267#[derive(Debug, Clone)]
268pub struct ReflogEntry {
269 pub index: usize,
270 pub target_oid: String,
271 pub selector: String,
272 pub command: String,
273 pub message: String,
274 pub when: String,
275 pub date: String,
276}
277
278#[derive(Debug, Clone)]
279pub struct RemoteInfo {
280 pub name: String,
281 pub url: String,
282 pub push_url: Option<String>,
283 pub refspecs: Vec<String>,
284}
285
286#[derive(Debug, Clone)]
287pub struct CommitEntry {
288 pub id: String,
290 pub oid: String,
292 pub author: String,
293 pub when: String,
294 pub date: String,
295 pub summary: String,
296 pub message: String,
297 pub refs: Vec<String>,
300 pub files: Vec<FileEntry>,
302 pub signature_status: String,
304}
305
306#[derive(Debug, Clone)]
307pub struct GraphLine {
308 pub graph: String,
309 pub commit: Option<GraphCommit>,
310}
311
312#[derive(Debug, Clone)]
313pub struct GraphCommit {
314 pub oid: String,
315 pub decoration: String,
316 pub summary: String,
317 pub author: String,
318 pub date: String,
319 pub signature_status: String,
321}
322
323#[derive(Debug, Clone)]
325pub struct FileEntry {
326 pub path: String,
328 pub label: &'static str,
330}
331
332#[derive(Debug, Default, Clone)]
335pub struct WorktreeChanges {
336 pub staged: Vec<FileEntry>,
337 pub unstaged: Vec<FileEntry>,
338 pub untracked: Vec<FileEntry>,
339 pub conflicted: Vec<FileEntry>,
340}
341
342#[derive(Debug, Clone, PartialEq)]
346pub enum DiffLineKind {
347 Header,
349 Added,
351 Removed,
353 Context,
355 ConflictOurs,
357 ConflictTheirs,
359 ConflictSeparator,
361}
362
363#[derive(Debug, Clone)]
365pub struct DiffLine {
366 pub kind: DiffLineKind,
367 pub content: String,
369 pub old_lineno: Option<u32>,
370 pub new_lineno: Option<u32>,
371 pub hunk_idx: Option<usize>,
372}
373
374pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
378 get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
379}
380
381pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
388 get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
389}
390
391pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
394 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
395 let mut index = repo.index().map_err(|e| e.to_string())?;
396 let full_path = repo_path.join(file_path);
397 if full_path.exists() {
398 index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
399 } else {
400 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
401 }
402 index.write().map_err(|e| e.to_string())?;
403 Ok(())
404}
405
406pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
411 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
412 if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
414 repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
415 .map_err(|e| e.to_string())?;
416 } else {
417 let mut index = repo.index().map_err(|e| e.to_string())?;
419 index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
420 index.write().map_err(|e| e.to_string())?;
421 }
422 Ok(())
423}
424
425pub fn sanitize_text(s: &str) -> String {
426 let mut result = String::with_capacity(s.len());
427 let bytes = s.as_bytes();
428 let mut i = 0;
429 while i < bytes.len() {
430 if bytes[i] == 0x1B {
431 i += 1;
433 if i < bytes.len() && bytes[i] == b'[' {
434 i += 1;
435 while i < bytes.len() {
436 let c = bytes[i];
437 i += 1;
438 if c.is_ascii_alphabetic() {
440 break;
441 }
442 }
443 } else {
444 while i < bytes.len() {
446 let c = bytes[i];
447 i += 1;
448 if c == b' ' || c.is_ascii_alphabetic() {
449 break;
450 }
451 }
452 }
453 } else {
454 let c = bytes[i];
455 if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
457 result.push(' ');
458 } else {
459 result.push(c as char);
460 }
461 i += 1;
462 }
463 }
464 result
465}
466
467pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
468 let mut end = len.min(sha.len());
469 while end > 0 && !sha.is_char_boundary(end) {
470 end -= 1;
471 }
472 &sha[..end]
473}
474
475fn ssh_command_val() -> &'static str {
476 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
477 "ssh -o StrictHostKeyChecking=yes"
478 } else {
479 "ssh -o StrictHostKeyChecking=accept-new"
480 }
481}
482
483fn git_command() -> std::process::Command {
484 let mut cmd = std::process::Command::new("git");
485 cmd.env("GIT_TERMINAL_PROMPT", "0");
486 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
487 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
488 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
489 cmd
490}
491
492pub fn safe_ref(r: &str) -> Result<&str, String> {
493 let trimmed = r.trim();
494 if trimmed.starts_with('-') {
495 return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
496 }
497 if trimmed.is_empty() {
498 return Err("Ref name cannot be empty".to_string());
499 }
500 Ok(trimmed)
501}
502
503pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
505 let output = git_command()
506 .arg("add")
507 .arg("-A")
508 .current_dir(repo_path)
509 .output()
510 .map_err(|e| e.to_string())?;
511
512 if output.status.success() {
513 Ok(())
514 } else {
515 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
516 }
517}
518
519pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
521 let output =
522 git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
523
524 if output.status.success() {
525 Ok(())
526 } else {
527 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
528 }
529}
530
531pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
533 let _ = git_command().arg("reset").current_dir(repo_path).output();
535
536 let checkout_out = git_command()
538 .arg("checkout")
539 .arg("--")
540 .arg(".")
541 .current_dir(repo_path)
542 .output()
543 .map_err(|e| e.to_string())?;
544
545 if !checkout_out.status.success() {
546 return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
547 }
548
549 let clean_out = git_command()
551 .arg("clean")
552 .arg("-fd")
553 .current_dir(repo_path)
554 .output()
555 .map_err(|e| e.to_string())?;
556
557 if !clean_out.status.success() {
558 return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
559 }
560
561 Ok(())
562}
563
564pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
566 apply_hunk_patch(repo_path, file_path, hunk, false, true)
567}
568
569pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
571 apply_hunk_patch(repo_path, file_path, hunk, true, true)
572}
573
574pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
576 apply_hunk_patch(repo_path, file_path, hunk, true, false)
577}
578
579pub fn stage_line(
581 repo_path: &Path,
582 file_path: &str,
583 hunk: &[DiffLine],
584 selected_line_idx: usize,
585) -> Result<(), String> {
586 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
587}
588
589pub fn unstage_line(
591 repo_path: &Path,
592 file_path: &str,
593 hunk: &[DiffLine],
594 selected_line_idx: usize,
595) -> Result<(), String> {
596 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
597}
598
599pub fn discard_line(
601 repo_path: &Path,
602 file_path: &str,
603 hunk: &[DiffLine],
604 selected_line_idx: usize,
605) -> Result<(), String> {
606 apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
607}
608
609fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
610 if !header.starts_with("@@") {
611 return None;
612 }
613 let parts: Vec<&str> = header.split("@@").collect();
614 if parts.len() < 3 {
615 return None;
616 }
617 let meta = parts[1].trim();
618 let subparts: Vec<&str> = meta.split_whitespace().collect();
619 if subparts.len() < 2 {
620 return None;
621 }
622
623 let parse_part = |p: &str| -> (usize, usize) {
624 let s = p.trim_start_matches(['-', '+']);
625 let comps: Vec<&str> = s.split(',').collect();
626 let start = comps[0].parse::<usize>().unwrap_or(0);
627 let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
628 (start, count)
629 };
630
631 let (old_start, old_count) = parse_part(subparts[0]);
632 let (new_start, new_count) = parse_part(subparts[1]);
633 Some((old_start, old_count, new_start, new_count))
634}
635
636fn apply_line_patch_inner(
637 repo_path: &Path,
638 file_path: &str,
639 hunk: &[DiffLine],
640 selected_line_idx_in_hunk: usize,
641 revert: bool,
642 target_has_modification: bool,
643 cached: bool,
644) -> Result<(), String> {
645 use std::io::Write;
646 use std::process::{Command, Stdio};
647
648 let path_check = std::path::Path::new(file_path);
649 if path_check.is_absolute()
650 || path_check.components().any(|c| c == std::path::Component::ParentDir)
651 {
652 return Err("Path escapes repository root".to_string());
653 }
654
655 if hunk.is_empty() {
656 return Err("Empty hunk".to_string());
657 }
658
659 let selected_line = match hunk.get(selected_line_idx_in_hunk) {
660 Some(line) => line,
661 None => return Err("Invalid line index".to_string()),
662 };
663
664 if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
665 return Err("Selected line is not a modification (must be + or -)".to_string());
666 }
667
668 let header_line = &hunk[0];
669 let (old_start, _old_count, new_start, _new_count) =
670 match parse_hunk_header(&header_line.content) {
671 Some(coords) => coords,
672 None => return Err(format!("Invalid hunk header: {}", header_line.content)),
673 };
674
675 let mut patch_lines = Vec::new();
676 let mut new_old_count = 0;
677 let mut new_new_count = 0;
678
679 for (i, line) in hunk.iter().enumerate() {
680 if i == 0 {
681 continue;
682 }
683
684 if i == selected_line_idx_in_hunk {
685 if revert {
686 match line.kind {
687 DiffLineKind::Added => {
688 patch_lines.push(DiffLine {
689 kind: DiffLineKind::Removed,
690 content: line.content.clone(),
691 old_lineno: None,
692 new_lineno: None,
693 hunk_idx: None,
694 });
695 new_old_count += 1;
696 }
697 DiffLineKind::Removed => {
698 patch_lines.push(DiffLine {
699 kind: DiffLineKind::Added,
700 content: line.content.clone(),
701 old_lineno: None,
702 new_lineno: None,
703 hunk_idx: None,
704 });
705 new_new_count += 1;
706 }
707 _ => {}
708 }
709 } else {
710 match line.kind {
711 DiffLineKind::Added => {
712 patch_lines.push(DiffLine {
713 kind: DiffLineKind::Added,
714 content: line.content.clone(),
715 old_lineno: None,
716 new_lineno: None,
717 hunk_idx: None,
718 });
719 new_new_count += 1;
720 }
721 DiffLineKind::Removed => {
722 patch_lines.push(DiffLine {
723 kind: DiffLineKind::Removed,
724 content: line.content.clone(),
725 old_lineno: None,
726 new_lineno: None,
727 hunk_idx: None,
728 });
729 new_old_count += 1;
730 }
731 _ => {}
732 }
733 }
734 } else {
735 match line.kind {
736 DiffLineKind::Context => {
737 patch_lines.push(line.clone());
738 new_old_count += 1;
739 new_new_count += 1;
740 }
741 DiffLineKind::Added => {
742 if target_has_modification {
743 patch_lines.push(DiffLine {
744 kind: DiffLineKind::Context,
745 content: line.content.clone(),
746 old_lineno: None,
747 new_lineno: None,
748 hunk_idx: None,
749 });
750 new_old_count += 1;
751 new_new_count += 1;
752 } else {
753 }
755 }
756 DiffLineKind::Removed => {
757 if target_has_modification {
758 } else {
760 patch_lines.push(DiffLine {
761 kind: DiffLineKind::Context,
762 content: line.content.clone(),
763 old_lineno: None,
764 new_lineno: None,
765 hunk_idx: None,
766 });
767 new_old_count += 1;
768 new_new_count += 1;
769 }
770 }
771 _ => {}
772 }
773 }
774 }
775
776 let mut patch = String::new();
777 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
778 patch.push_str(&format!("--- a/{}\n", file_path));
779 patch.push_str(&format!("+++ b/{}\n", file_path));
780 patch.push_str(&format!(
781 "@@ -{},{} +{},{} @@\n",
782 old_start, new_old_count, new_start, new_new_count
783 ));
784
785 for line in patch_lines {
786 let prefix = match line.kind {
787 DiffLineKind::Added => "+",
788 DiffLineKind::Removed => "-",
789 DiffLineKind::Context => " ",
790 DiffLineKind::Header => "",
791 _ => "",
792 };
793 patch.push_str(prefix);
794 patch.push_str(&line.content);
795 patch.push('\n');
796 }
797
798 let mut args = vec!["apply"];
799 if cached {
800 args.push("--cached");
801 }
802 args.push("-");
803
804 let mut cmd = Command::new("git");
805 let mut child = cmd
806 .env("GIT_TERMINAL_PROMPT", "0")
807 .env("GIT_SSH_COMMAND", ssh_command_val())
808 .args(&args)
809 .current_dir(repo_path)
810 .stdin(Stdio::piped())
811 .stdout(Stdio::piped())
812 .stderr(Stdio::piped())
813 .spawn()
814 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
815
816 if let Some(mut stdin) = child.stdin.take() {
817 stdin
818 .write_all(patch.as_bytes())
819 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
820 }
821
822 let output =
823 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
824
825 if !output.status.success() {
826 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
827 return Err(format!("git apply failed: {}", err_msg.trim()));
828 }
829
830 Ok(())
831}
832
833fn apply_hunk_patch(
834 repo_path: &Path,
835 file_path: &str,
836 hunk: &[DiffLine],
837 reverse: bool,
838 cached: bool,
839) -> Result<(), String> {
840 use std::io::Write;
841 use std::process::{Command, Stdio};
842
843 let path_check = std::path::Path::new(file_path);
844 if path_check.is_absolute()
845 || path_check.components().any(|c| c == std::path::Component::ParentDir)
846 {
847 return Err("Path escapes repository root".to_string());
848 }
849
850 let mut patch = String::new();
851 patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
852 patch.push_str(&format!("--- a/{}\n", file_path));
853 patch.push_str(&format!("+++ b/{}\n", file_path));
854 for line in hunk {
855 let prefix = match line.kind {
856 DiffLineKind::Added => "+",
857 DiffLineKind::Removed => "-",
858 DiffLineKind::Context => " ",
859 DiffLineKind::Header => "",
860 _ => "",
861 };
862 patch.push_str(prefix);
863 patch.push_str(&line.content);
864 patch.push('\n');
865 }
866
867 let mut args = vec!["apply"];
868 if cached {
869 args.push("--cached");
870 }
871 if reverse {
872 args.push("--reverse");
873 }
874 args.push("-");
875
876 let mut cmd = Command::new("git");
877 let mut child = cmd
878 .env("GIT_TERMINAL_PROMPT", "0")
879 .env("GIT_SSH_COMMAND", ssh_command_val())
880 .args(&args)
881 .current_dir(repo_path)
882 .stdin(Stdio::piped())
883 .stdout(Stdio::piped())
884 .stderr(Stdio::piped())
885 .spawn()
886 .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
887
888 if let Some(mut stdin) = child.stdin.take() {
889 stdin
890 .write_all(patch.as_bytes())
891 .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
892 }
893
894 let output =
895 child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
896
897 if !output.status.success() {
898 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
899 return Err(format!("git apply failed: {}", err_msg.trim()));
900 }
901
902 Ok(())
903}
904
905pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
910 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
911
912 if staged {
913 unstage_file(repo_path, file_path)?;
915 }
916
917 let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
919 status.contains(git2::Status::WT_NEW)
920 } else {
921 false
922 };
923
924 if is_untracked {
925 let full_path = repo_path.join(file_path);
926 if full_path.exists() {
927 if full_path.is_file() {
928 std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
929 } else if full_path.is_dir() {
930 std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
931 }
932 }
933 } else {
934 let mut checkout_opts = git2::build::CheckoutBuilder::new();
936 checkout_opts.path(Path::new(file_path));
937 checkout_opts.force();
938 repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
939 }
940
941 Ok(())
942}
943
944pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
947 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
948 let mut index = repo.index().map_err(|e| e.to_string())?;
949 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
950 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
951
952 let signature = repo
953 .signature()
954 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
955
956 let mut parents = Vec::new();
958 let mut has_head = false;
959 if let Ok(head) = repo.head() {
960 if let Ok(parent_commit) = head.peel_to_commit() {
961 has_head = true;
962 let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
964 if parent_tree.id() == tree_id {
965 return Err("No staged changes to commit".to_string());
966 }
967 parents.push(parent_commit);
968 }
969 }
970
971 if !has_head && index.is_empty() {
972 return Err("No staged changes to commit (index is empty)".to_string());
973 }
974
975 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
976
977 repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
978 .map_err(|e| e.to_string())?;
979
980 Ok(())
981}
982
983pub fn expand_tilde(s: &str) -> PathBuf {
989 if s == "~" {
990 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
991 }
992 if let Some(stripped) = s.strip_prefix("~/")
993 && let Some(home) = dirs::home_dir()
994 {
995 return home.join(stripped);
996 }
997 PathBuf::from(s)
998}
999
1000pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1002 let repo = Repository::open(repo_path)?;
1003 repo.remote(name, url)?;
1004 Ok(())
1005}
1006
1007pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1009 let repo = Repository::open(repo_path)?;
1010 repo.remote_delete(name)?;
1011 Ok(())
1012}
1013
1014pub fn inspect_summary(item: &str) -> ItemStatus {
1016 let path = expand_tilde(item);
1017 if !path.is_dir() {
1018 return ItemStatus::Missing;
1019 }
1020 if !path.join(".git").exists() {
1021 return ItemStatus::Directory;
1022 }
1023 match Repository::open(&path) {
1024 Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1025 Err(_) => ItemStatus::GitRepo(None),
1026 }
1027}
1028
1029pub fn inspect_detail(
1031 item: &str,
1032 commit_limit: usize,
1033 graph_max_commits: usize,
1034 enable_commit_signatures: bool,
1035) -> ItemDetail {
1036 let resolved = expand_tilde(item);
1037 if !resolved.is_dir() {
1038 return ItemDetail::Missing { resolved };
1039 }
1040 if !resolved.join(".git").exists() {
1041 return ItemDetail::Directory { resolved };
1042 }
1043 match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1044 Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1045 Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1046 }
1047}
1048
1049fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1050 let mut sigs = std::collections::HashMap::new();
1051 let mut cmd = std::process::Command::new("git");
1052 cmd.env("GIT_TERMINAL_PROMPT", "0")
1053 .env("GIT_SSH_COMMAND", ssh_command_val())
1054 .arg("log")
1055 .arg("--all");
1056
1057 if limit > 0 {
1058 cmd.arg(format!("-n{}", limit));
1059 }
1060
1061 cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1062
1063 if let Ok(out) = cmd.output() {
1064 if out.status.success() {
1065 let stdout_str = String::from_utf8_lossy(&out.stdout);
1066 for line in stdout_str.lines() {
1067 let parts: Vec<&str> = line.split_whitespace().collect();
1068 if parts.len() == 2 {
1069 sigs.insert(parts[0].to_string(), parts[1].to_string());
1070 } else if parts.len() == 1 {
1071 sigs.insert(parts[0].to_string(), "N".to_string());
1072 }
1073 }
1074 }
1075 }
1076 sigs
1077}
1078
1079#[derive(serde::Serialize, serde::Deserialize, Clone)]
1080struct CachedCommit {
1081 id: String,
1082 author: String,
1083 date: String,
1084 summary: String,
1085 message: String,
1086 time: i64,
1087}
1088
1089fn hash_path(path: &Path) -> String {
1090 use std::collections::hash_map::DefaultHasher;
1091 use std::hash::{Hash, Hasher};
1092 let mut hasher = DefaultHasher::new();
1093 path.hash(&mut hasher);
1094 format!("{:x}", hasher.finish())
1095}
1096
1097fn collect_commits(
1098 repo: &Repository,
1099 limit: usize,
1100 repo_path: &Path,
1101 enable_commit_signatures: bool,
1102) -> Result<Vec<CommitEntry>, git2::Error> {
1103 let mut walk = repo.revwalk()?;
1104 if walk.push_head().is_err() {
1105 return Ok(Vec::new());
1106 }
1107 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1108
1109 let mut commits = Vec::new();
1110 let oids: Vec<Result<git2::Oid, git2::Error>> =
1111 if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1112
1113 let sig_map = if enable_commit_signatures {
1114 collect_signatures(repo_path, limit)
1115 } else {
1116 std::collections::HashMap::new()
1117 };
1118 let ref_map = get_cached_ref_map(repo, repo_path);
1119
1120 let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1122 if let Some(ref dir) = cache_dir {
1123 let _ = std::fs::create_dir_all(dir);
1124 }
1125 let hash = hash_path(repo_path);
1126 let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1127 let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1128 .as_ref()
1129 .and_then(|f| std::fs::read_to_string(f).ok())
1130 .and_then(|s| serde_json::from_str(&s).ok())
1131 .unwrap_or_default();
1132
1133 let mut cache_updated = false;
1134
1135 for id in oids {
1136 let oid = id?;
1137 let oid_str = oid.to_string();
1138 let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1139 let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1140 let files = Vec::new();
1141
1142 if let Some(cached) = cache.get(&oid_str) {
1143 let when = format_relative_time(cached.time);
1144 commits.push(CommitEntry {
1145 id: cached.id.clone(),
1146 oid: oid_str,
1147 author: sanitize_text(&cached.author),
1148 when,
1149 date: cached.date.clone(),
1150 summary: sanitize_text(&cached.summary),
1151 message: sanitize_text(&cached.message),
1152 refs,
1153 files,
1154 signature_status: sig_status,
1155 });
1156 } else if let Ok(commit) = repo.find_commit(oid) {
1157 let short_id = format!("{:.7}", commit.id());
1158 let summary =
1159 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1160 let author = commit.author();
1161 let author_name = author.name().unwrap_or("?");
1162 let author_email = author.email().unwrap_or("?");
1163 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1164 let time_secs = commit.time().seconds();
1165 let when = format_relative_time(time_secs);
1166 let date = format_utc_date(time_secs);
1167 let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1168
1169 let cached = CachedCommit {
1170 id: short_id.clone(),
1171 author: author_str.clone(),
1172 date: date.clone(),
1173 summary: summary.clone(),
1174 message: message.clone(),
1175 time: time_secs,
1176 };
1177 cache.insert(oid_str.clone(), cached);
1178 cache_updated = true;
1179
1180 commits.push(CommitEntry {
1181 id: short_id,
1182 oid: oid_str,
1183 author: author_str,
1184 when,
1185 date,
1186 summary,
1187 message,
1188 refs,
1189 files,
1190 signature_status: sig_status,
1191 });
1192 }
1193 }
1194
1195 if cache_updated {
1196 if let Some(ref f) = cache_file {
1197 if let Ok(json) = serde_json::to_string(&cache) {
1198 let _ = std::fs::write(f, json);
1199 }
1200 }
1201 }
1202
1203 Ok(commits)
1204}
1205
1206pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1207 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1208 let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1209 if walk.push_head().is_err() {
1210 return Ok(Vec::new());
1211 }
1212 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1213
1214 let mut revisions = Vec::new();
1215
1216 for oid_res in walk {
1217 let oid = oid_res.map_err(|e| e.to_string())?;
1218 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1219
1220 let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1221 let mut modified = false;
1222
1223 if commit.parent_count() > 0 {
1224 for i in 0..commit.parent_count() {
1225 if let Ok(parent) = commit.parent(i) {
1226 if let Ok(parent_tree) = parent.tree() {
1227 let mut diff_opts = git2::DiffOptions::new();
1228 diff_opts.pathspec(file_path);
1229 if let Ok(diff) = repo.diff_tree_to_tree(
1230 Some(&parent_tree),
1231 Some(&commit_tree),
1232 Some(&mut diff_opts),
1233 ) {
1234 if diff.deltas().len() > 0 {
1235 modified = true;
1236 break;
1237 }
1238 }
1239 }
1240 }
1241 }
1242 } else {
1243 let mut diff_opts = git2::DiffOptions::new();
1244 diff_opts.pathspec(file_path);
1245 if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1246 {
1247 if diff.deltas().len() > 0 {
1248 modified = true;
1249 }
1250 }
1251 }
1252
1253 if modified {
1254 let author = commit.author();
1255 let author_name = author.name().unwrap_or("?");
1256 let author_email = author.email().unwrap_or("?");
1257 let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1258 let time_secs = commit.time().seconds();
1259 let when = format_relative_time(time_secs);
1260 let date = format_utc_date(time_secs);
1261 let summary =
1262 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1263
1264 revisions.push(FileRevision {
1265 commit_oid: oid.to_string(),
1266 author: author_str,
1267 date,
1268 when,
1269 summary,
1270 });
1271 }
1272 }
1273
1274 Ok(revisions)
1275}
1276
1277#[derive(Debug, Clone)]
1278pub struct BlameEntry {
1279 pub line_no: usize,
1280 pub commit_id: String,
1281 pub author: String,
1282 pub date: String,
1283}
1284
1285pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1286 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1287 let mut opts = git2::BlameOptions::new();
1288 let blame = repo
1289 .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1290 .map_err(|e| e.to_string())?;
1291
1292 let mut entries = Vec::new();
1293 for i in 0..blame.len() {
1294 if let Some(hunk) = blame.get_index(i) {
1295 let commit_id = hunk.final_commit_id().to_string();
1296 let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1297
1298 let mut author_name = "Unknown".to_string();
1299 let mut date_str = "unknown".to_string();
1300 if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1301 author_name = commit.author().name().unwrap_or("Unknown").to_string();
1302 date_str = format_utc_date(commit.time().seconds());
1303 }
1304
1305 let start = hunk.final_start_line();
1306 let count = hunk.lines_in_hunk();
1307 for line_idx in 0..count {
1308 entries.push(BlameEntry {
1309 line_no: start + line_idx,
1310 commit_id: short_id.to_string(),
1311 author: author_name.clone(),
1312 date: date_str.clone(),
1313 });
1314 }
1315 }
1316 }
1317
1318 entries.sort_by_key(|e| e.line_no);
1319 Ok(entries)
1320}
1321
1322fn collect_committer_stats(
1323 repo: &Repository,
1324 limit: usize,
1325) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1326 let mut walk = repo.revwalk()?;
1327 if walk.push_head().is_err() {
1328 return Ok((Vec::new(), false));
1329 }
1330 let mut counts = std::collections::HashMap::new();
1331 let mut count = 0;
1332 let mut limit_reached = false;
1333 for id in walk {
1334 let oid = id?;
1335 if let Ok(commit) = repo.find_commit(oid) {
1336 let author = commit.author();
1337 let name = author.name().unwrap_or("?").to_string();
1338 let email = author.email().unwrap_or("?").to_string();
1339 let key = (name, email);
1340 *counts.entry(key).or_insert(0) += 1;
1341 count += 1;
1342 if count >= limit {
1343 limit_reached = true;
1344 break;
1345 }
1346 }
1347 }
1348
1349 let mut stats: Vec<CommitterStat> = counts
1350 .into_iter()
1351 .map(|((name, email), count)| CommitterStat { name, email, count })
1352 .collect();
1353
1354 stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1355
1356 Ok((stats, limit_reached))
1357}
1358
1359fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1363 let commit_tree = match commit.tree() {
1364 Ok(t) => t,
1365 Err(_) => return Vec::new(),
1366 };
1367 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1370
1371 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1372 Ok(d) => d,
1373 Err(_) => return Vec::new(),
1374 };
1375
1376 let mut files = Vec::new();
1377 for delta in diff.deltas() {
1378 if files.len() >= MAX_FILES_PER_SECTION {
1379 break;
1380 }
1381 let path = delta
1382 .new_file()
1383 .path()
1384 .or_else(|| delta.old_file().path())
1385 .map(|p| p.to_string_lossy().into_owned())
1386 .unwrap_or_else(|| "(unknown)".to_string());
1387
1388 let label: &'static str = match delta.status() {
1389 git2::Delta::Added => "N",
1390 git2::Delta::Deleted => "D",
1391 git2::Delta::Modified => "M",
1392 git2::Delta::Renamed => "R",
1393 git2::Delta::Typechange => "T",
1394 _ => "M",
1395 };
1396 files.push(FileEntry { path, label });
1397 }
1398 files
1399}
1400
1401pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1402 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1403 let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1404 let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1405 Ok(commit_changed_files(&repo, &commit))
1406}
1407fn match_pattern(path: &str, pattern: &str) -> bool {
1408 if let Some(suffix) = pattern.strip_prefix('*') {
1409 return path.ends_with(suffix);
1410 }
1411 if let Some(prefix) = pattern.strip_suffix('*') {
1412 return path.starts_with(prefix);
1413 }
1414 if pattern.contains('*') {
1415 let parts: Vec<&str> = pattern.split('*').collect();
1416 if parts.len() == 2 {
1417 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1418 }
1419 }
1420 path == pattern || path.ends_with(&format!("/{}", pattern))
1421}
1422
1423fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1424 let mut tracked = std::collections::HashSet::new();
1425 let mut installed = false;
1426
1427 if let Ok(output) = std::process::Command::new("git")
1428 .arg("lfs")
1429 .arg("--version")
1430 .output()
1431 {
1432 installed = output.status.success();
1433 }
1434
1435 if installed {
1436 if let Ok(output) = std::process::Command::new("git")
1437 .arg("lfs")
1438 .arg("ls-files")
1439 .arg("-n")
1440 .current_dir(repo_path)
1441 .output()
1442 {
1443 if output.status.success() {
1444 if let Ok(stdout) = String::from_utf8(output.stdout) {
1445 for line in stdout.lines() {
1446 let trimmed = line.trim();
1447 if !trimmed.is_empty() {
1448 tracked.insert(trimmed.to_string());
1449 }
1450 }
1451 }
1452 }
1453 }
1454 }
1455
1456 let gitattributes_path = repo_path.join(".gitattributes");
1457 if gitattributes_path.exists() {
1458 if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1459 let mut patterns = Vec::new();
1460 for line in content.lines() {
1461 let line = line.trim();
1462 if line.is_empty() || line.starts_with('#') {
1463 continue;
1464 }
1465 if line.contains("filter=lfs") || line.contains("diff=lfs") || line.contains("merge=lfs") {
1466 if let Some(pattern) = line.split_whitespace().next() {
1467 patterns.push(pattern.to_string());
1468 }
1469 }
1470 }
1471
1472 if !patterns.is_empty() {
1473 if let Ok(repo) = Repository::open(repo_path) {
1474 if let Ok(index) = repo.index() {
1475 for entry in index.iter() {
1476 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1477 for pat in &patterns {
1478 if match_pattern(path_str, pat) {
1479 tracked.insert(path_str.to_string());
1480 break;
1481 }
1482 }
1483 }
1484 }
1485 }
1486 }
1487 }
1488 }
1489 }
1490
1491 (installed, tracked)
1492}
1493
1494fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1495 let lfs_path = repo_path.join(".git").join("lfs");
1496 if !lfs_path.exists() {
1497 return None;
1498 }
1499
1500 fn calculate_dir_size(dir: &Path) -> u64 {
1501 let mut size = 0;
1502 if let Ok(entries) = std::fs::read_dir(dir) {
1503 for entry in entries.flatten() {
1504 if let Ok(metadata) = entry.metadata() {
1505 if metadata.is_dir() {
1506 size += calculate_dir_size(&entry.path());
1507 } else {
1508 size += metadata.len();
1509 }
1510 }
1511 }
1512 }
1513 size
1514 }
1515
1516 Some(calculate_dir_size(&lfs_path))
1517}
1518
1519fn collect_info(
1522 path: &Path,
1523 commit_limit: usize,
1524 _graph_max_commits: usize,
1525 enable_commit_signatures: bool,
1526) -> Result<RepoInfo, git2::Error> {
1527 let repo = Repository::open(path)?;
1528 let mut summary = RepoSummary::default();
1529 if let Ok(head) = repo.head() {
1530 summary.branch = head.shorthand().ok().map(String::from);
1531 }
1532 populate_ahead_behind(&repo, &mut summary);
1533
1534 let mut info = RepoInfo { summary, ..RepoInfo::default() };
1535 let (lfs_inst, lfs_f) = get_lfs_info(path);
1536 info.lfs_installed = lfs_inst;
1537 info.lfs_files = lfs_f;
1538 info.lfs_storage_size = get_lfs_storage_size(path);
1539
1540 if let Ok(head) = repo.head() {
1541 info.branch = head.shorthand().ok().map(String::from);
1542
1543 if let Ok(commit) = head.peel_to_commit() {
1544 let short_id = format!("{:.7}", commit.id());
1545 let summary_text =
1546 sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1547 let author = commit.author();
1548 let author_str = sanitize_text(&format!(
1549 "{} <{}>",
1550 author.name().unwrap_or("?"),
1551 author.email().unwrap_or("?")
1552 ));
1553 let when = format_relative_time(commit.time().seconds());
1554 info.head =
1555 Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1556 }
1557
1558 if let Ok(head_name) = head.name() {
1559 info.upstream = upstream_short_name(&repo, head_name);
1560 }
1561 }
1562
1563 if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1564 info.commits = commits;
1565 }
1566
1567 populate_summary_and_file_changes(&repo, &mut info);
1568
1569 if let Ok(remotes) = load_tab_remotes(path) {
1570 info.remotes = TabData::Loaded(remotes);
1571 info.tab_loaded_at[5] = Some(std::time::Instant::now());
1572 }
1573
1574 Ok(info)
1575}
1576
1577pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1578 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1579 let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1580 let mut entries = Vec::new();
1581 for (i, entry) in reflog.iter().enumerate() {
1582 let target_oid = format!("{}", entry.id_new());
1583 let selector = format!("HEAD@{{{}}}", i);
1584 let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1585
1586 let (command, message) = if let Some(pos) = msg.find(':') {
1587 (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1588 } else {
1589 (String::new(), msg)
1590 };
1591
1592 let sig = entry.committer();
1593 let when_secs = sig.when().seconds();
1594 let when = format_relative_time(when_secs);
1595 let date = format_utc_date(when_secs);
1596
1597 entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1598 }
1599 Ok(entries)
1600}
1601
1602pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1603 let output = std::process::Command::new("git")
1604 .env("GIT_TERMINAL_PROMPT", "0")
1605 .env("GIT_SSH_COMMAND", ssh_command_val())
1606 .arg("checkout")
1607 .arg(commit_oid)
1608 .current_dir(repo_path)
1609 .output()
1610 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1611
1612 if !output.status.success() {
1613 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1614 return Err(git2::Error::from_str(&err));
1615 }
1616 Ok(())
1617}
1618
1619pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1620 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1621 let mut files = Vec::new();
1622 if let Ok(index) = repo.index() {
1623 for entry in index.iter() {
1624 if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1625 files.push(path_str.to_string());
1626 }
1627 }
1628 }
1629 Ok(files)
1630}
1631
1632pub fn load_tab_graph_stream(
1633 repo_path: &Path,
1634 graph_max_commits: usize,
1635 repo_resolved_path: String,
1636 tab_idx: usize,
1637 tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1638) -> Result<Vec<GraphLine>, String> {
1639 let mut graph_lines = Vec::new();
1640 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1641
1642 let mut args = vec![
1643 "log".to_string(),
1644 "--graph".to_string(),
1645 "--all".to_string(),
1646 "--date=relative".to_string(),
1647 ];
1648 if graph_max_commits > 0 {
1649 args.push(format!("--max-count={}", graph_max_commits));
1650 }
1651 args.push(format!("--pretty=format:{}", format_str));
1652 args.push("--color=never".to_string());
1653
1654 let mut child = std::process::Command::new("git")
1655 .env("GIT_TERMINAL_PROMPT", "0")
1656 .env("GIT_SSH_COMMAND", ssh_command_val())
1657 .args(&args)
1658 .current_dir(repo_path)
1659 .stdout(std::process::Stdio::piped())
1660 .spawn()
1661 .map_err(|e| e.to_string())?;
1662
1663 let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1664 let reader = std::io::BufReader::new(stdout);
1665 use std::io::BufRead;
1666
1667 for (idx, line_res) in reader.lines().enumerate() {
1668 let line = line_res.map_err(|e| e.to_string())?;
1669 let parsed = parse_graph_line(&line);
1670 graph_lines.push(parsed);
1671
1672 if (idx + 1) % 200 == 0 {
1674 let _ = tx.send((
1675 repo_resolved_path.clone(),
1676 tab_idx,
1677 TabPayload::Graph(Ok(graph_lines.clone())),
1678 ));
1679 }
1680 }
1681
1682 let status = child.wait().map_err(|e| e.to_string())?;
1684 if !status.success() && graph_lines.is_empty() {
1685 return Err("git log failed".to_string());
1686 }
1687
1688 Ok(graph_lines)
1689}
1690
1691pub fn load_tab_branches(
1692 repo_path: &Path,
1693) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1694 let repo = match Repository::open(repo_path) {
1695 Ok(r) => r,
1696 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1697 };
1698
1699 let mut local_branches = Vec::new();
1700 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1701 for (branch, _) in branches.flatten() {
1702 if let Ok(Some(name)) = branch.name() {
1703 let is_head = branch.is_head();
1704 let mut short_sha = String::new();
1705 let mut short_message = String::new();
1706 if let Ok(target) = branch.get().peel_to_commit() {
1707 let id = target.id();
1708 let id_str = id.to_string();
1709 short_sha = safe_sha_slice(&id_str, 7).to_string();
1710 if let Ok(Some(summary)) = target.summary() {
1711 short_message = summary.to_string();
1712 }
1713 }
1714 local_branches.push(BranchInfo {
1715 name: sanitize_text(name),
1716 is_head,
1717 short_sha,
1718 short_message: sanitize_text(&short_message),
1719 });
1720 }
1721 }
1722 }
1723 local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1724
1725 let mut remote_branches = Vec::new();
1726 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1727 for (branch, _) in branches.flatten() {
1728 if let Ok(Some(name)) = branch.name() {
1729 if !name.ends_with("/HEAD") {
1730 let is_head = branch.is_head();
1731 let mut short_sha = String::new();
1732 let mut short_message = String::new();
1733 if let Ok(target) = branch.get().peel_to_commit() {
1734 let id = target.id();
1735 let id_str = id.to_string();
1736 short_sha = safe_sha_slice(&id_str, 7).to_string();
1737 if let Ok(Some(summary)) = target.summary() {
1738 short_message = summary.to_string();
1739 }
1740 }
1741 remote_branches.push(BranchInfo {
1742 name: sanitize_text(name),
1743 is_head,
1744 short_sha,
1745 short_message: sanitize_text(&short_message),
1746 });
1747 }
1748 }
1749 }
1750 }
1751 remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1752
1753 (Ok(local_branches), Ok(remote_branches))
1754}
1755
1756pub fn load_tab_tags(
1757 repo_path: &Path,
1758) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1759 let repo = match Repository::open(repo_path) {
1760 Ok(r) => r,
1761 Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1762 };
1763
1764 let mut local_tags = Vec::new();
1765 if let Ok(tags) = repo.tag_names(None) {
1766 for tag_opt in tags.iter() {
1767 if let Ok(Some(tag)) = tag_opt {
1768 let mut short_sha = String::new();
1769 let mut short_message = String::new();
1770 if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1771 if let Ok(target) = reference.peel_to_commit() {
1772 let id = target.id();
1773 let id_str = id.to_string();
1774 short_sha = safe_sha_slice(&id_str, 7).to_string();
1775 if let Ok(Some(summary)) = target.summary() {
1776 short_message = summary.to_string();
1777 }
1778 }
1779 }
1780 local_tags.push(BranchInfo {
1781 name: sanitize_text(tag),
1782 is_head: false,
1783 short_sha,
1784 short_message: sanitize_text(&short_message),
1785 });
1786 }
1787 }
1788 }
1789 local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1790
1791 (Ok(local_tags), Ok(Vec::new()))
1792}
1793
1794pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1795 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1796 let mut remotes_list = Vec::new();
1797 if let Ok(remotes) = repo.remotes() {
1798 for name in remotes.iter() {
1799 let Ok(Some(name)) = name else { continue };
1800 if let Ok(remote) = repo.find_remote(name) {
1801 let push_url = remote.pushurl().ok().flatten().map(String::from);
1802 let mut refspecs = Vec::new();
1803 for r in remote.refspecs() {
1804 if let Ok(s) = r.str() {
1805 refspecs.push(s.to_string());
1806 }
1807 }
1808 remotes_list.push(RemoteInfo {
1809 name: name.to_string(),
1810 url: remote.url().unwrap_or("(no url)").to_string(),
1811 push_url,
1812 refspecs,
1813 });
1814 }
1815 }
1816 }
1817 Ok(remotes_list)
1818}
1819
1820pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1821 let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1822 let mut temp_stashes = Vec::new();
1823 let _ = repo.stash_foreach(|index, message, oid| {
1824 temp_stashes.push((index, message.to_string(), *oid));
1825 true
1826 });
1827
1828 let mut stashes = Vec::new();
1829 for (index, message, oid) in temp_stashes {
1830 let mut files = Vec::new();
1831 if let Ok(commit) = repo.find_commit(oid) {
1832 files = commit_changed_files(&repo, &commit);
1833 }
1834 stashes.push(StashInfo {
1835 index,
1836 message: sanitize_text(&message),
1837 commit_id: oid.to_string(),
1838 files,
1839 });
1840 }
1841 Ok(stashes)
1842}
1843
1844pub fn load_tab_overview(
1845 repo_path: &Path,
1846 _commit_limit: usize,
1847) -> Result<(Vec<CommitterStat>, bool), String> {
1848 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1849 let (stats, limit_reached) =
1850 collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
1851 Ok((stats, limit_reached))
1852}
1853
1854pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1855 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1856 let submodules = repo.submodules().map_err(|e| e.to_string())?;
1857 let mut list = Vec::new();
1858 for sub in submodules {
1859 let name = sub.name().unwrap_or("").to_string();
1860 let path = sub.path().to_path_buf();
1861 let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
1862 let commit_id = sub.index_id().map(|id| id.to_string());
1863 let head_id = sub.head_id().map(|id| id.to_string());
1864
1865 let mut is_initialized = true;
1866 let mut is_dirty = false;
1867
1868 if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
1869 is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
1870 is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
1871 || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
1872 || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
1873 || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
1874 || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
1875 }
1876
1877 list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
1878 }
1879 Ok(list)
1880}
1881
1882fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1887 let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1888 std::collections::HashMap::new();
1889
1890 if let Ok(refs) = repo.references() {
1891 for reference in refs.flatten() {
1892 let Ok(target) = reference.peel_to_commit() else {
1894 continue;
1895 };
1896 let oid = target.id();
1897
1898 let Ok(full_name) = reference.name() else {
1899 continue;
1900 };
1901
1902 let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1903 branch.to_string()
1904 } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1905 format!("tag:{}", tag)
1906 } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1907 if remote.ends_with("/HEAD") {
1909 continue;
1910 }
1911 format!("remote:{}", remote)
1912 } else {
1913 continue;
1914 };
1915
1916 map.entry(oid).or_default().push(label);
1917 }
1918 }
1919 map
1920}
1921
1922#[allow(clippy::type_complexity)]
1923static REF_MAP_CACHE: std::sync::OnceLock<
1924 std::sync::Mutex<
1925 std::collections::HashMap<
1926 String,
1927 (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1928 >,
1929 >,
1930> = std::sync::OnceLock::new();
1931
1932fn get_cached_ref_map(
1933 repo: &Repository,
1934 repo_path: &Path,
1935) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1936 let cache_lock =
1937 REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1938 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1939 let path_key = repo_path.to_string_lossy().to_string();
1940
1941 if let Some((map, loaded_at)) = cache.get(&path_key) {
1942 if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1943 return map.clone();
1944 }
1945 }
1946
1947 let map = build_ref_map(repo);
1948 cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1949 map
1950}
1951
1952pub fn invalidate_ref_map_cache(repo_path: &Path) {
1953 if let Some(cache_lock) = REF_MAP_CACHE.get() {
1954 if let Ok(mut cache) = cache_lock.lock() {
1955 cache.remove(&repo_path.to_string_lossy().to_string());
1956 }
1957 }
1958}
1959
1960const MAX_FILES_PER_SECTION: usize = 100;
1963
1964fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1966 let mut opts = StatusOptions::new();
1967 opts.include_untracked(true)
1968 .renames_head_to_index(true)
1969 .recurse_untracked_dirs(true)
1970 .show(StatusShow::IndexAndWorkdir);
1971 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1972 return;
1973 };
1974 for entry in statuses.iter() {
1975 let path = entry.path().unwrap_or("(unknown)").to_string();
1976 let flags = entry.status();
1977
1978 if flags.is_conflicted() {
1980 info.summary.conflicted += 1;
1981 } else {
1982 if flags.is_wt_new() {
1983 info.summary.untracked += 1;
1984 }
1985 if flags.is_wt_modified()
1986 || flags.is_wt_deleted()
1987 || flags.is_wt_renamed()
1988 || flags.is_wt_typechange()
1989 {
1990 info.summary.modified += 1;
1991 }
1992 if flags.is_index_new()
1993 || flags.is_index_modified()
1994 || flags.is_index_deleted()
1995 || flags.is_index_renamed()
1996 || flags.is_index_typechange()
1997 {
1998 info.summary.staged += 1;
1999 }
2000 }
2001
2002 let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2005 if path_buf.is_dir() {
2006 continue;
2007 }
2008
2009 if flags.is_conflicted() {
2010 if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2011 info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2012 }
2013 continue;
2014 }
2015
2016 if (flags.is_index_new()
2018 || flags.is_index_modified()
2019 || flags.is_index_deleted()
2020 || flags.is_index_renamed()
2021 || flags.is_index_typechange())
2022 && info.changes.staged.len() < MAX_FILES_PER_SECTION
2023 {
2024 let label = if flags.is_index_new() {
2025 "N"
2026 } else if flags.is_index_deleted() {
2027 "D"
2028 } else if flags.is_index_renamed() {
2029 "R"
2030 } else if flags.is_index_typechange() {
2031 "T"
2032 } else {
2033 "M"
2034 };
2035 info.changes.staged.push(FileEntry { path: path.clone(), label });
2036 }
2037
2038 if flags.is_wt_new() {
2040 if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2041 info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2042 }
2043 if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2044 info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2045 }
2046 } else if (flags.is_wt_modified()
2047 || flags.is_wt_deleted()
2048 || flags.is_wt_renamed()
2049 || flags.is_wt_typechange())
2050 && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2051 {
2052 let label = if flags.is_wt_deleted() {
2053 "D"
2054 } else if flags.is_wt_renamed() {
2055 "R"
2056 } else if flags.is_wt_typechange() {
2057 "T"
2058 } else {
2059 "M"
2060 };
2061 info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2062 }
2063 }
2064}
2065
2066fn collect_summary(repo: &Repository) -> RepoSummary {
2070 let mut s = RepoSummary::default();
2071 if let Ok(head) = repo.head() {
2073 s.branch = head.shorthand().ok().map(String::from);
2074 if let Ok(commit) = head.peel_to_commit() {
2075 s.last_commit_time = Some(commit.time().seconds());
2076 }
2077 }
2078 populate_worktree(repo, &mut s);
2079 populate_ahead_behind(repo, &mut s);
2080 s.state = match repo.state() {
2081 git2::RepositoryState::Clean => RepoState::Clean,
2082 git2::RepositoryState::Merge => RepoState::Merge,
2083 git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2084 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2085 RepoState::CherryPick
2086 }
2087 git2::RepositoryState::Bisect => RepoState::Bisect,
2088 git2::RepositoryState::Rebase
2089 | git2::RepositoryState::RebaseInteractive
2090 | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2091 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2092 RepoState::ApplyMailbox
2093 }
2094 };
2095 s
2096}
2097
2098fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2099 let mut opts = StatusOptions::new();
2100 opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2101 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2102 return;
2103 };
2104 for entry in statuses.iter() {
2105 let flags = entry.status();
2106 if flags.is_conflicted() {
2107 s.conflicted += 1;
2108 continue;
2109 }
2110 if flags.is_wt_new() {
2111 s.untracked += 1;
2112 }
2113 if flags.is_wt_modified()
2114 || flags.is_wt_deleted()
2115 || flags.is_wt_renamed()
2116 || flags.is_wt_typechange()
2117 {
2118 s.modified += 1;
2119 }
2120 if flags.is_index_new()
2121 || flags.is_index_modified()
2122 || flags.is_index_deleted()
2123 || flags.is_index_renamed()
2124 || flags.is_index_typechange()
2125 {
2126 s.staged += 1;
2127 }
2128 }
2129}
2130
2131fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2135 let Ok(head) = repo.head() else { return };
2136 let Some(local_oid) = head.target() else {
2137 return;
2138 };
2139 let Ok(head_name) = head.name() else { return };
2140 let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2141 return;
2142 };
2143 let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2144 return;
2145 };
2146 let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2147 return;
2148 };
2149 let Some(upstream_oid) = upstream_ref.target() else {
2150 return;
2151 };
2152 if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2153 s.ahead = ahead;
2154 s.behind = behind;
2155 }
2156}
2157
2158fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2160 let buf = repo.branch_upstream_name(head_name).ok()?;
2161 let raw = std::str::from_utf8(&buf).ok()?;
2162 Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2163}
2164
2165pub fn format_relative_time(secs: i64) -> String {
2167 if secs <= 0 {
2168 return "unknown".to_string();
2169 }
2170 let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2171 let now = SystemTime::now();
2172 let Ok(elapsed) = now.duration_since(then) else {
2173 return "in the future".to_string();
2174 };
2175 let secs = elapsed.as_secs();
2176 let (n, unit) = if secs < 60 {
2177 (secs, "second")
2178 } else if secs < 3600 {
2179 (secs / 60, "minute")
2180 } else if secs < 86_400 {
2181 (secs / 3600, "hour")
2182 } else if secs < 86_400 * 30 {
2183 (secs / 86_400, "day")
2184 } else if secs < 86_400 * 365 {
2185 (secs / (86_400 * 30), "month")
2186 } else {
2187 (secs / (86_400 * 365), "year")
2188 };
2189 let plural = if n == 1 { "" } else { "s" };
2190 format!("{} {}{} ago", n, unit, plural)
2191}
2192
2193fn format_utc_date(secs: i64) -> String {
2195 if secs <= 0 {
2196 return "unknown".to_string();
2197 }
2198 let seconds_in_day = 86400;
2199 let day_number = secs / seconds_in_day;
2200 let time_of_day = secs % seconds_in_day;
2201
2202 let mut hour = time_of_day / 3600;
2203 let mut minute = (time_of_day % 3600) / 60;
2204 let mut second = time_of_day % 60;
2205 if hour < 0 {
2206 hour += 24;
2207 }
2208 if minute < 0 {
2209 minute += 60;
2210 }
2211 if second < 0 {
2212 second += 60;
2213 }
2214
2215 let z = day_number + 719468;
2217 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2218 let doe = (z - era * 146097) as u32;
2219 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2220 let y = (yoe as i32) + (era as i32) * 400;
2221 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2222 let mp = (5 * doy + 2) / 153;
2223 let d = doy - (153 * mp + 2) / 5 + 1;
2224 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2225 let y = y + if m <= 2 { 1 } else { 0 };
2226
2227 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2228}
2229
2230fn get_file_diff_inner(
2233 repo_path: &Path,
2234 commit_oid: &str,
2235 file_path: &str,
2236) -> Option<Vec<DiffLine>> {
2237 let repo = Repository::open(repo_path).ok()?;
2238 let oid = git2::Oid::from_str(commit_oid).ok()?;
2239 let commit = repo.find_commit(oid).ok()?;
2240
2241 let commit_tree = commit.tree().ok()?;
2242 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2244
2245 let mut opts = git2::DiffOptions::new();
2246 opts.pathspec(file_path);
2247
2248 let diff =
2249 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2250
2251 collect_diff_lines(&diff)
2252}
2253
2254fn get_worktree_diff_inner(
2259 repo_path: &Path,
2260 file_path: &str,
2261 staged: bool,
2262) -> Option<Vec<DiffLine>> {
2263 let repo = Repository::open(repo_path).ok()?;
2264 let mut opts = git2::DiffOptions::new();
2265 opts.pathspec(file_path);
2266 opts.include_untracked(true);
2267 opts.recurse_untracked_dirs(true);
2268 opts.show_untracked_content(true);
2269
2270 let diff = if staged {
2271 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2273 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2274 } else {
2275 repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2277 };
2278
2279 collect_diff_lines(&diff)
2280}
2281
2282fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2284 let mut lines: Vec<DiffLine> = Vec::new();
2285 let mut current_hunk_idx = None;
2286 let mut hunk_count = 0;
2287 diff.print(git2::DiffFormat::Patch, |_, _, line| {
2288 let kind = match line.origin() {
2289 '+' => DiffLineKind::Added,
2290 '-' => DiffLineKind::Removed,
2291 'H' => {
2292 current_hunk_idx = Some(hunk_count);
2293 hunk_count += 1;
2294 DiffLineKind::Header
2295 }
2296 ' ' => DiffLineKind::Context,
2297 _ => return true, };
2299 let content = String::from_utf8_lossy(line.content())
2300 .trim_end_matches('\n')
2301 .trim_end_matches('\r')
2302 .to_string();
2303 lines.push(DiffLine {
2304 kind,
2305 content,
2306 old_lineno: line.old_lineno(),
2307 new_lineno: line.new_lineno(),
2308 hunk_idx: current_hunk_idx,
2309 });
2310 true
2311 })
2312 .ok()?;
2313 Some(lines)
2314}
2315
2316fn parse_graph_line(line: &str) -> GraphLine {
2317 if line.contains("__TWIG_SEP__") {
2318 let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2319 if parts.len() >= 5 {
2320 let graph_and_hash = parts[0];
2321 let decoration = parts[1].trim().to_string();
2322 let summary = parts[2].trim().to_string();
2323 let author = parts[3].trim().to_string();
2324 let date = parts[4].trim().to_string();
2325 let signature_status =
2326 if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2327
2328 let char_count = graph_and_hash.chars().count();
2329 if char_count >= 40 {
2330 let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2331 let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2332 GraphLine {
2333 graph,
2334 commit: Some(GraphCommit {
2335 oid,
2336 decoration,
2337 summary,
2338 author,
2339 date,
2340 signature_status,
2341 }),
2342 }
2343 } else {
2344 GraphLine { graph: graph_and_hash.to_string(), commit: None }
2345 }
2346 } else {
2347 GraphLine { graph: line.to_string(), commit: None }
2348 }
2349 } else {
2350 GraphLine { graph: line.to_string(), commit: None }
2351 }
2352}
2353
2354#[allow(dead_code)]
2355fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2356 let mut graph_lines = Vec::new();
2357 let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2358
2359 let mut args = vec![
2360 "log".to_string(),
2361 "--graph".to_string(),
2362 "--all".to_string(),
2363 "--date=relative".to_string(),
2364 ];
2365 if graph_max_commits > 0 {
2366 args.push(format!("--max-count={}", graph_max_commits));
2367 }
2368 args.push(format!("--pretty=format:{}", format_str));
2369 args.push("--color=never".to_string());
2370
2371 let output = std::process::Command::new("git")
2372 .env("GIT_TERMINAL_PROMPT", "0")
2373 .env("GIT_SSH_COMMAND", ssh_command_val())
2374 .args(&args)
2375 .current_dir(repo_path)
2376 .output();
2377
2378 if let Ok(out) = output {
2379 if out.status.success() {
2380 let stdout_str = String::from_utf8_lossy(&out.stdout);
2381 for line in stdout_str.lines() {
2382 graph_lines.push(parse_graph_line(line));
2383 }
2384 }
2385 }
2386 graph_lines
2387}
2388
2389pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2390 let output = std::process::Command::new("git")
2391 .env("GIT_TERMINAL_PROMPT", "0")
2392 .env("GIT_SSH_COMMAND", ssh_command_val())
2393 .arg("checkout")
2394 .arg(branch_name)
2395 .current_dir(repo_path)
2396 .output()
2397 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2398
2399 if !output.status.success() {
2400 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2401 return Err(git2::Error::from_str(&err));
2402 }
2403 Ok(())
2404}
2405
2406pub fn checkout_remote_branch(
2407 repo_path: &Path,
2408 remote_branch_name: &str,
2409) -> Result<String, git2::Error> {
2410 let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2411 if parts.len() < 2 {
2412 return Err(git2::Error::from_str("Invalid remote branch name"));
2413 }
2414 let local_name = parts[1];
2415
2416 let output = std::process::Command::new("git")
2417 .env("GIT_TERMINAL_PROMPT", "0")
2418 .env("GIT_SSH_COMMAND", ssh_command_val())
2419 .arg("checkout")
2420 .arg(local_name)
2421 .current_dir(repo_path)
2422 .output()
2423 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2424
2425 if output.status.success() {
2426 return Ok(format!("Switched to existing branch '{}'", local_name));
2427 }
2428
2429 let output = std::process::Command::new("git")
2430 .env("GIT_TERMINAL_PROMPT", "0")
2431 .env("GIT_SSH_COMMAND", ssh_command_val())
2432 .arg("checkout")
2433 .arg("--track")
2434 .arg(remote_branch_name)
2435 .current_dir(repo_path)
2436 .output()
2437 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2438
2439 if !output.status.success() {
2440 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2441 return Err(git2::Error::from_str(&err));
2442 }
2443
2444 Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2445}
2446
2447pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2449 let repo = Repository::open(repo_path)?;
2450 let head = repo.head()?;
2451 let target_commit = head.peel_to_commit()?;
2452 repo.branch(branch_name, &target_commit, false)?;
2453 Ok(())
2454}
2455
2456pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2458 let repo = Repository::open(repo_path)?;
2459 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2460 branch.delete()?;
2461 Ok(())
2462}
2463
2464pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2466 let repo = Repository::open(repo_path)?;
2467 let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2468 branch.delete()?;
2469 Ok(())
2470}
2471
2472pub fn create_tag(
2474 repo_path: &Path,
2475 tag_name: &str,
2476 commit_oid_str: &str,
2477) -> Result<(), git2::Error> {
2478 let repo = Repository::open(repo_path)?;
2479 let oid = git2::Oid::from_str(commit_oid_str)?;
2480 let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2481 repo.tag_lightweight(tag_name, &target_object, false)?;
2482 Ok(())
2483}
2484
2485pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2487 let repo = Repository::open(repo_path)?;
2488 repo.tag_delete(tag_name)?;
2489 Ok(())
2490}
2491
2492pub fn delete_remote_tag(
2494 repo_path: &Path,
2495 remote_name: &str,
2496 tag_name: &str,
2497) -> Result<(), Box<dyn std::error::Error>> {
2498 let safe_remote = safe_ref(remote_name)?;
2499 let safe_tag = safe_ref(tag_name)?;
2500 let output = git_command()
2501 .arg("push")
2502 .arg(safe_remote)
2503 .arg("--delete")
2504 .arg(safe_tag)
2505 .current_dir(repo_path)
2506 .output()?;
2507 if !output.status.success() {
2508 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2509 return Err(err.into());
2510 }
2511 Ok(())
2512}
2513
2514pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2515 let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2516 let output = git_command()
2517 .arg("checkout")
2518 .arg(safe_tag)
2519 .current_dir(repo_path)
2520 .output()
2521 .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2522
2523 if !output.status.success() {
2524 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2525 return Err(git2::Error::from_str(&err));
2526 }
2527 Ok(())
2528}
2529
2530pub fn get_remote_tags(
2532 repo_path: &Path,
2533 remote_name: &str,
2534) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2535 let safe_remote = safe_ref(remote_name)?;
2536 let output = git_command()
2537 .arg("ls-remote")
2538 .arg("--tags")
2539 .arg(safe_remote)
2540 .current_dir(repo_path)
2541 .output()?;
2542
2543 if !output.status.success() {
2544 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2545 return Err(err.into());
2546 }
2547
2548 let stdout = String::from_utf8_lossy(&output.stdout);
2549 let repo = git2::Repository::open(repo_path)?;
2550 let mut tags_map = std::collections::HashMap::new();
2551
2552 for line in stdout.lines() {
2553 let parts: Vec<&str> = line.split_whitespace().collect();
2554 if parts.len() >= 2 {
2555 let sha = parts[0];
2556 let ref_name = parts[1];
2557 if ref_name.starts_with("refs/tags/") {
2558 let is_peeled = ref_name.ends_with("^{}");
2559 let clean_ref = if is_peeled {
2560 safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2561 } else {
2562 ref_name
2563 };
2564 let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2565 let short_sha = safe_sha_slice(sha, 7);
2566
2567 let mut short_message = String::new();
2569 if let Ok(oid) = git2::Oid::from_str(sha) {
2570 if let Ok(commit) = repo.find_commit(oid) {
2571 if let Ok(Some(summary)) = commit.summary() {
2572 short_message = summary.to_string();
2573 }
2574 }
2575 }
2576 if short_message.is_empty() {
2577 short_message = "(not fetched)".to_string();
2578 }
2579
2580 let sanitized_tag = sanitize_text(tag_name);
2581 let sanitized_msg = sanitize_text(&short_message);
2582
2583 if is_peeled {
2584 tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2585 } else {
2586 tags_map
2587 .entry(sanitized_tag)
2588 .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2589 }
2590 }
2591 }
2592 }
2593
2594 let mut tags = Vec::new();
2595 for (name, (short_sha, short_message)) in tags_map {
2596 tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2597 }
2598 tags.sort_by(|a, b| b.name.cmp(&a.name));
2599 Ok(tags)
2600}
2601
2602pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2603 let mut s = String::new();
2604 for tag in tags {
2605 s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2606 }
2607 s
2608}
2609
2610pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2611 let mut tags = Vec::new();
2612 for line in s.lines() {
2613 let parts: Vec<&str> = line.split('|').collect();
2614 if parts.len() >= 3 {
2615 tags.push(BranchInfo {
2616 name: parts[0].to_string(),
2617 is_head: false,
2618 short_sha: parts[1].to_string(),
2619 short_message: parts[2].to_string(),
2620 });
2621 }
2622 }
2623 tags
2624}
2625
2626pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2627 let mut repo = Repository::open(repo_path)?;
2628 repo.stash_drop(index)?;
2629 Ok(())
2630}
2631
2632pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2633 let stash_ref = format!("stash@{{{}}}", index);
2634 let output = std::process::Command::new("git")
2635 .env("GIT_TERMINAL_PROMPT", "0")
2636 .env("GIT_SSH_COMMAND", ssh_command_val())
2637 .arg("stash")
2638 .arg("apply")
2639 .arg(&stash_ref)
2640 .current_dir(repo_path)
2641 .output()
2642 .map_err(|e| e.to_string())?;
2643
2644 if !output.status.success() {
2645 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2646 return Err(err_msg);
2647 }
2648 Ok(())
2649}
2650
2651pub fn save_stash(
2652 repo_path: &Path,
2653 message: &str,
2654 include_untracked: bool,
2655 keep_index: bool,
2656) -> Result<(), String> {
2657 let mut cmd = std::process::Command::new("git");
2658 cmd.env("GIT_TERMINAL_PROMPT", "0")
2659 .env("GIT_SSH_COMMAND", ssh_command_val())
2660 .arg("stash")
2661 .arg("push");
2662
2663 if include_untracked {
2664 cmd.arg("--include-untracked");
2665 }
2666 if keep_index {
2667 cmd.arg("--keep-index");
2668 }
2669
2670 if !message.is_empty() {
2671 cmd.arg("-m").arg(message);
2672 }
2673
2674 let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2675
2676 if !output.status.success() {
2677 let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2678 return Err(err_msg);
2679 }
2680 Ok(())
2681}
2682
2683pub fn get_latest_change_time(item: &str) -> u64 {
2684 let path = expand_tilde(item);
2685 if !path.exists() {
2686 return 0;
2687 }
2688
2689 if path.join(".git").exists() {
2690 if let Ok(repo) = Repository::open(&path) {
2691 if let Ok(head) = repo.head() {
2692 if let Ok(commit) = head.peel_to_commit() {
2693 return commit.time().seconds() as u64;
2694 }
2695 }
2696 }
2697 }
2698
2699 if let Ok(meta) = std::fs::metadata(&path) {
2700 if let Ok(modified) = meta.modified() {
2701 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2702 return duration.as_secs();
2703 }
2704 }
2705 }
2706 0
2707}
2708
2709pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2710 if let Ok(repo) = Repository::open(repo_path) {
2711 if let Ok(head) = repo.head() {
2712 if let Ok(commit) = head.peel_to_commit() {
2713 if let Ok(msg) = commit.message() {
2714 return Some(msg.to_string());
2715 }
2716 }
2717 }
2718 }
2719 None
2720}
2721
2722pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2723 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2724 let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2725 let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2726
2727 let mut index = repo.index().map_err(|e| e.to_string())?;
2728 let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2729 let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2730
2731 let signature = repo
2732 .signature()
2733 .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2734
2735 head_commit
2736 .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2737 .map_err(|e| e.to_string())?;
2738
2739 Ok(())
2740}
2741
2742pub fn is_merging(repo_path: &Path) -> bool {
2747 repo_path.join(".git/MERGE_HEAD").exists()
2748}
2749
2750pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2753 let full_path = repo_path.join(file_path);
2754 let content = match std::fs::read_to_string(&full_path) {
2755 Ok(s) => s,
2756 Err(_) => return Vec::new(),
2757 };
2758
2759 let mut lines = Vec::new();
2760 let mut in_ours = false;
2761 let mut in_theirs = false;
2762
2763 for line in content.lines() {
2764 if line.starts_with("<<<<<<<") {
2765 in_ours = true;
2766 in_theirs = false;
2767 lines.push(DiffLine {
2768 kind: DiffLineKind::ConflictSeparator,
2769 content: line.to_string(),
2770 old_lineno: None,
2771 new_lineno: None,
2772 hunk_idx: None,
2773 });
2774 } else if line.starts_with("=======") {
2775 in_ours = false;
2776 in_theirs = true;
2777 lines.push(DiffLine {
2778 kind: DiffLineKind::ConflictSeparator,
2779 content: line.to_string(),
2780 old_lineno: None,
2781 new_lineno: None,
2782 hunk_idx: None,
2783 });
2784 } else if line.starts_with(">>>>>>>") {
2785 in_ours = false;
2786 in_theirs = false;
2787 lines.push(DiffLine {
2788 kind: DiffLineKind::ConflictSeparator,
2789 content: line.to_string(),
2790 old_lineno: None,
2791 new_lineno: None,
2792 hunk_idx: None,
2793 });
2794 } else if in_ours {
2795 lines.push(DiffLine {
2796 kind: DiffLineKind::ConflictOurs,
2797 content: line.to_string(),
2798 old_lineno: None,
2799 new_lineno: None,
2800 hunk_idx: None,
2801 });
2802 } else if in_theirs {
2803 lines.push(DiffLine {
2804 kind: DiffLineKind::ConflictTheirs,
2805 content: line.to_string(),
2806 old_lineno: None,
2807 new_lineno: None,
2808 hunk_idx: None,
2809 });
2810 } else {
2811 lines.push(DiffLine {
2812 kind: DiffLineKind::Context,
2813 content: line.to_string(),
2814 old_lineno: None,
2815 new_lineno: None,
2816 hunk_idx: None,
2817 });
2818 }
2819 }
2820 lines
2821}
2822
2823pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2826 let output1 = std::process::Command::new("git")
2827 .env("GIT_TERMINAL_PROMPT", "0")
2828 .env("GIT_SSH_COMMAND", ssh_command_val())
2829 .args(["checkout", "--ours", file_path])
2830 .current_dir(repo_path)
2831 .output()
2832 .map_err(|e| e.to_string())?;
2833 if !output1.status.success() {
2834 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2835 }
2836 stage_file(repo_path, file_path)?;
2837 Ok(())
2838}
2839
2840pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2843 let output1 = std::process::Command::new("git")
2844 .env("GIT_TERMINAL_PROMPT", "0")
2845 .env("GIT_SSH_COMMAND", ssh_command_val())
2846 .args(["checkout", "--theirs", file_path])
2847 .current_dir(repo_path)
2848 .output()
2849 .map_err(|e| e.to_string())?;
2850 if !output1.status.success() {
2851 return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2852 }
2853 stage_file(repo_path, file_path)?;
2854 Ok(())
2855}
2856
2857pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2859 stage_file(repo_path, file_path)
2860}
2861
2862pub fn resolve_conflict_hunk(
2866 repo_path: &Path,
2867 file_path: &str,
2868 hunk_idx: usize,
2869 accept_ours: bool,
2870) -> Result<(), String> {
2871 let full_path = repo_path.join(file_path);
2872 let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2873
2874 let mut new_lines = Vec::new();
2875 let mut lines_iter = content.lines().peekable();
2876 let mut current_hunk_idx = 0;
2877
2878 while let Some(line) = lines_iter.next() {
2879 if line.starts_with("<<<<<<<") {
2880 let mut ours_block = Vec::new();
2881 let mut theirs_block = Vec::new();
2882
2883 let mut found_separator = false;
2885 while let Some(&next_line) = lines_iter.peek() {
2886 if next_line.starts_with("=======") {
2887 lines_iter.next(); found_separator = true;
2889 break;
2890 }
2891 if let Some(line) = lines_iter.next() {
2892 ours_block.push(line.to_string());
2893 }
2894 }
2895
2896 let mut found_end = false;
2898 let mut end_line_marker = ">>>>>>>".to_string();
2899 while let Some(&next_line) = lines_iter.peek() {
2900 if next_line.starts_with(">>>>>>>") {
2901 if let Some(marker) = lines_iter.next() {
2902 end_line_marker = marker.to_string(); }
2904 found_end = true;
2905 break;
2906 }
2907 if let Some(line) = lines_iter.next() {
2908 theirs_block.push(line.to_string());
2909 }
2910 }
2911
2912 if current_hunk_idx == hunk_idx {
2913 if accept_ours {
2914 new_lines.extend(ours_block);
2915 } else {
2916 new_lines.extend(theirs_block);
2917 }
2918 } else {
2919 new_lines.push(line.to_string());
2920 new_lines.extend(ours_block);
2921 if found_separator {
2922 new_lines.push("=======".to_string());
2923 }
2924 new_lines.extend(theirs_block);
2925 if found_end {
2926 new_lines.push(end_line_marker);
2927 }
2928 }
2929
2930 current_hunk_idx += 1;
2931 } else {
2932 new_lines.push(line.to_string());
2933 }
2934 }
2935
2936 let mut new_content = new_lines.join("\n");
2937 if content.ends_with('\n') && !new_content.ends_with('\n') {
2938 new_content.push('\n');
2939 }
2940 std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2941
2942 let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2944 let has_conflict_markers = updated_content
2945 .lines()
2946 .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2947
2948 if !has_conflict_markers {
2949 stage_file(repo_path, file_path)?;
2950 }
2951
2952 Ok(())
2953}
2954
2955pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2957 let output = std::process::Command::new("git")
2958 .env("GIT_TERMINAL_PROMPT", "0")
2959 .env("GIT_SSH_COMMAND", ssh_command_val())
2960 .args(["merge", "--abort"])
2961 .current_dir(repo_path)
2962 .output()
2963 .map_err(|e| e.to_string())?;
2964 if !output.status.success() {
2965 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2966 }
2967 Ok(())
2968}
2969
2970pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2972 let output = std::process::Command::new("git")
2973 .env("GIT_TERMINAL_PROMPT", "0")
2974 .env("GIT_SSH_COMMAND", ssh_command_val())
2975 .args(["merge", "--continue"])
2976 .env("GIT_EDITOR", "true")
2977 .current_dir(repo_path)
2978 .output()
2979 .map_err(|e| e.to_string())?;
2980 if !output.status.success() {
2981 return Err(String::from_utf8_lossy(&output.stderr).to_string());
2982 }
2983 Ok(())
2984}
2985
2986pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2988 let repo = Repository::open(repo_path).ok()?;
2989 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2990 let _upstream = branch.upstream().ok()?;
2991 let local_ref = branch.get().name().ok()?;
2992 let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
2993 remote_buf.as_str().ok().map(|s| s.to_string())
2994}
2995
2996pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2998 get_branch_upstream_remote(repo_path, branch_name).is_some()
2999}
3000
3001pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3003 let repo = Repository::open(repo_path).ok()?;
3004 let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3005 if branch.upstream().is_ok() {
3006 if let Ok(local_ref) = branch.get().name() {
3007 if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3008 if let Ok(name) = remote_buf.as_str() {
3009 return Some((name.to_string(), false));
3010 }
3011 }
3012 }
3013 }
3014 let remotes = repo.remotes().ok()?;
3015 let first_remote = remotes.iter().next()?.ok()??.to_string();
3016 Some((first_remote, true))
3017}
3018
3019pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3021 if let Ok(repo) = Repository::open(repo_path) {
3022 if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3023 if let Ok(commit) = repo.find_commit(oid) {
3024 return commit.parent_count() == 0;
3025 }
3026 }
3027 }
3028 false
3029}
3030
3031pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3032 let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3033 let mut worktrees_list = Vec::new();
3034 if let Ok(worktree_names) = repo.worktrees() {
3035 for name in worktree_names.iter() {
3036 let Ok(Some(wt_name)) = name else { continue };
3037 if let Ok(wt) = repo.find_worktree(wt_name) {
3038 let path = wt.path().to_path_buf();
3039 let mut branch = None;
3040 if let Ok(wt_repo) = Repository::open(&path) {
3041 if let Ok(head) = wt_repo.head() {
3042 branch = head.shorthand().map(String::from).ok();
3043 }
3044 }
3045
3046 let mut is_locked = false;
3047 let mut lock_reason = None;
3048 if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3049 is_locked = true;
3050 if let Some(reason) = reason_opt {
3051 if !reason.is_empty() {
3052 lock_reason = Some(reason);
3053 }
3054 }
3055 }
3056
3057 worktrees_list.push(WorktreeInfo {
3058 name: wt_name.to_string(),
3059 path,
3060 branch,
3061 is_locked,
3062 lock_reason,
3063 });
3064 }
3065 }
3066 }
3067 Ok(worktrees_list)
3068}
3069
3070pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3071 let output = std::process::Command::new("git")
3072 .arg("worktree")
3073 .arg("add")
3074 .arg(wt_path)
3075 .arg(branch)
3076 .current_dir(repo_path)
3077 .output()
3078 .map_err(|e| e.to_string())?;
3079
3080 if !output.status.success() {
3081 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3082 }
3083 Ok(())
3084}
3085
3086pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3087 let output = std::process::Command::new("git")
3088 .arg("worktree")
3089 .arg("lock")
3090 .arg("--reason")
3091 .arg(reason)
3092 .arg(name)
3093 .current_dir(repo_path)
3094 .output()
3095 .map_err(|e| e.to_string())?;
3096
3097 if !output.status.success() {
3098 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3099 }
3100 Ok(())
3101}
3102
3103pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3104 let output = std::process::Command::new("git")
3105 .arg("worktree")
3106 .arg("unlock")
3107 .arg(name)
3108 .current_dir(repo_path)
3109 .output()
3110 .map_err(|e| e.to_string())?;
3111
3112 if !output.status.success() {
3113 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3114 }
3115 Ok(())
3116}
3117
3118pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3119 let mut cmd = std::process::Command::new("git");
3120 cmd.arg("worktree").arg("remove");
3121 if force {
3122 cmd.arg("--force");
3123 }
3124 let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3125
3126 if !output.status.success() {
3127 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3128 }
3129 Ok(())
3130}
3131
3132pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3133 let output = std::process::Command::new("git")
3134 .arg("worktree")
3135 .arg("prune")
3136 .current_dir(repo_path)
3137 .output()
3138 .map_err(|e| e.to_string())?;
3139
3140 if !output.status.success() {
3141 return Err(String::from_utf8_lossy(&output.stderr).to_string());
3142 }
3143 Ok(())
3144}
3145
3146#[cfg(test)]
3147#[allow(clippy::unwrap_used, clippy::panic)]
3148mod tests {
3149 use super::*;
3150 use std::fs::File;
3151
3152 #[test]
3153 fn test_match_pattern() {
3154 assert!(match_pattern("test.psd", "*.psd"));
3155 assert!(match_pattern("assets/test.psd", "*.psd"));
3156 assert!(match_pattern("assets/img.png", "assets/*.png"));
3157 assert!(match_pattern("src/app.rs", "src/*"));
3158 assert!(match_pattern("test.txt", "test.txt"));
3159 assert!(!match_pattern("src/app.rs", "*.psd"));
3160 }
3161 use std::io::Write;
3162
3163 #[test]
3164 fn test_commit_amend() {
3165 let mut temp_path = std::env::temp_dir();
3166 temp_path.push(format!(
3167 "twig_test_{}",
3168 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3169 ));
3170 std::fs::create_dir_all(&temp_path).unwrap();
3171
3172 let repo = Repository::init(&temp_path).unwrap();
3174
3175 let mut config = repo.config().unwrap();
3177 config.set_str("user.name", "Test User").unwrap();
3178 config.set_str("user.email", "test@example.com").unwrap();
3179
3180 let file_path = temp_path.join("test.txt");
3182 let mut file = File::create(&file_path).unwrap();
3183 writeln!(file, "initial content").unwrap();
3184
3185 stage_file(&temp_path, "test.txt").unwrap();
3187 commit_changes(&temp_path, "initial commit").unwrap();
3188
3189 let msg = get_last_commit_message(&temp_path).unwrap();
3191 assert_eq!(msg, "initial commit");
3192
3193 commit_amend(&temp_path, "amended commit").unwrap();
3195
3196 let amended_msg = get_last_commit_message(&temp_path).unwrap();
3198 assert_eq!(amended_msg, "amended commit");
3199
3200 let _ = std::fs::remove_dir_all(&temp_path);
3202 }
3203
3204 #[test]
3205 fn test_commit_signatures_collection() {
3206 let mut temp_path = std::env::temp_dir();
3207 temp_path.push(format!(
3208 "twig_test_sig_{}",
3209 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3210 ));
3211 std::fs::create_dir_all(&temp_path).unwrap();
3212
3213 let repo = Repository::init(&temp_path).unwrap();
3215
3216 let mut config = repo.config().unwrap();
3218 config.set_str("user.name", "Test User").unwrap();
3219 config.set_str("user.email", "test@example.com").unwrap();
3220
3221 let file_path = temp_path.join("test.txt");
3223 let mut file = File::create(&file_path).unwrap();
3224 writeln!(file, "initial content").unwrap();
3225
3226 stage_file(&temp_path, "test.txt").unwrap();
3228 commit_changes(&temp_path, "initial commit").unwrap();
3229
3230 let sigs = collect_signatures(&temp_path, 0);
3232 assert_eq!(sigs.len(), 1);
3233 let head_oid = repo.head().unwrap().target().unwrap().to_string();
3234 let sig_status = sigs.get(&head_oid).unwrap();
3235 assert_eq!(sig_status, "N");
3236
3237 let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
3239 assert_eq!(commits.len(), 1);
3240 assert_eq!(commits[0].signature_status, "N");
3241 assert!(commits[0].files.is_empty());
3242
3243 let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
3245 assert_eq!(files.len(), 1);
3246 assert_eq!(files[0].path, "test.txt");
3247 assert_eq!(files[0].label, "N");
3248
3249 let graph = collect_graph_lines(&temp_path, 1000);
3251 assert_eq!(graph.len(), 1);
3252 assert!(graph[0].commit.is_some());
3253 assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
3254
3255 let _ = std::fs::remove_dir_all(&temp_path);
3257 }
3258
3259 #[test]
3260 fn test_ref_map_cache_behavior() {
3261 let temp_dir = std::env::temp_dir();
3262 let repo_path = temp_dir.join("test_ref_map_repo");
3263 let _ = std::fs::remove_dir_all(&repo_path);
3264 std::fs::create_dir_all(&repo_path).unwrap();
3265
3266 let repo = Repository::init(&repo_path).unwrap();
3267
3268 let map1 = get_cached_ref_map(&repo, &repo_path);
3270
3271 let map2 = get_cached_ref_map(&repo, &repo_path);
3273 assert_eq!(map1.len(), map2.len());
3274
3275 invalidate_ref_map_cache(&repo_path);
3277
3278 let _ = std::fs::remove_dir_all(&repo_path);
3280 }
3281
3282 #[test]
3283 fn test_get_latest_change_time() {
3284 let mut temp_path = std::env::temp_dir();
3285 temp_path.push(format!(
3286 "twig_test_{}",
3287 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3288 ));
3289 std::fs::create_dir_all(&temp_path).unwrap();
3290
3291 let change_time = get_latest_change_time(temp_path.to_str().unwrap());
3292 assert!(change_time > 0);
3293
3294 let _ = std::fs::remove_dir_all(&temp_path);
3295 }
3296
3297 #[test]
3298 fn test_committer_stats() {
3299 let mut temp_path = std::env::temp_dir();
3300 temp_path.push(format!(
3301 "twig_test_{}",
3302 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3303 ));
3304 std::fs::create_dir_all(&temp_path).unwrap();
3305
3306 let repo = Repository::init(&temp_path).unwrap();
3308
3309 let mut config = repo.config().unwrap();
3311 config.set_str("user.name", "Test User").unwrap();
3312 config.set_str("user.email", "test@example.com").unwrap();
3313
3314 let file_path = temp_path.join("test.txt");
3316 let mut file = File::create(&file_path).unwrap();
3317 writeln!(file, "initial content").unwrap();
3318
3319 stage_file(&temp_path, "test.txt").unwrap();
3321 commit_changes(&temp_path, "initial commit").unwrap();
3322
3323 let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
3325 assert_eq!(stats.len(), 1);
3326 assert_eq!(stats[0].name, "Test User");
3327 assert_eq!(stats[0].email, "test@example.com");
3328 assert_eq!(stats[0].count, 1);
3329 assert!(!limit_reached);
3330
3331 let _ = std::fs::remove_dir_all(&temp_path);
3333 }
3334
3335 #[test]
3336 fn test_untracked_files_in_unstaged() {
3337 let mut temp_path = std::env::temp_dir();
3338 temp_path.push(format!(
3339 "twig_test_{}",
3340 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3341 ));
3342 std::fs::create_dir_all(&temp_path).unwrap();
3343
3344 let _repo = Repository::init(&temp_path).unwrap();
3346
3347 let file_path = temp_path.join("untracked.txt");
3349 let mut file = File::create(&file_path).unwrap();
3350 writeln!(file, "hello untracked").unwrap();
3351
3352 let untracked_dir = temp_path.join("untracked_dir");
3354 std::fs::create_dir_all(&untracked_dir).unwrap();
3355 let nested_file_path = untracked_dir.join("nested.txt");
3356 std::fs::write(&nested_file_path, "nested untracked file").unwrap();
3357
3358 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3360 match detail {
3361 ItemDetail::Repo { info, .. } => {
3362 let unstaged_paths: Vec<String> =
3364 info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
3365 let untracked_paths: Vec<String> =
3366 info.changes.untracked.iter().map(|f| f.path.clone()).collect();
3367
3368 assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
3370 assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
3371 assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
3372 assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
3373
3374 assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
3376 assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
3377 assert!(untracked_paths.contains(&"untracked.txt".to_string()));
3378 assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
3379 }
3380 _ => panic!("Expected ItemDetail::Repo"),
3381 }
3382
3383 let _ = std::fs::remove_dir_all(&temp_path);
3385 }
3386
3387 #[test]
3388 fn test_stage_new_and_deleted_files() {
3389 let mut temp_path = std::env::temp_dir();
3390 temp_path.push(format!(
3391 "twig_test_{}",
3392 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3393 ));
3394 std::fs::create_dir_all(&temp_path).unwrap();
3395
3396 let repo = Repository::init(&temp_path).unwrap();
3398
3399 let mut config = repo.config().unwrap();
3401 config.set_str("user.name", "Test User").unwrap();
3402 config.set_str("user.email", "test@example.com").unwrap();
3403
3404 let init_file = temp_path.join("init.txt");
3406 std::fs::write(&init_file, "initial").unwrap();
3407 stage_file(&temp_path, "init.txt").unwrap();
3408 commit_changes(&temp_path, "initial commit").unwrap();
3409
3410 let untracked_file = temp_path.join("untracked.txt");
3412 std::fs::write(&untracked_file, "new file content").unwrap();
3413
3414 stage_file(&temp_path, "untracked.txt").unwrap();
3416
3417 std::fs::remove_file(&init_file).unwrap();
3419
3420 stage_file(&temp_path, "init.txt").unwrap();
3422
3423 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3425 match detail {
3426 ItemDetail::Repo { info, .. } => {
3427 assert_eq!(info.changes.staged.len(), 2);
3429 let paths: Vec<String> =
3430 info.changes.staged.iter().map(|f| f.path.clone()).collect();
3431 assert!(paths.contains(&"untracked.txt".to_string()));
3432 assert!(paths.contains(&"init.txt".to_string()));
3433 }
3434 _ => panic!("Expected ItemDetail::Repo"),
3435 }
3436
3437 let _ = std::fs::remove_dir_all(&temp_path);
3439 }
3440
3441 #[test]
3442 fn test_discard_file_changes_all_cases() {
3443 let mut temp_path = std::env::temp_dir();
3444 temp_path.push(format!(
3445 "twig_test_{}",
3446 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3447 ));
3448 std::fs::create_dir_all(&temp_path).unwrap();
3449
3450 let repo = Repository::init(&temp_path).unwrap();
3452
3453 let mut config = repo.config().unwrap();
3455 config.set_str("user.name", "Test User").unwrap();
3456 config.set_str("user.email", "test@example.com").unwrap();
3457
3458 let file_tracked = temp_path.join("tracked.txt");
3460 std::fs::write(&file_tracked, "original content\n").unwrap();
3461 stage_file(&temp_path, "tracked.txt").unwrap();
3462 commit_changes(&temp_path, "initial commit").unwrap();
3463
3464 let file_untracked = temp_path.join("untracked.txt");
3466 std::fs::write(&file_untracked, "new untracked file\n").unwrap();
3467 assert!(file_untracked.exists());
3468 discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
3469 assert!(!file_untracked.exists());
3470
3471 std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
3473 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3474 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3475
3476 std::fs::write(&file_tracked, "staged modifications\n").unwrap();
3478 stage_file(&temp_path, "tracked.txt").unwrap();
3479 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3481 match detail {
3482 ItemDetail::Repo { info, .. } => {
3483 assert!(!info.changes.staged.is_empty());
3484 }
3485 _ => panic!("Expected ItemDetail::Repo"),
3486 }
3487 discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
3488 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3489 let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3491 match detail {
3492 ItemDetail::Repo { info, .. } => {
3493 assert!(info.changes.staged.is_empty());
3494 assert!(info.changes.unstaged.is_empty());
3495 }
3496 _ => panic!("Expected ItemDetail::Repo"),
3497 }
3498
3499 std::fs::remove_file(&file_tracked).unwrap();
3501 assert!(!file_tracked.exists());
3502 discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3503 assert!(file_tracked.exists());
3504 assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3505
3506 let _ = std::fs::remove_dir_all(&temp_path);
3508 }
3509
3510 #[test]
3511 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3512 fn test_stage_unstage_by_hunk() {
3513 let mut temp_path = std::env::temp_dir();
3514 temp_path.push(format!(
3515 "twig_test_{}",
3516 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3517 ));
3518 std::fs::create_dir_all(&temp_path).unwrap();
3519
3520 let repo = Repository::init(&temp_path).unwrap();
3522
3523 let mut config = repo.config().unwrap();
3525 config.set_str("user.name", "Test User").unwrap();
3526 config.set_str("user.email", "test@example.com").unwrap();
3527
3528 let file_path = temp_path.join("multihunk.txt");
3530 let mut file = File::create(&file_path).unwrap();
3531 for i in 1..=20 {
3532 writeln!(file, "Line {}", i).unwrap();
3533 }
3534 drop(file);
3535
3536 stage_file(&temp_path, "multihunk.txt").unwrap();
3538 commit_changes(&temp_path, "initial commit").unwrap();
3539
3540 let mut file = File::create(&file_path).unwrap();
3542 for i in 1..=20 {
3543 if i == 2 || i == 18 {
3544 writeln!(file, "Line {} modified", i).unwrap();
3545 } else {
3546 writeln!(file, "Line {}", i).unwrap();
3547 }
3548 }
3549 drop(file);
3550
3551 let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3553 let mut hunk_ranges = Vec::new();
3555 let mut current_start = None;
3556 for (i, line) in diff_lines.iter().enumerate() {
3557 if line.kind == DiffLineKind::Header {
3558 if let Some(start) = current_start {
3559 hunk_ranges.push(start..i);
3560 }
3561 current_start = Some(i);
3562 }
3563 }
3564 if let Some(start) = current_start {
3565 hunk_ranges.push(start..diff_lines.len());
3566 }
3567
3568 assert_eq!(hunk_ranges.len(), 2);
3570
3571 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3573 stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
3574
3575 let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3577 let staged_content: String =
3578 staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3579 assert!(staged_content.contains("Line 18 modified"));
3580 assert!(!staged_content.contains("Line 2 modified"));
3581
3582 let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3584 let unstaged_content: String =
3585 unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3586 assert!(unstaged_content.contains("Line 2 modified"));
3587 assert!(!unstaged_content.contains("Line 18 modified"));
3588
3589 let staged_hunk_ranges = {
3591 let mut ranges = Vec::new();
3592 let mut current_start = None;
3593 for (i, line) in staged_diff.iter().enumerate() {
3594 if line.kind == DiffLineKind::Header {
3595 if let Some(start) = current_start {
3596 ranges.push(start..i);
3597 }
3598 current_start = Some(i);
3599 }
3600 }
3601 if let Some(start) = current_start {
3602 ranges.push(start..staged_diff.len());
3603 }
3604 ranges
3605 };
3606 assert_eq!(staged_hunk_ranges.len(), 1);
3607 let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
3608 unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
3609
3610 let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3612 assert!(staged_diff_after.is_empty());
3613
3614 let _ = std::fs::remove_dir_all(&temp_path);
3616 }
3617
3618 #[test]
3619 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3620 fn test_discard_hunk() {
3621 let mut temp_path = std::env::temp_dir();
3622 temp_path.push(format!(
3623 "twig_test_{}",
3624 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3625 ));
3626 std::fs::create_dir_all(&temp_path).unwrap();
3627
3628 let repo = Repository::init(&temp_path).unwrap();
3630
3631 let mut config = repo.config().unwrap();
3633 config.set_str("user.name", "Test User").unwrap();
3634 config.set_str("user.email", "test@example.com").unwrap();
3635
3636 let file_path = temp_path.join("discardhunk.txt");
3638 let mut file = File::create(&file_path).unwrap();
3639 for i in 1..=20 {
3640 writeln!(file, "Line {}", i).unwrap();
3641 }
3642 drop(file);
3643
3644 stage_file(&temp_path, "discardhunk.txt").unwrap();
3646 commit_changes(&temp_path, "initial commit").unwrap();
3647
3648 let mut file = File::create(&file_path).unwrap();
3650 for i in 1..=20 {
3651 if i == 2 || i == 18 {
3652 writeln!(file, "Line {} modified", i).unwrap();
3653 } else {
3654 writeln!(file, "Line {}", i).unwrap();
3655 }
3656 }
3657 drop(file);
3658
3659 let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
3661 let mut hunk_ranges = Vec::new();
3663 let mut current_start = None;
3664 for (i, line) in diff_lines.iter().enumerate() {
3665 if line.kind == DiffLineKind::Header {
3666 if let Some(start) = current_start {
3667 hunk_ranges.push(start..i);
3668 }
3669 current_start = Some(i);
3670 }
3671 }
3672 if let Some(start) = current_start {
3673 hunk_ranges.push(start..diff_lines.len());
3674 }
3675
3676 assert_eq!(hunk_ranges.len(), 2);
3678
3679 let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3681 discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
3682
3683 let contents = std::fs::read_to_string(&file_path).unwrap();
3685 assert!(contents.contains("Line 2 modified"));
3686 assert!(contents.contains("Line 18\n"));
3687 assert!(!contents.contains("Line 18 modified"));
3688
3689 let _ = std::fs::remove_dir_all(&temp_path);
3691 }
3692
3693 #[test]
3694 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3695 fn test_stage_unstage_discard_line() {
3696 let mut temp_path = std::env::temp_dir();
3697 temp_path.push(format!(
3698 "twig_test_{}",
3699 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3700 ));
3701 std::fs::create_dir_all(&temp_path).unwrap();
3702
3703 let repo = Repository::init(&temp_path).unwrap();
3704 let mut config = repo.config().unwrap();
3705 config.set_str("user.name", "Test User").unwrap();
3706 config.set_str("user.email", "test@example.com").unwrap();
3707
3708 let file_path = temp_path.join("line_test.txt");
3710 let mut file = File::create(&file_path).unwrap();
3711 writeln!(file, "line A").unwrap();
3712 writeln!(file, "line B").unwrap();
3713 writeln!(file, "line C").unwrap();
3714 drop(file);
3715
3716 stage_file(&temp_path, "line_test.txt").unwrap();
3717 commit_changes(&temp_path, "initial").unwrap();
3718
3719 let mut file = File::create(&file_path).unwrap();
3721 writeln!(file, "line A modified").unwrap();
3722 writeln!(file, "line B").unwrap();
3723 writeln!(file, "line C modified").unwrap();
3724 drop(file);
3725
3726 let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3727 let mut hunk_ranges = Vec::new();
3728 let mut current_start = None;
3729 for (i, line) in diff_lines.iter().enumerate() {
3730 if line.kind == DiffLineKind::Header {
3731 if let Some(start) = current_start {
3732 hunk_ranges.push(start..i);
3733 }
3734 current_start = Some(i);
3735 }
3736 }
3737 if let Some(start) = current_start {
3738 hunk_ranges.push(start..diff_lines.len());
3739 }
3740
3741 assert_eq!(hunk_ranges.len(), 1);
3742 let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3743
3744 assert_eq!(hunk0[2].content, "line A modified");
3745 assert_eq!(hunk0[5].content, "line C modified");
3746
3747 stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3749
3750 let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3752 assert!(
3753 staged_diff
3754 .iter()
3755 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3756 );
3757 assert!(
3758 !staged_diff
3759 .iter()
3760 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3761 );
3762
3763 let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3765 assert!(
3766 !unstaged_diff
3767 .iter()
3768 .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3769 );
3770 assert!(
3771 unstaged_diff
3772 .iter()
3773 .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3774 );
3775
3776 assert_eq!(staged_diff[2].content, "line A modified");
3778 unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3779
3780 assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3782
3783 let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3785 assert_eq!(unstaged_diff2[5].content, "line C modified");
3786 discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3787
3788 let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3789 let remove_idx = unstaged_diff3
3790 .iter()
3791 .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3792 .unwrap();
3793 discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3794
3795 let contents = std::fs::read_to_string(&file_path).unwrap();
3797 assert!(contents.contains("line A modified"));
3798 assert!(contents.contains("line B"));
3799 assert!(contents.contains("line C\n"));
3800 assert!(!contents.contains("line C modified"));
3801
3802 let _ = std::fs::remove_dir_all(&temp_path);
3804 }
3805
3806 #[test]
3807 #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3808 fn test_stage_unstage_discard_all_changes() {
3809 let mut temp_path = std::env::temp_dir();
3810 temp_path.push(format!(
3811 "twig_test_all_{}",
3812 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3813 ));
3814 std::fs::create_dir_all(&temp_path).unwrap();
3815
3816 let repo = Repository::init(&temp_path).unwrap();
3818
3819 let mut config = repo.config().unwrap();
3821 config.set_str("user.name", "Test User").unwrap();
3822 config.set_str("user.email", "test@example.com").unwrap();
3823
3824 let file_path = temp_path.join("tracked.txt");
3826 std::fs::write(&file_path, "original content\n").unwrap();
3827 stage_file(&temp_path, "tracked.txt").unwrap();
3828 commit_changes(&temp_path, "initial").unwrap();
3829
3830 std::fs::write(&file_path, "modified content\n").unwrap();
3832 let untracked_path = temp_path.join("untracked.txt");
3833 std::fs::write(&untracked_path, "untracked content\n").unwrap();
3834
3835 let status = repo.statuses(None).unwrap();
3837 assert_eq!(status.len(), 2);
3838
3839 stage_all_changes(&temp_path).unwrap();
3841
3842 let status = repo.statuses(None).unwrap();
3844 for entry in status.iter() {
3845 assert!(
3846 entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
3847 );
3848 }
3849
3850 unstage_all_changes(&temp_path).unwrap();
3852
3853 let status = repo.statuses(None).unwrap();
3855 for entry in status.iter() {
3856 assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
3857 }
3858
3859 discard_all_changes(&temp_path).unwrap();
3861
3862 let status = repo.statuses(None).unwrap();
3864 assert_eq!(status.len(), 0);
3865
3866 let contents = std::fs::read_to_string(&file_path).unwrap();
3868 assert_eq!(contents, "original content\n");
3869 assert!(!untracked_path.exists());
3870
3871 let _ = std::fs::remove_dir_all(&temp_path);
3873 }
3874
3875 #[test]
3876 fn test_merge_conflicts_flow() {
3877 let mut temp_path = std::env::temp_dir();
3878 temp_path.push(format!(
3879 "twig_test_conflict_{}",
3880 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3881 ));
3882 std::fs::create_dir_all(&temp_path).unwrap();
3883
3884 let repo = Repository::init(&temp_path).unwrap();
3886
3887 let mut config = repo.config().unwrap();
3889 config.set_str("user.name", "Test User").unwrap();
3890 config.set_str("user.email", "test@example.com").unwrap();
3891
3892 let file_path = temp_path.join("conflict.txt");
3894 std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3895 stage_file(&temp_path, "conflict.txt").unwrap();
3896 commit_changes(&temp_path, "initial commit").unwrap();
3897
3898 let output = std::process::Command::new("git")
3900 .env("GIT_TERMINAL_PROMPT", "0")
3901 .env("GIT_SSH_COMMAND", ssh_command_val())
3902 .args(["symbolic-ref", "--short", "HEAD"])
3903 .current_dir(&temp_path)
3904 .output()
3905 .unwrap();
3906 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3907
3908 std::process::Command::new("git")
3910 .env("GIT_TERMINAL_PROMPT", "0")
3911 .env("GIT_SSH_COMMAND", ssh_command_val())
3912 .args(["checkout", "-b", "feature"])
3913 .current_dir(&temp_path)
3914 .output()
3915 .unwrap();
3916
3917 std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
3918 stage_file(&temp_path, "conflict.txt").unwrap();
3919 commit_changes(&temp_path, "feature commit").unwrap();
3920
3921 std::process::Command::new("git")
3923 .env("GIT_TERMINAL_PROMPT", "0")
3924 .env("GIT_SSH_COMMAND", ssh_command_val())
3925 .args(["checkout", &main_branch])
3926 .current_dir(&temp_path)
3927 .output()
3928 .unwrap();
3929
3930 std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
3931 stage_file(&temp_path, "conflict.txt").unwrap();
3932 commit_changes(&temp_path, "main commit").unwrap();
3933
3934 assert!(!is_merging(&temp_path));
3936 let merge_output = std::process::Command::new("git")
3937 .env("GIT_TERMINAL_PROMPT", "0")
3938 .env("GIT_SSH_COMMAND", ssh_command_val())
3939 .args(["merge", "feature"])
3940 .current_dir(&temp_path)
3941 .output()
3942 .unwrap();
3943
3944 assert!(!merge_output.status.success());
3945 assert!(is_merging(&temp_path));
3946
3947 let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
3949 assert!(!diff.is_empty());
3950 let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
3951 let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
3952 let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
3953 assert!(has_separator);
3954 assert!(has_ours);
3955 assert!(has_theirs);
3956
3957 abort_merge(&temp_path).unwrap();
3959 assert!(!is_merging(&temp_path));
3960
3961 std::process::Command::new("git")
3963 .env("GIT_TERMINAL_PROMPT", "0")
3964 .env("GIT_SSH_COMMAND", ssh_command_val())
3965 .args(["merge", "feature"])
3966 .current_dir(&temp_path)
3967 .output()
3968 .unwrap();
3969 assert!(is_merging(&temp_path));
3970
3971 resolve_ours(&temp_path, "conflict.txt").unwrap();
3973 let contents = std::fs::read_to_string(&file_path).unwrap();
3974 assert!(contents.contains("line 2 on main"));
3975 assert!(!contents.contains("<<<<<<<"));
3976
3977 continue_merge(&temp_path).unwrap();
3979 assert!(!is_merging(&temp_path));
3980
3981 std::process::Command::new("git")
3983 .env("GIT_TERMINAL_PROMPT", "0")
3984 .env("GIT_SSH_COMMAND", ssh_command_val())
3985 .args(["reset", "--hard", "HEAD~1"])
3986 .current_dir(&temp_path)
3987 .output()
3988 .unwrap();
3989
3990 std::process::Command::new("git")
3991 .env("GIT_TERMINAL_PROMPT", "0")
3992 .env("GIT_SSH_COMMAND", ssh_command_val())
3993 .args(["merge", "feature"])
3994 .current_dir(&temp_path)
3995 .output()
3996 .unwrap();
3997 assert!(is_merging(&temp_path));
3998
3999 resolve_theirs(&temp_path, "conflict.txt").unwrap();
4000 let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4001 assert!(contents_theirs.contains("line 2 on feature"));
4002 assert!(!contents_theirs.contains("<<<<<<<"));
4003
4004 continue_merge(&temp_path).unwrap();
4005 assert!(!is_merging(&temp_path));
4006
4007 let _ = std::fs::remove_dir_all(&temp_path);
4009 }
4010
4011 #[test]
4012 fn test_resolve_conflict_hunk() {
4013 let mut temp_path = std::env::temp_dir();
4014 temp_path.push(format!(
4015 "twig_test_hunk_conflict_{}",
4016 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4017 ));
4018 std::fs::create_dir_all(&temp_path).unwrap();
4019
4020 let repo = Repository::init(&temp_path).unwrap();
4022
4023 let mut config = repo.config().unwrap();
4025 config.set_str("user.name", "Test User").unwrap();
4026 config.set_str("user.email", "test@example.com").unwrap();
4027
4028 let file_path = temp_path.join("conflict.txt");
4030 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";
4031 std::fs::write(&file_path, initial_lines).unwrap();
4032 stage_file(&temp_path, "conflict.txt").unwrap();
4033 commit_changes(&temp_path, "initial commit").unwrap();
4034
4035 let output = std::process::Command::new("git")
4037 .env("GIT_TERMINAL_PROMPT", "0")
4038 .env("GIT_SSH_COMMAND", ssh_command_val())
4039 .args(["symbolic-ref", "--short", "HEAD"])
4040 .current_dir(&temp_path)
4041 .output()
4042 .unwrap();
4043 let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4044
4045 std::process::Command::new("git")
4047 .env("GIT_TERMINAL_PROMPT", "0")
4048 .env("GIT_SSH_COMMAND", ssh_command_val())
4049 .args(["checkout", "-b", "feature"])
4050 .current_dir(&temp_path)
4051 .output()
4052 .unwrap();
4053 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";
4054 std::fs::write(&file_path, feature_lines).unwrap();
4055 stage_file(&temp_path, "conflict.txt").unwrap();
4056 commit_changes(&temp_path, "feature commit").unwrap();
4057
4058 std::process::Command::new("git")
4060 .env("GIT_TERMINAL_PROMPT", "0")
4061 .env("GIT_SSH_COMMAND", ssh_command_val())
4062 .args(["checkout", &main_branch])
4063 .current_dir(&temp_path)
4064 .output()
4065 .unwrap();
4066 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";
4067 std::fs::write(&file_path, main_lines).unwrap();
4068 stage_file(&temp_path, "conflict.txt").unwrap();
4069 commit_changes(&temp_path, "main commit").unwrap();
4070
4071 let merge_output = std::process::Command::new("git")
4073 .env("GIT_TERMINAL_PROMPT", "0")
4074 .env("GIT_SSH_COMMAND", ssh_command_val())
4075 .args(["merge", "feature"])
4076 .current_dir(&temp_path)
4077 .output()
4078 .unwrap();
4079 assert!(!merge_output.status.success());
4080 assert!(is_merging(&temp_path));
4081
4082 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
4084 let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
4085 assert!(contents_after_first.contains("line 2 on main"));
4087 assert!(!contents_after_first.contains("line 2 on feature"));
4088 assert!(contents_after_first.contains("<<<<<<<"));
4090 assert!(contents_after_first.contains("line 11 on main"));
4091 assert!(contents_after_first.contains("line 11 on feature"));
4092
4093 assert!(is_merging(&temp_path));
4095
4096 resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
4101 let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
4102 assert!(contents_after_second.contains("line 2 on main"));
4104 assert!(contents_after_second.contains("line 11 on feature"));
4105 assert!(!contents_after_second.contains("<<<<<<<"));
4106
4107 let status = repo.statuses(None).unwrap();
4109 assert_eq!(status.len(), 1);
4110 assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
4111
4112 continue_merge(&temp_path).unwrap();
4114 assert!(!is_merging(&temp_path));
4115
4116 let _ = std::fs::remove_dir_all(&temp_path);
4118 }
4119
4120 #[test]
4121 fn test_branch_and_commit_helpers() {
4122 let mut temp_path = std::env::temp_dir();
4123 temp_path.push(format!(
4124 "twig_test_helpers_{}",
4125 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4126 ));
4127 let _ = std::fs::remove_dir_all(&temp_path);
4128 std::fs::create_dir_all(&temp_path).unwrap();
4129
4130 let repo = Repository::init(&temp_path).unwrap();
4132
4133 let mut config = repo.config().unwrap();
4135 config.set_str("user.name", "Test User").unwrap();
4136 config.set_str("user.email", "test@example.com").unwrap();
4137
4138 let file_path = temp_path.join("test.txt");
4140 std::fs::write(&file_path, "root content").unwrap();
4141 stage_file(&temp_path, "test.txt").unwrap();
4142 commit_changes(&temp_path, "root commit").unwrap();
4143
4144 let head_oid = repo.head().unwrap().target().unwrap().to_string();
4145 assert!(is_root_commit(&temp_path, &head_oid));
4146
4147 std::fs::write(&file_path, "second content").unwrap();
4149 stage_file(&temp_path, "test.txt").unwrap();
4150 commit_changes(&temp_path, "second commit").unwrap();
4151
4152 let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
4153 assert!(!is_root_commit(&temp_path, &new_head_oid));
4154
4155 remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
4160 let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
4161 "master"
4162 } else {
4163 "main"
4164 };
4165 let target = get_branch_push_target(&temp_path, active_branch);
4166 assert!(target.is_some());
4167 let (remote_name, set_upstream) = target.unwrap();
4168 assert_eq!(remote_name, "origin");
4169 assert!(set_upstream);
4170
4171 let mut git_config = repo.config().unwrap();
4173 git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
4174 git_config
4175 .set_str(
4176 &format!("branch.{}.merge", active_branch),
4177 &format!("refs/heads/{}", active_branch),
4178 )
4179 .unwrap();
4180
4181 assert!(has_upstream_remote(&temp_path, active_branch));
4183 assert_eq!(
4184 get_branch_upstream_remote(&temp_path, active_branch),
4185 Some("origin".to_string())
4186 );
4187
4188 let target_tracking = get_branch_push_target(&temp_path, active_branch);
4190 assert!(target_tracking.is_some());
4191 let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
4192 assert_eq!(remote_name_tr, "origin");
4193 assert!(!set_upstream_tr);
4194
4195 let _ = std::fs::remove_dir_all(&temp_path);
4197 }
4198
4199 #[test]
4200 fn test_core_helpers_comprehensive() {
4201 assert_eq!(format_relative_time(0), "unknown");
4203 assert_eq!(format_relative_time(-100), "unknown");
4204
4205 let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
4206 assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
4207 assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
4208 assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
4209 assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
4210 assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
4211 assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
4212
4213 assert_eq!(format_utc_date(0), "unknown");
4215 assert_eq!(format_utc_date(-1), "unknown");
4216 let date_str = format_utc_date(1700000000);
4217 assert!(date_str.contains("2023-11-14"));
4218
4219 assert_eq!(expand_tilde("path"), PathBuf::from("path"));
4221 let tilde_path = expand_tilde("~/path");
4222 assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
4223
4224 assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
4226 assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
4227
4228 assert_eq!(safe_sha_slice("12345", 10), "12345");
4230 assert_eq!(safe_sha_slice("12345", 3), "123");
4231
4232 assert_eq!(safe_ref(" refs/heads/main "), Ok("refs/heads/main"));
4234 assert!(safe_ref("-invalid").is_err());
4235 assert!(safe_ref(" ").is_err());
4236
4237 let mut status = RepoSummary::default();
4239 assert!(status.is_clean());
4240 assert!(status.is_synced());
4241 assert!(status.unchanged());
4242
4243 status.modified = 1;
4244 assert!(!status.is_clean());
4245 assert!(!status.unchanged());
4246
4247 status.modified = 0;
4248 status.ahead = 1;
4249 assert!(!status.is_synced());
4250 assert!(!status.unchanged());
4251
4252 let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
4254 assert!(data_not_loaded.is_not_loaded());
4255 assert!(!data_not_loaded.is_loading());
4256 assert!(!data_not_loaded.is_loaded());
4257 assert_eq!(data_not_loaded.len(), 0);
4258 assert!(data_not_loaded.is_empty());
4259 assert!(data_not_loaded.as_ref().is_none());
4260 assert!(data_not_loaded.first().is_none());
4261 assert!(data_not_loaded.get(0).is_none());
4262 assert_eq!(data_not_loaded.iter().count(), 0);
4263 assert_eq!(data_not_loaded.as_slice().len(), 0);
4264
4265 let data_loading: TabData<Vec<i32>> = TabData::Loading;
4266 assert!(!data_loading.is_not_loaded());
4267 assert!(data_loading.is_loading());
4268
4269 let data_loaded = TabData::Loaded(vec![10, 20]);
4270 assert!(!data_loaded.is_not_loaded());
4271 assert!(!data_loaded.is_loading());
4272 assert!(data_loaded.is_loaded());
4273 assert_eq!(data_loaded.len(), 2);
4274 assert!(!data_loaded.is_empty());
4275 assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
4276 assert_eq!(data_loaded.first().unwrap(), &10);
4277 assert_eq!(data_loaded.get(1).unwrap(), &20);
4278 assert_eq!(data_loaded.iter().count(), 2);
4279 assert_eq!(data_loaded.as_slice(), &[10, 20]);
4280 }
4281}