Skip to main content

gitwig_core/
lib.rs

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