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