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, Instant, SystemTime, UNIX_EPOCH};
19
20use git2::{Repository, StatusOptions, StatusShow};
21
22mod process;
23pub use process::{
24    detached_command, git_command, spawn_pipe_reader, ssh_command_val, summarize_stderr,
25    tool_command,
26};
27
28// ── Card-level status ──────────────────────────────────────────────────────
29
30/// Per-item filesystem classification carried alongside `config.items`.
31/// `GitRepo`'s inner `Option` is `None` when `.git` exists but libgit2
32/// couldn't open or read the repo — we know it's a repo, we just can't
33/// summarize its state.
34#[derive(Debug, Clone)]
35pub enum ItemStatus {
36    Loading,
37    Missing,
38    Directory,
39    GitRepo(Option<RepoSummary>),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum RepoState {
44    #[default]
45    Clean,
46    Merge,
47    Revert,
48    CherryPick,
49    Bisect,
50    Rebase,
51    ApplyMailbox,
52}
53
54/// Compact summary used to draw the per-card indicator. Also embedded in
55/// `RepoInfo` so the Detail view doesn't re-collect the same data.
56#[derive(Debug, Default, Clone)]
57pub struct RepoSummary {
58    /// Current branch shorthand (e.g. `"main"`). `None` for detached HEAD
59    /// or when the ref cannot be read.
60    pub branch: Option<String>,
61    pub staged: usize,
62    pub modified: usize,
63    pub untracked: usize,
64    pub conflicted: usize,
65    pub ahead: usize,
66    pub behind: usize,
67    pub state: RepoState,
68    pub last_commit_time: Option<i64>,
69}
70
71impl RepoSummary {
72    pub fn is_clean(&self) -> bool {
73        self.staged + self.modified + self.untracked + self.conflicted == 0
74    }
75    pub fn is_synced(&self) -> bool {
76        self.ahead + self.behind == 0
77    }
78    pub fn unchanged(&self) -> bool {
79        self.is_clean() && self.is_synced()
80    }
81}
82
83// ── Detail view ────────────────────────────────────────────────────────────
84
85#[derive(Debug, Clone)]
86pub enum ItemDetail {
87    Missing { resolved: PathBuf },
88    Directory { resolved: PathBuf },
89    Repo { resolved: PathBuf, info: Box<RepoInfo> },
90    Error { resolved: PathBuf, message: String },
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct BranchInfo {
95    pub name: String,
96    pub is_head: bool,
97    pub short_sha: String,
98    pub short_message: String,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct FileRevision {
103    pub commit_oid: String,
104    pub author: String,
105    pub date: String,
106    pub when: String,
107    pub summary: String,
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct StashInfo {
112    pub index: usize,
113    pub message: String,
114    pub commit_id: String,
115    pub files: Vec<FileEntry>,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct CommitterStat {
120    pub name: String,
121    pub email: String,
122    pub count: usize,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub enum TabData<T> {
127    #[default]
128    NotLoaded,
129    Loading,
130    Loaded(T),
131    Error(String),
132}
133
134impl<T> TabData<T> {
135    pub fn is_not_loaded(&self) -> bool {
136        matches!(self, TabData::NotLoaded)
137    }
138    pub fn is_loading(&self) -> bool {
139        matches!(self, TabData::Loading)
140    }
141    #[allow(dead_code)]
142    pub fn is_loaded(&self) -> bool {
143        matches!(self, TabData::Loaded(_))
144    }
145    pub fn as_ref(&self) -> Option<&T> {
146        match self {
147            TabData::Loaded(val) => Some(val),
148            _ => None,
149        }
150    }
151}
152
153impl<T> TabData<Vec<T>> {
154    pub fn len(&self) -> usize {
155        self.as_ref().map(|v| v.len()).unwrap_or(0)
156    }
157    pub fn is_empty(&self) -> bool {
158        self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
159    }
160    pub fn first(&self) -> Option<&T> {
161        self.as_ref().and_then(|v| v.first())
162    }
163    pub fn get(&self, index: usize) -> Option<&T> {
164        self.as_ref().and_then(|v| v.get(index))
165    }
166    pub fn iter(&self) -> std::slice::Iter<'_, T> {
167        match self {
168            TabData::Loaded(v) => v.iter(),
169            _ => [].iter(),
170        }
171    }
172    pub fn as_slice(&self) -> &[T] {
173        match self {
174            TabData::Loaded(v) => v.as_slice(),
175            _ => &[],
176        }
177    }
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct WorktreeInfo {
182    pub name: String,
183    pub path: PathBuf,
184    pub branch: Option<String>,
185    pub is_locked: bool,
186    pub lock_reason: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct SubmoduleInfo {
191    pub name: String,
192    pub path: PathBuf,
193    pub url: String,
194    pub commit_id: Option<String>,
195    pub head_id: Option<String>,
196    pub is_initialized: bool,
197    pub is_dirty: bool,
198}
199
200#[derive(Debug, Clone)]
201pub enum TabPayload {
202    Files(Result<Vec<String>, String>),
203    Graph(Result<Vec<GraphLine>, String>),
204    Branches {
205        local: Result<Vec<BranchInfo>, String>,
206        remote: Result<Vec<BranchInfo>, String>,
207    },
208    Tags {
209        local: Result<Vec<BranchInfo>, String>,
210        remote: Result<Vec<BranchInfo>, String>,
211    },
212    Remotes(Result<Vec<RemoteInfo>, String>),
213    Stashes(Result<Vec<StashInfo>, String>),
214    Overview(Result<(Vec<CommitterStat>, bool), String>),
215    Worktrees(Result<Vec<WorktreeInfo>, String>),
216    Submodules(Result<Vec<SubmoduleInfo>, String>),
217    Reflog(Result<Vec<ReflogEntry>, String>),
218    /// A non-fatal message for the status bar, sent alongside a tab payload
219    /// (for example: git could not run the signature program).
220    Notice(String),
221    ForgeIssues(Result<Vec<ForgeIssue>, String>),
222    ForgePRs(Result<Vec<ForgePR>, String>),
223    PRComments(Result<Vec<ForgePRComment>, String>),
224}
225
226#[derive(Debug, Default, Clone)]
227pub struct RepoInfo {
228    pub branch: Option<String>,
229    pub head: Option<HeadInfo>,
230    pub remotes: TabData<Vec<RemoteInfo>>,
231    /// Configured upstream branch (e.g. "origin/main") if HEAD tracks one.
232    pub upstream: Option<String>,
233    pub summary: RepoSummary,
234    /// File-level changes, populated by `collect_info` for the Detail view.
235    pub changes: WorktreeChanges,
236    /// Recent commits in this repository.
237    pub commits: Vec<CommitEntry>,
238    /// Graph view lines for the repository.
239    pub graph_lines: TabData<Vec<GraphLine>>,
240    /// Local branches in the repository.
241    pub local_branches: TabData<Vec<BranchInfo>>,
242    /// Remote branches in the repository.
243    pub remote_branches: TabData<Vec<BranchInfo>>,
244    /// Local tags in the repository.
245    pub local_tags: TabData<Vec<BranchInfo>>,
246    /// Remote tags in the repository.
247    pub remote_tags: TabData<Vec<BranchInfo>>,
248    /// Whether remote tags have been loaded from the remote repository.
249    pub remote_tags_loaded: bool,
250    /// Whether a remote tag fetch has been attempted in this session.
251    pub remote_tags_attempted: bool,
252    /// Tracked files in the repository.
253    pub files: TabData<Vec<String>>,
254    /// Available stashes in the repository.
255    pub stashes: TabData<Vec<StashInfo>>,
256    /// Available worktrees in the repository.
257    pub worktrees: TabData<Vec<WorktreeInfo>>,
258    /// Configured submodules in the repository.
259    pub submodules: TabData<Vec<SubmoduleInfo>>,
260    /// Available reflog entries.
261    pub reflog: TabData<Vec<ReflogEntry>>,
262    pub forge_issues: TabData<Vec<ForgeIssue>>,
263    pub forge_prs: TabData<Vec<ForgePR>>,
264    /// Committer statistics.
265    pub committer_stats: TabData<Vec<CommitterStat>>,
266    /// Whether the committer statistics walk was capped by the limit.
267    pub committer_stats_limit_reached: bool,
268    /// Timestamps when each tab was loaded (index matches tab_idx)
269    pub tab_loaded_at: [Option<std::time::Instant>; 12],
270    /// Whether each tab is currently loading in the background (index matches tab_idx)
271    pub tab_loading: [bool; 12],
272    /// Files tracked by Git LFS in the index/working copy.
273    pub lfs_files: std::collections::HashSet<String>,
274    /// Whether git-lfs is installed in the system PATH.
275    pub lfs_installed: bool,
276    /// Storage size of the local LFS directory if LFS is used.
277    pub lfs_storage_size: Option<u64>,
278}
279
280#[derive(Debug, Clone)]
281pub struct HeadInfo {
282    pub short_id: String,
283    pub summary: String,
284    pub author: String,
285    pub when: String,
286}
287
288#[derive(Debug, Clone)]
289pub struct ReflogEntry {
290    pub index: usize,
291    pub target_oid: String,
292    pub selector: String,
293    pub command: String,
294    pub message: String,
295    pub when: String,
296    pub date: String,
297}
298
299#[derive(Debug, Clone)]
300pub struct RemoteInfo {
301    pub name: String,
302    pub url: String,
303    pub push_url: Option<String>,
304    pub refspecs: Vec<String>,
305}
306
307#[derive(Debug, Clone)]
308pub struct CommitEntry {
309    /// Short 7-char display ID.
310    pub id: String,
311    /// Full 40-char hex OID — used for diff lookup.
312    pub oid: String,
313    pub author: String,
314    pub when: String,
315    pub date: String,
316    pub summary: String,
317    pub message: String,
318    /// Local branch names and tags pointing at this commit.
319    /// Tags are prefixed with `"tag:"`, remote branches with `"remote:"`.
320    pub refs: Vec<String>,
321    /// Files changed in this commit (diff against first parent, or empty tree).
322    pub files: Vec<FileEntry>,
323    /// GPG/SSH signature status.
324    pub signature_status: String,
325}
326
327#[derive(Debug, Clone)]
328pub struct GraphLine {
329    pub graph: String,
330    pub commit: Option<GraphCommit>,
331}
332
333#[derive(Debug, Clone)]
334pub struct GraphCommit {
335    pub oid: String,
336    pub decoration: String,
337    pub summary: String,
338    pub author: String,
339    pub date: String,
340    /// GPG/SSH signature status.
341    pub signature_status: String,
342}
343
344/// One changed file in the working tree or index.
345#[derive(Debug, Clone)]
346pub struct FileEntry {
347    /// Path relative to the repository root.
348    pub path: String,
349    /// Short human-readable label: "N", "M", "D", "R", "T", "?", or "C".
350    pub label: &'static str,
351}
352
353/// File-level working-tree state collected for the Detail view.
354/// Split into four buckets so the UI can render them as separate sections.
355#[derive(Debug, Default, Clone)]
356pub struct WorktreeChanges {
357    pub staged: Vec<FileEntry>,
358    pub unstaged: Vec<FileEntry>,
359    pub untracked: Vec<FileEntry>,
360    pub conflicted: Vec<FileEntry>,
361}
362
363// ── Per-file diff ──────────────────────────────────────────────────────────
364
365/// The type of a single line in a unified diff.
366#[derive(Debug, Clone, PartialEq)]
367pub enum DiffLineKind {
368    /// `@@ ... @@` hunk header.
369    Header,
370    /// `+` added line.
371    Added,
372    /// `-` removed line.
373    Removed,
374    /// Unchanged context line.
375    Context,
376    /// Line in OURS section of a conflict.
377    ConflictOurs,
378    /// Line in THEIRS section of a conflict.
379    ConflictTheirs,
380    /// Conflict marker line (<<<<<<<, =======, >>>>>>>).
381    ConflictSeparator,
382}
383
384/// One line of a unified diff, as rendered in the Diff panel.
385#[derive(Debug, Clone)]
386pub struct DiffLine {
387    pub kind: DiffLineKind,
388    /// Raw content (already includes the leading +/−/space prefix character).
389    pub content: String,
390    pub old_lineno: Option<u32>,
391    pub new_lineno: Option<u32>,
392    pub hunk_idx: Option<usize>,
393}
394
395/// Return the unified diff of `file_path` as it changed in `commit_oid`
396/// (hex string) inside the repository at `repo_path`.
397/// Returns an empty Vec on any error.
398pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
399    get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
400}
401
402/// Return the diff for `file_path` in the working tree.
403///
404/// - `staged = true`:  diff between HEAD and the index (what would be committed).
405/// - `staged = false`: diff between the index and the working directory (unstaged changes).
406///
407/// Returns an empty Vec on any error.
408pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
409    get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
410}
411
412/// Add `file_path` to the index (equivalent to `git add <file>`).
413/// Returns a human-readable error string on failure.
414pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
415    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
416    let mut index = repo.index().map_err(|e| e.to_string())?;
417    let full_path = repo_path.join(file_path);
418    if full_path.exists() {
419        index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
420    } else {
421        index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
422    }
423    index.write().map_err(|e| e.to_string())?;
424    Ok(())
425}
426
427/// Remove `file_path` from the index (equivalent to `git restore --staged <file>`).
428/// When HEAD exists the index entry is reset to the HEAD tree value; for a brand-new
429/// repo with no commits the entry is simply removed from the index.
430/// Returns a human-readable error string on failure.
431pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
432    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
433    // Prefer reset_default (git reset HEAD -- <file>) when a HEAD commit exists.
434    if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
435        repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
436            .map_err(|e| e.to_string())?;
437    } else {
438        // New repo with no commits: just remove the entry from the index.
439        let mut index = repo.index().map_err(|e| e.to_string())?;
440        index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
441        index.write().map_err(|e| e.to_string())?;
442    }
443    Ok(())
444}
445
446pub fn sanitize_text(s: &str) -> String {
447    let mut result = String::with_capacity(s.len());
448    let mut chars = s.chars().peekable();
449    while let Some(c) = chars.next() {
450        if c == '\x1B' {
451            // Escape character: let's skip the sequence
452            if let Some(&'[') = chars.peek() {
453                chars.next();
454                for seq_c in chars.by_ref() {
455                    // ANSI escape sequences end with letters (A-Z, a-z)
456                    if seq_c.is_ascii_alphabetic() {
457                        break;
458                    }
459                }
460            } else {
461                // Single ESC character or other VT100: skip it
462                for seq_c in chars.by_ref() {
463                    if seq_c == ' ' || seq_c.is_ascii_alphabetic() {
464                        break;
465                    }
466                }
467            }
468        } else if (c < ' ' || c == '\x7F') && c != '\n' && c != '\r' && c != '\t' {
469            // Replace non-printable control characters except \n, \r, \t
470            result.push(' ');
471        } else {
472            result.push(c);
473        }
474    }
475    result
476}
477
478pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
479    let mut end = len.min(sha.len());
480    while end > 0 && !sha.is_char_boundary(end) {
481        end -= 1;
482    }
483    &sha[..end]
484}
485
486/// How often the supervising thread checks whether the child has exited.
487const GIT_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
488
489/// Runs `cmd` to completion, killing it if it exceeds `timeout`.
490///
491/// A remote that accepts the TCP connection but never replies would otherwise
492/// leave the caller blocked forever inside `Command::output()`. Mirrors
493/// `crate::git_cmd::run_git_with_timeout` in the `gitwig` binary crate, which
494/// this crate cannot depend on. A zero timeout means "no limit".
495fn run_git_with_timeout(
496    mut cmd: std::process::Command,
497    timeout: Duration,
498) -> Result<std::process::Output, Box<dyn std::error::Error>> {
499    if timeout.is_zero() {
500        return Ok(cmd.output()?);
501    }
502
503    cmd.stdin(std::process::Stdio::null())
504        .stdout(std::process::Stdio::piped())
505        .stderr(std::process::Stdio::piped());
506
507    let mut child = cmd.spawn()?;
508
509    let mut out_pipe = child.stdout.take();
510    let mut err_pipe = child.stderr.take();
511    let out_reader = std::thread::spawn(move || {
512        let mut buf = Vec::new();
513        if let Some(p) = out_pipe.as_mut() {
514            let _ = std::io::Read::read_to_end(p, &mut buf);
515        }
516        buf
517    });
518    let err_reader = std::thread::spawn(move || {
519        let mut buf = Vec::new();
520        if let Some(p) = err_pipe.as_mut() {
521            let _ = std::io::Read::read_to_end(p, &mut buf);
522        }
523        buf
524    });
525
526    let started = Instant::now();
527    let status = loop {
528        match child.try_wait() {
529            Ok(Some(status)) => break Some(status),
530            Ok(None) => {
531                if started.elapsed() >= timeout {
532                    let _ = child.kill();
533                    let _ = child.wait();
534                    break None;
535                }
536                std::thread::sleep(GIT_TIMEOUT_POLL_INTERVAL);
537            }
538            Err(e) => {
539                let _ = child.kill();
540                let _ = child.wait();
541                return Err(e.into());
542            }
543        }
544    };
545
546    let stdout = out_reader.join().unwrap_or_default();
547    let stderr = err_reader.join().unwrap_or_default();
548
549    match status {
550        Some(status) => Ok(std::process::Output { status, stdout, stderr }),
551        None => {
552            Err(format!("git did not respond within {}s and was cancelled", timeout.as_secs())
553                .into())
554        }
555    }
556}
557
558pub fn safe_ref(r: &str) -> Result<&str, String> {
559    let trimmed = r.trim();
560    if trimmed.starts_with('-') {
561        return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
562    }
563    if trimmed.is_empty() {
564        return Err("Ref name cannot be empty".to_string());
565    }
566    Ok(trimmed)
567}
568
569/// Stage all unstaged/untracked changes (equivalent to `git add -A`).
570pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
571    let output = git_command()
572        .arg("add")
573        .arg("-A")
574        .current_dir(repo_path)
575        .output()
576        .map_err(|e| e.to_string())?;
577
578    if output.status.success() {
579        Ok(())
580    } else {
581        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
582    }
583}
584
585/// Unstage all staged changes (equivalent to `git reset`).
586pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
587    let output =
588        git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
589
590    if output.status.success() {
591        Ok(())
592    } else {
593        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
594    }
595}
596
597/// Discard all staged, unstaged, and untracked changes in the repository.
598pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
599    // 1. Unstage all first so everything is in the working tree
600    let _ = git_command().arg("reset").current_dir(repo_path).output();
601
602    // 2. Discard all tracked modifications
603    let checkout_out = git_command()
604        .arg("checkout")
605        .arg("--")
606        .arg(".")
607        .current_dir(repo_path)
608        .output()
609        .map_err(|e| e.to_string())?;
610
611    if !checkout_out.status.success() {
612        return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
613    }
614
615    // 3. Clean all untracked files/folders
616    let clean_out = git_command()
617        .arg("clean")
618        .arg("-fd")
619        .current_dir(repo_path)
620        .output()
621        .map_err(|e| e.to_string())?;
622
623    if !clean_out.status.success() {
624        return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
625    }
626
627    Ok(())
628}
629
630/// Stage a single hunk of unstaged changes (equivalent to `git apply --cached -`).
631pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
632    apply_hunk_patch(repo_path, file_path, hunk, false, true)
633}
634
635/// Unstage a single hunk of staged changes (equivalent to `git apply --cached --reverse -`).
636pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
637    apply_hunk_patch(repo_path, file_path, hunk, true, true)
638}
639
640/// Discard a single hunk of unstaged changes in the working tree (equivalent to `git apply --reverse -`).
641pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
642    apply_hunk_patch(repo_path, file_path, hunk, true, false)
643}
644
645/// Stage a single line from the Unstaged diff.
646pub fn stage_line(
647    repo_path: &Path,
648    file_path: &str,
649    hunk: &[DiffLine],
650    selected_line_idx: usize,
651) -> Result<(), String> {
652    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
653}
654
655/// Unstage a single line from the Staged diff.
656pub fn unstage_line(
657    repo_path: &Path,
658    file_path: &str,
659    hunk: &[DiffLine],
660    selected_line_idx: usize,
661) -> Result<(), String> {
662    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
663}
664
665/// Discard a single line from the Unstaged diff in the working tree.
666pub fn discard_line(
667    repo_path: &Path,
668    file_path: &str,
669    hunk: &[DiffLine],
670    selected_line_idx: usize,
671) -> Result<(), String> {
672    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
673}
674
675fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
676    if !header.starts_with("@@") {
677        return None;
678    }
679    let parts: Vec<&str> = header.split("@@").collect();
680    if parts.len() < 3 {
681        return None;
682    }
683    let meta = parts[1].trim();
684    let subparts: Vec<&str> = meta.split_whitespace().collect();
685    if subparts.len() < 2 {
686        return None;
687    }
688
689    let parse_part = |p: &str| -> (usize, usize) {
690        let s = p.trim_start_matches(['-', '+']);
691        let comps: Vec<&str> = s.split(',').collect();
692        let start = comps[0].parse::<usize>().unwrap_or(0);
693        let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
694        (start, count)
695    };
696
697    let (old_start, old_count) = parse_part(subparts[0]);
698    let (new_start, new_count) = parse_part(subparts[1]);
699    Some((old_start, old_count, new_start, new_count))
700}
701
702fn apply_line_patch_inner(
703    repo_path: &Path,
704    file_path: &str,
705    hunk: &[DiffLine],
706    selected_line_idx_in_hunk: usize,
707    revert: bool,
708    target_has_modification: bool,
709    cached: bool,
710) -> Result<(), String> {
711    use std::io::Write;
712    use std::process::Stdio;
713
714    let path_check = std::path::Path::new(file_path);
715    if path_check.is_absolute()
716        || path_check.components().any(|c| c == std::path::Component::ParentDir)
717    {
718        return Err("Path escapes repository root".to_string());
719    }
720
721    if hunk.is_empty() {
722        return Err("Empty hunk".to_string());
723    }
724
725    let selected_line = match hunk.get(selected_line_idx_in_hunk) {
726        Some(line) => line,
727        None => return Err("Invalid line index".to_string()),
728    };
729
730    if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
731        return Err("Selected line is not a modification (must be + or -)".to_string());
732    }
733
734    let header_line = &hunk[0];
735    let (old_start, _old_count, new_start, _new_count) =
736        match parse_hunk_header(&header_line.content) {
737            Some(coords) => coords,
738            None => return Err(format!("Invalid hunk header: {}", header_line.content)),
739        };
740
741    let mut patch_lines = Vec::new();
742    let mut new_old_count = 0;
743    let mut new_new_count = 0;
744
745    for (i, line) in hunk.iter().enumerate() {
746        if i == 0 {
747            continue;
748        }
749
750        if i == selected_line_idx_in_hunk {
751            if revert {
752                match line.kind {
753                    DiffLineKind::Added => {
754                        patch_lines.push(DiffLine {
755                            kind: DiffLineKind::Removed,
756                            content: line.content.clone(),
757                            old_lineno: None,
758                            new_lineno: None,
759                            hunk_idx: None,
760                        });
761                        new_old_count += 1;
762                    }
763                    DiffLineKind::Removed => {
764                        patch_lines.push(DiffLine {
765                            kind: DiffLineKind::Added,
766                            content: line.content.clone(),
767                            old_lineno: None,
768                            new_lineno: None,
769                            hunk_idx: None,
770                        });
771                        new_new_count += 1;
772                    }
773                    _ => {}
774                }
775            } else {
776                match line.kind {
777                    DiffLineKind::Added => {
778                        patch_lines.push(DiffLine {
779                            kind: DiffLineKind::Added,
780                            content: line.content.clone(),
781                            old_lineno: None,
782                            new_lineno: None,
783                            hunk_idx: None,
784                        });
785                        new_new_count += 1;
786                    }
787                    DiffLineKind::Removed => {
788                        patch_lines.push(DiffLine {
789                            kind: DiffLineKind::Removed,
790                            content: line.content.clone(),
791                            old_lineno: None,
792                            new_lineno: None,
793                            hunk_idx: None,
794                        });
795                        new_old_count += 1;
796                    }
797                    _ => {}
798                }
799            }
800        } else {
801            match line.kind {
802                DiffLineKind::Context => {
803                    patch_lines.push(line.clone());
804                    new_old_count += 1;
805                    new_new_count += 1;
806                }
807                DiffLineKind::Added => {
808                    if target_has_modification {
809                        patch_lines.push(DiffLine {
810                            kind: DiffLineKind::Context,
811                            content: line.content.clone(),
812                            old_lineno: None,
813                            new_lineno: None,
814                            hunk_idx: None,
815                        });
816                        new_old_count += 1;
817                        new_new_count += 1;
818                    } else {
819                        // Omit
820                    }
821                }
822                DiffLineKind::Removed => {
823                    if target_has_modification {
824                        // Omit
825                    } else {
826                        patch_lines.push(DiffLine {
827                            kind: DiffLineKind::Context,
828                            content: line.content.clone(),
829                            old_lineno: None,
830                            new_lineno: None,
831                            hunk_idx: None,
832                        });
833                        new_old_count += 1;
834                        new_new_count += 1;
835                    }
836                }
837                _ => {}
838            }
839        }
840    }
841
842    let mut patch = String::new();
843    patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
844    patch.push_str(&format!("--- a/{}\n", file_path));
845    patch.push_str(&format!("+++ b/{}\n", file_path));
846    patch.push_str(&format!(
847        "@@ -{},{} +{},{} @@\n",
848        old_start, new_old_count, new_start, new_new_count
849    ));
850
851    for line in patch_lines {
852        let prefix = match line.kind {
853            DiffLineKind::Added => "+",
854            DiffLineKind::Removed => "-",
855            DiffLineKind::Context => " ",
856            DiffLineKind::Header => "",
857            _ => "",
858        };
859        patch.push_str(prefix);
860        patch.push_str(&line.content);
861        patch.push('\n');
862    }
863
864    let mut args = vec!["apply"];
865    if cached {
866        args.push("--cached");
867    }
868    args.push("-");
869
870    let mut cmd = git_command();
871    let mut child = cmd
872        .args(&args)
873        .current_dir(repo_path)
874        .stdin(Stdio::piped())
875        .stdout(Stdio::piped())
876        .stderr(Stdio::piped())
877        .spawn()
878        .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
879
880    if let Some(mut stdin) = child.stdin.take() {
881        stdin
882            .write_all(patch.as_bytes())
883            .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
884    }
885
886    let output =
887        child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
888
889    if !output.status.success() {
890        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
891        return Err(format!("git apply failed: {}", err_msg.trim()));
892    }
893
894    Ok(())
895}
896
897fn apply_hunk_patch(
898    repo_path: &Path,
899    file_path: &str,
900    hunk: &[DiffLine],
901    reverse: bool,
902    cached: bool,
903) -> Result<(), String> {
904    use std::io::Write;
905    use std::process::Stdio;
906
907    let path_check = std::path::Path::new(file_path);
908    if path_check.is_absolute()
909        || path_check.components().any(|c| c == std::path::Component::ParentDir)
910    {
911        return Err("Path escapes repository root".to_string());
912    }
913
914    let mut patch = String::new();
915    patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
916    patch.push_str(&format!("--- a/{}\n", file_path));
917    patch.push_str(&format!("+++ b/{}\n", file_path));
918    for line in hunk {
919        let prefix = match line.kind {
920            DiffLineKind::Added => "+",
921            DiffLineKind::Removed => "-",
922            DiffLineKind::Context => " ",
923            DiffLineKind::Header => "",
924            _ => "",
925        };
926        patch.push_str(prefix);
927        patch.push_str(&line.content);
928        patch.push('\n');
929    }
930
931    let mut args = vec!["apply"];
932    if cached {
933        args.push("--cached");
934    }
935    if reverse {
936        args.push("--reverse");
937    }
938    args.push("-");
939
940    let mut cmd = git_command();
941    let mut child = cmd
942        .args(&args)
943        .current_dir(repo_path)
944        .stdin(Stdio::piped())
945        .stdout(Stdio::piped())
946        .stderr(Stdio::piped())
947        .spawn()
948        .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
949
950    if let Some(mut stdin) = child.stdin.take() {
951        stdin
952            .write_all(patch.as_bytes())
953            .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
954    }
955
956    let output =
957        child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
958
959    if !output.status.success() {
960        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
961        return Err(format!("git apply failed: {}", err_msg.trim()));
962    }
963
964    Ok(())
965}
966
967/// Discards uncommitted changes in `file_path`.
968/// - If the file is untracked, it is deleted from the filesystem.
969/// - If the file is tracked and modified/deleted, it is restored from the index.
970/// - If the file is staged, it is first unstaged (reset to HEAD) and then restored from index.
971pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
972    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
973
974    if staged {
975        // First unstage it (reset to HEAD)
976        unstage_file(repo_path, file_path)?;
977    }
978
979    // Now check if the file is untracked
980    let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
981        status.contains(git2::Status::WT_NEW)
982    } else {
983        false
984    };
985
986    if is_untracked {
987        let full_path = repo_path.join(file_path);
988        if full_path.exists() {
989            if full_path.is_file() {
990                std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
991            } else if full_path.is_dir() {
992                std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
993            }
994        }
995    } else {
996        // Tracked file: checkout from index to working tree
997        let mut checkout_opts = git2::build::CheckoutBuilder::new();
998        checkout_opts.path(Path::new(file_path));
999        checkout_opts.force();
1000        repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
1001    }
1002
1003    Ok(())
1004}
1005
1006/// Create a commit in the repository with the given message.
1007/// Returns a human-readable error string on failure.
1008pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
1009    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1010    let mut index = repo.index().map_err(|e| e.to_string())?;
1011    let tree_id = index.write_tree().map_err(|e| e.to_string())?;
1012    let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
1013
1014    let signature = repo
1015        .signature()
1016        .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
1017
1018    // Find parent commits
1019    let mut parents = Vec::new();
1020    let mut has_head = false;
1021    if let Ok(head) = repo.head() {
1022        if let Ok(parent_commit) = head.peel_to_commit() {
1023            has_head = true;
1024            // Check if there are changes staged compared to HEAD
1025            let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
1026            if parent_tree.id() == tree_id {
1027                return Err("No staged changes to commit".to_string());
1028            }
1029            parents.push(parent_commit);
1030        }
1031    }
1032
1033    if !has_head && index.is_empty() {
1034        return Err("No staged changes to commit (index is empty)".to_string());
1035    }
1036
1037    let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1038
1039    repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
1040        .map_err(|e| e.to_string())?;
1041
1042    Ok(())
1043}
1044
1045// ── Public entry points ────────────────────────────────────────────────────
1046
1047/// Expand a leading `~` or `~/` in a user-supplied path to the user's home
1048/// directory. Returns the input unchanged if there is no home dir or no
1049/// tilde to expand.
1050pub fn expand_tilde(s: &str) -> PathBuf {
1051    if s == "~" {
1052        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
1053    }
1054    if let Some(stripped) = s.strip_prefix("~/")
1055        && let Some(home) = dirs::home_dir()
1056    {
1057        return home.join(stripped);
1058    }
1059    PathBuf::from(s)
1060}
1061
1062/// Walk up from `start` and return the first directory containing a `.git`
1063/// entry, or `None` if there is no repository above it.
1064///
1065/// `.git` is matched as either a directory (an ordinary clone) or a file (a
1066/// linked worktree or a submodule, where it holds a `gitdir:` pointer), so a
1067/// worktree checkout reports its own root rather than the main one.
1068pub fn discover_repo_root(start: &Path) -> Option<PathBuf> {
1069    let mut dir = if start.is_dir() { Some(start) } else { start.parent() };
1070    while let Some(current) = dir {
1071        if current.join(".git").exists() {
1072            return Some(current.to_path_buf());
1073        }
1074        dir = current.parent();
1075    }
1076    None
1077}
1078
1079/// Add a new git remote.
1080pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
1081    let repo = Repository::open(repo_path)?;
1082    repo.remote(name, url)?;
1083    Ok(())
1084}
1085
1086/// Delete an existing git remote.
1087pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
1088    let repo = Repository::open(repo_path)?;
1089    repo.remote_delete(name)?;
1090    Ok(())
1091}
1092
1093/// Classify `item` and produce a card-level summary. Used by the list view.
1094pub fn inspect_summary(item: &str) -> ItemStatus {
1095    let path = expand_tilde(item);
1096    if !path.is_dir() {
1097        return ItemStatus::Missing;
1098    }
1099    if !path.join(".git").exists() {
1100        return ItemStatus::Directory;
1101    }
1102    match Repository::open(&path) {
1103        Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
1104        Err(_) => ItemStatus::GitRepo(None),
1105    }
1106}
1107
1108/// Inspect `item` and produce the rich detail report shown on Enter.
1109pub fn inspect_detail(
1110    item: &str,
1111    commit_limit: usize,
1112    graph_max_commits: usize,
1113    enable_commit_signatures: bool,
1114) -> ItemDetail {
1115    let resolved = expand_tilde(item);
1116    if !resolved.is_dir() {
1117        return ItemDetail::Missing { resolved };
1118    }
1119    if !resolved.join(".git").exists() {
1120        return ItemDetail::Directory { resolved };
1121    }
1122    match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1123        Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1124        Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1125    }
1126}
1127
1128fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1129    let mut sigs = std::collections::HashMap::new();
1130    let mut cmd = git_command();
1131    cmd.arg("log").arg("--all");
1132
1133    if limit > 0 {
1134        cmd.arg(format!("-n{}", limit));
1135    }
1136
1137    cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1138
1139    if let Ok(out) = cmd.output() {
1140        if out.status.success() {
1141            let stdout_str = String::from_utf8_lossy(&out.stdout);
1142            for line in stdout_str.lines() {
1143                let parts: Vec<&str> = line.split_whitespace().collect();
1144                if parts.len() == 2 {
1145                    sigs.insert(parts[0].to_string(), parts[1].to_string());
1146                } else if parts.len() == 1 {
1147                    sigs.insert(parts[0].to_string(), "N".to_string());
1148                }
1149            }
1150        }
1151    }
1152    sigs
1153}
1154
1155#[derive(serde::Serialize, serde::Deserialize, Clone)]
1156struct CachedCommit {
1157    id: String,
1158    author: String,
1159    date: String,
1160    summary: String,
1161    message: String,
1162    time: i64,
1163}
1164
1165fn hash_path(path: &Path) -> String {
1166    use std::collections::hash_map::DefaultHasher;
1167    use std::hash::{Hash, Hasher};
1168    let mut hasher = DefaultHasher::new();
1169    path.hash(&mut hasher);
1170    format!("{:x}", hasher.finish())
1171}
1172
1173fn collect_commits(
1174    repo: &Repository,
1175    limit: usize,
1176    repo_path: &Path,
1177    enable_commit_signatures: bool,
1178) -> Result<Vec<CommitEntry>, git2::Error> {
1179    let mut walk = repo.revwalk()?;
1180    if walk.push_head().is_err() {
1181        return Ok(Vec::new());
1182    }
1183    walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1184
1185    let mut commits = Vec::new();
1186    let oids: Vec<Result<git2::Oid, git2::Error>> =
1187        if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1188
1189    let sig_map = if enable_commit_signatures {
1190        collect_signatures(repo_path, limit)
1191    } else {
1192        std::collections::HashMap::new()
1193    };
1194    let ref_map = get_cached_ref_map(repo, repo_path);
1195
1196    // Load commit cache
1197    let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1198    if let Some(ref dir) = cache_dir {
1199        let _ = std::fs::create_dir_all(dir);
1200    }
1201    let hash = hash_path(repo_path);
1202    let cache_file = cache_dir.as_ref().map(|d| d.join(format!("v2_{}.json", hash)));
1203    let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1204        .as_ref()
1205        .and_then(|f| std::fs::read_to_string(f).ok())
1206        .and_then(|s| serde_json::from_str(&s).ok())
1207        .unwrap_or_default();
1208
1209    let mut cache_updated = false;
1210
1211    for id in oids {
1212        let oid = id?;
1213        let oid_str = oid.to_string();
1214        let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1215        let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1216        let files = Vec::new();
1217
1218        if let Some(cached) = cache.get(&oid_str) {
1219            let when = format_relative_time(cached.time);
1220            commits.push(CommitEntry {
1221                id: cached.id.clone(),
1222                oid: oid_str,
1223                author: sanitize_text(&cached.author),
1224                when,
1225                date: cached.date.clone(),
1226                summary: sanitize_text(&cached.summary),
1227                message: sanitize_text(&cached.message),
1228                refs,
1229                files,
1230                signature_status: sig_status,
1231            });
1232        } else if let Ok(commit) = repo.find_commit(oid) {
1233            let short_id = format!("{:.7}", commit.id());
1234            let summary =
1235                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1236            let author = commit.author();
1237            let author_name = author.name().unwrap_or("?");
1238            let author_email = author.email().unwrap_or("?");
1239            let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1240            let time_secs = commit.time().seconds();
1241            let when = format_relative_time(time_secs);
1242            let date = format_utc_date(time_secs);
1243            let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1244
1245            let cached = CachedCommit {
1246                id: short_id.clone(),
1247                author: author_str.clone(),
1248                date: date.clone(),
1249                summary: summary.clone(),
1250                message: message.clone(),
1251                time: time_secs,
1252            };
1253            cache.insert(oid_str.clone(), cached);
1254            cache_updated = true;
1255
1256            commits.push(CommitEntry {
1257                id: short_id,
1258                oid: oid_str,
1259                author: author_str,
1260                when,
1261                date,
1262                summary,
1263                message,
1264                refs,
1265                files,
1266                signature_status: sig_status,
1267            });
1268        }
1269    }
1270
1271    if cache_updated {
1272        if let Some(ref f) = cache_file {
1273            if let Ok(json) = serde_json::to_string(&cache) {
1274                let _ = std::fs::write(f, json);
1275            }
1276        }
1277    }
1278
1279    Ok(commits)
1280}
1281
1282pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1283    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1284    let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1285    if walk.push_head().is_err() {
1286        return Ok(Vec::new());
1287    }
1288    walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1289
1290    let mut revisions = Vec::new();
1291
1292    for oid_res in walk {
1293        let oid = oid_res.map_err(|e| e.to_string())?;
1294        let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1295
1296        let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1297        let mut modified = false;
1298
1299        if commit.parent_count() > 0 {
1300            for i in 0..commit.parent_count() {
1301                if let Ok(parent) = commit.parent(i) {
1302                    if let Ok(parent_tree) = parent.tree() {
1303                        let mut diff_opts = git2::DiffOptions::new();
1304                        diff_opts.pathspec(file_path);
1305                        if let Ok(diff) = repo.diff_tree_to_tree(
1306                            Some(&parent_tree),
1307                            Some(&commit_tree),
1308                            Some(&mut diff_opts),
1309                        ) {
1310                            if diff.deltas().len() > 0 {
1311                                modified = true;
1312                                break;
1313                            }
1314                        }
1315                    }
1316                }
1317            }
1318        } else {
1319            let mut diff_opts = git2::DiffOptions::new();
1320            diff_opts.pathspec(file_path);
1321            if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1322            {
1323                if diff.deltas().len() > 0 {
1324                    modified = true;
1325                }
1326            }
1327        }
1328
1329        if modified {
1330            let author = commit.author();
1331            let author_name = author.name().unwrap_or("?");
1332            let author_email = author.email().unwrap_or("?");
1333            let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1334            let time_secs = commit.time().seconds();
1335            let when = format_relative_time(time_secs);
1336            let date = format_utc_date(time_secs);
1337            let summary =
1338                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1339
1340            revisions.push(FileRevision {
1341                commit_oid: oid.to_string(),
1342                author: author_str,
1343                date,
1344                when,
1345                summary,
1346            });
1347        }
1348    }
1349
1350    Ok(revisions)
1351}
1352
1353#[derive(Debug, Clone)]
1354pub struct BlameEntry {
1355    pub line_no: usize,
1356    pub commit_id: String,
1357    pub author: String,
1358    pub date: String,
1359}
1360
1361pub fn get_file_blame(repo_path: &Path, file_path: &str) -> Result<Vec<BlameEntry>, String> {
1362    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1363    let mut opts = git2::BlameOptions::new();
1364    let blame = repo
1365        .blame_file(std::path::Path::new(file_path), Some(&mut opts))
1366        .map_err(|e| e.to_string())?;
1367
1368    let mut entries = Vec::new();
1369    for i in 0..blame.len() {
1370        if let Some(hunk) = blame.get_index(i) {
1371            let commit_id = hunk.final_commit_id().to_string();
1372            let short_id = if commit_id.len() >= 7 { &commit_id[..7] } else { &commit_id };
1373
1374            let mut author_name = "Unknown".to_string();
1375            let mut date_str = "unknown".to_string();
1376            if let Ok(commit) = repo.find_commit(hunk.final_commit_id()) {
1377                author_name = commit.author().name().unwrap_or("Unknown").to_string();
1378                date_str = format_utc_date(commit.time().seconds());
1379            }
1380
1381            let start = hunk.final_start_line();
1382            let count = hunk.lines_in_hunk();
1383            for line_idx in 0..count {
1384                entries.push(BlameEntry {
1385                    line_no: start + line_idx,
1386                    commit_id: short_id.to_string(),
1387                    author: author_name.clone(),
1388                    date: date_str.clone(),
1389                });
1390            }
1391        }
1392    }
1393
1394    entries.sort_by_key(|e| e.line_no);
1395    Ok(entries)
1396}
1397
1398fn collect_committer_stats(
1399    repo: &Repository,
1400    limit: usize,
1401) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1402    let mut walk = repo.revwalk()?;
1403    if walk.push_head().is_err() {
1404        return Ok((Vec::new(), false));
1405    }
1406    let mut counts = std::collections::HashMap::new();
1407    let mut count = 0;
1408    let mut limit_reached = false;
1409    for id in walk {
1410        let oid = id?;
1411        if let Ok(commit) = repo.find_commit(oid) {
1412            let author = commit.author();
1413            let name = author.name().unwrap_or("?").to_string();
1414            let email = author.email().unwrap_or("?").to_string();
1415            let key = (name, email);
1416            *counts.entry(key).or_insert(0) += 1;
1417            count += 1;
1418            if count >= limit {
1419                limit_reached = true;
1420                break;
1421            }
1422        }
1423    }
1424
1425    let mut stats: Vec<CommitterStat> = counts
1426        .into_iter()
1427        .map(|((name, email), count)| CommitterStat { name, email, count })
1428        .collect();
1429
1430    stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1431
1432    Ok((stats, limit_reached))
1433}
1434
1435/// Diff `commit` against its first parent (or against an empty tree for the
1436/// initial commit) and return the list of changed files. Capped at
1437/// `MAX_FILES_PER_SECTION` entries.
1438fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1439    let commit_tree = match commit.tree() {
1440        Ok(t) => t,
1441        Err(_) => return Vec::new(),
1442    };
1443    // For the initial commit parent_tree is None — libgit2 treats that as an
1444    // empty tree, so all files appear as "added".
1445    let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1446
1447    let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1448        Ok(d) => d,
1449        Err(_) => return Vec::new(),
1450    };
1451
1452    let mut files = Vec::new();
1453    for delta in diff.deltas() {
1454        if files.len() >= MAX_FILES_PER_SECTION {
1455            break;
1456        }
1457        let path = delta
1458            .new_file()
1459            .path()
1460            .or_else(|| delta.old_file().path())
1461            .map(|p| p.to_string_lossy().into_owned())
1462            .unwrap_or_else(|| "(unknown)".to_string());
1463
1464        let label: &'static str = match delta.status() {
1465            git2::Delta::Added => "N",
1466            git2::Delta::Deleted => "D",
1467            git2::Delta::Modified => "M",
1468            git2::Delta::Renamed => "R",
1469            git2::Delta::Typechange => "T",
1470            _ => "M",
1471        };
1472        files.push(FileEntry { path, label });
1473    }
1474    files
1475}
1476
1477pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1478    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1479    let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1480    let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1481    Ok(commit_changed_files(&repo, &commit))
1482}
1483fn match_pattern(path: &str, pattern: &str) -> bool {
1484    if let Some(suffix) = pattern.strip_prefix('*') {
1485        return path.ends_with(suffix);
1486    }
1487    if let Some(prefix) = pattern.strip_suffix('*') {
1488        return path.starts_with(prefix);
1489    }
1490    if pattern.contains('*') {
1491        let parts: Vec<&str> = pattern.split('*').collect();
1492        if parts.len() == 2 {
1493            return path.starts_with(parts[0]) && path.ends_with(parts[1]);
1494        }
1495    }
1496    path == pattern || path.ends_with(&format!("/{}", pattern))
1497}
1498
1499static LFS_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1500
1501fn is_lfs_installed() -> bool {
1502    *LFS_INSTALLED.get_or_init(|| {
1503        if let Ok(output) = git_command().arg("lfs").arg("--version").output() {
1504            output.status.success()
1505        } else {
1506            false
1507        }
1508    })
1509}
1510
1511fn get_lfs_info(repo_path: &Path) -> (bool, std::collections::HashSet<String>) {
1512    let mut tracked = std::collections::HashSet::new();
1513
1514    // Skip LFS subprocesses entirely if the repo is not using LFS
1515    let lfs_dir = repo_path.join(".git").join("lfs");
1516    let gitattributes_path = repo_path.join(".gitattributes");
1517    let has_lfs_attributes = if gitattributes_path.exists() {
1518        if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1519            content.contains("filter=lfs")
1520                || content.contains("diff=lfs")
1521                || content.contains("merge=lfs")
1522        } else {
1523            false
1524        }
1525    } else {
1526        false
1527    };
1528
1529    if !lfs_dir.exists() && !has_lfs_attributes {
1530        return (false, tracked);
1531    }
1532
1533    let installed = is_lfs_installed();
1534    if installed {
1535        if let Ok(output) =
1536            git_command().arg("lfs").arg("ls-files").arg("-n").current_dir(repo_path).output()
1537        {
1538            if output.status.success() {
1539                if let Ok(stdout) = String::from_utf8(output.stdout) {
1540                    for line in stdout.lines() {
1541                        let trimmed = line.trim();
1542                        if !trimmed.is_empty() {
1543                            tracked.insert(trimmed.to_string());
1544                        }
1545                    }
1546                }
1547            }
1548        }
1549    }
1550
1551    let gitattributes_path = repo_path.join(".gitattributes");
1552    if gitattributes_path.exists() {
1553        if let Ok(content) = std::fs::read_to_string(&gitattributes_path) {
1554            let mut patterns = Vec::new();
1555            for line in content.lines() {
1556                let line = line.trim();
1557                if line.is_empty() || line.starts_with('#') {
1558                    continue;
1559                }
1560                if line.contains("filter=lfs")
1561                    || line.contains("diff=lfs")
1562                    || line.contains("merge=lfs")
1563                {
1564                    if let Some(pattern) = line.split_whitespace().next() {
1565                        patterns.push(pattern.to_string());
1566                    }
1567                }
1568            }
1569
1570            if !patterns.is_empty() {
1571                if let Ok(repo) = Repository::open(repo_path) {
1572                    if let Ok(index) = repo.index() {
1573                        for entry in index.iter() {
1574                            if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1575                                for pat in &patterns {
1576                                    if match_pattern(path_str, pat) {
1577                                        tracked.insert(path_str.to_string());
1578                                        break;
1579                                    }
1580                                }
1581                            }
1582                        }
1583                    }
1584                }
1585            }
1586        }
1587    }
1588
1589    (installed, tracked)
1590}
1591
1592fn get_lfs_storage_size(repo_path: &Path) -> Option<u64> {
1593    let lfs_path = repo_path.join(".git").join("lfs");
1594    if !lfs_path.exists() {
1595        return None;
1596    }
1597
1598    fn calculate_dir_size(dir: &Path) -> u64 {
1599        let mut size = 0;
1600        if let Ok(entries) = std::fs::read_dir(dir) {
1601            for entry in entries.flatten() {
1602                if let Ok(metadata) = entry.metadata() {
1603                    if metadata.is_dir() {
1604                        size += calculate_dir_size(&entry.path());
1605                    } else {
1606                        size += metadata.len();
1607                    }
1608                }
1609            }
1610        }
1611        size
1612    }
1613
1614    Some(calculate_dir_size(&lfs_path))
1615}
1616
1617// ── Internal collection ────────────────────────────────────────────────────
1618
1619fn collect_info(
1620    path: &Path,
1621    commit_limit: usize,
1622    _graph_max_commits: usize,
1623    enable_commit_signatures: bool,
1624) -> Result<RepoInfo, git2::Error> {
1625    let repo = Repository::open(path)?;
1626    let mut summary = RepoSummary::default();
1627    if let Ok(head) = repo.head() {
1628        summary.branch = head.shorthand().ok().map(String::from);
1629    }
1630    populate_ahead_behind(&repo, &mut summary);
1631
1632    let mut info = RepoInfo { summary, ..RepoInfo::default() };
1633    let (lfs_inst, lfs_f) = get_lfs_info(path);
1634    info.lfs_installed = lfs_inst;
1635    info.lfs_files = lfs_f;
1636    info.lfs_storage_size = get_lfs_storage_size(path);
1637
1638    let is_detached = repo.head_detached().unwrap_or(false);
1639    if is_detached {
1640        info.branch = Some("HEAD".to_string());
1641        if let Ok(head) = repo.head() {
1642            if let Ok(commit) = head.peel_to_commit() {
1643                let short_id = format!("{:.7}", commit.id());
1644                let summary_text =
1645                    sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1646                let author = commit.author();
1647                let author_str = sanitize_text(&format!(
1648                    "{} <{}>",
1649                    author.name().unwrap_or("?"),
1650                    author.email().unwrap_or("?")
1651                ));
1652                let when = format_relative_time(commit.time().seconds());
1653                info.head =
1654                    Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1655            }
1656        }
1657    } else if let Ok(head) = repo.head() {
1658        info.branch = head.shorthand().ok().map(String::from);
1659
1660        if let Ok(commit) = head.peel_to_commit() {
1661            let short_id = format!("{:.7}", commit.id());
1662            let summary_text =
1663                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1664            let author = commit.author();
1665            let author_str = sanitize_text(&format!(
1666                "{} <{}>",
1667                author.name().unwrap_or("?"),
1668                author.email().unwrap_or("?")
1669            ));
1670            let when = format_relative_time(commit.time().seconds());
1671            info.head =
1672                Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1673        }
1674
1675        if let Ok(head_name) = head.name() {
1676            info.upstream = upstream_short_name(&repo, head_name);
1677        }
1678    }
1679
1680    if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1681        info.commits = commits;
1682    }
1683
1684    populate_summary_and_file_changes(&repo, &mut info);
1685
1686    if let Ok(remotes) = load_tab_remotes(path) {
1687        info.remotes = TabData::Loaded(remotes);
1688        info.tab_loaded_at[5] = Some(std::time::Instant::now());
1689    }
1690
1691    Ok(info)
1692}
1693
1694pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1695    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1696    let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1697    let mut entries = Vec::new();
1698    for (i, entry) in reflog.iter().enumerate() {
1699        let target_oid = format!("{}", entry.id_new());
1700        let selector = format!("HEAD@{{{}}}", i);
1701        let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1702
1703        let (command, message) = if let Some(pos) = msg.find(':') {
1704            (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1705        } else {
1706            (String::new(), msg)
1707        };
1708
1709        let sig = entry.committer();
1710        let when_secs = sig.when().seconds();
1711        let when = format_relative_time(when_secs);
1712        let date = format_utc_date(when_secs);
1713
1714        entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1715    }
1716    Ok(entries)
1717}
1718
1719pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1720    let output = git_command()
1721        .arg("checkout")
1722        .arg(commit_oid)
1723        .current_dir(repo_path)
1724        .output()
1725        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1726
1727    if !output.status.success() {
1728        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1729        return Err(git2::Error::from_str(&err));
1730    }
1731    Ok(())
1732}
1733
1734pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1735    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1736    let mut files = Vec::new();
1737    if let Ok(index) = repo.index() {
1738        for entry in index.iter() {
1739            if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1740                files.push(path_str.to_string());
1741            }
1742        }
1743    }
1744    Ok(files)
1745}
1746
1747/// Everything the Graph tab needs from one `git log --graph` run.
1748#[derive(Debug, Clone, Default)]
1749pub struct GraphLoad {
1750    pub lines: Vec<GraphLine>,
1751    /// One-line, deduplicated summary of anything git wrote to stderr while
1752    /// still producing a graph (typically a missing signature program). The
1753    /// UI shows it in the status bar; it never replaces the graph itself.
1754    pub notice: Option<String>,
1755}
1756
1757pub fn load_tab_graph_stream(
1758    repo_path: &Path,
1759    graph_max_commits: usize,
1760    enable_commit_signatures: bool,
1761    repo_resolved_path: String,
1762    tab_idx: usize,
1763    tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1764) -> Result<GraphLoad, String> {
1765    let mut graph_lines = Vec::new();
1766    // `%G?` makes git exec gpg/ssh-keygen for every signed commit, so it is only
1767    // requested when the user opted into signature badges.
1768    let format_str = if enable_commit_signatures {
1769        "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?"
1770    } else {
1771        "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad"
1772    };
1773
1774    let mut args = vec![
1775        "log".to_string(),
1776        "--graph".to_string(),
1777        "--all".to_string(),
1778        "--date=relative".to_string(),
1779    ];
1780    if graph_max_commits > 0 {
1781        args.push(format!("--max-count={}", graph_max_commits));
1782    }
1783    args.push(format!("--pretty=format:{}", format_str));
1784    args.push("--color=never".to_string());
1785
1786    let mut child =
1787        git_command().args(&args).current_dir(repo_path).spawn().map_err(|e| e.to_string())?;
1788
1789    // stderr must be drained alongside stdout: left unread it would fill and
1790    // stall git; left inherited (the old bug) it would print over the TUI.
1791    let stderr_reader = spawn_pipe_reader(&mut child);
1792    let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1793    let reader = std::io::BufReader::new(stdout);
1794    use std::io::BufRead;
1795
1796    for (idx, line_res) in reader.lines().enumerate() {
1797        let line = line_res.map_err(|e| e.to_string())?;
1798        let parsed = parse_graph_line(&line);
1799        graph_lines.push(parsed);
1800
1801        // Every 200 lines, send a cloned batch to UI
1802        if (idx + 1) % 200 == 0 {
1803            let _ = tx.send((
1804                repo_resolved_path.clone(),
1805                tab_idx,
1806                TabPayload::Graph(Ok(graph_lines.clone())),
1807            ));
1808        }
1809    }
1810
1811    // Wait for the child process to exit
1812    let status = child.wait().map_err(|e| e.to_string())?;
1813    let stderr = stderr_reader.join().unwrap_or_default();
1814    let notice = summarize_stderr(&stderr);
1815    if !status.success() && graph_lines.is_empty() {
1816        return Err(notice.unwrap_or_else(|| "git log failed".to_string()));
1817    }
1818
1819    Ok(GraphLoad { lines: graph_lines, notice })
1820}
1821
1822pub fn load_tab_branches(
1823    repo_path: &Path,
1824) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1825    let repo = match Repository::open(repo_path) {
1826        Ok(r) => r,
1827        Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1828    };
1829
1830    let mut local_branches = Vec::new();
1831    let is_detached = repo.head_detached().unwrap_or(false);
1832    if is_detached {
1833        let mut short_sha = String::new();
1834        let mut short_message = String::new();
1835        if let Ok(head) = repo.head() {
1836            if let Ok(target) = head.peel_to_commit() {
1837                let id = target.id();
1838                let id_str = id.to_string();
1839                short_sha = safe_sha_slice(&id_str, 7).to_string();
1840                if let Ok(Some(summary)) = target.summary() {
1841                    short_message = summary.to_string();
1842                }
1843            }
1844        }
1845        local_branches.push(BranchInfo {
1846            name: "HEAD".to_string(),
1847            is_head: true,
1848            short_sha,
1849            short_message: sanitize_text(&short_message),
1850        });
1851    }
1852    if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1853        for (branch, _) in branches.flatten() {
1854            if let Ok(Some(name)) = branch.name() {
1855                let is_head = branch.is_head();
1856                let mut short_sha = String::new();
1857                let mut short_message = String::new();
1858                if let Ok(target) = branch.get().peel_to_commit() {
1859                    let id = target.id();
1860                    let id_str = id.to_string();
1861                    short_sha = safe_sha_slice(&id_str, 7).to_string();
1862                    if let Ok(Some(summary)) = target.summary() {
1863                        short_message = summary.to_string();
1864                    }
1865                }
1866                local_branches.push(BranchInfo {
1867                    name: sanitize_text(name),
1868                    is_head,
1869                    short_sha,
1870                    short_message: sanitize_text(&short_message),
1871                });
1872            }
1873        }
1874    }
1875    local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1876
1877    let mut remote_branches = Vec::new();
1878    if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1879        for (branch, _) in branches.flatten() {
1880            if let Ok(Some(name)) = branch.name() {
1881                if !name.ends_with("/HEAD") {
1882                    let is_head = branch.is_head();
1883                    let mut short_sha = String::new();
1884                    let mut short_message = String::new();
1885                    if let Ok(target) = branch.get().peel_to_commit() {
1886                        let id = target.id();
1887                        let id_str = id.to_string();
1888                        short_sha = safe_sha_slice(&id_str, 7).to_string();
1889                        if let Ok(Some(summary)) = target.summary() {
1890                            short_message = summary.to_string();
1891                        }
1892                    }
1893                    remote_branches.push(BranchInfo {
1894                        name: sanitize_text(name),
1895                        is_head,
1896                        short_sha,
1897                        short_message: sanitize_text(&short_message),
1898                    });
1899                }
1900            }
1901        }
1902    }
1903    remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1904
1905    (Ok(local_branches), Ok(remote_branches))
1906}
1907
1908pub fn load_tab_tags(
1909    repo_path: &Path,
1910) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1911    let repo = match Repository::open(repo_path) {
1912        Ok(r) => r,
1913        Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1914    };
1915
1916    let mut local_tags = Vec::new();
1917    if let Ok(tags) = repo.tag_names(None) {
1918        for tag_opt in tags.iter() {
1919            if let Ok(Some(tag)) = tag_opt {
1920                let mut short_sha = String::new();
1921                let mut short_message = String::new();
1922                if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1923                    if let Ok(target) = reference.peel_to_commit() {
1924                        let id = target.id();
1925                        let id_str = id.to_string();
1926                        short_sha = safe_sha_slice(&id_str, 7).to_string();
1927                        if let Ok(Some(summary)) = target.summary() {
1928                            short_message = summary.to_string();
1929                        }
1930                    }
1931                }
1932                local_tags.push(BranchInfo {
1933                    name: sanitize_text(tag),
1934                    is_head: false,
1935                    short_sha,
1936                    short_message: sanitize_text(&short_message),
1937                });
1938            }
1939        }
1940    }
1941    local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1942
1943    (Ok(local_tags), Ok(Vec::new()))
1944}
1945
1946pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1947    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1948    let mut remotes_list = Vec::new();
1949    if let Ok(remotes) = repo.remotes() {
1950        for name in remotes.iter() {
1951            let Ok(Some(name)) = name else { continue };
1952            if let Ok(remote) = repo.find_remote(name) {
1953                let push_url = remote.pushurl().ok().flatten().map(String::from);
1954                let mut refspecs = Vec::new();
1955                for r in remote.refspecs() {
1956                    if let Ok(s) = r.str() {
1957                        refspecs.push(s.to_string());
1958                    }
1959                }
1960                remotes_list.push(RemoteInfo {
1961                    name: name.to_string(),
1962                    url: remote.url().unwrap_or("(no url)").to_string(),
1963                    push_url,
1964                    refspecs,
1965                });
1966            }
1967        }
1968    }
1969    Ok(remotes_list)
1970}
1971
1972pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1973    let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1974    let mut temp_stashes = Vec::new();
1975    let _ = repo.stash_foreach(|index, message, oid| {
1976        temp_stashes.push((index, message.to_string(), *oid));
1977        true
1978    });
1979
1980    let mut stashes = Vec::new();
1981    for (index, message, oid) in temp_stashes {
1982        let mut files = Vec::new();
1983        if let Ok(commit) = repo.find_commit(oid) {
1984            files = commit_changed_files(&repo, &commit);
1985        }
1986        stashes.push(StashInfo {
1987            index,
1988            message: sanitize_text(&message),
1989            commit_id: oid.to_string(),
1990            files,
1991        });
1992    }
1993    Ok(stashes)
1994}
1995
1996pub fn load_tab_overview(
1997    repo_path: &Path,
1998    _commit_limit: usize,
1999) -> Result<(Vec<CommitterStat>, bool), String> {
2000    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2001    let (stats, limit_reached) =
2002        collect_committer_stats(&repo, usize::MAX).map_err(|e| e.to_string())?;
2003    Ok((stats, limit_reached))
2004}
2005
2006pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
2007    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2008    let submodules = repo.submodules().map_err(|e| e.to_string())?;
2009    let mut list = Vec::new();
2010    for sub in submodules {
2011        let name = sub.name().unwrap_or("").to_string();
2012        let path = sub.path().to_path_buf();
2013        let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
2014        let commit_id = sub.index_id().map(|id| id.to_string());
2015        let head_id = sub.head_id().map(|id| id.to_string());
2016
2017        let mut is_initialized = true;
2018        let mut is_dirty = false;
2019
2020        if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
2021            is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
2022            is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
2023                || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
2024                || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
2025                || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
2026                || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
2027        }
2028
2029        list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
2030    }
2031    Ok(list)
2032}
2033
2034/// Build a map from commit `Oid` → list of ref names that point to it.
2035/// Local branches are stored as plain names (e.g. `"main"`).
2036/// Lightweight and annotated tags are stored with a `"tag:"` prefix
2037/// (e.g. `"tag:v1.0"`) so the UI can colour them differently.
2038fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2039    let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
2040        std::collections::HashMap::new();
2041
2042    if let Ok(refs) = repo.references() {
2043        for reference in refs.flatten() {
2044            // Resolve to the underlying commit Oid (peeling through tags).
2045            let Ok(target) = reference.peel_to_commit() else {
2046                continue;
2047            };
2048            let oid = target.id();
2049
2050            let Ok(full_name) = reference.name() else {
2051                continue;
2052            };
2053
2054            let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
2055                branch.to_string()
2056            } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
2057                format!("tag:{}", tag)
2058            } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
2059                // Skip the symbolic HEAD pointer each remote keeps (e.g. origin/HEAD).
2060                if remote.ends_with("/HEAD") {
2061                    continue;
2062                }
2063                format!("remote:{}", remote)
2064            } else {
2065                continue;
2066            };
2067
2068            map.entry(oid).or_default().push(label);
2069        }
2070    }
2071
2072    if repo.head_detached().unwrap_or(false) {
2073        if let Ok(head) = repo.head() {
2074            if let Ok(target) = head.peel_to_commit() {
2075                let oid = target.id();
2076                let label = "head:HEAD".to_string();
2077                let mut entries = map.remove(&oid).unwrap_or_default();
2078                entries.insert(0, label);
2079                map.insert(oid, entries);
2080            }
2081        }
2082    }
2083
2084    map
2085}
2086
2087#[allow(clippy::type_complexity)]
2088static REF_MAP_CACHE: std::sync::OnceLock<
2089    std::sync::Mutex<
2090        std::collections::HashMap<
2091            String,
2092            (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
2093        >,
2094    >,
2095> = std::sync::OnceLock::new();
2096
2097fn get_cached_ref_map(
2098    repo: &Repository,
2099    repo_path: &Path,
2100) -> std::collections::HashMap<git2::Oid, Vec<String>> {
2101    let cache_lock =
2102        REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
2103    let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
2104    let path_key = repo_path.to_string_lossy().to_string();
2105
2106    if let Some((map, loaded_at)) = cache.get(&path_key) {
2107        if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
2108            return map.clone();
2109        }
2110    }
2111
2112    let map = build_ref_map(repo);
2113    cache.insert(path_key, (map.clone(), std::time::Instant::now()));
2114    map
2115}
2116
2117pub fn invalidate_ref_map_cache(repo_path: &Path) {
2118    if let Some(cache_lock) = REF_MAP_CACHE.get() {
2119        if let Ok(mut cache) = cache_lock.lock() {
2120            cache.remove(&repo_path.to_string_lossy().to_string());
2121        }
2122    }
2123}
2124
2125/// Maximum file entries collected per bucket. Prevents pathologically large
2126/// working trees from overwhelming the detail view.
2127const MAX_FILES_PER_SECTION: usize = 100;
2128
2129/// Walk the working-tree status once and collect both summary counts and per-file info.
2130fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
2131    let mut opts = StatusOptions::new();
2132    opts.include_untracked(true)
2133        .renames_head_to_index(true)
2134        .recurse_untracked_dirs(true)
2135        .show(StatusShow::IndexAndWorkdir);
2136    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2137        return;
2138    };
2139    for entry in statuses.iter() {
2140        let path = entry.path().unwrap_or("(unknown)").to_string();
2141        let flags = entry.status();
2142
2143        // 1. Populate summary counters
2144        if flags.is_conflicted() {
2145            info.summary.conflicted += 1;
2146        } else {
2147            if flags.is_wt_new() {
2148                info.summary.untracked += 1;
2149            }
2150            if flags.is_wt_modified()
2151                || flags.is_wt_deleted()
2152                || flags.is_wt_renamed()
2153                || flags.is_wt_typechange()
2154            {
2155                info.summary.modified += 1;
2156            }
2157            if flags.is_index_new()
2158                || flags.is_index_modified()
2159                || flags.is_index_deleted()
2160                || flags.is_index_renamed()
2161                || flags.is_index_typechange()
2162            {
2163                info.summary.staged += 1;
2164            }
2165        }
2166
2167        // 2. Populate file entries
2168        // Skip directories to avoid showing folders in staging panels.
2169        // We only call is_dir() for untracked files (since tracked items in git status are always files).
2170        let is_dir = if flags.is_wt_new() && !flags.is_index_new() {
2171            let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
2172            path_buf.is_dir()
2173        } else {
2174            false
2175        };
2176        if is_dir {
2177            continue;
2178        }
2179
2180        if flags.is_conflicted() {
2181            if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
2182                info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
2183            }
2184            continue;
2185        }
2186
2187        // Index (staged) changes
2188        if (flags.is_index_new()
2189            || flags.is_index_modified()
2190            || flags.is_index_deleted()
2191            || flags.is_index_renamed()
2192            || flags.is_index_typechange())
2193            && info.changes.staged.len() < MAX_FILES_PER_SECTION
2194        {
2195            let label = if flags.is_index_new() {
2196                "N"
2197            } else if flags.is_index_deleted() {
2198                "D"
2199            } else if flags.is_index_renamed() {
2200                "R"
2201            } else if flags.is_index_typechange() {
2202                "T"
2203            } else {
2204                "M"
2205            };
2206            info.changes.staged.push(FileEntry { path: path.clone(), label });
2207        }
2208
2209        // Working-tree changes
2210        if flags.is_wt_new() {
2211            if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
2212                info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
2213            }
2214            if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
2215                info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
2216            }
2217        } else if (flags.is_wt_modified()
2218            || flags.is_wt_deleted()
2219            || flags.is_wt_renamed()
2220            || flags.is_wt_typechange())
2221            && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
2222        {
2223            let label = if flags.is_wt_deleted() {
2224                "D"
2225            } else if flags.is_wt_renamed() {
2226                "R"
2227            } else if flags.is_wt_typechange() {
2228                "T"
2229            } else {
2230                "M"
2231            };
2232            info.changes.unstaged.push(FileEntry { path: path.clone(), label });
2233        }
2234    }
2235}
2236
2237/// Collect the branch name, worktree counts, and ahead/behind for an opened
2238/// repo. Used by both `inspect_summary` (card) and `collect_info` (detail)
2239/// so the values shown in both places always agree.
2240fn collect_summary(repo: &Repository) -> RepoSummary {
2241    let mut s = RepoSummary::default();
2242    let is_detached = repo.head_detached().unwrap_or(false);
2243    if is_detached {
2244        s.branch = Some("HEAD".to_string());
2245        if let Ok(head) = repo.head() {
2246            if let Ok(commit) = head.peel_to_commit() {
2247                s.last_commit_time = Some(commit.time().seconds());
2248            }
2249        }
2250    } else if let Ok(head) = repo.head() {
2251        s.branch = head.shorthand().ok().map(String::from);
2252        if let Ok(commit) = head.peel_to_commit() {
2253            s.last_commit_time = Some(commit.time().seconds());
2254        }
2255    }
2256    populate_worktree(repo, &mut s);
2257    populate_ahead_behind(repo, &mut s);
2258    s.state = match repo.state() {
2259        git2::RepositoryState::Clean => RepoState::Clean,
2260        git2::RepositoryState::Merge => RepoState::Merge,
2261        git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
2262        git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
2263            RepoState::CherryPick
2264        }
2265        git2::RepositoryState::Bisect => RepoState::Bisect,
2266        git2::RepositoryState::Rebase
2267        | git2::RepositoryState::RebaseInteractive
2268        | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
2269        git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
2270            RepoState::ApplyMailbox
2271        }
2272    };
2273    s
2274}
2275
2276fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
2277    let mut opts = StatusOptions::new();
2278    opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
2279    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
2280        return;
2281    };
2282    for entry in statuses.iter() {
2283        let flags = entry.status();
2284        if flags.is_conflicted() {
2285            s.conflicted += 1;
2286            continue;
2287        }
2288        if flags.is_wt_new() {
2289            s.untracked += 1;
2290        }
2291        if flags.is_wt_modified()
2292            || flags.is_wt_deleted()
2293            || flags.is_wt_renamed()
2294            || flags.is_wt_typechange()
2295        {
2296            s.modified += 1;
2297        }
2298        if flags.is_index_new()
2299            || flags.is_index_modified()
2300            || flags.is_index_deleted()
2301            || flags.is_index_renamed()
2302            || flags.is_index_typechange()
2303        {
2304            s.staged += 1;
2305        }
2306    }
2307}
2308
2309/// Compute commits ahead/behind the upstream branch. Silently leaves
2310/// both at 0 if HEAD is detached, the branch has no upstream configured,
2311/// or any libgit2 lookup fails — the card simply shows no ↑/↓ then.
2312fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
2313    let Ok(head) = repo.head() else { return };
2314    let Some(local_oid) = head.target() else {
2315        return;
2316    };
2317    let Ok(head_name) = head.name() else { return };
2318    let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
2319        return;
2320    };
2321    let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
2322        return;
2323    };
2324    let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
2325        return;
2326    };
2327    let Some(upstream_oid) = upstream_ref.target() else {
2328        return;
2329    };
2330    if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
2331        s.ahead = ahead;
2332        s.behind = behind;
2333    }
2334}
2335
2336/// `"origin/main"`-style short name for HEAD's upstream, or `None`.
2337fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
2338    let buf = repo.branch_upstream_name(head_name).ok()?;
2339    let raw = std::str::from_utf8(&buf).ok()?;
2340    Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
2341}
2342
2343/// Format a unix-epoch timestamp as a relative time string ("3 days ago").
2344pub fn format_relative_time(secs: i64) -> String {
2345    if secs <= 0 {
2346        return "unknown".to_string();
2347    }
2348    let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
2349    let now = SystemTime::now();
2350    let Ok(elapsed) = now.duration_since(then) else {
2351        return "in the future".to_string();
2352    };
2353    let secs = elapsed.as_secs();
2354    let (n, unit) = if secs < 60 {
2355        (secs, "second")
2356    } else if secs < 3600 {
2357        (secs / 60, "minute")
2358    } else if secs < 86_400 {
2359        (secs / 3600, "hour")
2360    } else if secs < 86_400 * 30 {
2361        (secs / 86_400, "day")
2362    } else if secs < 86_400 * 365 {
2363        (secs / (86_400 * 30), "month")
2364    } else {
2365        (secs / (86_400 * 365), "year")
2366    };
2367    let plural = if n == 1 { "" } else { "s" };
2368    format!("{} {}{} ago", n, unit, plural)
2369}
2370
2371/// Format a unix-epoch timestamp as a UTC date string ("YYYY-MM-DD HH:MM:SS UTC").
2372fn format_utc_date(secs: i64) -> String {
2373    if secs <= 0 {
2374        return "unknown".to_string();
2375    }
2376    let seconds_in_day = 86400;
2377    let day_number = secs / seconds_in_day;
2378    let time_of_day = secs % seconds_in_day;
2379
2380    let mut hour = time_of_day / 3600;
2381    let mut minute = (time_of_day % 3600) / 60;
2382    let mut second = time_of_day % 60;
2383    if hour < 0 {
2384        hour += 24;
2385    }
2386    if minute < 0 {
2387        minute += 60;
2388    }
2389    if second < 0 {
2390        second += 60;
2391    }
2392
2393    // Howard Hinnant's civil date from epoch days algorithm
2394    let z = day_number + 719468;
2395    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2396    let doe = (z - era * 146097) as u32;
2397    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2398    let y = (yoe as i32) + (era as i32) * 400;
2399    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2400    let mp = (5 * doy + 2) / 153;
2401    let d = doy - (153 * mp + 2) / 5 + 1;
2402    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2403    let y = y + if m <= 2 { 1 } else { 0 };
2404
2405    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2406}
2407
2408// ── Per-file diff (private) ────────────────────────────────────────────────
2409
2410fn get_file_diff_inner(
2411    repo_path: &Path,
2412    commit_oid: &str,
2413    file_path: &str,
2414) -> Option<Vec<DiffLine>> {
2415    let repo = Repository::open(repo_path).ok()?;
2416    let oid = git2::Oid::from_str(commit_oid).ok()?;
2417    let commit = repo.find_commit(oid).ok()?;
2418
2419    let commit_tree = commit.tree().ok()?;
2420    // For the initial commit, parent_tree is None; libgit2 treats it as empty.
2421    let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2422
2423    let mut opts = git2::DiffOptions::new();
2424    opts.pathspec(file_path);
2425
2426    let diff =
2427        repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2428
2429    collect_diff_lines(&diff)
2430}
2431
2432/// Diff a single file in the working tree.
2433///
2434/// `staged = true`:  HEAD-tree → index (what `git diff --cached` shows).
2435/// `staged = false`: index → working directory (what `git diff` shows).
2436fn get_worktree_diff_inner(
2437    repo_path: &Path,
2438    file_path: &str,
2439    staged: bool,
2440) -> Option<Vec<DiffLine>> {
2441    let repo = Repository::open(repo_path).ok()?;
2442    let mut opts = git2::DiffOptions::new();
2443    opts.pathspec(file_path);
2444    opts.include_untracked(true);
2445    opts.recurse_untracked_dirs(true);
2446    opts.show_untracked_content(true);
2447
2448    let diff = if staged {
2449        // Staged: diff HEAD tree (or empty tree for new repos) → index.
2450        let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2451        repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2452    } else {
2453        // Unstaged: diff index → working directory.
2454        repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2455    };
2456
2457    collect_diff_lines(&diff)
2458}
2459
2460/// Walk a libgit2 `Diff` and collect coloured `DiffLine` values.
2461fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2462    let mut lines: Vec<DiffLine> = Vec::new();
2463    let mut current_hunk_idx = None;
2464    let mut hunk_count = 0;
2465    diff.print(git2::DiffFormat::Patch, |_, _, line| {
2466        let kind = match line.origin() {
2467            '+' => DiffLineKind::Added,
2468            '-' => DiffLineKind::Removed,
2469            'H' => {
2470                current_hunk_idx = Some(hunk_count);
2471                hunk_count += 1;
2472                DiffLineKind::Header
2473            }
2474            ' ' => DiffLineKind::Context,
2475            _ => return true, // skip file-header meta lines
2476        };
2477        let content = String::from_utf8_lossy(line.content())
2478            .trim_end_matches('\n')
2479            .trim_end_matches('\r')
2480            .to_string();
2481        lines.push(DiffLine {
2482            kind,
2483            content,
2484            old_lineno: line.old_lineno(),
2485            new_lineno: line.new_lineno(),
2486            hunk_idx: current_hunk_idx,
2487        });
2488        true
2489    })
2490    .ok()?;
2491    Some(lines)
2492}
2493
2494fn parse_graph_line(line: &str) -> GraphLine {
2495    if line.contains("__TWIG_SEP__") {
2496        let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2497        if parts.len() >= 5 {
2498            let graph_and_hash = parts[0];
2499            let decoration = parts[1].trim().to_string();
2500            let summary = parts[2].trim().to_string();
2501            let author = parts[3].trim().to_string();
2502            let date = parts[4].trim().to_string();
2503            let signature_status =
2504                if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2505
2506            let char_count = graph_and_hash.chars().count();
2507            if char_count >= 40 {
2508                let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2509                let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2510                GraphLine {
2511                    graph,
2512                    commit: Some(GraphCommit {
2513                        oid,
2514                        decoration,
2515                        summary,
2516                        author,
2517                        date,
2518                        signature_status,
2519                    }),
2520                }
2521            } else {
2522                GraphLine { graph: graph_and_hash.to_string(), commit: None }
2523            }
2524        } else {
2525            GraphLine { graph: line.to_string(), commit: None }
2526        }
2527    } else {
2528        GraphLine { graph: line.to_string(), commit: None }
2529    }
2530}
2531
2532#[allow(dead_code)]
2533fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2534    let mut graph_lines = Vec::new();
2535    let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2536
2537    let mut args = vec![
2538        "log".to_string(),
2539        "--graph".to_string(),
2540        "--all".to_string(),
2541        "--date=relative".to_string(),
2542    ];
2543    if graph_max_commits > 0 {
2544        args.push(format!("--max-count={}", graph_max_commits));
2545    }
2546    args.push(format!("--pretty=format:{}", format_str));
2547    args.push("--color=never".to_string());
2548
2549    let output = git_command().args(&args).current_dir(repo_path).output();
2550
2551    if let Ok(out) = output {
2552        if out.status.success() {
2553            let stdout_str = String::from_utf8_lossy(&out.stdout);
2554            for line in stdout_str.lines() {
2555                graph_lines.push(parse_graph_line(line));
2556            }
2557        }
2558    }
2559    graph_lines
2560}
2561
2562pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2563    let output = git_command()
2564        .arg("checkout")
2565        .arg(branch_name)
2566        .current_dir(repo_path)
2567        .output()
2568        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2569
2570    if !output.status.success() {
2571        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2572        return Err(git2::Error::from_str(&err));
2573    }
2574    Ok(())
2575}
2576
2577pub fn checkout_remote_branch(
2578    repo_path: &Path,
2579    remote_branch_name: &str,
2580) -> Result<String, git2::Error> {
2581    let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2582    if parts.len() < 2 {
2583        return Err(git2::Error::from_str("Invalid remote branch name"));
2584    }
2585    let local_name = parts[1];
2586
2587    let output = git_command()
2588        .arg("checkout")
2589        .arg(local_name)
2590        .current_dir(repo_path)
2591        .output()
2592        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2593
2594    if output.status.success() {
2595        return Ok(format!("Switched to existing branch '{}'", local_name));
2596    }
2597
2598    let output = git_command()
2599        .arg("checkout")
2600        .arg("--track")
2601        .arg(remote_branch_name)
2602        .current_dir(repo_path)
2603        .output()
2604        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2605
2606    if !output.status.success() {
2607        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2608        return Err(git2::Error::from_str(&err));
2609    }
2610
2611    Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2612}
2613
2614/// Creates a new local branch pointing at HEAD.
2615pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2616    let repo = Repository::open(repo_path)?;
2617    let head = repo.head()?;
2618    let target_commit = head.peel_to_commit()?;
2619    repo.branch(branch_name, &target_commit, false)?;
2620    Ok(())
2621}
2622
2623/// Deletes a local branch.
2624pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2625    let repo = Repository::open(repo_path)?;
2626    let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2627    branch.delete()?;
2628    Ok(())
2629}
2630
2631/// Deletes a remote-tracking branch locally.
2632pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2633    let repo = Repository::open(repo_path)?;
2634    let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2635    branch.delete()?;
2636    Ok(())
2637}
2638
2639/// Creates or updates a tag pointing at the specified commit OID with optional message and force flag.
2640pub fn create_tag(
2641    repo_path: &Path,
2642    tag_name: &str,
2643    commit_oid_str: &str,
2644    message: Option<&str>,
2645    force: bool,
2646) -> Result<(), git2::Error> {
2647    let repo = Repository::open(repo_path)?;
2648    let oid = git2::Oid::from_str(commit_oid_str)?;
2649    let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2650    if let Some(msg) = message.filter(|m| !m.trim().is_empty()) {
2651        let tagger_sig = repo.signature();
2652        let default_sig;
2653        let tagger = match &tagger_sig {
2654            Ok(s) => s,
2655            Err(_) => {
2656                if let Ok(sig) = git2::Signature::now("Gitwig", "gitwig@local") {
2657                    default_sig = sig;
2658                    &default_sig
2659                } else {
2660                    return Err(git2::Error::from_str("Failed to create signature"));
2661                }
2662            }
2663        };
2664        repo.tag(tag_name, &target_object, tagger, msg, force)?;
2665    } else {
2666        repo.tag_lightweight(tag_name, &target_object, force)?;
2667    }
2668    Ok(())
2669}
2670
2671/// Checks if a local tag exists in the repository.
2672pub fn tag_exists(repo_path: &Path, tag_name: &str) -> bool {
2673    if let Ok(repo) = Repository::open(repo_path) {
2674        let ref_name = format!("refs/tags/{}", tag_name);
2675        repo.find_reference(&ref_name).is_ok()
2676    } else {
2677        false
2678    }
2679}
2680
2681/// Deletes a local tag.
2682pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2683    let repo = Repository::open(repo_path)?;
2684    repo.tag_delete(tag_name)?;
2685    Ok(())
2686}
2687
2688/// Deletes a tag on the remote.
2689pub fn delete_remote_tag(
2690    repo_path: &Path,
2691    remote_name: &str,
2692    tag_name: &str,
2693    timeout: Duration,
2694) -> Result<(), Box<dyn std::error::Error>> {
2695    let safe_remote = safe_ref(remote_name)?;
2696    let safe_tag = safe_ref(tag_name)?;
2697    let mut cmd = git_command();
2698    cmd.arg("push").arg(safe_remote).arg("--delete").arg(safe_tag).current_dir(repo_path);
2699    let output = run_git_with_timeout(cmd, timeout)?;
2700    if !output.status.success() {
2701        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2702        return Err(err.into());
2703    }
2704    Ok(())
2705}
2706
2707pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2708    let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2709    let output = git_command()
2710        .arg("checkout")
2711        .arg(safe_tag)
2712        .current_dir(repo_path)
2713        .output()
2714        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2715
2716    if !output.status.success() {
2717        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2718        return Err(git2::Error::from_str(&err));
2719    }
2720    Ok(())
2721}
2722
2723/// Helper to run `git ls-remote --tags` and return parsed tag information.
2724pub fn get_remote_tags(
2725    repo_path: &Path,
2726    remote_name: &str,
2727    timeout: Duration,
2728) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2729    let safe_remote = safe_ref(remote_name)?;
2730    let mut cmd = git_command();
2731    cmd.arg("ls-remote").arg("--tags").arg(safe_remote).current_dir(repo_path);
2732    let output = run_git_with_timeout(cmd, timeout)?;
2733
2734    if !output.status.success() {
2735        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2736        return Err(err.into());
2737    }
2738
2739    let stdout = String::from_utf8_lossy(&output.stdout);
2740    let repo = git2::Repository::open(repo_path)?;
2741    let mut tags_map = std::collections::HashMap::new();
2742
2743    for line in stdout.lines() {
2744        let parts: Vec<&str> = line.split_whitespace().collect();
2745        if parts.len() >= 2 {
2746            let sha = parts[0];
2747            let ref_name = parts[1];
2748            if ref_name.starts_with("refs/tags/") {
2749                let is_peeled = ref_name.ends_with("^{}");
2750                let clean_ref = if is_peeled {
2751                    safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2752                } else {
2753                    ref_name
2754                };
2755                let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2756                let short_sha = safe_sha_slice(sha, 7);
2757
2758                // Try to resolve the summary locally
2759                let mut short_message = String::new();
2760                if let Ok(oid) = git2::Oid::from_str(sha) {
2761                    if let Ok(commit) = repo.find_commit(oid) {
2762                        if let Ok(Some(summary)) = commit.summary() {
2763                            short_message = summary.to_string();
2764                        }
2765                    }
2766                }
2767                if short_message.is_empty() {
2768                    short_message = "(not fetched)".to_string();
2769                }
2770
2771                let sanitized_tag = sanitize_text(tag_name);
2772                let sanitized_msg = sanitize_text(&short_message);
2773
2774                if is_peeled {
2775                    tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2776                } else {
2777                    tags_map
2778                        .entry(sanitized_tag)
2779                        .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2780                }
2781            }
2782        }
2783    }
2784
2785    let mut tags = Vec::new();
2786    for (name, (short_sha, short_message)) in tags_map {
2787        tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2788    }
2789    tags.sort_by(|a, b| b.name.cmp(&a.name));
2790    Ok(tags)
2791}
2792
2793pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2794    let mut s = String::new();
2795    for tag in tags {
2796        s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2797    }
2798    s
2799}
2800
2801pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2802    let mut tags = Vec::new();
2803    for line in s.lines() {
2804        let parts: Vec<&str> = line.split('|').collect();
2805        if parts.len() >= 3 {
2806            tags.push(BranchInfo {
2807                name: parts[0].to_string(),
2808                is_head: false,
2809                short_sha: parts[1].to_string(),
2810                short_message: parts[2].to_string(),
2811            });
2812        }
2813    }
2814    tags
2815}
2816
2817pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2818    let mut repo = Repository::open(repo_path)?;
2819    repo.stash_drop(index)?;
2820    Ok(())
2821}
2822
2823pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2824    let stash_ref = format!("stash@{{{}}}", index);
2825    let output = git_command()
2826        .arg("stash")
2827        .arg("apply")
2828        .arg(&stash_ref)
2829        .current_dir(repo_path)
2830        .output()
2831        .map_err(|e| e.to_string())?;
2832
2833    if !output.status.success() {
2834        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2835        return Err(err_msg);
2836    }
2837    Ok(())
2838}
2839
2840pub fn save_stash(
2841    repo_path: &Path,
2842    message: &str,
2843    include_untracked: bool,
2844    keep_index: bool,
2845) -> Result<(), String> {
2846    let mut cmd = git_command();
2847    cmd.arg("stash").arg("push");
2848
2849    if include_untracked {
2850        cmd.arg("--include-untracked");
2851    }
2852    if keep_index {
2853        cmd.arg("--keep-index");
2854    }
2855
2856    if !message.is_empty() {
2857        cmd.arg("-m").arg(message);
2858    }
2859
2860    let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2861
2862    if !output.status.success() {
2863        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2864        return Err(err_msg);
2865    }
2866    Ok(())
2867}
2868
2869pub fn get_latest_change_time(item: &str) -> u64 {
2870    let path = expand_tilde(item);
2871    if !path.exists() {
2872        return 0;
2873    }
2874
2875    if path.join(".git").exists() {
2876        if let Ok(repo) = Repository::open(&path) {
2877            if let Ok(head) = repo.head() {
2878                if let Ok(commit) = head.peel_to_commit() {
2879                    return commit.time().seconds() as u64;
2880                }
2881            }
2882        }
2883    }
2884
2885    if let Ok(meta) = std::fs::metadata(&path) {
2886        if let Ok(modified) = meta.modified() {
2887            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2888                return duration.as_secs();
2889            }
2890        }
2891    }
2892    0
2893}
2894
2895pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2896    if let Ok(repo) = Repository::open(repo_path) {
2897        if let Ok(head) = repo.head() {
2898            if let Ok(commit) = head.peel_to_commit() {
2899                if let Ok(msg) = commit.message() {
2900                    return Some(msg.to_string());
2901                }
2902            }
2903        }
2904    }
2905    None
2906}
2907
2908pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2909    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2910    let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2911    let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2912
2913    let mut index = repo.index().map_err(|e| e.to_string())?;
2914    let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2915    let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2916
2917    let signature = repo
2918        .signature()
2919        .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2920
2921    head_commit
2922        .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2923        .map_err(|e| e.to_string())?;
2924
2925    Ok(())
2926}
2927
2928// ── Merge Conflict Helpers ──────────────────────────────────────────────────
2929
2930/// Returns `true` when `.git/MERGE_HEAD` exists — i.e. a merge is in progress.
2931/// Cheap file-existence check, no libgit2 required.
2932pub fn is_merging(repo_path: &Path) -> bool {
2933    repo_path.join(".git/MERGE_HEAD").exists()
2934}
2935
2936/// Returns the conflict-marker diff for a conflicted file by parsing the file on disk.
2937/// Colorizes conflict blocks using DiffLineKind variants.
2938pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2939    let full_path = repo_path.join(file_path);
2940    let content = match std::fs::read_to_string(&full_path) {
2941        Ok(s) => s,
2942        Err(_) => return Vec::new(),
2943    };
2944
2945    let mut lines = Vec::new();
2946    let mut in_ours = false;
2947    let mut in_theirs = false;
2948
2949    for line in content.lines() {
2950        if line.starts_with("<<<<<<<") {
2951            in_ours = true;
2952            in_theirs = false;
2953            lines.push(DiffLine {
2954                kind: DiffLineKind::ConflictSeparator,
2955                content: line.to_string(),
2956                old_lineno: None,
2957                new_lineno: None,
2958                hunk_idx: None,
2959            });
2960        } else if line.starts_with("=======") {
2961            in_ours = false;
2962            in_theirs = true;
2963            lines.push(DiffLine {
2964                kind: DiffLineKind::ConflictSeparator,
2965                content: line.to_string(),
2966                old_lineno: None,
2967                new_lineno: None,
2968                hunk_idx: None,
2969            });
2970        } else if line.starts_with(">>>>>>>") {
2971            in_ours = false;
2972            in_theirs = false;
2973            lines.push(DiffLine {
2974                kind: DiffLineKind::ConflictSeparator,
2975                content: line.to_string(),
2976                old_lineno: None,
2977                new_lineno: None,
2978                hunk_idx: None,
2979            });
2980        } else if in_ours {
2981            lines.push(DiffLine {
2982                kind: DiffLineKind::ConflictOurs,
2983                content: line.to_string(),
2984                old_lineno: None,
2985                new_lineno: None,
2986                hunk_idx: None,
2987            });
2988        } else if in_theirs {
2989            lines.push(DiffLine {
2990                kind: DiffLineKind::ConflictTheirs,
2991                content: line.to_string(),
2992                old_lineno: None,
2993                new_lineno: None,
2994                hunk_idx: None,
2995            });
2996        } else {
2997            lines.push(DiffLine {
2998                kind: DiffLineKind::Context,
2999                content: line.to_string(),
3000                old_lineno: None,
3001                new_lineno: None,
3002                hunk_idx: None,
3003            });
3004        }
3005    }
3006    lines
3007}
3008
3009/// Accept the OURS (HEAD) version of a conflicted file.
3010/// Equivalent to: git checkout --ours <file> && git add <file>
3011pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
3012    let output1 = git_command()
3013        .args(["checkout", "--ours", file_path])
3014        .current_dir(repo_path)
3015        .output()
3016        .map_err(|e| e.to_string())?;
3017    if !output1.status.success() {
3018        return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3019    }
3020    stage_file(repo_path, file_path)?;
3021    Ok(())
3022}
3023
3024/// Accept the THEIRS (incoming) version of a conflicted file.
3025/// Equivalent to: git checkout --theirs <file> && git add <file>
3026pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
3027    let output1 = git_command()
3028        .args(["checkout", "--theirs", file_path])
3029        .current_dir(repo_path)
3030        .output()
3031        .map_err(|e| e.to_string())?;
3032    if !output1.status.success() {
3033        return Err(String::from_utf8_lossy(&output1.stderr).to_string());
3034    }
3035    stage_file(repo_path, file_path)?;
3036    Ok(())
3037}
3038
3039/// Mark the file as resolved (stage it) after manual edits.
3040pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
3041    stage_file(repo_path, file_path)
3042}
3043
3044/// Resolve a specific conflict hunk inside a file (Ours vs Theirs).
3045/// Replaces the hunk at index `hunk_idx` in the file on disk.
3046/// If no more conflicts remain in the file, it automatically stages the file.
3047pub fn resolve_conflict_hunk(
3048    repo_path: &Path,
3049    file_path: &str,
3050    hunk_idx: usize,
3051    accept_ours: bool,
3052) -> Result<(), String> {
3053    let full_path = repo_path.join(file_path);
3054    let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3055
3056    let mut new_lines = Vec::new();
3057    let mut lines_iter = content.lines().peekable();
3058    let mut current_hunk_idx = 0;
3059
3060    while let Some(line) = lines_iter.next() {
3061        if line.starts_with("<<<<<<<") {
3062            let mut ours_block = Vec::new();
3063            let mut theirs_block = Vec::new();
3064
3065            // Read ours block (until =======)
3066            let mut found_separator = false;
3067            while let Some(&next_line) = lines_iter.peek() {
3068                if next_line.starts_with("=======") {
3069                    lines_iter.next(); // consume =======
3070                    found_separator = true;
3071                    break;
3072                }
3073                if let Some(line) = lines_iter.next() {
3074                    ours_block.push(line.to_string());
3075                }
3076            }
3077
3078            // Read theirs block (until >>>>>>>)
3079            let mut found_end = false;
3080            let mut end_line_marker = ">>>>>>>".to_string();
3081            while let Some(&next_line) = lines_iter.peek() {
3082                if next_line.starts_with(">>>>>>>") {
3083                    if let Some(marker) = lines_iter.next() {
3084                        end_line_marker = marker.to_string(); // consume >>>>>>>
3085                    }
3086                    found_end = true;
3087                    break;
3088                }
3089                if let Some(line) = lines_iter.next() {
3090                    theirs_block.push(line.to_string());
3091                }
3092            }
3093
3094            if current_hunk_idx == hunk_idx {
3095                if accept_ours {
3096                    new_lines.extend(ours_block);
3097                } else {
3098                    new_lines.extend(theirs_block);
3099                }
3100            } else {
3101                new_lines.push(line.to_string());
3102                new_lines.extend(ours_block);
3103                if found_separator {
3104                    new_lines.push("=======".to_string());
3105                }
3106                new_lines.extend(theirs_block);
3107                if found_end {
3108                    new_lines.push(end_line_marker);
3109                }
3110            }
3111
3112            current_hunk_idx += 1;
3113        } else {
3114            new_lines.push(line.to_string());
3115        }
3116    }
3117
3118    let mut new_content = new_lines.join("\n");
3119    if content.ends_with('\n') && !new_content.ends_with('\n') {
3120        new_content.push('\n');
3121    }
3122    std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
3123
3124    // Check if any conflict markers remain in the file
3125    let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
3126    let has_conflict_markers = updated_content
3127        .lines()
3128        .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
3129
3130    if !has_conflict_markers {
3131        stage_file(repo_path, file_path)?;
3132    }
3133
3134    Ok(())
3135}
3136
3137/// Abort the in-progress merge.
3138pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
3139    let output = git_command()
3140        .args(["merge", "--abort"])
3141        .current_dir(repo_path)
3142        .output()
3143        .map_err(|e| e.to_string())?;
3144    if !output.status.success() {
3145        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3146    }
3147    Ok(())
3148}
3149
3150/// Continue the merge after conflicts are resolved.
3151pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
3152    let output = git_command()
3153        .args(["merge", "--continue"])
3154        .env("GIT_EDITOR", "true")
3155        .current_dir(repo_path)
3156        .output()
3157        .map_err(|e| e.to_string())?;
3158    if !output.status.success() {
3159        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3160    }
3161    Ok(())
3162}
3163
3164/// Returns the upstream tracking remote name for a branch, if configured.
3165pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
3166    let repo = Repository::open(repo_path).ok()?;
3167    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3168    let _upstream = branch.upstream().ok()?;
3169    let local_ref = branch.get().name().ok()?;
3170    let remote_buf = repo.branch_upstream_remote(local_ref).ok()?;
3171    remote_buf.as_str().ok().map(|s| s.to_string())
3172}
3173
3174/// Returns whether the specified branch has a configured upstream tracking branch.
3175pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
3176    get_branch_upstream_remote(repo_path, branch_name).is_some()
3177}
3178
3179/// Finds the remote target for pushing a branch. Returns `(remote_name, set_upstream)`.
3180pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
3181    let repo = Repository::open(repo_path).ok()?;
3182    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
3183    if branch.upstream().is_ok() {
3184        if let Ok(local_ref) = branch.get().name() {
3185            if let Ok(remote_buf) = repo.branch_upstream_remote(local_ref) {
3186                if let Ok(name) = remote_buf.as_str() {
3187                    return Some((name.to_string(), false));
3188                }
3189            }
3190        }
3191    }
3192    let remotes = repo.remotes().ok()?;
3193    let first_remote = remotes.iter().next()?.ok()??.to_string();
3194    Some((first_remote, true))
3195}
3196
3197/// Returns whether the specified commit OID has no parents (i.e. it is the root commit).
3198pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
3199    if let Ok(repo) = Repository::open(repo_path) {
3200        if let Ok(oid) = git2::Oid::from_str(commit_oid) {
3201            if let Ok(commit) = repo.find_commit(oid) {
3202                return commit.parent_count() == 0;
3203            }
3204        }
3205    }
3206    false
3207}
3208
3209pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
3210    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3211    let mut worktrees_list = Vec::new();
3212    if let Ok(worktree_names) = repo.worktrees() {
3213        for name in worktree_names.iter() {
3214            let Ok(Some(wt_name)) = name else { continue };
3215            if let Ok(wt) = repo.find_worktree(wt_name) {
3216                let path = wt.path().to_path_buf();
3217                let mut branch = None;
3218                if let Ok(wt_repo) = Repository::open(&path) {
3219                    if let Ok(head) = wt_repo.head() {
3220                        branch = head.shorthand().map(String::from).ok();
3221                    }
3222                }
3223
3224                let mut is_locked = false;
3225                let mut lock_reason = None;
3226                if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
3227                    is_locked = true;
3228                    if let Some(reason) = reason_opt {
3229                        if !reason.is_empty() {
3230                            lock_reason = Some(reason);
3231                        }
3232                    }
3233                }
3234
3235                worktrees_list.push(WorktreeInfo {
3236                    name: wt_name.to_string(),
3237                    path,
3238                    branch,
3239                    is_locked,
3240                    lock_reason,
3241                });
3242            }
3243        }
3244    }
3245    Ok(worktrees_list)
3246}
3247
3248pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
3249    let output = git_command()
3250        .arg("worktree")
3251        .arg("add")
3252        .arg(wt_path)
3253        .arg(branch)
3254        .current_dir(repo_path)
3255        .output()
3256        .map_err(|e| e.to_string())?;
3257
3258    if !output.status.success() {
3259        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3260    }
3261    Ok(())
3262}
3263
3264pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
3265    let output = git_command()
3266        .arg("worktree")
3267        .arg("lock")
3268        .arg("--reason")
3269        .arg(reason)
3270        .arg(name)
3271        .current_dir(repo_path)
3272        .output()
3273        .map_err(|e| e.to_string())?;
3274
3275    if !output.status.success() {
3276        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3277    }
3278    Ok(())
3279}
3280
3281pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
3282    let output = git_command()
3283        .arg("worktree")
3284        .arg("unlock")
3285        .arg(name)
3286        .current_dir(repo_path)
3287        .output()
3288        .map_err(|e| e.to_string())?;
3289
3290    if !output.status.success() {
3291        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3292    }
3293    Ok(())
3294}
3295
3296pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
3297    let mut cmd = git_command();
3298    cmd.arg("worktree").arg("remove");
3299    if force {
3300        cmd.arg("--force");
3301    }
3302    let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
3303
3304    if !output.status.success() {
3305        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3306    }
3307    Ok(())
3308}
3309
3310pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
3311    let output = git_command()
3312        .arg("worktree")
3313        .arg("prune")
3314        .current_dir(repo_path)
3315        .output()
3316        .map_err(|e| e.to_string())?;
3317
3318    if !output.status.success() {
3319        return Err(String::from_utf8_lossy(&output.stderr).to_string());
3320    }
3321    Ok(())
3322}
3323
3324pub fn load_tab_forge_issues(
3325    repo_path: &Path,
3326    assigned_only: bool,
3327) -> Result<Vec<ForgeIssue>, String> {
3328    let mut cmd = tool_command("gh");
3329    cmd.arg("issue").arg("list");
3330    if assigned_only {
3331        cmd.arg("--assignee").arg("@me");
3332    }
3333    cmd.arg("--limit")
3334        .arg("50")
3335        .arg("--json")
3336        .arg("number,title,state,author,assignees,url")
3337        .current_dir(repo_path);
3338
3339    let output = match cmd.output() {
3340        Ok(out) => out,
3341        Err(e) => {
3342            return Err(format!(
3343                "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3344                e
3345            ));
3346        }
3347    };
3348
3349    if !output.status.success() {
3350        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3351        return Err(format!(
3352            "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3353            err_msg
3354        ));
3355    }
3356
3357    #[derive(serde::Deserialize)]
3358    struct GhAuthor {
3359        login: String,
3360    }
3361
3362    #[derive(serde::Deserialize)]
3363    struct GhAssignee {
3364        login: String,
3365    }
3366
3367    #[derive(serde::Deserialize)]
3368    struct GhIssue {
3369        number: u32,
3370        title: String,
3371        state: String,
3372        author: Option<GhAuthor>,
3373        assignees: Option<Vec<GhAssignee>>,
3374        url: String,
3375    }
3376
3377    let raw_issues: Vec<GhIssue> = serde_json::from_slice(&output.stdout)
3378        .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3379
3380    let issues = raw_issues
3381        .into_iter()
3382        .map(|item| {
3383            let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3384            let assignees =
3385                item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3386            ForgeIssue {
3387                number: item.number,
3388                title: item.title,
3389                state: item.state,
3390                author,
3391                assignees,
3392                url: item.url,
3393            }
3394        })
3395        .collect();
3396
3397    Ok(issues)
3398}
3399
3400#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3401pub struct ForgePR {
3402    pub number: u32,
3403    pub title: String,
3404    pub state: String,
3405    pub author: String,
3406    pub assignees: Vec<String>,
3407    pub url: String,
3408    pub head_ref: String,
3409    pub head_ref_oid: String,
3410    pub body: String,
3411    pub status_checks: Vec<CIStatusCheck>,
3412    pub reviews: Vec<PRReview>,
3413}
3414
3415#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3416pub struct ForgePRComment {
3417    pub path: String,
3418    pub line: Option<u32>,
3419    pub body: String,
3420    pub author: String,
3421    pub commit_id: String,
3422}
3423
3424#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3425pub struct CIStatusCheck {
3426    pub name: String,
3427    pub state: Option<String>,
3428    pub status: Option<String>,
3429    pub conclusion: Option<String>,
3430}
3431
3432#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3433pub struct PRReview {
3434    pub author: String,
3435    pub body: String,
3436    pub state: String,
3437}
3438
3439pub fn load_tab_forge_prs(repo_path: &Path) -> Result<Vec<ForgePR>, String> {
3440    let mut cmd = tool_command("gh");
3441    cmd.arg("pr")
3442        .arg("list")
3443        .arg("--limit")
3444        .arg("50")
3445        .arg("--json")
3446        .arg("number,title,state,author,assignees,url,headRefName,headRefOid,statusCheckRollup,body,reviews")
3447        .current_dir(repo_path);
3448
3449    let output = match cmd.output() {
3450        Ok(out) => out,
3451        Err(e) => {
3452            return Err(format!(
3453                "GitHub CLI ('gh') not found or failed to execute: {}. Ensure 'gh' is installed and in your PATH.",
3454                e
3455            ));
3456        }
3457    };
3458
3459    if !output.status.success() {
3460        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
3461        return Err(format!(
3462            "GitHub CLI error: {}. Make sure you are authenticated (run 'gh auth login') and this is a GitHub repository.",
3463            err_msg
3464        ));
3465    }
3466
3467    #[derive(serde::Deserialize)]
3468    struct GhAuthor {
3469        login: String,
3470    }
3471
3472    #[derive(serde::Deserialize)]
3473    struct GhAssignee {
3474        login: String,
3475    }
3476
3477    #[derive(serde::Deserialize)]
3478    struct GhReview {
3479        author: Option<GhAuthor>,
3480        body: String,
3481        state: String,
3482    }
3483
3484    #[derive(serde::Deserialize)]
3485    struct GhStatusCheck {
3486        name: String,
3487        state: Option<String>,
3488        status: Option<String>,
3489        conclusion: Option<String>,
3490    }
3491
3492    #[derive(serde::Deserialize)]
3493    struct GhPR {
3494        number: u32,
3495        title: String,
3496        state: String,
3497        author: Option<GhAuthor>,
3498        assignees: Option<Vec<GhAssignee>>,
3499        url: String,
3500        #[serde(rename = "headRefName")]
3501        head_ref_name: String,
3502        #[serde(rename = "headRefOid")]
3503        head_ref_oid: String,
3504        body: String,
3505        #[serde(rename = "statusCheckRollup")]
3506        status_check_rollup: Option<Vec<GhStatusCheck>>,
3507        reviews: Option<Vec<GhReview>>,
3508    }
3509
3510    let raw_prs: Vec<GhPR> = serde_json::from_slice(&output.stdout)
3511        .map_err(|e| format!("Failed to parse GitHub CLI response: {}", e))?;
3512
3513    let prs = raw_prs
3514        .into_iter()
3515        .map(|item| {
3516            let author = item.author.map(|a| a.login).unwrap_or_else(|| "none".to_string());
3517            let assignees =
3518                item.assignees.unwrap_or_default().into_iter().map(|a| a.login).collect();
3519            let status_checks = item
3520                .status_check_rollup
3521                .unwrap_or_default()
3522                .into_iter()
3523                .map(|c| CIStatusCheck {
3524                    name: c.name,
3525                    state: c.state,
3526                    status: c.status,
3527                    conclusion: c.conclusion,
3528                })
3529                .collect();
3530            let reviews = item
3531                .reviews
3532                .unwrap_or_default()
3533                .into_iter()
3534                .map(|r| PRReview {
3535                    author: r.author.map(|a| a.login).unwrap_or_else(|| "none".to_string()),
3536                    body: r.body,
3537                    state: r.state,
3538                })
3539                .collect();
3540
3541            ForgePR {
3542                number: item.number,
3543                title: item.title,
3544                state: item.state,
3545                author,
3546                assignees,
3547                url: item.url,
3548                head_ref: item.head_ref_name,
3549                head_ref_oid: item.head_ref_oid,
3550                body: item.body,
3551                status_checks,
3552                reviews,
3553            }
3554        })
3555        .collect();
3556
3557    Ok(prs)
3558}
3559
3560pub fn load_pr_comments(repo_path: &Path, pr_number: u32) -> Result<Vec<ForgePRComment>, String> {
3561    let mut cmd = tool_command("gh");
3562    cmd.arg("api")
3563        .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3564        .current_dir(repo_path);
3565
3566    let output = match cmd.output() {
3567        Ok(out) => out,
3568        Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3569    };
3570
3571    if !output.status.success() {
3572        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3573        return Err(format!("Failed to load PR comments: {}", err));
3574    }
3575
3576    #[derive(serde::Deserialize)]
3577    struct GhUser {
3578        login: String,
3579    }
3580
3581    #[derive(serde::Deserialize)]
3582    struct GhPRComment {
3583        path: String,
3584        line: Option<u32>,
3585        body: String,
3586        user: Option<GhUser>,
3587        #[serde(rename = "commit_id")]
3588        commit_id: String,
3589    }
3590
3591    let raw_comments: Vec<GhPRComment> = serde_json::from_slice(&output.stdout)
3592        .map_err(|e| format!("Failed to parse PR comments: {}", e))?;
3593
3594    let comments = raw_comments
3595        .into_iter()
3596        .map(|c| ForgePRComment {
3597            path: c.path,
3598            line: c.line,
3599            body: c.body,
3600            author: c.user.map(|u| u.login).unwrap_or_else(|| "none".to_string()),
3601            commit_id: c.commit_id,
3602        })
3603        .collect();
3604
3605    Ok(comments)
3606}
3607
3608pub fn add_pr_line_comment(
3609    repo_path: &Path,
3610    pr_number: u32,
3611    commit_id: &str,
3612    path: &str,
3613    line: u32,
3614    body: &str,
3615) -> Result<(), String> {
3616    let mut cmd = tool_command("gh");
3617    cmd.arg("api")
3618        .arg(format!("repos/:owner/:repo/pulls/{}/comments", pr_number))
3619        .arg("--method")
3620        .arg("POST")
3621        .arg("-F")
3622        .arg(format!("body={}", body))
3623        .arg("-F")
3624        .arg(format!("commit_id={}", commit_id))
3625        .arg("-F")
3626        .arg(format!("path={}", path))
3627        .arg("-F")
3628        .arg(format!("line={}", line))
3629        .arg("-F")
3630        .arg("side=RIGHT")
3631        .current_dir(repo_path);
3632
3633    let output = match cmd.output() {
3634        Ok(out) => out,
3635        Err(e) => return Err(format!("Failed to execute gh: {}", e)),
3636    };
3637
3638    if !output.status.success() {
3639        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3640        return Err(format!("Failed to add PR comment: {}", err));
3641    }
3642    Ok(())
3643}
3644
3645pub fn checkout_pr_branch(repo_path: &Path, pr_number: u32) -> Result<String, String> {
3646    let mut cmd = tool_command("gh");
3647    cmd.arg("pr").arg("checkout").arg(pr_number.to_string()).current_dir(repo_path);
3648
3649    let output = cmd.output().map_err(|e| e.to_string())?;
3650    if !output.status.success() {
3651        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
3652        return Err(format!("Failed to checkout PR branch: {}", err));
3653    }
3654    Ok(format!("Checked out branch for PR #{}", pr_number))
3655}
3656
3657pub fn resolve_and_checkout_issue_branch(
3658    repo_path: &Path,
3659    issue_number: u32,
3660) -> Result<String, String> {
3661    let mut cmd = tool_command("gh");
3662    cmd.arg("issue")
3663        .arg("view")
3664        .arg(issue_number.to_string())
3665        .arg("--json")
3666        .arg("developmentBranch")
3667        .current_dir(repo_path);
3668
3669    if let Ok(output) = cmd.output() {
3670        if output.status.success() {
3671            #[derive(serde::Deserialize)]
3672            struct GhDevBranch {
3673                name: String,
3674            }
3675            #[derive(serde::Deserialize)]
3676            struct GhIssueView {
3677                #[serde(rename = "developmentBranch")]
3678                development_branch: Option<GhDevBranch>,
3679            }
3680            if let Ok(parsed) = serde_json::from_slice::<GhIssueView>(&output.stdout) {
3681                if let Some(dev_branch) = parsed.development_branch {
3682                    if !dev_branch.name.is_empty() {
3683                        let name = dev_branch.name;
3684                        if checkout_local_branch(repo_path, &name).is_ok() {
3685                            return Ok(format!("Checked out linked local branch '{}'", name));
3686                        }
3687                        if let Ok(msg) = checkout_remote_branch(repo_path, &name) {
3688                            return Ok(format!("Checked out linked remote branch '{}'", msg));
3689                        }
3690                        let checkout_out = git_command()
3691                            .arg("checkout")
3692                            .arg("-b")
3693                            .arg(&name)
3694                            .current_dir(repo_path)
3695                            .output();
3696                        if let Ok(out) = checkout_out {
3697                            if out.status.success() {
3698                                return Ok(format!(
3699                                    "Created and switched to linked branch '{}'",
3700                                    name
3701                                ));
3702                            }
3703                        }
3704                    }
3705                }
3706            }
3707        }
3708    }
3709
3710    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
3711    let target_pattern = format!("{}", issue_number);
3712    let mut found_local = None;
3713    let mut found_remote = None;
3714
3715    if let Ok(branches) = repo.branches(None) {
3716        for (branch, branch_type) in branches.flatten() {
3717            if let Ok(Some(name)) = branch.name() {
3718                if name.contains(&target_pattern) {
3719                    match branch_type {
3720                        git2::BranchType::Local => {
3721                            found_local = Some(name.to_string());
3722                            break;
3723                        }
3724                        git2::BranchType::Remote => {
3725                            found_remote = Some(name.to_string());
3726                        }
3727                    }
3728                }
3729            }
3730        }
3731    }
3732
3733    if let Some(local_name) = found_local {
3734        checkout_local_branch(repo_path, &local_name).map_err(|e| e.to_string())?;
3735        return Ok(format!("Checked out matched local branch '{}'", local_name));
3736    }
3737
3738    if let Some(remote_name) = found_remote {
3739        let msg = checkout_remote_branch(repo_path, &remote_name).map_err(|e| e.to_string())?;
3740        return Ok(format!("Checked out matched remote branch: {}", msg));
3741    }
3742
3743    let new_branch_name = format!("issue-{}", issue_number);
3744    let checkout_out = git_command()
3745        .arg("checkout")
3746        .arg("-b")
3747        .arg(&new_branch_name)
3748        .current_dir(repo_path)
3749        .output()
3750        .map_err(|e| e.to_string())?;
3751
3752    if !checkout_out.status.success() {
3753        let err = String::from_utf8_lossy(&checkout_out.stderr).trim().to_string();
3754        return Err(format!("Failed to create new branch: {}", err));
3755    }
3756
3757    Ok(format!("Created and switched to new branch '{}'", new_branch_name))
3758}
3759
3760pub fn open_browser(url: &str) {
3761    #[cfg(target_os = "macos")]
3762    let _ = detached_command("open").arg(url).status();
3763    #[cfg(target_os = "windows")]
3764    let _ = detached_command("cmd").args(["/C", "start", url]).status();
3765    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3766    let _ = detached_command("xdg-open").arg(url).status();
3767}
3768
3769#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
3770pub struct ForgeIssue {
3771    pub number: u32,
3772    pub title: String,
3773    pub state: String,
3774    pub author: String,
3775    pub assignees: Vec<String>,
3776    pub url: String,
3777}
3778
3779pub fn get_remote_url(repo_path: &Path, remote_name: &str) -> Option<String> {
3780    let remotes = load_tab_remotes(repo_path).ok()?;
3781    remotes.into_iter().find(|r| r.name == remote_name).map(|r| r.url)
3782}
3783
3784pub fn get_default_remote_url(repo_path: &Path) -> Option<String> {
3785    let remotes = load_tab_remotes(repo_path).ok()?;
3786    remotes.into_iter().next().map(|r| r.url)
3787}
3788
3789#[cfg(test)]
3790#[allow(clippy::unwrap_used, clippy::panic)]
3791// Test fixtures drive real `git` to build repositories; the TUI is not running here.
3792#[allow(clippy::disallowed_methods)]
3793mod tests {
3794    use super::*;
3795    use std::fs::File;
3796
3797    #[test]
3798    fn test_match_pattern() {
3799        assert!(match_pattern("test.psd", "*.psd"));
3800        assert!(match_pattern("assets/test.psd", "*.psd"));
3801        assert!(match_pattern("assets/img.png", "assets/*.png"));
3802        assert!(match_pattern("src/app.rs", "src/*"));
3803        assert!(match_pattern("test.txt", "test.txt"));
3804        assert!(!match_pattern("src/app.rs", "*.psd"));
3805    }
3806
3807    #[test]
3808    fn test_run_git_with_timeout_kills_a_hanging_child() {
3809        // Stand in for a remote that accepts the connection and never replies.
3810        let mut cmd = if cfg!(target_os = "windows") {
3811            let mut c = std::process::Command::new("cmd");
3812            c.arg("/c").arg("timeout").arg("/t").arg("30").arg("/nobreak");
3813            c
3814        } else {
3815            let mut c = std::process::Command::new("sleep");
3816            c.arg("30");
3817            c
3818        };
3819        cmd.stdin(std::process::Stdio::null());
3820
3821        let started = Instant::now();
3822        let res = run_git_with_timeout(cmd, Duration::from_millis(300));
3823        let elapsed = started.elapsed();
3824
3825        assert!(res.is_err(), "expected a timeout error, got {:?}", res);
3826        assert!(elapsed < Duration::from_secs(10), "child was not killed promptly: {:?}", elapsed);
3827    }
3828
3829    #[test]
3830    fn test_get_lfs_info() {
3831        let mut temp_path = std::env::temp_dir();
3832        temp_path.push(format!(
3833            "twig_test_lfs_{}",
3834            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3835        ));
3836        std::fs::create_dir_all(&temp_path).unwrap();
3837
3838        // 1. Create a dummy git repo with .git/lfs directory to bypass the short-circuit check
3839        let _repo = Repository::init(&temp_path).unwrap();
3840        let lfs_dir = temp_path.join(".git").join("lfs");
3841        std::fs::create_dir_all(&lfs_dir).unwrap();
3842
3843        // 2. Call get_lfs_info (which will trigger is_lfs_installed and cover them)
3844        let (installed, tracked) = get_lfs_info(&temp_path);
3845
3846        // Assert that get_lfs_info completed (whether lfs is installed on user's machine or not)
3847        assert!(tracked.is_empty());
3848
3849        // 3. Create .gitattributes with lfs pattern
3850        let gitattributes_path = temp_path.join(".gitattributes");
3851        std::fs::write(&gitattributes_path, "*.bin filter=lfs diff=lfs merge=lfs").unwrap();
3852
3853        let (installed2, _tracked2) = get_lfs_info(&temp_path);
3854        assert_eq!(installed, installed2);
3855
3856        // Clean up
3857        let _ = std::fs::remove_dir_all(&temp_path);
3858    }
3859
3860    #[test]
3861    fn test_comprehensive_gitwig_core_coverage() {
3862        let mut temp_path = std::env::temp_dir();
3863        temp_path.push(format!(
3864            "twig_test_cov_{}",
3865            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3866        ));
3867        std::fs::create_dir_all(&temp_path).unwrap();
3868
3869        // 1. Repository init
3870        let repo = Repository::init(&temp_path).unwrap();
3871
3872        // 2. Configure author
3873        let mut config = repo.config().unwrap();
3874        config.set_str("user.name", "Test User").unwrap();
3875        config.set_str("user.email", "test@example.com").unwrap();
3876
3877        // 3. Create dummy file & commit
3878        let file_path = temp_path.join("test.txt");
3879        std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3880        stage_file(&temp_path, "test.txt").unwrap();
3881        commit_changes(&temp_path, "first commit").unwrap();
3882
3883        let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3884        let head_oid = head_commit.id().to_string();
3885
3886        // 4. Test diff & worktree helpers
3887        let _diffs = get_commit_file_diff(&temp_path, &head_oid, "test.txt");
3888        let _worktree_diff = get_worktree_file_diff(&temp_path, "test.txt", false);
3889
3890        // 5. Test stage/unstage/discard all
3891        let _ = stage_all_changes(&temp_path);
3892        let _ = unstage_all_changes(&temp_path);
3893
3894        std::fs::write(&file_path, "modified content").unwrap();
3895        let _ = discard_all_changes(&temp_path);
3896
3897        // 6. Test LFS functions
3898        let _lfs_size = get_lfs_storage_size(&temp_path);
3899
3900        // 7. Test load tab listings
3901        let _reflog = load_tab_reflog(&temp_path);
3902        let _tags = load_tab_tags(&temp_path);
3903        let _stashes = load_tab_stashes(&temp_path);
3904        let _submodules = load_tab_submodules(&temp_path);
3905        let _worktrees = load_tab_worktrees(&temp_path);
3906
3907        // 8. Test remote add / delete
3908        let _ = remote_add(&temp_path, "upstream", "https://github.com/example/upstream.git");
3909        let _ = remote_delete(&temp_path, "upstream");
3910
3911        // 9. Test worktree add / remove
3912        let wt_path = temp_path.join("wt-path");
3913        let _ = worktree_add(&temp_path, "new-branch", &wt_path);
3914        let _ = worktree_remove(&temp_path, "wt-path", true);
3915
3916        // 10. Test tag delete
3917        let _ = delete_tag(&temp_path, "v1.0.0");
3918
3919        // 11. Test stash apply & drop
3920        let _ = apply_stash(&temp_path, 0);
3921        let _ = delete_stash(&temp_path, 0);
3922
3923        // 12. Test merge abort / continue
3924        let _ = abort_merge(&temp_path);
3925        let _ = continue_merge(&temp_path);
3926
3927        // 13. Test merge conflict resolver helpers
3928        let _ = is_merging(&temp_path);
3929        let _ = get_conflict_markers_diff(&temp_path, "test.txt");
3930        let _ = resolve_ours(&temp_path, "test.txt");
3931        let _ = resolve_theirs(&temp_path, "test.txt");
3932        let _ = mark_resolved(&temp_path, "test.txt");
3933
3934        // 14. Test push target & upstream
3935        let _target = get_branch_push_target(&temp_path, "master");
3936        let _target2 = get_branch_push_target(&temp_path, "main");
3937
3938        // 15. Test inspect_detail & inspect_summary
3939        let _detail = inspect_detail(temp_path.to_str().unwrap(), 10, 10, true);
3940        let _summary = inspect_summary(temp_path.to_str().unwrap());
3941
3942        // Clean up
3943        let _ = std::fs::remove_dir_all(&temp_path);
3944    }
3945
3946    #[test]
3947    fn test_more_gitwig_core_coverage() {
3948        let mut temp_path = std::env::temp_dir();
3949        temp_path.push(format!(
3950            "twig_test_cov2_{}",
3951            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3952        ));
3953        std::fs::create_dir_all(&temp_path).unwrap();
3954
3955        // 1. Repository init
3956        let repo = Repository::init(&temp_path).unwrap();
3957
3958        // 2. Configure author
3959        let mut config = repo.config().unwrap();
3960        config.set_str("user.name", "Test User").unwrap();
3961        config.set_str("user.email", "test@example.com").unwrap();
3962
3963        // 3. Create dummy file & commit
3964        let file_path = temp_path.join("test.txt");
3965        std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3966        stage_file(&temp_path, "test.txt").unwrap();
3967        commit_changes(&temp_path, "first commit").unwrap();
3968
3969        let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
3970        let head_oid = head_commit.id().to_string();
3971
3972        // 4. Test checkout functions
3973        let _ = checkout_commit(&temp_path, &head_oid);
3974        let _ = checkout_local_branch(&temp_path, "master");
3975        let _ = checkout_remote_branch(&temp_path, "origin/master");
3976        let _ = checkout_tag(&temp_path, "v1.0.0");
3977
3978        // 5. Test branch / tag creation & deletion
3979        let _ = create_branch(&temp_path, "new-branch-2");
3980        let _ = delete_local_branch(&temp_path, "new-branch-2");
3981        let _ = delete_remote_branch(&temp_path, "origin/new-branch-2");
3982        let _ = create_tag(&temp_path, "v1.0.0", &head_oid, None, false);
3983        let _ = delete_remote_tag(&temp_path, "origin", "v1.0.0", Duration::from_secs(5));
3984        let _ = get_remote_tags(&temp_path, "origin", Duration::from_secs(5));
3985
3986        // 6. Test serialize & deserialize tags
3987        let tag_info = vec![BranchInfo {
3988            name: "v1.0.0".to_string(),
3989            is_head: false,
3990            short_sha: "abc1234".to_string(),
3991            short_message: "tag msg".to_string(),
3992        }];
3993        let serialized = serialize_tags(&tag_info);
3994        let deserialized = deserialize_tags(&serialized);
3995        assert_eq!(deserialized.len(), 1);
3996        assert_eq!(deserialized[0].name, "v1.0.0");
3997
3998        // 7. Test history / blame / commit files
3999        let _ = get_file_history(&temp_path, "test.txt");
4000        let _ = get_file_blame(&temp_path, "test.txt");
4001        let _ = get_commit_files(&temp_path, &head_oid);
4002
4003        // 8. Test load tab functions
4004        let _ = load_tab_files(&temp_path);
4005        let _ = load_tab_branches(&temp_path);
4006        let _ = load_tab_remotes(&temp_path);
4007        let _ = load_tab_overview(&temp_path, 10);
4008
4009        // 9. Test load_tab_graph_stream
4010        let (tx, _rx) = std::sync::mpsc::channel();
4011        let _ = load_tab_graph_stream(
4012            &temp_path,
4013            10,
4014            true,
4015            temp_path.to_str().unwrap().to_string(),
4016            0,
4017            tx,
4018        );
4019
4020        // 10. Test invalidate cache
4021        invalidate_ref_map_cache(&temp_path);
4022
4023        // 11. Test Forge API/command execution fallbacks/errors
4024        let _ = load_tab_forge_issues(&temp_path, false);
4025        let _ = load_tab_forge_issues(&temp_path, true);
4026        let _ = load_tab_forge_prs(&temp_path);
4027        let _ = load_pr_comments(&temp_path, 1);
4028        let _ = add_pr_line_comment(&temp_path, 1, "sha", "file.txt", 10, "comment");
4029
4030        // Clean up
4031        let _ = std::fs::remove_dir_all(&temp_path);
4032    }
4033
4034    #[test]
4035    fn test_create_tag_annotated_and_force_update() {
4036        let mut temp_path = std::env::temp_dir();
4037        temp_path.push(format!(
4038            "twig_test_tag_force_{}",
4039            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4040        ));
4041        std::fs::create_dir_all(&temp_path).unwrap();
4042
4043        let repo = Repository::init(&temp_path).unwrap();
4044        let mut config = repo.config().unwrap();
4045        config.set_str("user.name", "Test User").unwrap();
4046        config.set_str("user.email", "test@example.com").unwrap();
4047
4048        let file_path = temp_path.join("test.txt");
4049        std::fs::write(&file_path, "commit 1\n").unwrap();
4050        stage_file(&temp_path, "test.txt").unwrap();
4051        commit_changes(&temp_path, "commit 1").unwrap();
4052
4053        let head_commit1 = repo.head().unwrap().peel_to_commit().unwrap();
4054        let oid1 = head_commit1.id().to_string();
4055
4056        assert!(!tag_exists(&temp_path, "v1.0.1"));
4057        assert!(create_tag(&temp_path, "v1.0.1", &oid1, Some("Release v1.0.1"), false).is_ok());
4058        assert!(tag_exists(&temp_path, "v1.0.1"));
4059
4060        // Creating without force should fail if tag exists
4061        std::fs::write(&file_path, "commit 2\n").unwrap();
4062        stage_file(&temp_path, "test.txt").unwrap();
4063        commit_changes(&temp_path, "commit 2").unwrap();
4064
4065        let head_commit2 = repo.head().unwrap().peel_to_commit().unwrap();
4066        let oid2 = head_commit2.id().to_string();
4067
4068        assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), false).is_err());
4069
4070        // Creating with force = true should succeed and update tag
4071        assert!(create_tag(&temp_path, "v1.0.1", &oid2, Some("Updated release"), true).is_ok());
4072
4073        let _ = std::fs::remove_dir_all(&temp_path);
4074    }
4075
4076    use std::io::Write;
4077
4078    #[test]
4079    fn test_commit_amend() {
4080        let mut temp_path = std::env::temp_dir();
4081        temp_path.push(format!(
4082            "twig_test_amend_{}",
4083            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4084        ));
4085        std::fs::create_dir_all(&temp_path).unwrap();
4086
4087        // Init repo
4088        let repo = Repository::init(&temp_path).unwrap();
4089
4090        // Configure author
4091        let mut config = repo.config().unwrap();
4092        config.set_str("user.name", "Test User").unwrap();
4093        config.set_str("user.email", "test@example.com").unwrap();
4094
4095        // Create initial file
4096        let file_path = temp_path.join("test.txt");
4097        let mut file = File::create(&file_path).unwrap();
4098        writeln!(file, "initial content").unwrap();
4099
4100        // Stage and commit initial
4101        stage_file(&temp_path, "test.txt").unwrap();
4102        commit_changes(&temp_path, "initial commit").unwrap();
4103
4104        // Verify message
4105        let msg = get_last_commit_message(&temp_path).unwrap();
4106        assert_eq!(msg, "initial commit");
4107
4108        // Amend the commit message
4109        commit_amend(&temp_path, "amended commit").unwrap();
4110
4111        // Verify amended message
4112        let amended_msg = get_last_commit_message(&temp_path).unwrap();
4113        assert_eq!(amended_msg, "amended commit");
4114
4115        // Clean up
4116        let _ = std::fs::remove_dir_all(&temp_path);
4117    }
4118
4119    #[test]
4120    fn test_commit_signatures_collection() {
4121        let mut temp_path = std::env::temp_dir();
4122        temp_path.push(format!(
4123            "twig_test_sig_{}",
4124            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4125        ));
4126        std::fs::create_dir_all(&temp_path).unwrap();
4127
4128        // Init repo
4129        let repo = Repository::init(&temp_path).unwrap();
4130
4131        // Configure author
4132        let mut config = repo.config().unwrap();
4133        config.set_str("user.name", "Test User").unwrap();
4134        config.set_str("user.email", "test@example.com").unwrap();
4135
4136        // Create initial file
4137        let file_path = temp_path.join("test.txt");
4138        let mut file = File::create(&file_path).unwrap();
4139        writeln!(file, "initial content").unwrap();
4140
4141        // Stage and commit initial
4142        stage_file(&temp_path, "test.txt").unwrap();
4143        commit_changes(&temp_path, "initial commit").unwrap();
4144
4145        // 1. Test collect_signatures
4146        let sigs = collect_signatures(&temp_path, 0);
4147        assert_eq!(sigs.len(), 1);
4148        let head_oid = repo.head().unwrap().target().unwrap().to_string();
4149        let sig_status = sigs.get(&head_oid).unwrap();
4150        assert_eq!(sig_status, "N");
4151
4152        // 2. Test collect_commits (files should be empty by default)
4153        let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
4154        assert_eq!(commits.len(), 1);
4155        assert_eq!(commits[0].signature_status, "N");
4156        assert!(commits[0].files.is_empty());
4157
4158        // Test get_commit_files (lazy loading)
4159        let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
4160        assert_eq!(files.len(), 1);
4161        assert_eq!(files[0].path, "test.txt");
4162        assert_eq!(files[0].label, "N");
4163
4164        // 3. Test collect_graph_lines
4165        let graph = collect_graph_lines(&temp_path, 1000);
4166        assert_eq!(graph.len(), 1);
4167        assert!(graph[0].commit.is_some());
4168        assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
4169
4170        // Clean up
4171        let _ = std::fs::remove_dir_all(&temp_path);
4172    }
4173
4174    /// Builds a repo whose only commit carries a (fake) gpg signature and whose
4175    /// `gpg.program` points at a binary that does not exist. Any `%G?` lookup then
4176    /// makes git print `error: cannot run …` on stderr and still exit 0, which is
4177    /// exactly what a machine without gpg does for a real signed commit.
4178    fn make_repo_with_unverifiable_signature() -> PathBuf {
4179        use std::io::Write;
4180        use std::process::Stdio;
4181        let mut temp_path = std::env::temp_dir();
4182        temp_path.push(format!(
4183            "twig_test_nogpg_{}",
4184            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4185        ));
4186        std::fs::create_dir_all(&temp_path).unwrap();
4187        let repo = Repository::init(&temp_path).unwrap();
4188        let mut config = repo.config().unwrap();
4189        config.set_str("user.name", "Test User").unwrap();
4190        config.set_str("user.email", "test@example.com").unwrap();
4191        config.set_str("gpg.program", "/nonexistent/gitwig-test-no-gpg").unwrap();
4192
4193        let mut file = File::create(temp_path.join("test.txt")).unwrap();
4194        writeln!(file, "initial content").unwrap();
4195        stage_file(&temp_path, "test.txt").unwrap();
4196        commit_changes(&temp_path, "signed commit").unwrap();
4197
4198        // Re-hash the commit with a gpgsig header spliced in after `committer`.
4199        let raw = git_command()
4200            .args(["cat-file", "-p", "HEAD"])
4201            .current_dir(&temp_path)
4202            .output()
4203            .unwrap();
4204        let mut signed = String::new();
4205        for line in String::from_utf8_lossy(&raw.stdout).lines() {
4206            signed.push_str(line);
4207            signed.push('\n');
4208            if line.starts_with("committer ") {
4209                signed.push_str("gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n");
4210            }
4211        }
4212        let mut hash_cmd = git_command();
4213        hash_cmd.args(["hash-object", "-t", "commit", "-w", "--stdin"]).current_dir(&temp_path);
4214        hash_cmd.stdin(Stdio::piped());
4215        let mut child = hash_cmd.spawn().unwrap();
4216        child.stdin.take().unwrap().write_all(signed.as_bytes()).unwrap();
4217        let out = child.wait_with_output().unwrap();
4218        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
4219        let new_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
4220        let head_ref = String::from_utf8_lossy(
4221            &git_command()
4222                .args(["symbolic-ref", "HEAD"])
4223                .current_dir(&temp_path)
4224                .output()
4225                .unwrap()
4226                .stdout,
4227        )
4228        .trim()
4229        .to_string();
4230        let upd = git_command()
4231            .args(["update-ref", &head_ref, &new_oid])
4232            .current_dir(&temp_path)
4233            .output()
4234            .unwrap();
4235        assert!(upd.status.success());
4236        temp_path
4237    }
4238
4239    #[test]
4240    fn graph_survives_missing_signature_program_and_reports_it() {
4241        let temp_path = make_repo_with_unverifiable_signature();
4242        let (tx, rx) = std::sync::mpsc::channel();
4243
4244        let load = load_tab_graph_stream(&temp_path, 100, true, String::new(), 2, tx)
4245            .expect("a missing gpg must not fail the graph");
4246        assert_eq!(load.lines.len(), 1);
4247        assert!(load.lines[0].commit.is_some());
4248        let notice = load.notice.expect("git's stderr must surface as a notice");
4249        assert!(
4250            notice.contains("/nonexistent/gitwig-test-no-gpg"),
4251            "notice must name the program git could not start: {notice}"
4252        );
4253        assert!(!notice.starts_with("error:"), "prefix should be stripped: {notice}");
4254        assert!(rx.try_recv().is_err(), "no batch is streamed for a one-commit graph");
4255
4256        let _ = std::fs::remove_dir_all(&temp_path);
4257    }
4258
4259    #[test]
4260    fn graph_skips_signature_lookup_when_disabled() {
4261        let temp_path = make_repo_with_unverifiable_signature();
4262        let (tx, _rx) = std::sync::mpsc::channel();
4263
4264        let load = load_tab_graph_stream(&temp_path, 100, false, String::new(), 2, tx).unwrap();
4265        assert_eq!(load.lines.len(), 1);
4266        assert_eq!(load.lines[0].commit.as_ref().unwrap().signature_status, "N");
4267        assert!(load.notice.is_none(), "no %G? means git never touches gpg");
4268
4269        let _ = std::fs::remove_dir_all(&temp_path);
4270    }
4271
4272    #[test]
4273    fn test_ref_map_cache_behavior() {
4274        let temp_dir = std::env::temp_dir();
4275        let repo_path = temp_dir.join("test_ref_map_repo");
4276        let _ = std::fs::remove_dir_all(&repo_path);
4277        std::fs::create_dir_all(&repo_path).unwrap();
4278
4279        let repo = Repository::init(&repo_path).unwrap();
4280
4281        // 1. First fetch (rebuilds and caches)
4282        let map1 = get_cached_ref_map(&repo, &repo_path);
4283
4284        // 2. Second fetch (returns cached map)
4285        let map2 = get_cached_ref_map(&repo, &repo_path);
4286        assert_eq!(map1.len(), map2.len());
4287
4288        // 3. Invalidate cache
4289        invalidate_ref_map_cache(&repo_path);
4290
4291        // Clean up
4292        let _ = std::fs::remove_dir_all(&repo_path);
4293    }
4294
4295    #[test]
4296    fn test_get_latest_change_time() {
4297        let mut temp_path = std::env::temp_dir();
4298        temp_path.push(format!(
4299            "twig_test_time_{}",
4300            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4301        ));
4302        std::fs::create_dir_all(&temp_path).unwrap();
4303
4304        let change_time = get_latest_change_time(temp_path.to_str().unwrap());
4305        assert!(change_time > 0);
4306
4307        let _ = std::fs::remove_dir_all(&temp_path);
4308    }
4309
4310    #[test]
4311    fn test_committer_stats() {
4312        let mut temp_path = std::env::temp_dir();
4313        temp_path.push(format!(
4314            "twig_test_stats_{}",
4315            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4316        ));
4317        std::fs::create_dir_all(&temp_path).unwrap();
4318
4319        // Init repo
4320        let repo = Repository::init(&temp_path).unwrap();
4321
4322        // Configure author
4323        let mut config = repo.config().unwrap();
4324        config.set_str("user.name", "Test User").unwrap();
4325        config.set_str("user.email", "test@example.com").unwrap();
4326
4327        // Create initial file
4328        let file_path = temp_path.join("test.txt");
4329        let mut file = File::create(&file_path).unwrap();
4330        writeln!(file, "initial content").unwrap();
4331
4332        // Stage and commit initial
4333        stage_file(&temp_path, "test.txt").unwrap();
4334        commit_changes(&temp_path, "initial commit").unwrap();
4335
4336        // Collect stats
4337        let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
4338        assert_eq!(stats.len(), 1);
4339        assert_eq!(stats[0].name, "Test User");
4340        assert_eq!(stats[0].email, "test@example.com");
4341        assert_eq!(stats[0].count, 1);
4342        assert!(!limit_reached);
4343
4344        // Clean up
4345        let _ = std::fs::remove_dir_all(&temp_path);
4346    }
4347
4348    #[test]
4349    fn test_untracked_files_in_unstaged() {
4350        let mut temp_path = std::env::temp_dir();
4351        temp_path.push(format!(
4352            "twig_test_untracked_{}",
4353            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4354        ));
4355        std::fs::create_dir_all(&temp_path).unwrap();
4356
4357        // Init repo
4358        let _repo = Repository::init(&temp_path).unwrap();
4359
4360        // Create an untracked file
4361        let file_path = temp_path.join("untracked.txt");
4362        let mut file = File::create(&file_path).unwrap();
4363        writeln!(file, "hello untracked").unwrap();
4364
4365        // Create an untracked directory and a file inside it
4366        let untracked_dir = temp_path.join("untracked_dir");
4367        std::fs::create_dir_all(&untracked_dir).unwrap();
4368        let nested_file_path = untracked_dir.join("nested.txt");
4369        std::fs::write(&nested_file_path, "nested untracked file").unwrap();
4370
4371        // Inspect detail
4372        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4373        match detail {
4374            ItemDetail::Repo { info, .. } => {
4375                // Verify no folders are in the unstaged/untracked list
4376                let unstaged_paths: Vec<String> =
4377                    info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
4378                let untracked_paths: Vec<String> =
4379                    info.changes.untracked.iter().map(|f| f.path.clone()).collect();
4380
4381                // Folder itself should NOT be listed
4382                assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
4383                assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
4384                assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
4385                assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
4386
4387                // Untracked files (both root and nested) should be listed
4388                assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
4389                assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
4390                assert!(untracked_paths.contains(&"untracked.txt".to_string()));
4391                assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
4392            }
4393            _ => panic!("Expected ItemDetail::Repo"),
4394        }
4395
4396        // Clean up
4397        let _ = std::fs::remove_dir_all(&temp_path);
4398    }
4399
4400    #[test]
4401    fn test_stage_new_and_deleted_files() {
4402        let mut temp_path = std::env::temp_dir();
4403        temp_path.push(format!(
4404            "twig_test_new_del_{}",
4405            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4406        ));
4407        std::fs::create_dir_all(&temp_path).unwrap();
4408
4409        // Init repo
4410        let repo = Repository::init(&temp_path).unwrap();
4411
4412        // Configure author
4413        let mut config = repo.config().unwrap();
4414        config.set_str("user.name", "Test User").unwrap();
4415        config.set_str("user.email", "test@example.com").unwrap();
4416
4417        // Create initial file & commit
4418        let init_file = temp_path.join("init.txt");
4419        std::fs::write(&init_file, "initial").unwrap();
4420        stage_file(&temp_path, "init.txt").unwrap();
4421        commit_changes(&temp_path, "initial commit").unwrap();
4422
4423        // 1. Create a new file (untracked)
4424        let untracked_file = temp_path.join("untracked.txt");
4425        std::fs::write(&untracked_file, "new file content").unwrap();
4426
4427        // Try staging untracked file
4428        stage_file(&temp_path, "untracked.txt").unwrap();
4429
4430        // 2. Delete the initial file
4431        std::fs::remove_file(&init_file).unwrap();
4432
4433        // Try staging deleted file
4434        stage_file(&temp_path, "init.txt").unwrap();
4435
4436        // Check status of repo
4437        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4438        match detail {
4439            ItemDetail::Repo { info, .. } => {
4440                // Both should be in staged changes
4441                assert_eq!(info.changes.staged.len(), 2);
4442                let paths: Vec<String> =
4443                    info.changes.staged.iter().map(|f| f.path.clone()).collect();
4444                assert!(paths.contains(&"untracked.txt".to_string()));
4445                assert!(paths.contains(&"init.txt".to_string()));
4446            }
4447            _ => panic!("Expected ItemDetail::Repo"),
4448        }
4449
4450        // Clean up
4451        let _ = std::fs::remove_dir_all(&temp_path);
4452    }
4453
4454    #[test]
4455    fn test_discard_file_changes_all_cases() {
4456        let mut temp_path = std::env::temp_dir();
4457        temp_path.push(format!(
4458            "twig_test_discard_all_{}",
4459            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4460        ));
4461        std::fs::create_dir_all(&temp_path).unwrap();
4462
4463        // Init repo
4464        let repo = Repository::init(&temp_path).unwrap();
4465
4466        // Configure author
4467        let mut config = repo.config().unwrap();
4468        config.set_str("user.name", "Test User").unwrap();
4469        config.set_str("user.email", "test@example.com").unwrap();
4470
4471        // Create and commit initial files
4472        let file_tracked = temp_path.join("tracked.txt");
4473        std::fs::write(&file_tracked, "original content\n").unwrap();
4474        stage_file(&temp_path, "tracked.txt").unwrap();
4475        commit_changes(&temp_path, "initial commit").unwrap();
4476
4477        // Case 1: Untracked file
4478        let file_untracked = temp_path.join("untracked.txt");
4479        std::fs::write(&file_untracked, "new untracked file\n").unwrap();
4480        assert!(file_untracked.exists());
4481        discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
4482        assert!(!file_untracked.exists());
4483
4484        // Case 2: Tracked file with unstaged modification
4485        std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
4486        discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4487        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4488
4489        // Case 3: Tracked file with staged modification
4490        std::fs::write(&file_tracked, "staged modifications\n").unwrap();
4491        stage_file(&temp_path, "tracked.txt").unwrap();
4492        // verify it's staged
4493        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4494        match detail {
4495            ItemDetail::Repo { info, .. } => {
4496                assert!(!info.changes.staged.is_empty());
4497            }
4498            _ => panic!("Expected ItemDetail::Repo"),
4499        }
4500        discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
4501        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4502        // verify it's no longer staged/unstaged (it's clean)
4503        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
4504        match detail {
4505            ItemDetail::Repo { info, .. } => {
4506                assert!(info.changes.staged.is_empty());
4507                assert!(info.changes.unstaged.is_empty());
4508            }
4509            _ => panic!("Expected ItemDetail::Repo"),
4510        }
4511
4512        // Case 4: Tracked deleted file
4513        std::fs::remove_file(&file_tracked).unwrap();
4514        assert!(!file_tracked.exists());
4515        discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
4516        assert!(file_tracked.exists());
4517        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
4518
4519        // Clean up
4520        let _ = std::fs::remove_dir_all(&temp_path);
4521    }
4522
4523    #[test]
4524    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4525    fn test_stage_unstage_by_hunk() {
4526        let mut temp_path = std::env::temp_dir();
4527        temp_path.push(format!(
4528            "twig_test_hunk_{}",
4529            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4530        ));
4531        std::fs::create_dir_all(&temp_path).unwrap();
4532
4533        // Init repo
4534        let repo = Repository::init(&temp_path).unwrap();
4535
4536        // Configure author
4537        let mut config = repo.config().unwrap();
4538        config.set_str("user.name", "Test User").unwrap();
4539        config.set_str("user.email", "test@example.com").unwrap();
4540
4541        // Create initial file with multiple lines
4542        let file_path = temp_path.join("multihunk.txt");
4543        let mut file = File::create(&file_path).unwrap();
4544        for i in 1..=20 {
4545            writeln!(file, "Line {}", i).unwrap();
4546        }
4547        drop(file);
4548
4549        // Stage and commit initial
4550        stage_file(&temp_path, "multihunk.txt").unwrap();
4551        commit_changes(&temp_path, "initial commit").unwrap();
4552
4553        // Now modify lines 2 and 18 to create two distinct hunks
4554        let mut file = File::create(&file_path).unwrap();
4555        for i in 1..=20 {
4556            if i == 2 || i == 18 {
4557                writeln!(file, "Line {} modified", i).unwrap();
4558            } else {
4559                writeln!(file, "Line {}", i).unwrap();
4560            }
4561        }
4562        drop(file);
4563
4564        // Get the unstaged diff lines
4565        let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4566        // Identify hunk ranges. A hunk header starts with "@@"
4567        let mut hunk_ranges = Vec::new();
4568        let mut current_start = None;
4569        for (i, line) in diff_lines.iter().enumerate() {
4570            if line.kind == DiffLineKind::Header {
4571                if let Some(start) = current_start {
4572                    hunk_ranges.push(start..i);
4573                }
4574                current_start = Some(i);
4575            }
4576        }
4577        if let Some(start) = current_start {
4578            hunk_ranges.push(start..diff_lines.len());
4579        }
4580
4581        // We expect exactly 2 hunks
4582        assert_eq!(hunk_ranges.len(), 2);
4583
4584        // Stage the second hunk
4585        let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4586        stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
4587
4588        // Now check staged diff for the file: it should contain the second modification
4589        let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4590        let staged_content: String =
4591            staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4592        assert!(staged_content.contains("Line 18 modified"));
4593        assert!(!staged_content.contains("Line 2 modified"));
4594
4595        // Check unstaged diff for the file: it should contain the first modification
4596        let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
4597        let unstaged_content: String =
4598            unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
4599        assert!(unstaged_content.contains("Line 2 modified"));
4600        assert!(!unstaged_content.contains("Line 18 modified"));
4601
4602        // Unstage the staged hunk
4603        let staged_hunk_ranges = {
4604            let mut ranges = Vec::new();
4605            let mut current_start = None;
4606            for (i, line) in staged_diff.iter().enumerate() {
4607                if line.kind == DiffLineKind::Header {
4608                    if let Some(start) = current_start {
4609                        ranges.push(start..i);
4610                    }
4611                    current_start = Some(i);
4612                }
4613            }
4614            if let Some(start) = current_start {
4615                ranges.push(start..staged_diff.len());
4616            }
4617            ranges
4618        };
4619        assert_eq!(staged_hunk_ranges.len(), 1);
4620        let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
4621        unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
4622
4623        // Staged diff should now be empty
4624        let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
4625        assert!(staged_diff_after.is_empty());
4626
4627        // Clean up
4628        let _ = std::fs::remove_dir_all(&temp_path);
4629    }
4630
4631    #[test]
4632    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4633    fn test_discard_hunk() {
4634        let mut temp_path = std::env::temp_dir();
4635        temp_path.push(format!(
4636            "twig_test_discard_h_{}",
4637            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4638        ));
4639        std::fs::create_dir_all(&temp_path).unwrap();
4640
4641        // Init repo
4642        let repo = Repository::init(&temp_path).unwrap();
4643
4644        // Configure author
4645        let mut config = repo.config().unwrap();
4646        config.set_str("user.name", "Test User").unwrap();
4647        config.set_str("user.email", "test@example.com").unwrap();
4648
4649        // Create initial file with multiple lines
4650        let file_path = temp_path.join("discardhunk.txt");
4651        let mut file = File::create(&file_path).unwrap();
4652        for i in 1..=20 {
4653            writeln!(file, "Line {}", i).unwrap();
4654        }
4655        drop(file);
4656
4657        // Stage and commit initial
4658        stage_file(&temp_path, "discardhunk.txt").unwrap();
4659        commit_changes(&temp_path, "initial commit").unwrap();
4660
4661        // Now modify lines 2 and 18 to create two distinct hunks
4662        let mut file = File::create(&file_path).unwrap();
4663        for i in 1..=20 {
4664            if i == 2 || i == 18 {
4665                writeln!(file, "Line {} modified", i).unwrap();
4666            } else {
4667                writeln!(file, "Line {}", i).unwrap();
4668            }
4669        }
4670        drop(file);
4671
4672        // Get the unstaged diff lines
4673        let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
4674        // Identify hunk ranges
4675        let mut hunk_ranges = Vec::new();
4676        let mut current_start = None;
4677        for (i, line) in diff_lines.iter().enumerate() {
4678            if line.kind == DiffLineKind::Header {
4679                if let Some(start) = current_start {
4680                    hunk_ranges.push(start..i);
4681                }
4682                current_start = Some(i);
4683            }
4684        }
4685        if let Some(start) = current_start {
4686            hunk_ranges.push(start..diff_lines.len());
4687        }
4688
4689        // We expect exactly 2 hunks
4690        assert_eq!(hunk_ranges.len(), 2);
4691
4692        // Discard the second hunk (Line 18 modified)
4693        let hunk2 = &diff_lines[hunk_ranges[1].clone()];
4694        discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
4695
4696        // Now check file contents: line 18 should be reverted to "Line 18", while line 2 should remain "Line 2 modified"
4697        let contents = std::fs::read_to_string(&file_path).unwrap();
4698        assert!(contents.contains("Line 2 modified"));
4699        assert!(contents.contains("Line 18\n"));
4700        assert!(!contents.contains("Line 18 modified"));
4701
4702        // Clean up
4703        let _ = std::fs::remove_dir_all(&temp_path);
4704    }
4705
4706    #[test]
4707    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4708    fn test_stage_unstage_discard_line() {
4709        let mut temp_path = std::env::temp_dir();
4710        temp_path.push(format!(
4711            "twig_test_line_{}",
4712            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4713        ));
4714        std::fs::create_dir_all(&temp_path).unwrap();
4715
4716        let repo = Repository::init(&temp_path).unwrap();
4717        let mut config = repo.config().unwrap();
4718        config.set_str("user.name", "Test User").unwrap();
4719        config.set_str("user.email", "test@example.com").unwrap();
4720
4721        // 1. Create initial file
4722        let file_path = temp_path.join("line_test.txt");
4723        let mut file = File::create(&file_path).unwrap();
4724        writeln!(file, "line A").unwrap();
4725        writeln!(file, "line B").unwrap();
4726        writeln!(file, "line C").unwrap();
4727        drop(file);
4728
4729        stage_file(&temp_path, "line_test.txt").unwrap();
4730        commit_changes(&temp_path, "initial").unwrap();
4731
4732        // 2. Modify to introduce two distinct changes in one hunk
4733        let mut file = File::create(&file_path).unwrap();
4734        writeln!(file, "line A modified").unwrap();
4735        writeln!(file, "line B").unwrap();
4736        writeln!(file, "line C modified").unwrap();
4737        drop(file);
4738
4739        let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4740        let mut hunk_ranges = Vec::new();
4741        let mut current_start = None;
4742        for (i, line) in diff_lines.iter().enumerate() {
4743            if line.kind == DiffLineKind::Header {
4744                if let Some(start) = current_start {
4745                    hunk_ranges.push(start..i);
4746                }
4747                current_start = Some(i);
4748            }
4749        }
4750        if let Some(start) = current_start {
4751            hunk_ranges.push(start..diff_lines.len());
4752        }
4753
4754        assert_eq!(hunk_ranges.len(), 1);
4755        let hunk0 = &diff_lines[hunk_ranges[0].clone()];
4756
4757        assert_eq!(hunk0[2].content, "line A modified");
4758        assert_eq!(hunk0[5].content, "line C modified");
4759
4760        // A) Stage line A modified (relative index 2)
4761        stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
4762
4763        // Check staged diff
4764        let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
4765        assert!(
4766            staged_diff
4767                .iter()
4768                .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4769        );
4770        assert!(
4771            !staged_diff
4772                .iter()
4773                .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4774        );
4775
4776        // Check unstaged diff
4777        let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4778        assert!(
4779            !unstaged_diff
4780                .iter()
4781                .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
4782        );
4783        assert!(
4784            unstaged_diff
4785                .iter()
4786                .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
4787        );
4788
4789        // B) Unstage line A modified
4790        assert_eq!(staged_diff[2].content, "line A modified");
4791        unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
4792
4793        // Staged diff should now be empty
4794        assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
4795
4796        // C) Discard line C modified (index 5) in unstaged diff
4797        let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4798        assert_eq!(unstaged_diff2[5].content, "line C modified");
4799        discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
4800
4801        let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
4802        let remove_idx = unstaged_diff3
4803            .iter()
4804            .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
4805            .unwrap();
4806        discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
4807
4808        // File contents check
4809        let contents = std::fs::read_to_string(&file_path).unwrap();
4810        assert!(contents.contains("line A modified"));
4811        assert!(contents.contains("line B"));
4812        assert!(contents.contains("line C\n"));
4813        assert!(!contents.contains("line C modified"));
4814
4815        // Clean up
4816        let _ = std::fs::remove_dir_all(&temp_path);
4817    }
4818
4819    #[test]
4820    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
4821    fn test_stage_unstage_discard_all_changes() {
4822        let mut temp_path = std::env::temp_dir();
4823        temp_path.push(format!(
4824            "twig_test_all_{}",
4825            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4826        ));
4827        std::fs::create_dir_all(&temp_path).unwrap();
4828
4829        // Init repo
4830        let repo = Repository::init(&temp_path).unwrap();
4831
4832        // Configure author
4833        let mut config = repo.config().unwrap();
4834        config.set_str("user.name", "Test User").unwrap();
4835        config.set_str("user.email", "test@example.com").unwrap();
4836
4837        // Create initial file & commit it
4838        let file_path = temp_path.join("tracked.txt");
4839        std::fs::write(&file_path, "original content\n").unwrap();
4840        stage_file(&temp_path, "tracked.txt").unwrap();
4841        commit_changes(&temp_path, "initial").unwrap();
4842
4843        // 1. Make a modification and create a new untracked file
4844        std::fs::write(&file_path, "modified content\n").unwrap();
4845        let untracked_path = temp_path.join("untracked.txt");
4846        std::fs::write(&untracked_path, "untracked content\n").unwrap();
4847
4848        // Verify status has unstaged changes
4849        let status = repo.statuses(None).unwrap();
4850        assert_eq!(status.len(), 2);
4851
4852        // Stage all changes
4853        stage_all_changes(&temp_path).unwrap();
4854
4855        // Verify all changes are staged
4856        let status = repo.statuses(None).unwrap();
4857        for entry in status.iter() {
4858            assert!(
4859                entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
4860            );
4861        }
4862
4863        // Unstage all changes
4864        unstage_all_changes(&temp_path).unwrap();
4865
4866        // Verify all changes are unstaged again
4867        let status = repo.statuses(None).unwrap();
4868        for entry in status.iter() {
4869            assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
4870        }
4871
4872        // Discard all changes
4873        discard_all_changes(&temp_path).unwrap();
4874
4875        // Verify repo is completely clean
4876        let status = repo.statuses(None).unwrap();
4877        assert_eq!(status.len(), 0);
4878
4879        // Verify tracked file is reset and untracked file is removed
4880        let contents = std::fs::read_to_string(&file_path).unwrap();
4881        assert_eq!(contents, "original content\n");
4882        assert!(!untracked_path.exists());
4883
4884        // Clean up
4885        let _ = std::fs::remove_dir_all(&temp_path);
4886    }
4887
4888    #[test]
4889    fn test_merge_conflicts_flow() {
4890        let mut temp_path = std::env::temp_dir();
4891        temp_path.push(format!(
4892            "twig_test_conflict_{}",
4893            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4894        ));
4895        std::fs::create_dir_all(&temp_path).unwrap();
4896
4897        // Init repo
4898        let repo = Repository::init(&temp_path).unwrap();
4899
4900        // Configure author
4901        let mut config = repo.config().unwrap();
4902        config.set_str("user.name", "Test User").unwrap();
4903        config.set_str("user.email", "test@example.com").unwrap();
4904
4905        // 1. Initial commit on main
4906        let file_path = temp_path.join("conflict.txt");
4907        std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
4908        stage_file(&temp_path, "conflict.txt").unwrap();
4909        commit_changes(&temp_path, "initial commit").unwrap();
4910
4911        // Get the main branch name first
4912        let output = git_command()
4913            .args(["symbolic-ref", "--short", "HEAD"])
4914            .current_dir(&temp_path)
4915            .output()
4916            .unwrap();
4917        let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
4918
4919        // 2. Create feature branch and edit
4920        git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
4921
4922        std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
4923        stage_file(&temp_path, "conflict.txt").unwrap();
4924        commit_changes(&temp_path, "feature commit").unwrap();
4925
4926        // 3. Checkout main/master and edit differently
4927        git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
4928
4929        std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
4930        stage_file(&temp_path, "conflict.txt").unwrap();
4931        commit_changes(&temp_path, "main commit").unwrap();
4932
4933        // 4. Merge feature into main -> conflict
4934        assert!(!is_merging(&temp_path));
4935        let merge_output =
4936            git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4937
4938        assert!(!merge_output.status.success());
4939        assert!(is_merging(&temp_path));
4940
4941        // 5. Check conflict markers diff
4942        let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
4943        assert!(!diff.is_empty());
4944        let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
4945        let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
4946        let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
4947        assert!(has_separator);
4948        assert!(has_ours);
4949        assert!(has_theirs);
4950
4951        // 6. Abort merge and verify
4952        abort_merge(&temp_path).unwrap();
4953        assert!(!is_merging(&temp_path));
4954
4955        // 7. Conflict again to test resolve_ours/resolve_theirs
4956        git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4957        assert!(is_merging(&temp_path));
4958
4959        // Test resolve_ours
4960        resolve_ours(&temp_path, "conflict.txt").unwrap();
4961        let contents = std::fs::read_to_string(&file_path).unwrap();
4962        assert!(contents.contains("line 2 on main"));
4963        assert!(!contents.contains("<<<<<<<"));
4964
4965        // Since it's resolved, we can continue merge
4966        continue_merge(&temp_path).unwrap();
4967        assert!(!is_merging(&temp_path));
4968
4969        // 8. Test resolve_theirs by resetting main to before the merge
4970        git_command().args(["reset", "--hard", "HEAD~1"]).current_dir(&temp_path).output().unwrap();
4971
4972        git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
4973        assert!(is_merging(&temp_path));
4974
4975        resolve_theirs(&temp_path, "conflict.txt").unwrap();
4976        let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
4977        assert!(contents_theirs.contains("line 2 on feature"));
4978        assert!(!contents_theirs.contains("<<<<<<<"));
4979
4980        continue_merge(&temp_path).unwrap();
4981        assert!(!is_merging(&temp_path));
4982
4983        // Clean up
4984        let _ = std::fs::remove_dir_all(&temp_path);
4985    }
4986
4987    #[test]
4988    fn test_resolve_conflict_hunk() {
4989        let mut temp_path = std::env::temp_dir();
4990        temp_path.push(format!(
4991            "twig_test_hunk_conflict_{}",
4992            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
4993        ));
4994        std::fs::create_dir_all(&temp_path).unwrap();
4995
4996        // Init repo
4997        let repo = Repository::init(&temp_path).unwrap();
4998
4999        // Configure author
5000        let mut config = repo.config().unwrap();
5001        config.set_str("user.name", "Test User").unwrap();
5002        config.set_str("user.email", "test@example.com").unwrap();
5003
5004        // 1. Initial commit on main
5005        let file_path = temp_path.join("conflict.txt");
5006        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";
5007        std::fs::write(&file_path, initial_lines).unwrap();
5008        stage_file(&temp_path, "conflict.txt").unwrap();
5009        commit_changes(&temp_path, "initial commit").unwrap();
5010
5011        // Get the main branch name first
5012        let output = git_command()
5013            .args(["symbolic-ref", "--short", "HEAD"])
5014            .current_dir(&temp_path)
5015            .output()
5016            .unwrap();
5017        let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
5018
5019        // 2. Create feature branch and edit line 2 and line 11
5020        git_command().args(["checkout", "-b", "feature"]).current_dir(&temp_path).output().unwrap();
5021        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";
5022        std::fs::write(&file_path, feature_lines).unwrap();
5023        stage_file(&temp_path, "conflict.txt").unwrap();
5024        commit_changes(&temp_path, "feature commit").unwrap();
5025
5026        // 3. Checkout main/master and edit line 2 and line 11 differently
5027        git_command().args(["checkout", &main_branch]).current_dir(&temp_path).output().unwrap();
5028        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";
5029        std::fs::write(&file_path, main_lines).unwrap();
5030        stage_file(&temp_path, "conflict.txt").unwrap();
5031        commit_changes(&temp_path, "main commit").unwrap();
5032
5033        // 4. Merge feature into main -> conflict
5034        let merge_output =
5035            git_command().args(["merge", "feature"]).current_dir(&temp_path).output().unwrap();
5036        assert!(!merge_output.status.success());
5037        assert!(is_merging(&temp_path));
5038
5039        // 5. Resolve first hunk as Ours
5040        resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
5041        let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
5042        // Line 2 should be resolved to main
5043        assert!(contents_after_first.contains("line 2 on main"));
5044        assert!(!contents_after_first.contains("line 2 on feature"));
5045        // Line 11 should still have conflict markers
5046        assert!(contents_after_first.contains("<<<<<<<"));
5047        assert!(contents_after_first.contains("line 11 on main"));
5048        assert!(contents_after_first.contains("line 11 on feature"));
5049
5050        // Repo should still be in a merging state because 1 conflict hunk remains
5051        assert!(is_merging(&temp_path));
5052
5053        // 6. Resolve second hunk (which is now hunk 0, since hunk 0 was resolved and removed)
5054        // Wait, did the hunk count change? Yes, the first conflict block was removed,
5055        // so the remaining conflict block at line 11 becomes the 0th hunk in the file!
5056        // Let's call resolve_conflict_hunk with hunk_idx 0!
5057        resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
5058        let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
5059        // Both lines should be resolved, no conflict markers left
5060        assert!(contents_after_second.contains("line 2 on main"));
5061        assert!(contents_after_second.contains("line 11 on feature"));
5062        assert!(!contents_after_second.contains("<<<<<<<"));
5063
5064        // File is fully resolved so it should have been automatically staged
5065        let status = repo.statuses(None).unwrap();
5066        assert_eq!(status.len(), 1);
5067        assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
5068
5069        // Continue and finalize the merge
5070        continue_merge(&temp_path).unwrap();
5071        assert!(!is_merging(&temp_path));
5072
5073        // Clean up
5074        let _ = std::fs::remove_dir_all(&temp_path);
5075    }
5076
5077    #[test]
5078    fn test_branch_and_commit_helpers() {
5079        let mut temp_path = std::env::temp_dir();
5080        temp_path.push(format!(
5081            "twig_test_helpers_{}",
5082            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5083        ));
5084        let _ = std::fs::remove_dir_all(&temp_path);
5085        std::fs::create_dir_all(&temp_path).unwrap();
5086
5087        // 1. Initialize repository
5088        let repo = Repository::init(&temp_path).unwrap();
5089
5090        // Configure author
5091        let mut config = repo.config().unwrap();
5092        config.set_str("user.name", "Test User").unwrap();
5093        config.set_str("user.email", "test@example.com").unwrap();
5094
5095        // 2. Create initial commit (root commit)
5096        let file_path = temp_path.join("test.txt");
5097        std::fs::write(&file_path, "root content").unwrap();
5098        stage_file(&temp_path, "test.txt").unwrap();
5099        commit_changes(&temp_path, "root commit").unwrap();
5100
5101        let head_oid = repo.head().unwrap().target().unwrap().to_string();
5102        assert!(is_root_commit(&temp_path, &head_oid));
5103
5104        // 3. Create second commit (non-root commit)
5105        std::fs::write(&file_path, "second content").unwrap();
5106        stage_file(&temp_path, "test.txt").unwrap();
5107        commit_changes(&temp_path, "second commit").unwrap();
5108
5109        let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
5110        assert!(!is_root_commit(&temp_path, &new_head_oid));
5111
5112        // 4. Test push target with first remote config
5113        // Currently there are no remotes, so it should return None or fallback.
5114        // Wait, get_branch_push_target returns None if no remotes are found and no upstream config.
5115        // Let's add a remote.
5116        remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
5117        let active_branch = if repo.find_branch("master", git2::BranchType::Local).is_ok() {
5118            "master"
5119        } else {
5120            "main"
5121        };
5122        let target = get_branch_push_target(&temp_path, active_branch);
5123        assert!(target.is_some());
5124        let (remote_name, set_upstream) = target.unwrap();
5125        assert_eq!(remote_name, "origin");
5126        assert!(set_upstream);
5127
5128        // Configure tracking branch
5129        let mut git_config = repo.config().unwrap();
5130        git_config.set_str(&format!("branch.{}.remote", active_branch), "origin").unwrap();
5131        git_config
5132            .set_str(
5133                &format!("branch.{}.merge", active_branch),
5134                &format!("refs/heads/{}", active_branch),
5135            )
5136            .unwrap();
5137
5138        // Create mock remote tracking reference so resolve succeeds
5139        let commit_obj = repo.head().unwrap().peel_to_commit().unwrap();
5140        repo.reference(
5141            &format!("refs/remotes/origin/{}", active_branch),
5142            commit_obj.id(),
5143            true,
5144            "create mock remote ref",
5145        )
5146        .unwrap();
5147
5148        // Test has_upstream_remote and get_branch_upstream_remote
5149        assert!(has_upstream_remote(&temp_path, active_branch));
5150        assert_eq!(
5151            get_branch_upstream_remote(&temp_path, active_branch),
5152            Some("origin".to_string())
5153        );
5154
5155        // Test push target with tracking branch configured
5156        let target_tracking = get_branch_push_target(&temp_path, active_branch);
5157        assert!(target_tracking.is_some());
5158        let (remote_name_tr, set_upstream_tr) = target_tracking.unwrap();
5159        assert_eq!(remote_name_tr, "origin");
5160        assert!(!set_upstream_tr);
5161
5162        // Clean up
5163        let _ = std::fs::remove_dir_all(&temp_path);
5164    }
5165
5166    #[test]
5167    fn test_core_helpers_comprehensive() {
5168        // 1. format_relative_time
5169        assert_eq!(format_relative_time(0), "unknown");
5170        assert_eq!(format_relative_time(-100), "unknown");
5171
5172        let now_sec = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
5173        assert_eq!(format_relative_time(now_sec - 10), "10 seconds ago");
5174        assert_eq!(format_relative_time(now_sec - 120), "2 minutes ago");
5175        assert_eq!(format_relative_time(now_sec - 7200), "2 hours ago");
5176        assert_eq!(format_relative_time(now_sec - 86400 * 2), "2 days ago");
5177        assert_eq!(format_relative_time(now_sec - 86400 * 60), "2 months ago");
5178        assert_eq!(format_relative_time(now_sec - 86400 * 730), "2 years ago");
5179
5180        // 2. format_utc_date
5181        assert_eq!(format_utc_date(0), "unknown");
5182        assert_eq!(format_utc_date(-1), "unknown");
5183        let date_str = format_utc_date(1700000000);
5184        assert!(date_str.contains("2023-11-14"));
5185
5186        // 3. expand_tilde
5187        assert_eq!(expand_tilde("path"), PathBuf::from("path"));
5188        let tilde_path = expand_tilde("~/path");
5189        assert!(tilde_path.is_absolute() || tilde_path.to_string_lossy().contains("path"));
5190
5191        // 4. sanitize_text
5192        assert_eq!(sanitize_text("hello\nworld\r\t\x01"), "hello\nworld\r\t ");
5193        assert_eq!(sanitize_text("hello\x1B[31mworld\x1B B"), "helloworldB");
5194        assert_eq!(sanitize_text("M4 — UAS"), "M4 — UAS");
5195        assert_eq!(
5196            sanitize_text("feat: support utf-8 — – … 🚀 日本語"),
5197            "feat: support utf-8 — – … 🚀 日本語"
5198        );
5199
5200        // 5. safe_sha_slice
5201        assert_eq!(safe_sha_slice("12345", 10), "12345");
5202        assert_eq!(safe_sha_slice("12345", 3), "123");
5203
5204        // 6. safe_ref
5205        assert_eq!(safe_ref("  refs/heads/main  "), Ok("refs/heads/main"));
5206        assert!(safe_ref("-invalid").is_err());
5207        assert!(safe_ref("   ").is_err());
5208
5209        // 7. RepoSummary helpers
5210        let mut status = RepoSummary::default();
5211        assert!(status.is_clean());
5212        assert!(status.is_synced());
5213        assert!(status.unchanged());
5214
5215        status.modified = 1;
5216        assert!(!status.is_clean());
5217        assert!(!status.unchanged());
5218
5219        status.modified = 0;
5220        status.ahead = 1;
5221        assert!(!status.is_synced());
5222        assert!(!status.unchanged());
5223
5224        // 8. TabData helpers
5225        let data_not_loaded: TabData<Vec<i32>> = TabData::NotLoaded;
5226        assert!(data_not_loaded.is_not_loaded());
5227        assert!(!data_not_loaded.is_loading());
5228        assert!(!data_not_loaded.is_loaded());
5229        assert_eq!(data_not_loaded.len(), 0);
5230        assert!(data_not_loaded.is_empty());
5231        assert!(data_not_loaded.as_ref().is_none());
5232        assert!(data_not_loaded.first().is_none());
5233        assert!(data_not_loaded.get(0).is_none());
5234        assert_eq!(data_not_loaded.iter().count(), 0);
5235        assert_eq!(data_not_loaded.as_slice().len(), 0);
5236
5237        let data_loading: TabData<Vec<i32>> = TabData::Loading;
5238        assert!(!data_loading.is_not_loaded());
5239        assert!(data_loading.is_loading());
5240
5241        let data_loaded = TabData::Loaded(vec![10, 20]);
5242        assert!(!data_loaded.is_not_loaded());
5243        assert!(!data_loaded.is_loading());
5244        assert!(data_loaded.is_loaded());
5245        assert_eq!(data_loaded.len(), 2);
5246        assert!(!data_loaded.is_empty());
5247        assert_eq!(data_loaded.as_ref().unwrap(), &vec![10, 20]);
5248        assert_eq!(data_loaded.first().unwrap(), &10);
5249        assert_eq!(data_loaded.get(1).unwrap(), &20);
5250        assert_eq!(data_loaded.iter().count(), 2);
5251        assert_eq!(data_loaded.as_slice(), &[10, 20]);
5252    }
5253
5254    #[test]
5255    fn test_comprehensive_gitwig_core_coverage_booster() {
5256        // 1. Trigger the then_with tie-breaker in collect_committer_stats
5257        let mut temp_path = std::env::temp_dir();
5258        temp_path.push(format!(
5259            "twig_test_boost_{}",
5260            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
5261        ));
5262        std::fs::create_dir_all(&temp_path).unwrap();
5263
5264        let repo = Repository::init(&temp_path).unwrap();
5265
5266        // Commit 1 under User A
5267        {
5268            let mut config = repo.config().unwrap();
5269            config.set_str("user.name", "User A").unwrap();
5270            config.set_str("user.email", "a@example.com").unwrap();
5271            std::fs::write(temp_path.join("file1.txt"), "content A").unwrap();
5272            stage_file(&temp_path, "file1.txt").unwrap();
5273            commit_changes(&temp_path, "commit A").unwrap();
5274        }
5275
5276        // Commit 2 under User B (both have 1 commit count)
5277        {
5278            let mut config = repo.config().unwrap();
5279            config.set_str("user.name", "User B").unwrap();
5280            config.set_str("user.email", "b@example.com").unwrap();
5281            std::fs::write(temp_path.join("file2.txt"), "content B").unwrap();
5282            stage_file(&temp_path, "file2.txt").unwrap();
5283            commit_changes(&temp_path, "commit B").unwrap();
5284        }
5285
5286        // Call collect_committer_stats - will sort and execute the then_with closure
5287        let (stats, _) = collect_committer_stats(&repo, 10).unwrap();
5288        assert_eq!(stats.len(), 2);
5289        assert_eq!(stats[0].count, 1);
5290        assert_eq!(stats[1].count, 1);
5291
5292        // 2. Trigger deleted file old_file().path() or_else fallback in commit_changed_files
5293        // Delete file1.txt
5294        std::fs::remove_file(temp_path.join("file1.txt")).unwrap();
5295        stage_file(&temp_path, "file1.txt").unwrap();
5296        commit_changes(&temp_path, "deleted file1").unwrap();
5297        let head_oid = repo.head().unwrap().target().unwrap().to_string();
5298        let files = get_commit_files(&temp_path, &head_oid).unwrap();
5299        assert!(files.iter().any(|f| f.path == "file1.txt" && f.label == "D"));
5300
5301        // 3. Trigger expand_tilde
5302        let path_tilde = expand_tilde("~");
5303        assert!(path_tilde.is_absolute());
5304
5305        // 4. Trigger calculate_dir_size inside get_lfs_storage_size
5306        let lfs_dir = temp_path.join(".git").join("lfs");
5307        std::fs::create_dir_all(&lfs_dir).unwrap();
5308        let nested_dir = lfs_dir.join("objects").join("xx");
5309        std::fs::create_dir_all(&nested_dir).unwrap();
5310        std::fs::write(nested_dir.join("dummy_lfs"), "hello").unwrap();
5311        let lfs_size = get_lfs_storage_size(&temp_path);
5312        assert_eq!(lfs_size, Some(5));
5313
5314        // 5. Trigger load_tab_submodules loop closures
5315        let mut sub = repo
5316            .submodule("https://github.com/example/sub.git", Path::new("sub-path"), true)
5317            .unwrap();
5318        let _ = sub.init(false);
5319        let subs = load_tab_submodules(&temp_path).unwrap();
5320        assert!(!subs.is_empty());
5321
5322        // 6. Test worktree locking/unlocking/pruning
5323        let wt_path = temp_path.join("wt-booster");
5324        let _ = worktree_add(&temp_path, "wt-branch", &wt_path);
5325        let _ = worktree_lock(&temp_path, "wt-booster", "lock reason");
5326        let _ = worktree_unlock(&temp_path, "wt-booster");
5327        let _ = worktree_prune(&temp_path);
5328        let _ = std::fs::remove_dir_all(&wt_path);
5329
5330        // 7. Test open_browser
5331        open_browser("");
5332
5333        // 8. Test error paths for almost all gitwig-core functions
5334        let nonexistent = Path::new("/nonexistent_directory_twig_test");
5335        let _ = stage_file(nonexistent, "test.txt");
5336        let _ = unstage_file(nonexistent, "test.txt");
5337        let _ = discard_all_changes(nonexistent);
5338        let _ = discard_file_changes(nonexistent, "test.txt", false);
5339        let _ = commit_changes(nonexistent, "msg");
5340        let _ = remote_add(nonexistent, "origin", "url");
5341        let _ = get_file_history(nonexistent, "test.txt");
5342        let _ = get_file_blame(nonexistent, "test.txt");
5343        let _ = get_commit_files(nonexistent, "sha");
5344        let _ = load_tab_reflog(nonexistent);
5345        let _ = load_tab_files(nonexistent);
5346        let _ = load_tab_branches(nonexistent);
5347        let _ = load_tab_tags(nonexistent);
5348        let _ = load_tab_remotes(nonexistent);
5349        let _ = load_tab_stashes(nonexistent);
5350        let _ = load_tab_overview(nonexistent, 10);
5351        let _ = load_tab_submodules(nonexistent);
5352        let _ = checkout_remote_branch(nonexistent, "origin/branch");
5353        let _ = checkout_local_branch(nonexistent, "branch");
5354        let _ = checkout_tag(nonexistent, "tag");
5355        let _ = delete_local_branch(nonexistent, "branch");
5356        let _ = delete_remote_branch(nonexistent, "origin/branch");
5357        let _ = create_tag(nonexistent, "tag", "sha", None, false);
5358        let _ = delete_remote_tag(nonexistent, "origin", "tag", Duration::from_secs(5));
5359        let _ = get_remote_tags(nonexistent, "origin", Duration::from_secs(5));
5360        let _ = apply_stash(nonexistent, 0);
5361        let _ = delete_stash(nonexistent, 0);
5362        let _ = save_stash(nonexistent, "msg", false, false);
5363        let _ = commit_amend(nonexistent, "msg");
5364        let _ = resolve_ours(nonexistent, "file");
5365        let _ = resolve_theirs(nonexistent, "file");
5366        let _ = resolve_conflict_hunk(nonexistent, "file", 0, true);
5367        let _ = abort_merge(nonexistent);
5368        let _ = continue_merge(nonexistent);
5369        let _ = get_branch_upstream_remote(nonexistent, "branch");
5370        let _ = load_tab_worktrees(nonexistent);
5371        let _ = worktree_add(nonexistent, "branch", Path::new("path"));
5372        let _ = worktree_lock(nonexistent, "name", "reason");
5373        let _ = worktree_unlock(nonexistent, "name");
5374        let _ = worktree_remove(nonexistent, "name", true);
5375        let _ = worktree_prune(nonexistent);
5376        let _ = load_tab_forge_issues(nonexistent, true);
5377        let _ = load_tab_forge_prs(nonexistent);
5378        let _ = load_pr_comments(nonexistent, 1);
5379        let _ = checkout_pr_branch(nonexistent, 1);
5380        let _ = resolve_and_checkout_issue_branch(nonexistent, 1);
5381        let _ = get_remote_url(nonexistent, "origin");
5382        let _ = get_default_remote_url(nonexistent);
5383        let _ = get_latest_change_time("/nonexistent");
5384
5385        let dummy_hunk = vec![
5386            DiffLine {
5387                kind: DiffLineKind::Header,
5388                content: "@@ -1,1 +1,1 @@".to_string(),
5389                old_lineno: None,
5390                new_lineno: None,
5391                hunk_idx: None,
5392            },
5393            DiffLine {
5394                kind: DiffLineKind::Added,
5395                content: "line".to_string(),
5396                old_lineno: None,
5397                new_lineno: None,
5398                hunk_idx: None,
5399            },
5400        ];
5401        let _ = stage_line(nonexistent, "file", &dummy_hunk, 1);
5402        let _ = unstage_line(nonexistent, "file", &dummy_hunk, 1);
5403        let _ = discard_line(nonexistent, "file", &dummy_hunk, 1);
5404        let _ = stage_hunk(nonexistent, "file", &dummy_hunk);
5405        let _ = unstage_hunk(nonexistent, "file", &dummy_hunk);
5406        let _ = discard_hunk(nonexistent, "file", &dummy_hunk);
5407
5408        // Clean up
5409        let _ = std::fs::remove_dir_all(&temp_path);
5410    }
5411}