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