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