Skip to main content

gitwig_core/
lib.rs

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