Skip to main content

git_cli/
commit_json.rs

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}