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