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