1use crate::clipboard;
2use crate::commit_shared::{
3 DiffNumstat, diff_numstat, git_output, git_status_code, git_stdout_trimmed_optional,
4 is_lockfile, parse_name_status_z,
5};
6use anyhow::{Result, anyhow};
7use nils_common::git::{self as common_git, GitContextError};
8use serde_json::{Map, Number, Value};
9use std::collections::BTreeMap;
10use std::env;
11use std::fs;
12use std::path::Path;
13use time::OffsetDateTime;
14use time::format_description;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17enum OutputMode {
18 Clipboard,
19 Stdout,
20 Both,
21}
22
23struct ContextJsonArgs {
24 mode: OutputMode,
25 pretty: bool,
26 bundle: bool,
27 out_dir: Option<String>,
28 extra_args: Vec<String>,
29}
30
31enum ParseOutcome<T> {
32 Continue(T),
33 Exit(i32),
34}
35
36pub fn run(args: &[String]) -> i32 {
37 match common_git::require_work_tree() {
38 Ok(()) => {}
39 Err(GitContextError::GitNotFound) => {
40 eprintln!("❗ git is required but was not found in PATH.");
41 return 1;
42 }
43 Err(GitContextError::NotRepository) => {
44 eprintln!("❌ Not a git repository.");
45 return 1;
46 }
47 }
48
49 let parsed = match parse_args(args) {
50 ParseOutcome::Continue(value) => value,
51 ParseOutcome::Exit(code) => return code,
52 };
53
54 if !parsed.extra_args.is_empty() {
55 eprintln!(
56 "⚠️ Ignoring unknown arguments: {}",
57 parsed.extra_args.join(" ")
58 );
59 }
60
61 match git_status_code(&["diff", "--cached", "--quiet", "--exit-code"]) {
62 Some(0) => {
63 eprintln!("⚠️ No staged changes to record");
64 return 1;
65 }
66 Some(1) => {}
67 _ => {
68 eprintln!("❌ Failed to check staged changes.");
69 return 1;
70 }
71 }
72
73 let out_dir = match resolve_out_dir(parsed.out_dir.as_deref()) {
74 Ok(dir) => dir,
75 Err(message) => {
76 eprintln!("{message}");
77 return 1;
78 }
79 };
80
81 if fs::create_dir_all(&out_dir).is_err() {
82 eprintln!("❌ Failed to create output directory: {out_dir}");
83 return 1;
84 }
85
86 let patch_path = format!("{out_dir}/staged.patch");
87 let manifest_path = format!("{out_dir}/commit-context.json");
88
89 let patch_bytes = match git_output(&[
90 "-c",
91 "core.quotepath=false",
92 "diff",
93 "--cached",
94 "--no-color",
95 ]) {
96 Ok(output) => output.stdout,
97 Err(_) => {
98 eprintln!("❌ Failed to write staged patch: {patch_path}");
99 return 1;
100 }
101 };
102
103 if fs::write(&patch_path, &patch_bytes).is_err() {
104 eprintln!("❌ Failed to write staged patch: {patch_path}");
105 return 1;
106 }
107
108 let json = match build_json(parsed.pretty) {
109 Ok(value) => value,
110 Err(err) => {
111 eprintln!("{err:#}");
112 return 1;
113 }
114 };
115
116 if fs::write(&manifest_path, format!("{json}\n")).is_err() {
117 eprintln!("❌ Failed to write JSON manifest: {manifest_path}");
118 return 1;
119 }
120
121 let patch_text = String::from_utf8_lossy(&patch_bytes).to_string();
122
123 match parsed.mode {
124 OutputMode::Stdout => {
125 print_bundle_or_json(&json, &patch_text, parsed.bundle);
126 return 0;
127 }
128 OutputMode::Both => {
129 print_bundle_or_json(&json, &patch_text, parsed.bundle);
130 }
131 OutputMode::Clipboard => {}
132 }
133
134 if parsed.bundle {
135 let bundle = build_bundle(&json, &patch_text);
136 let _ = clipboard::set_clipboard_best_effort(&bundle);
137 } else {
138 let _ = clipboard::set_clipboard_best_effort(&json);
139 }
140
141 if parsed.mode == OutputMode::Clipboard {
142 println!("✅ JSON commit context copied to clipboard with:");
143 if parsed.bundle {
144 println!(" • Bundle (JSON + patch)");
145 } else {
146 println!(" • JSON manifest");
147 }
148 println!(" • Patch file written to: {patch_path}");
149 println!(" • Manifest file written to: {manifest_path}");
150 }
151
152 0
153}
154
155fn parse_args(args: &[String]) -> ParseOutcome<ContextJsonArgs> {
156 let mut mode = OutputMode::Clipboard;
157 let mut pretty = false;
158 let mut bundle = false;
159 let mut out_dir: Option<String> = None;
160 let mut extra_args: Vec<String> = Vec::new();
161
162 let mut iter = args.iter().peekable();
163 while let Some(arg) = iter.next() {
164 match arg.as_str() {
165 "--stdout" | "-p" | "--print" => mode = OutputMode::Stdout,
166 "--both" => mode = OutputMode::Both,
167 "--pretty" => pretty = true,
168 "--bundle" => bundle = true,
169 "--out-dir" => {
170 let value = iter.next().map(|v| v.to_string()).unwrap_or_default();
171 if value.is_empty() {
172 eprintln!("❌ Missing value for --out-dir");
173 return ParseOutcome::Exit(2);
174 }
175 out_dir = Some(value);
176 }
177 value if value.starts_with("--out-dir=") => {
178 let value = value.trim_start_matches("--out-dir=").to_string();
179 out_dir = Some(value);
180 }
181 "--help" | "-h" => {
182 print_usage();
183 return ParseOutcome::Exit(0);
184 }
185 other => extra_args.push(other.to_string()),
186 }
187 }
188
189 ParseOutcome::Continue(ContextJsonArgs {
190 mode,
191 pretty,
192 bundle,
193 out_dir,
194 extra_args,
195 })
196}
197
198fn print_usage() {
199 println!(
200 "Usage: git-commit-context-json [--stdout|--both] [--pretty] [--bundle] [--out-dir <path>]"
201 );
202 println!(" --stdout Print to stdout only (JSON by default; bundle with --bundle)");
203 println!(
204 " --both Print to stdout and copy to clipboard (JSON by default; bundle with --bundle)"
205 );
206 println!(" --pretty Pretty-print JSON (default is compact)");
207 println!(" --bundle Print/copy a single bundle (JSON + patch content)");
208 println!(" --out-dir Write files to this directory (default: <git-dir>/commit-context)");
209}
210
211fn resolve_out_dir(out_dir: Option<&str>) -> Result<String> {
212 let trimmed = out_dir.map(|value| value.trim()).unwrap_or("");
213 if !trimmed.is_empty() {
214 return Ok(trimmed.trim_end_matches('/').to_string());
215 }
216
217 let git_dir = git_stdout_trimmed_optional(&["rev-parse", "--git-dir"]).unwrap_or_default();
218 if git_dir.is_empty() {
219 return Err(anyhow!("❌ Failed to resolve git dir."));
220 }
221
222 Ok(format!("{}/commit-context", git_dir.trim_end_matches('/')))
223}
224
225fn build_json(pretty: bool) -> Result<String> {
226 let branch = git_stdout_trimmed_optional(&["symbolic-ref", "--quiet", "--short", "HEAD"]);
227 let head = git_stdout_trimmed_optional(&["rev-parse", "--short", "HEAD"]);
228 let repo_name =
229 git_stdout_trimmed_optional(&["rev-parse", "--show-toplevel"]).and_then(|path| {
230 Path::new(&path)
231 .file_name()
232 .and_then(|s| s.to_str())
233 .map(|s| s.to_string())
234 });
235 let generated_at = generated_at();
236
237 let name_status = git_output(&[
238 "-c",
239 "core.quotepath=false",
240 "diff",
241 "--cached",
242 "--name-status",
243 "-z",
244 ])?;
245
246 let entries = parse_name_status_z(&name_status.stdout)?;
247
248 let mut status_counts: BTreeMap<String, i64> = BTreeMap::new();
249 let mut top_dir_counts: BTreeMap<String, i64> = BTreeMap::new();
250
251 let mut insertions: i64 = 0;
252 let mut deletions: i64 = 0;
253 let mut file_count: i64 = 0;
254 let mut binary_file_count: i64 = 0;
255 let mut lockfile_count: i64 = 0;
256 let mut root_file_count: i64 = 0;
257
258 let mut files: Vec<Value> = Vec::new();
259
260 for entry in entries {
261 file_count += 1;
262
263 let status_letter = entry
264 .status_raw
265 .chars()
266 .next()
267 .map(|ch| ch.to_string())
268 .unwrap_or_else(|| "".to_string());
269
270 *status_counts.entry(status_letter.clone()).or_insert(0) += 1;
271
272 if let Some((top, _)) = entry.path.split_once('/') {
273 *top_dir_counts.entry(top.to_string()).or_insert(0) += 1;
274 } else {
275 root_file_count += 1;
276 }
277
278 let lockfile = is_lockfile(&entry.path);
279 if lockfile {
280 lockfile_count += 1;
281 }
282
283 let diff = diff_numstat(&entry.path).unwrap_or(DiffNumstat {
284 added: None,
285 deleted: None,
286 binary: false,
287 });
288
289 if diff.binary {
290 binary_file_count += 1;
291 } else {
292 if let Some(n) = diff.added {
293 insertions += n;
294 }
295 if let Some(n) = diff.deleted {
296 deletions += n;
297 }
298 }
299
300 let mut file_obj = Map::new();
301 file_obj.insert("path".to_string(), Value::String(entry.path.clone()));
302 file_obj.insert("status".to_string(), Value::String(status_letter));
303
304 if entry.status_raw.len() > 1 {
305 let score_raw = &entry.status_raw[1..];
306 if let Ok(score) = score_raw.parse::<i64>() {
307 file_obj.insert("score".to_string(), Value::Number(Number::from(score)));
308 }
309 }
310
311 if let Some(old_path) = entry.old_path.as_ref() {
312 file_obj.insert("oldPath".to_string(), Value::String(old_path.clone()));
313 }
314
315 if diff.binary {
316 file_obj.insert("insertions".to_string(), Value::Null);
317 file_obj.insert("deletions".to_string(), Value::Null);
318 } else {
319 match diff.added {
320 Some(n) => {
321 file_obj.insert("insertions".to_string(), Value::Number(Number::from(n)));
322 }
323 None => {
324 file_obj.insert("insertions".to_string(), Value::Null);
325 }
326 }
327 match diff.deleted {
328 Some(n) => {
329 file_obj.insert("deletions".to_string(), Value::Number(Number::from(n)));
330 }
331 None => {
332 file_obj.insert("deletions".to_string(), Value::Null);
333 }
334 }
335 }
336
337 file_obj.insert("binary".to_string(), Value::Bool(diff.binary));
338 file_obj.insert("lockfile".to_string(), Value::Bool(lockfile));
339
340 files.push(Value::Object(file_obj));
341 }
342
343 let status_counts_values: Vec<Value> = status_counts
344 .into_iter()
345 .map(|(status, count)| {
346 let mut obj = Map::new();
347 obj.insert("status".to_string(), Value::String(status));
348 obj.insert("count".to_string(), Value::Number(Number::from(count)));
349 Value::Object(obj)
350 })
351 .collect();
352
353 let top_dir_values: Vec<Value> = top_dir_counts
354 .into_iter()
355 .map(|(name, count)| {
356 let mut obj = Map::new();
357 obj.insert("name".to_string(), Value::String(name));
358 obj.insert("count".to_string(), Value::Number(Number::from(count)));
359 Value::Object(obj)
360 })
361 .collect();
362
363 let mut summary = Map::new();
364 summary.insert(
365 "fileCount".to_string(),
366 Value::Number(Number::from(file_count)),
367 );
368 summary.insert(
369 "insertions".to_string(),
370 Value::Number(Number::from(insertions)),
371 );
372 summary.insert(
373 "deletions".to_string(),
374 Value::Number(Number::from(deletions)),
375 );
376 summary.insert(
377 "binaryFileCount".to_string(),
378 Value::Number(Number::from(binary_file_count)),
379 );
380 summary.insert(
381 "lockfileCount".to_string(),
382 Value::Number(Number::from(lockfile_count)),
383 );
384 summary.insert(
385 "rootFileCount".to_string(),
386 Value::Number(Number::from(root_file_count)),
387 );
388 summary.insert(
389 "topLevelDirCount".to_string(),
390 Value::Number(Number::from(top_dir_values.len() as i64)),
391 );
392
393 let mut staged = Map::new();
394 staged.insert("summary".to_string(), Value::Object(summary));
395 staged.insert(
396 "statusCounts".to_string(),
397 Value::Array(status_counts_values),
398 );
399
400 let mut structure = Map::new();
401 structure.insert("topLevelDirs".to_string(), Value::Array(top_dir_values));
402 staged.insert("structure".to_string(), Value::Object(structure));
403 staged.insert("files".to_string(), Value::Array(files));
404
405 let mut patch = Map::new();
406 patch.insert(
407 "path".to_string(),
408 Value::String("staged.patch".to_string()),
409 );
410 patch.insert(
411 "format".to_string(),
412 Value::String("git diff --cached".to_string()),
413 );
414 staged.insert("patch".to_string(), Value::Object(patch));
415
416 let mut repo = Map::new();
417 repo.insert(
418 "name".to_string(),
419 repo_name.map(Value::String).unwrap_or(Value::Null),
420 );
421
422 let mut git = Map::new();
423 git.insert(
424 "branch".to_string(),
425 branch.map(Value::String).unwrap_or(Value::Null),
426 );
427 git.insert(
428 "head".to_string(),
429 head.map(Value::String).unwrap_or(Value::Null),
430 );
431
432 let mut root = Map::new();
433 root.insert("schemaVersion".to_string(), Value::Number(Number::from(1)));
434 root.insert(
435 "generatedAt".to_string(),
436 generated_at.map(Value::String).unwrap_or(Value::Null),
437 );
438 root.insert("repo".to_string(), Value::Object(repo));
439 root.insert("git".to_string(), Value::Object(git));
440 root.insert("staged".to_string(), Value::Object(staged));
441
442 let value = Value::Object(root);
443
444 if pretty {
445 Ok(serde_json::to_string_pretty(&value)?)
446 } else {
447 Ok(serde_json::to_string(&value)?)
448 }
449}
450
451fn generated_at() -> Option<String> {
452 if env::var("GIT_CLI_FIXTURE_DATE_MODE").ok().as_deref() == Some("fixed") {
453 return Some("2000-01-02T03:04:05Z".to_string());
454 }
455
456 let format =
457 format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]Z").ok()?;
458 OffsetDateTime::now_utc().format(&format).ok()
459}
460
461fn print_bundle_or_json(json: &str, patch_text: &str, bundle: bool) {
462 if bundle {
463 print!("{}", build_bundle(json, patch_text));
464 } else {
465 println!("{json}");
466 }
467}
468
469fn build_bundle(json: &str, patch_text: &str) -> String {
470 let mut out = String::new();
471 out.push_str("===== commit-context.json =====\n");
472 out.push_str(json);
473 out.push('\n');
474 out.push('\n');
475 out.push_str("===== staged.patch =====\n");
476 out.push_str(patch_text);
477 out
478}