Skip to main content

gitwig_core/
lib.rs

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