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", ¤t_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", ¤t_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", ¤t_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}