Skip to main content

gitwig_core/
lib.rs

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