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