use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use git2::Repository;
use crate::cli::output::{LineSourceOutput, OutputFormat, MACHINE_OUTPUT_SCHEMA_VERSION};
use crate::storage::notes::NotesStore;
use crate::utils::{truncate, SHORT_COMMIT_LEN};
#[derive(Debug, Args)]
pub struct ShowArgs {
#[arg(default_value = "HEAD")]
pub commit: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Pretty)]
pub format: OutputFormat,
}
pub fn run(args: ShowArgs) -> Result<()> {
let repo = Repository::discover(".").context(
"Not in a git repository. \
Run 'git init' to create one, or 'cd' to a directory containing a .git folder.",
)?;
let obj = repo.revparse_single(&args.commit).with_context(|| {
format!(
"Failed to resolve '{}'. \n\
Suggestions:\n \
- Use a valid commit SHA: whogitit show abc1234\n \
- Use HEAD for latest: whogitit show HEAD\n \
- Use a branch name: whogitit show main\n \
- Use HEAD~N for parent: whogitit show HEAD~1",
args.commit
)
})?;
let commit = obj
.peel_to_commit()
.with_context(|| format!("'{}' is not a valid commit reference", args.commit))?;
let commit_id = commit.id().to_string();
let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];
let notes_store = NotesStore::new(&repo)?;
let attribution = notes_store.fetch_attribution(commit.id())?;
match attribution {
Some(attr) => {
if args.format == OutputFormat::Json {
let files_json: Vec<_> = attr
.files
.iter()
.map(|file| {
let lines_json: Vec<_> = file
.lines
.iter()
.map(|line| {
serde_json::json!({
"line_number": line.line_number,
"content": line.content,
"source": LineSourceOutput::from(&line.source),
"edit_id": line.edit_id,
"prompt_index": line.prompt_index,
"confidence": line.confidence,
})
})
.collect();
serde_json::json!({
"path": file.path,
"lines": lines_json,
"summary": file.summary,
})
})
.collect();
let output = serde_json::json!({
"schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
"schema": "whogitit.show.v1",
"has_attribution": true,
"commit": commit_id,
"commit_short": commit_short,
"attribution_version": attr.version,
"session": attr.session,
"prompts": attr.prompts,
"files": files_json,
"summary": {
"total_ai_lines": attr.total_ai_lines(),
"total_ai_modified_lines": attr.total_ai_modified_lines(),
"total_human_lines": attr.total_human_lines(),
"total_original_lines": attr.total_original_lines(),
}
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
print_summary(commit_short, &attr);
}
}
None => {
if args.format == OutputFormat::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
"schema": "whogitit.show.v1",
"has_attribution": false,
"commit": commit_id,
"commit_short": commit_short,
}))?
);
} else {
println!("No AI attribution found for commit {}", commit_short);
println!("This commit was not made with AI assistance tracked by whogitit.");
}
}
}
Ok(())
}
fn print_summary(commit_short: &str, attr: &crate::core::attribution::AIAttribution) {
println!("{}: {}", "Commit".bold(), commit_short.yellow());
println!("{}: {}", "Session".bold(), attr.session.session_id.cyan());
println!("{}: {}", "Model".bold(), attr.session.model.id);
println!("{}: {}", "Started".bold(), attr.session.started_at.dimmed());
println!();
if !attr.prompts.is_empty() {
println!("{}", "Prompts used:".bold());
for prompt in &attr.prompts {
let preview = truncate(&prompt.text, 60);
println!(" #{}: \"{}\"", prompt.index, preview.dimmed());
}
println!();
}
println!("{}", "Files with AI changes:".bold());
let mut total_ai = 0usize;
let mut total_ai_modified = 0usize;
let mut total_human = 0usize;
let mut total_original = 0usize;
for file in &attr.files {
let s = &file.summary;
total_ai += s.ai_lines;
total_ai_modified += s.ai_modified_lines;
total_human += s.human_lines;
total_original += s.original_lines;
let ai_str = format!("{} AI", s.ai_lines).green();
let modified_str = if s.ai_modified_lines > 0 {
format!(", {} modified", s.ai_modified_lines)
.yellow()
.to_string()
} else {
String::new()
};
let human_str = if s.human_lines > 0 {
format!(", {} human", s.human_lines).blue().to_string()
} else {
String::new()
};
let original_str = if s.original_lines > 0 {
format!(", {} original", s.original_lines)
.dimmed()
.to_string()
} else {
String::new()
};
println!(
" {} ({}{}{}{}) - {} total lines",
file.path, ai_str, modified_str, human_str, original_str, s.total_lines
);
}
println!();
println!("{}", "Summary:".bold());
println!(" {} AI-generated lines", total_ai.to_string().green());
if total_ai_modified > 0 {
println!(
" {} AI lines modified by human",
total_ai_modified.to_string().yellow()
);
}
if total_human > 0 {
println!(" {} human-added lines", total_human.to_string().blue());
}
if total_original > 0 {
println!(
" {} original/unchanged lines",
total_original.to_string().dimmed()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_show_args_default_commit() {
let args = ShowArgs {
commit: "HEAD".to_string(),
format: OutputFormat::Pretty,
};
assert_eq!(args.commit, "HEAD");
assert!(matches!(args.format, OutputFormat::Pretty));
}
#[test]
fn test_show_args_with_sha() {
let args = ShowArgs {
commit: "abc1234".to_string(),
format: OutputFormat::Json,
};
assert_eq!(args.commit, "abc1234");
assert!(matches!(args.format, OutputFormat::Json));
}
#[test]
fn test_show_args_with_branch() {
let args = ShowArgs {
commit: "main".to_string(),
format: OutputFormat::Pretty,
};
assert_eq!(args.commit, "main");
}
#[test]
fn test_show_args_with_parent_ref() {
let args = ShowArgs {
commit: "HEAD~3".to_string(),
format: OutputFormat::Pretty,
};
assert_eq!(args.commit, "HEAD~3");
}
#[test]
fn test_line_count_aggregation() {
let file_stats = vec![
(10, 2, 5, 100), (20, 5, 10, 200),
(5, 1, 2, 50),
];
let mut total_ai = 0usize;
let mut total_ai_modified = 0usize;
let mut total_human = 0usize;
let mut total_original = 0usize;
for (ai, ai_mod, human, original) in &file_stats {
total_ai += ai;
total_ai_modified += ai_mod;
total_human += human;
total_original += original;
}
assert_eq!(total_ai, 35);
assert_eq!(total_ai_modified, 8);
assert_eq!(total_human, 17);
assert_eq!(total_original, 350);
}
#[test]
fn test_commit_short_extraction() {
let commit_id = "abc1234def456789";
let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];
assert_eq!(commit_short, "abc1234");
assert_eq!(commit_short.len(), 7);
}
#[test]
fn test_commit_short_extraction_short_id() {
let commit_id = "abc12";
let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];
assert_eq!(commit_short, "abc12");
}
}