Skip to main content

gitwig_core/
lib.rs

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