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