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