Skip to main content

git_cli/
commit.rs

1use crate::clipboard;
2use crate::commit_json;
3use crate::commit_shared::{
4    DiffNumstat, diff_numstat, git_output, git_status_success, git_stdout_trimmed_optional,
5    is_lockfile, parse_name_status_z, trim_trailing_newlines,
6};
7use crate::prompt;
8use crate::util;
9use anyhow::{Result, anyhow};
10use nils_common::git::{self as common_git, GitContextError};
11use nils_common::shell::{AnsiStripMode, strip_ansi as strip_ansi_impl};
12use std::env;
13use std::io::Write;
14use std::process::{Command, Stdio};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17enum OutputMode {
18    Clipboard,
19    Stdout,
20    Both,
21}
22
23struct ContextArgs {
24    mode: OutputMode,
25    no_color: bool,
26    include_patterns: Vec<String>,
27    extra_args: Vec<String>,
28}
29
30enum ParseOutcome<T> {
31    Continue(T),
32    Exit(i32),
33}
34
35enum CommitCommand {
36    Context,
37    ContextJson,
38    ToStash,
39}
40
41pub fn dispatch(cmd_raw: &str, args: &[String]) -> i32 {
42    match parse_command(cmd_raw) {
43        Some(CommitCommand::Context) => run_context(args),
44        Some(CommitCommand::ContextJson) => commit_json::run(args),
45        Some(CommitCommand::ToStash) => run_to_stash(args),
46        None => {
47            eprintln!("Unknown commit command: {cmd_raw}");
48            2
49        }
50    }
51}
52
53fn parse_command(raw: &str) -> Option<CommitCommand> {
54    match raw {
55        "context" => Some(CommitCommand::Context),
56        "context-json" | "context_json" | "contextjson" | "json" => {
57            Some(CommitCommand::ContextJson)
58        }
59        "to-stash" | "stash" => Some(CommitCommand::ToStash),
60        _ => None,
61    }
62}
63
64fn run_context(args: &[String]) -> i32 {
65    if !ensure_git_work_tree() {
66        return 1;
67    }
68
69    let parsed = match parse_context_args(args) {
70        ParseOutcome::Continue(value) => value,
71        ParseOutcome::Exit(code) => return code,
72    };
73
74    if !parsed.extra_args.is_empty() {
75        eprintln!(
76            "⚠️  Ignoring unknown arguments: {}",
77            parsed.extra_args.join(" ")
78        );
79    }
80
81    let diff_output = match git_output(&[
82        "-c",
83        "core.quotepath=false",
84        "diff",
85        "--cached",
86        "--no-color",
87    ]) {
88        Ok(output) => output,
89        Err(err) => {
90            eprintln!("{err:#}");
91            return 1;
92        }
93    };
94    let diff_raw = String::from_utf8_lossy(&diff_output.stdout).to_string();
95    let diff = trim_trailing_newlines(&diff_raw);
96
97    if diff.trim().is_empty() {
98        eprintln!("⚠️  No staged changes to record");
99        return 1;
100    }
101
102    if !git_scope_available() {
103        eprintln!("❗ git-scope is required but was not found in PATH.");
104        return 1;
105    }
106
107    let scope = match git_scope_output(parsed.no_color) {
108        Ok(value) => value,
109        Err(err) => {
110            eprintln!("{err:#}");
111            return 1;
112        }
113    };
114
115    let contents = match build_staged_contents(&parsed.include_patterns) {
116        Ok(value) => value,
117        Err(err) => {
118            eprintln!("{err:#}");
119            return 1;
120        }
121    };
122
123    let context = format!(
124        "# Commit Context\n\n## Input expectations\n\n- Full-file reads are not required for commit message generation.\n- Base the message on staged diff, scope tree, and staged (index) version content.\n\n---\n\n## 📂 Scope and file tree:\n\n```text\n{scope}\n```\n\n## 📄 Git staged diff:\n\n```diff\n{diff}\n```\n\n  ## 📚 Staged file contents (index version):\n\n{contents}"
125    );
126
127    let context_with_newline = format!("{context}\n");
128
129    match parsed.mode {
130        OutputMode::Stdout => {
131            println!("{context}");
132        }
133        OutputMode::Both => {
134            println!("{context}");
135            let _ = clipboard::set_clipboard_best_effort(&context_with_newline);
136        }
137        OutputMode::Clipboard => {
138            let _ = clipboard::set_clipboard_best_effort(&context_with_newline);
139            println!("✅ Commit context copied to clipboard with:");
140            println!("  • Diff");
141            println!("  • Scope summary (via git-scope staged)");
142            println!("  • Staged file contents (index version)");
143        }
144    }
145
146    0
147}
148
149fn parse_context_args(args: &[String]) -> ParseOutcome<ContextArgs> {
150    let mut mode = OutputMode::Clipboard;
151    let mut no_color = false;
152    let mut include_patterns: Vec<String> = Vec::new();
153    let mut extra_args: Vec<String> = Vec::new();
154
155    let mut iter = args.iter().peekable();
156    while let Some(arg) = iter.next() {
157        match arg.as_str() {
158            "--stdout" | "-p" | "--print" => mode = OutputMode::Stdout,
159            "--both" => mode = OutputMode::Both,
160            "--no-color" | "no-color" => no_color = true,
161            "--include" => {
162                let value = iter.next().map(|v| v.to_string()).unwrap_or_default();
163                if value.is_empty() {
164                    eprintln!("❌ Missing value for --include");
165                    return ParseOutcome::Exit(2);
166                }
167                include_patterns.push(value);
168            }
169            value if value.starts_with("--include=") => {
170                include_patterns.push(value.trim_start_matches("--include=").to_string());
171            }
172            "--help" | "-h" => {
173                print_context_usage();
174                return ParseOutcome::Exit(0);
175            }
176            other => extra_args.push(other.to_string()),
177        }
178    }
179
180    ParseOutcome::Continue(ContextArgs {
181        mode,
182        no_color,
183        include_patterns,
184        extra_args,
185    })
186}
187
188fn print_context_usage() {
189    println!("Usage: git-commit-context [--stdout|--both] [--no-color] [--include <path/glob>]");
190    println!("  --stdout   Print commit context to stdout only");
191    println!("  --both     Print to stdout and copy to clipboard");
192    println!("  --no-color Disable ANSI colors (also via NO_COLOR)");
193    println!("  --include  Show full content for selected paths (repeatable)");
194}
195
196fn git_scope_available() -> bool {
197    if env::var("GIT_CLI_FIXTURE_GIT_SCOPE_MODE").ok().as_deref() == Some("missing") {
198        return false;
199    }
200    util::cmd_exists("git-scope")
201}
202
203fn git_scope_output(no_color: bool) -> Result<String> {
204    let mut args: Vec<&str> = vec!["staged"];
205    if no_color || env::var_os("NO_COLOR").is_some() {
206        args.push("--no-color");
207    }
208
209    let output = Command::new("git-scope")
210        .args(&args)
211        .stdout(Stdio::piped())
212        .stderr(Stdio::piped())
213        .output()
214        .map_err(|err| anyhow!("git-scope failed: {err}"))?;
215
216    let raw = String::from_utf8_lossy(&output.stdout).to_string();
217    let stripped = strip_ansi(&raw);
218    Ok(trim_trailing_newlines(&stripped))
219}
220
221fn strip_ansi(input: &str) -> String {
222    strip_ansi_impl(input, AnsiStripMode::CsiSgrOnly).into_owned()
223}
224
225fn build_staged_contents(include_patterns: &[String]) -> Result<String> {
226    let output = git_output(&[
227        "-c",
228        "core.quotepath=false",
229        "diff",
230        "--cached",
231        "--name-status",
232        "-z",
233    ])?;
234
235    let entries = parse_name_status_z(&output.stdout)?;
236    let mut out = String::new();
237
238    for entry in entries {
239        let (display_path, content_path, head_path) = match &entry.old_path {
240            Some(old) => (
241                format!("{old} -> {}", entry.path),
242                entry.path.clone(),
243                old.to_string(),
244            ),
245            None => (entry.path.clone(), entry.path.clone(), entry.path.clone()),
246        };
247
248        out.push_str(&format!("### {display_path} ({})\n\n", entry.status_raw));
249
250        let mut include_content = false;
251        for pattern in include_patterns {
252            if !pattern.is_empty() && pattern_matches(pattern, &content_path) {
253                include_content = true;
254                break;
255            }
256        }
257
258        let lockfile = is_lockfile(&content_path);
259        let diff = diff_numstat(&content_path).unwrap_or(DiffNumstat {
260            added: None,
261            deleted: None,
262            binary: false,
263        });
264
265        let mut binary_file = diff.binary;
266        let mut blob_type: Option<String> = None;
267
268        let blob_ref = if entry.status_raw == "D" {
269            format!("HEAD:{head_path}")
270        } else {
271            format!(":{content_path}")
272        };
273
274        if !binary_file
275            && let Some(detected) = file_probe(&blob_ref)
276            && detected.contains("charset=binary")
277        {
278            binary_file = true;
279            blob_type = Some(detected);
280        }
281
282        if binary_file {
283            let blob_size = git_stdout_trimmed_optional(&["cat-file", "-s", &blob_ref]);
284            out.push_str("[Binary file content hidden]\n\n");
285            if let Some(size) = blob_size {
286                out.push_str(&format!("Size: {size} bytes\n"));
287            }
288            if let Some(blob_type) = blob_type {
289                out.push_str(&format!("Type: {blob_type}\n"));
290            }
291            out.push('\n');
292            continue;
293        }
294
295        if lockfile && !include_content {
296            out.push_str("[Lockfile content hidden]\n\n");
297            if let (Some(added), Some(deleted)) = (diff.added, diff.deleted) {
298                out.push_str(&format!("Summary: +{added} -{deleted}\n"));
299            }
300            out.push_str(&format!(
301                "Tip: use --include {content_path} to show full content\n\n"
302            ));
303            continue;
304        }
305
306        if entry.status_raw == "D" {
307            if git_status_success(&["cat-file", "-e", &blob_ref]) {
308                out.push_str("[Deleted file, showing HEAD version]\n\n");
309                out.push_str("```ts\n");
310                match git_output(&["show", &blob_ref]) {
311                    Ok(output) => {
312                        out.push_str(&String::from_utf8_lossy(&output.stdout));
313                    }
314                    Err(_) => {
315                        out.push_str("[HEAD version not found]\n");
316                    }
317                }
318                out.push_str("```\n\n");
319            } else {
320                out.push_str("[Deleted file, no HEAD version found]\n\n");
321            }
322            continue;
323        }
324
325        if entry.status_raw == "A"
326            || entry.status_raw == "M"
327            || entry.status_raw.starts_with('R')
328            || entry.status_raw.starts_with('C')
329        {
330            out.push_str("```ts\n");
331            let index_ref = format!(":{content_path}");
332            match git_output(&["show", &index_ref]) {
333                Ok(output) => {
334                    out.push_str(&String::from_utf8_lossy(&output.stdout));
335                }
336                Err(_) => {
337                    out.push_str("[Index version not found]\n");
338                }
339            }
340            out.push_str("```\n\n");
341            continue;
342        }
343
344        out.push_str(&format!("[Unhandled status: {}]\n\n", entry.status_raw));
345    }
346
347    Ok(trim_trailing_newlines(&out))
348}
349
350fn pattern_matches(pattern: &str, text: &str) -> bool {
351    wildcard_match(pattern, text)
352}
353
354fn wildcard_match(pattern: &str, text: &str) -> bool {
355    let p: Vec<char> = pattern.chars().collect();
356    let t: Vec<char> = text.chars().collect();
357    let mut pi = 0;
358    let mut ti = 0;
359    let mut star_idx: Option<usize> = None;
360    let mut match_idx = 0;
361
362    while ti < t.len() {
363        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
364            pi += 1;
365            ti += 1;
366        } else if pi < p.len() && p[pi] == '*' {
367            star_idx = Some(pi);
368            match_idx = ti;
369            pi += 1;
370        } else if let Some(star) = star_idx {
371            pi = star + 1;
372            match_idx += 1;
373            ti = match_idx;
374        } else {
375            return false;
376        }
377    }
378
379    while pi < p.len() && p[pi] == '*' {
380        pi += 1;
381    }
382
383    pi == p.len()
384}
385
386fn file_probe(blob_ref: &str) -> Option<String> {
387    if env::var("GIT_CLI_FIXTURE_FILE_MODE").ok().as_deref() == Some("missing") {
388        return None;
389    }
390
391    if !util::cmd_exists("file") {
392        return None;
393    }
394
395    if !git_status_success(&["cat-file", "-e", blob_ref]) {
396        return None;
397    }
398
399    let blob = git_output(&["cat-file", "-p", blob_ref]).ok()?;
400    let sample_len = blob.stdout.len().min(8192);
401    let sample = &blob.stdout[..sample_len];
402
403    let mut child = Command::new("file")
404        .args(["-b", "--mime", "-"])
405        .stdin(Stdio::piped())
406        .stdout(Stdio::piped())
407        .stderr(Stdio::null())
408        .spawn()
409        .ok()?;
410
411    if let Some(mut stdin) = child.stdin.take() {
412        let _ = stdin.write_all(sample);
413    }
414
415    let output = child.wait_with_output().ok()?;
416    if !output.status.success() {
417        return None;
418    }
419
420    let out = String::from_utf8_lossy(&output.stdout).to_string();
421    let out = trim_trailing_newlines(&out);
422    if out.is_empty() { None } else { Some(out) }
423}
424
425fn run_to_stash(args: &[String]) -> i32 {
426    if !ensure_git_work_tree() {
427        return 1;
428    }
429
430    let commit_ref = args.first().map(|s| s.as_str()).unwrap_or("HEAD");
431    let commit_sha = match git_stdout_trimmed_optional(&[
432        "rev-parse",
433        "--verify",
434        &format!("{commit_ref}^{{commit}}"),
435    ]) {
436        Some(value) => value,
437        None => {
438            eprintln!("❌ Cannot resolve commit: {commit_ref}");
439            return 1;
440        }
441    };
442
443    let mut parent_sha =
444        match git_stdout_trimmed_optional(&["rev-parse", "--verify", &format!("{commit_sha}^")]) {
445            Some(value) => value,
446            None => {
447                eprintln!("❌ Commit {commit_sha} has no parent (root commit).");
448                eprintln!("🧠 Converting a root commit to stash is ambiguous; aborting.");
449                return 1;
450            }
451        };
452
453    if is_merge_commit(&commit_sha) {
454        println!("⚠️  Target commit is a merge commit (multiple parents).");
455        println!(
456            "🧠 This tool will use the FIRST parent to compute the patch: {commit_sha}^1..{commit_sha}"
457        );
458        if prompt::confirm_or_abort("❓ Proceed? [y/N] ").is_err() {
459            return 1;
460        }
461        if let Some(value) =
462            git_stdout_trimmed_optional(&["rev-parse", "--verify", &format!("{commit_sha}^1")])
463        {
464            parent_sha = value;
465        } else {
466            return 1;
467        }
468    }
469
470    let branch_name = git_stdout_trimmed_optional(&["rev-parse", "--abbrev-ref", "HEAD"])
471        .unwrap_or_else(|| "(unknown)".to_string());
472    let subject = git_stdout_trimmed_optional(&["log", "-1", "--pretty=%s", &commit_sha])
473        .unwrap_or_else(|| "(no subject)".to_string());
474
475    let short_commit = short_sha(&commit_sha);
476    let short_parent = short_sha(&parent_sha);
477    let stash_msg = format!(
478        "c2s: commit={short_commit} parent={short_parent} branch={branch_name} \"{subject}\""
479    );
480
481    let commit_oneline = git_stdout_trimmed_optional(&["log", "-1", "--oneline", &commit_sha])
482        .unwrap_or_else(|| commit_sha.clone());
483
484    println!("🧾 Convert commit → stash");
485    println!("   Commit : {commit_oneline}");
486    println!("   Parent : {short_parent}");
487    println!("   Branch : {branch_name}");
488    println!("   Message: {stash_msg}");
489    println!();
490    println!("This will:");
491    println!("  1) Create a stash entry containing the patch: {short_parent}..{short_commit}");
492    println!("  2) Optionally drop the commit from branch history by resetting to parent.");
493
494    if prompt::confirm_or_abort("❓ Proceed to create stash? [y/N] ").is_err() {
495        return 1;
496    }
497
498    let stash_result = create_stash_for_commit(&commit_sha, &parent_sha, &branch_name, &stash_msg);
499
500    let stash_created = match stash_result {
501        Ok(result) => result,
502        Err(err) => {
503            eprintln!("{err:#}");
504            return 1;
505        }
506    };
507
508    if stash_created.fallback_failed {
509        return 1;
510    }
511
512    if !stash_created.fallback_used {
513        let stash_line = git_stdout_trimmed_optional(&["stash", "list", "-1"]).unwrap_or_default();
514        println!("✅ Stash created: {stash_line}");
515    }
516
517    if commit_ref != "HEAD"
518        && git_stdout_trimmed_optional(&["rev-parse", "HEAD"]).as_deref()
519            != Some(commit_sha.as_str())
520    {
521        println!("ℹ️  Not dropping commit automatically because target is not HEAD.");
522        println!(
523            "🧠 If you want to remove it, do so explicitly (e.g., interactive rebase) after verifying stash."
524        );
525        return 0;
526    }
527
528    println!();
529    println!("Optional: drop the commit from current branch history?");
530    println!("  This would run: git reset --hard {short_parent}");
531    println!("  (Your work remains in stash; untracked files are unaffected.)");
532
533    match prompt::confirm("❓ Drop commit from history now? [y/N] ") {
534        Ok(true) => {}
535        Ok(false) => {
536            println!("✅ Done. Commit kept; stash saved.");
537            return 0;
538        }
539        Err(_) => return 1,
540    }
541
542    let upstream =
543        git_stdout_trimmed_optional(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
544            .unwrap_or_default();
545
546    if !upstream.is_empty()
547        && git_status_success(&["merge-base", "--is-ancestor", &commit_sha, &upstream])
548    {
549        println!("⚠️  This commit appears to be reachable from upstream ({upstream}).");
550        println!(
551            "🧨 Dropping it rewrites history and may require force push; it can affect others."
552        );
553        match prompt::confirm("❓ Still drop it? [y/N] ") {
554            Ok(true) => {}
555            Ok(false) => {
556                println!("✅ Done. Commit kept; stash saved.");
557                return 0;
558            }
559            Err(_) => return 1,
560        }
561    }
562
563    let final_prompt =
564        format!("❓ Final confirmation: run 'git reset --hard {short_parent}'? [y/N] ");
565    match prompt::confirm(&final_prompt) {
566        Ok(true) => {}
567        Ok(false) => {
568            println!("✅ Done. Commit kept; stash saved.");
569            return 0;
570        }
571        Err(_) => return 1,
572    }
573
574    if !git_status_success(&["reset", "--hard", &parent_sha]) {
575        println!("❌ Failed to reset branch to parent.");
576        println!(
577            "🧠 Your stash is still saved. You can manually recover the commit via reflog if needed."
578        );
579        return 1;
580    }
581
582    let stash_line = git_stdout_trimmed_optional(&["stash", "list", "-1"]).unwrap_or_default();
583    println!("✅ Commit dropped from history. Your work is in stash:");
584    println!("   {stash_line}");
585
586    0
587}
588
589fn is_merge_commit(commit_sha: &str) -> bool {
590    let output = match git_output(&["rev-list", "--parents", "-n", "1", commit_sha]) {
591        Ok(value) => value,
592        Err(_) => return false,
593    };
594    let line = String::from_utf8_lossy(&output.stdout).to_string();
595    let parts: Vec<&str> = line.split_whitespace().collect();
596    parts.len() > 2
597}
598
599struct StashResult {
600    fallback_used: bool,
601    fallback_failed: bool,
602}
603
604fn create_stash_for_commit(
605    commit_sha: &str,
606    parent_sha: &str,
607    branch_name: &str,
608    stash_msg: &str,
609) -> Result<StashResult> {
610    let force_fallback = env::var("GIT_CLI_FORCE_STASH_FALLBACK")
611        .ok()
612        .map(|v| {
613            let v = v.to_lowercase();
614            !(v == "0" || v == "false" || v.is_empty())
615        })
616        .unwrap_or(false);
617
618    let stash_sha = if force_fallback {
619        None
620    } else {
621        synthesize_stash_object(commit_sha, parent_sha, branch_name, stash_msg)
622    };
623
624    if let Some(stash_sha) = stash_sha {
625        if !git_status_success(&["stash", "store", "-m", stash_msg, &stash_sha]) {
626            return Err(anyhow!("❌ Failed to store stash object."));
627        }
628        return Ok(StashResult {
629            fallback_used: false,
630            fallback_failed: false,
631        });
632    }
633
634    println!("⚠️  Failed to synthesize stash object without touching worktree.");
635    println!("🧠 Fallback would require touching the working tree.");
636    if prompt::confirm_or_abort("❓ Fallback by temporarily checking out parent and applying patch (will modify worktree)? [y/N] ").is_err() {
637        return Ok(StashResult {
638            fallback_used: true,
639            fallback_failed: true,
640        });
641    }
642
643    let status = git_stdout_trimmed_optional(&["status", "--porcelain"]).unwrap_or_default();
644    if !status.trim().is_empty() {
645        println!("❌ Working tree is not clean; fallback requires clean state.");
646        println!("🧠 Commit/stash your current changes first, then retry.");
647        return Ok(StashResult {
648            fallback_used: true,
649            fallback_failed: true,
650        });
651    }
652
653    let current_head = match git_stdout_trimmed_optional(&["rev-parse", "HEAD"]) {
654        Some(value) => value,
655        None => {
656            return Ok(StashResult {
657                fallback_used: true,
658                fallback_failed: true,
659            });
660        }
661    };
662
663    if !git_status_success(&["checkout", "--detach", parent_sha]) {
664        println!("❌ Failed to checkout parent for fallback.");
665        return Ok(StashResult {
666            fallback_used: true,
667            fallback_failed: true,
668        });
669    }
670
671    if !git_status_success(&["cherry-pick", "-n", commit_sha]) {
672        println!("❌ Failed to apply commit patch in fallback mode.");
673        println!("🧠 Attempting to restore original HEAD.");
674        let _ = git_status_success(&["cherry-pick", "--abort"]);
675        let _ = git_status_success(&["checkout", &current_head]);
676        return Ok(StashResult {
677            fallback_used: true,
678            fallback_failed: true,
679        });
680    }
681
682    if !git_status_success(&["stash", "push", "-m", stash_msg]) {
683        println!("❌ Failed to stash changes in fallback mode.");
684        let _ = git_status_success(&["reset", "--hard"]);
685        let _ = git_status_success(&["checkout", &current_head]);
686        return Ok(StashResult {
687            fallback_used: true,
688            fallback_failed: true,
689        });
690    }
691
692    let _ = git_status_success(&["reset", "--hard"]);
693    let _ = git_status_success(&["checkout", &current_head]);
694
695    let stash_line = git_stdout_trimmed_optional(&["stash", "list", "-1"]).unwrap_or_default();
696    println!("✅ Stash created (fallback): {stash_line}");
697
698    Ok(StashResult {
699        fallback_used: true,
700        fallback_failed: false,
701    })
702}
703
704fn synthesize_stash_object(
705    commit_sha: &str,
706    parent_sha: &str,
707    branch_name: &str,
708    stash_msg: &str,
709) -> Option<String> {
710    let base_tree =
711        git_stdout_trimmed_optional(&["rev-parse", "--verify", &format!("{parent_sha}^{{tree}}")])?;
712    let commit_tree =
713        git_stdout_trimmed_optional(&["rev-parse", "--verify", &format!("{commit_sha}^{{tree}}")])?;
714
715    let index_msg = format!("index on {branch_name}: {stash_msg}");
716    let index_commit = git_stdout_trimmed_optional(&[
717        "commit-tree",
718        &base_tree,
719        "-p",
720        parent_sha,
721        "-m",
722        &index_msg,
723    ])?;
724
725    let wip_commit = git_stdout_trimmed_optional(&[
726        "commit-tree",
727        &commit_tree,
728        "-p",
729        parent_sha,
730        "-p",
731        &index_commit,
732        "-m",
733        stash_msg,
734    ])?;
735
736    Some(wip_commit)
737}
738
739fn short_sha(value: &str) -> String {
740    value.chars().take(7).collect()
741}
742
743fn ensure_git_work_tree() -> bool {
744    match common_git::require_work_tree() {
745        Ok(()) => true,
746        Err(GitContextError::GitNotFound) => {
747            eprintln!("❗ git is required but was not found in PATH.");
748            false
749        }
750        Err(GitContextError::NotRepository) => {
751            eprintln!("❌ Not a git repository.");
752            false
753        }
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::{
760        CommitCommand, OutputMode, ParseOutcome, dispatch, file_probe, git_scope_available,
761        parse_command, parse_context_args, pattern_matches, short_sha, strip_ansi, wildcard_match,
762    };
763    use nils_test_support::{CwdGuard, GlobalStateLock};
764    use pretty_assertions::assert_eq;
765
766    struct EnvGuard {
767        key: &'static str,
768        old: Option<std::ffi::OsString>,
769    }
770
771    impl EnvGuard {
772        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
773            let old = std::env::var_os(key);
774            // SAFETY: tests mutate process env only in scoped guard usage.
775            unsafe { std::env::set_var(key, value) };
776            Self { key, old }
777        }
778    }
779
780    impl Drop for EnvGuard {
781        fn drop(&mut self) {
782            if let Some(value) = self.old.take() {
783                // SAFETY: tests restore process env only in scoped guard usage.
784                unsafe { std::env::set_var(self.key, value) };
785            } else {
786                // SAFETY: tests restore process env only in scoped guard usage.
787                unsafe { std::env::remove_var(self.key) };
788            }
789        }
790    }
791
792    #[test]
793    fn parse_command_supports_aliases() {
794        assert!(matches!(
795            parse_command("context"),
796            Some(CommitCommand::Context)
797        ));
798        assert!(matches!(
799            parse_command("context-json"),
800            Some(CommitCommand::ContextJson)
801        ));
802        assert!(matches!(
803            parse_command("context_json"),
804            Some(CommitCommand::ContextJson)
805        ));
806        assert!(matches!(
807            parse_command("json"),
808            Some(CommitCommand::ContextJson)
809        ));
810        assert!(matches!(
811            parse_command("stash"),
812            Some(CommitCommand::ToStash)
813        ));
814        assert!(parse_command("unknown").is_none());
815    }
816
817    #[test]
818    fn parse_context_args_supports_modes_and_include_forms() {
819        let args = vec![
820            "--both".to_string(),
821            "--no-color".to_string(),
822            "--include".to_string(),
823            "src/*.rs".to_string(),
824            "--include=README.md".to_string(),
825            "--extra".to_string(),
826        ];
827
828        match parse_context_args(&args) {
829            ParseOutcome::Continue(parsed) => {
830                assert_eq!(parsed.mode, OutputMode::Both);
831                assert!(parsed.no_color);
832                assert_eq!(
833                    parsed.include_patterns,
834                    vec!["src/*.rs".to_string(), "README.md".to_string()]
835                );
836                assert_eq!(parsed.extra_args, vec!["--extra".to_string()]);
837            }
838            ParseOutcome::Exit(code) => panic!("unexpected early exit: {code}"),
839        }
840    }
841
842    #[test]
843    fn parse_context_args_reports_missing_include_value() {
844        let args = vec!["--include".to_string()];
845        match parse_context_args(&args) {
846            ParseOutcome::Exit(code) => assert_eq!(code, 2),
847            ParseOutcome::Continue(_) => panic!("expected usage exit"),
848        }
849    }
850
851    #[test]
852    fn wildcard_matching_handles_star_and_question_mark() {
853        assert!(wildcard_match("src/*.rs", "src/main.rs"));
854        assert!(wildcard_match("a?c", "abc"));
855        assert!(wildcard_match("*commit*", "git-commit"));
856        assert!(!wildcard_match("src/*.rs", "src/main.ts"));
857        assert!(!wildcard_match("a?c", "ac"));
858        assert!(pattern_matches("docs/**", "docs/plans/test.md"));
859    }
860
861    #[test]
862    fn short_sha_truncates_to_seven_chars() {
863        assert_eq!(short_sha("abcdef123456"), "abcdef1");
864        assert_eq!(short_sha("abc"), "abc");
865    }
866
867    #[test]
868    fn parse_context_args_help_exits_zero() {
869        let args = vec!["--help".to_string()];
870        match parse_context_args(&args) {
871            ParseOutcome::Exit(code) => assert_eq!(code, 0),
872            ParseOutcome::Continue(_) => panic!("expected help exit"),
873        }
874    }
875
876    #[test]
877    fn git_scope_available_honors_fixture_override() {
878        let _guard = EnvGuard::set("GIT_CLI_FIXTURE_GIT_SCOPE_MODE", "missing");
879        assert!(!git_scope_available());
880    }
881
882    #[test]
883    fn file_probe_respects_missing_file_fixture() {
884        let _guard = EnvGuard::set("GIT_CLI_FIXTURE_FILE_MODE", "missing");
885        assert_eq!(file_probe("HEAD:README.md"), None);
886    }
887
888    #[test]
889    fn strip_ansi_removes_sgr_sequences() {
890        assert_eq!(strip_ansi("\u{1b}[31mred\u{1b}[0m"), "red");
891    }
892
893    #[test]
894    fn dispatch_context_and_stash_fail_fast_outside_git_repo() {
895        let lock = GlobalStateLock::new();
896        let dir = tempfile::TempDir::new().expect("tempdir");
897        let _cwd = CwdGuard::set(&lock, dir.path()).expect("cwd");
898        assert_eq!(dispatch("context", &[]), 1);
899        assert_eq!(dispatch("stash", &[]), 1);
900    }
901}