Skip to main content

gitwig_core/
lib.rs

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