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