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