use std::env;
use std::path::Path;
use anyhow::{Context, Result};
use git2::Repository;
use serde::{Deserialize, Serialize};
use crate::capture::pending::{PendingBuffer, PendingStore};
use crate::capture::threeway::ThreeWayAnalyzer;
use crate::core::attribution::{AIAttribution, PromptInfo, SessionMetadata};
use crate::privacy::{Redactor, WhogititConfig};
use crate::storage::notes::NotesStore;
const ENV_SESSION_ID: &str = "WHOGITIT_SESSION_ID";
const ENV_MODEL_ID: &str = "WHOGITIT_MODEL_ID";
const DEFAULT_MODEL: &str = "claude-opus-4-5-20251101";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookContext {
#[serde(default)]
pub plan_mode: bool,
#[serde(default)]
pub is_subagent: bool,
#[serde(default)]
pub agent_depth: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookInput {
pub tool: String,
pub file_path: String,
pub prompt: String,
pub old_content: Option<String>,
pub new_content: String,
#[serde(default)]
pub context: Option<HookContext>,
}
pub struct CaptureHook {
repo_root: std::path::PathBuf,
redactor: Redactor,
audit_enabled: bool,
}
impl CaptureHook {
pub fn new(repo_path: &Path) -> Result<Self> {
let repo_root = repo_path.to_path_buf();
let config = WhogititConfig::load(&repo_root).unwrap_or_default();
let redactor = config.privacy.build_redactor();
let audit_enabled = config.privacy.audit_log;
Ok(Self {
repo_root,
redactor,
audit_enabled,
})
}
fn get_session_id() -> String {
env::var(ENV_SESSION_ID).unwrap_or_else(|_| uuid::Uuid::new_v4().to_string())
}
fn get_model_id() -> String {
env::var(ENV_MODEL_ID).unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
pub fn on_file_change(&self, input: HookInput) -> Result<()> {
let store = PendingStore::new(&self.repo_root);
let mut buffer = match store.load()? {
Some(b) => {
let current_session = Self::get_session_id();
if b.session.session_id != current_session && env::var(ENV_SESSION_ID).is_ok() {
if b.has_changes() {
eprintln!(
"whogitit: Warning - discarding {} uncommitted edits from previous session",
b.total_edits()
);
}
PendingBuffer::new(¤t_session, &Self::get_model_id())
} else {
b
}
}
None => {
let mut buffer = PendingBuffer::new(&Self::get_session_id(), &Self::get_model_id());
buffer.audit_logging_enabled = self.audit_enabled;
buffer
}
};
let relative_path = self.make_relative_path(&input.file_path)?;
if relative_path.is_empty() {
anyhow::bail!("Empty file path");
}
if relative_path.contains("..") {
anyhow::bail!(
"Path traversal detected in file path: '{}'. Paths containing '..' are not allowed.",
relative_path
);
}
if relative_path.starts_with('/') {
anyhow::bail!(
"Absolute path not allowed: '{}'. Paths must be relative to repository root.",
relative_path
);
}
if input.new_content.is_empty() && input.tool != "Delete" {
eprintln!("whogitit: Warning - empty new_content for non-delete operation");
}
let old_content = match input.old_content {
Some(content) => Some(content),
None => {
self.get_content_from_git_head(&relative_path)
}
};
let edit_context =
input
.context
.as_ref()
.map(|ctx| crate::capture::snapshot::EditContext {
plan_mode: ctx.plan_mode,
subagent_id: ctx.subagent_id.clone(),
agent_depth: ctx.agent_depth,
plan_step: None,
});
buffer.record_edit_with_context(
&relative_path,
old_content.as_deref(),
&input.new_content,
&input.tool,
&input.prompt,
Some(&self.redactor),
edit_context,
);
store.save(&buffer)?;
Ok(())
}
fn get_content_from_git_head(&self, path: &str) -> Option<String> {
let repo = match Repository::open(&self.repo_root) {
Ok(r) => r,
Err(e) => {
eprintln!(
"whogitit: Warning - failed to open repository at '{}': {}",
self.repo_root.display(),
e
);
return None;
}
};
let head = match repo.head() {
Ok(h) => h,
Err(e) => {
if e.code() != git2::ErrorCode::UnbornBranch {
eprintln!("whogitit: Warning - failed to get HEAD: {}", e);
}
return None;
}
};
let commit = match head.peel_to_commit() {
Ok(c) => c,
Err(e) => {
eprintln!("whogitit: Warning - failed to peel HEAD to commit: {}", e);
return None;
}
};
let tree = match commit.tree() {
Ok(t) => t,
Err(e) => {
eprintln!("whogitit: Warning - failed to get commit tree: {}", e);
return None;
}
};
let entry = match tree.get_path(std::path::Path::new(path)) {
Ok(e) => e,
Err(_) => return None, };
let blob = match repo.find_blob(entry.id()) {
Ok(b) => b,
Err(e) => {
eprintln!(
"whogitit: Warning - failed to read blob for '{}': {}",
path, e
);
return None;
}
};
match std::str::from_utf8(blob.content()) {
Ok(content) => Some(content.to_string()),
Err(_) => None, }
}
pub fn on_post_commit(&self) -> Result<Option<AIAttribution>> {
let store = PendingStore::new(&self.repo_root);
let buffer = match store.load()? {
Some(b) if b.has_changes() => b,
_ => return Ok(None),
};
let repo = Repository::open(&self.repo_root).context("Failed to open repository")?;
let head = repo
.head()
.context("Failed to get HEAD")?
.peel_to_commit()
.context("Failed to get HEAD commit")?;
let mut file_results = Vec::new();
let tree = head.tree()?;
for (path, history) in &buffer.file_histories {
let committed_content = match tree.get_path(std::path::Path::new(path)) {
Ok(entry) => {
let blob = repo.find_blob(entry.id())?;
String::from_utf8_lossy(blob.content()).to_string()
}
Err(_) => {
continue;
}
};
let result = ThreeWayAnalyzer::analyze_with_diff(history, &committed_content);
file_results.push(result);
}
let used_plan_mode = buffer
.file_histories
.values()
.flat_map(|h| h.edits.iter())
.any(|e| e.context.plan_mode);
let subagent_count = buffer
.file_histories
.values()
.flat_map(|h| h.edits.iter())
.filter(|e| e.context.agent_depth > 0)
.count() as u32;
let attribution = AIAttribution {
version: 3,
session: SessionMetadata {
session_id: buffer.session.session_id.clone(),
model: buffer.session.model.clone(),
started_at: buffer.session.started_at.clone(),
prompt_count: buffer.session.prompt_count,
used_plan_mode,
subagent_count,
},
prompts: buffer
.session
.prompts
.iter()
.map(|p| PromptInfo {
index: p.index,
text: p.text.clone(),
timestamp: p.timestamp.clone(),
affected_files: p.affected_files.clone(),
})
.collect(),
files: file_results,
};
let notes_store = NotesStore::new(&repo)?;
notes_store.store_attribution(head.id(), &attribution)?;
store.delete()?;
let total_ai = attribution
.files
.iter()
.map(|f| f.summary.ai_lines + f.summary.ai_modified_lines)
.sum::<usize>();
let total_human = attribution
.files
.iter()
.map(|f| f.summary.human_lines)
.sum::<usize>();
eprintln!(
"whogitit: Attached attribution - {} AI lines, {} human lines across {} files",
total_ai,
total_human,
attribution.files.len()
);
Ok(Some(attribution))
}
fn make_relative_path(&self, path: &str) -> Result<String> {
let abs_path = if Path::new(path).is_absolute() {
Path::new(path).to_path_buf()
} else {
self.repo_root.join(path)
};
let relative = abs_path
.strip_prefix(&self.repo_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string());
Ok(relative)
}
pub fn status(&self) -> Result<PendingStatus> {
let store = PendingStore::new(&self.repo_root);
match store.load_quiet()? {
Some(buffer) => {
let session_id = buffer.session.session_id.clone();
let file_count = buffer.file_count();
let line_count = buffer.total_lines();
let edit_count = buffer.total_edits();
let prompt_count = buffer.session.prompt_count;
let has_pending = buffer.has_changes();
let is_stale = buffer.is_stale();
let age = buffer.age_string();
Ok(PendingStatus {
has_pending,
session_id: Some(session_id),
file_count,
line_count,
edit_count,
prompt_count,
is_stale,
age,
})
}
None => Ok(PendingStatus {
has_pending: false,
session_id: None,
file_count: 0,
line_count: 0,
edit_count: 0,
prompt_count: 0,
is_stale: false,
age: String::new(),
}),
}
}
pub fn clear_pending(&self) -> Result<()> {
let store = PendingStore::new(&self.repo_root);
store.delete()
}
}
#[derive(Debug)]
pub struct PendingStatus {
pub has_pending: bool,
pub session_id: Option<String>,
pub file_count: usize,
pub line_count: u32,
pub edit_count: usize,
pub prompt_count: u32,
pub is_stale: bool,
pub age: String,
}
pub fn run_capture_hook() -> Result<()> {
let input: HookInput = serde_json::from_reader(std::io::stdin())
.context("Failed to read hook input from stdin")?;
let repo_root = find_repo_root()?;
if !is_repo_initialized(&repo_root) {
return Ok(());
}
let hook = CaptureHook::new(&repo_root)?;
hook.on_file_change(input)?;
Ok(())
}
fn find_repo_root() -> Result<std::path::PathBuf> {
let current = env::current_dir()?;
let repo = Repository::discover(¤t).context("Not in a git repository")?;
repo.workdir()
.map(|p| p.to_path_buf())
.context("Repository has no working directory")
}
fn is_repo_initialized(repo_root: &std::path::Path) -> bool {
let post_commit = repo_root.join(".git/hooks/post-commit");
if let Ok(content) = std::fs::read_to_string(&post_commit) {
content.contains("whogitit")
} else {
false
}
}
pub fn run_post_commit_hook() -> Result<()> {
let repo_root = find_repo_root()?;
let hook = CaptureHook::new(&repo_root)?;
hook.on_post_commit()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Signature;
use tempfile::TempDir;
fn create_test_repo() -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
{
let sig = Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])
.unwrap();
}
(dir, repo)
}
#[test]
fn test_capture_hook_on_file_change() {
let (dir, _repo) = create_test_repo();
let hook = CaptureHook::new(dir.path()).unwrap();
let input = HookInput {
tool: "Write".to_string(),
file_path: "test.rs".to_string(),
prompt: "Create a test file".to_string(),
old_content: None,
new_content: "fn test() {}\n".to_string(),
context: None,
};
hook.on_file_change(input).unwrap();
let status = hook.status().unwrap();
assert!(status.has_pending);
assert_eq!(status.file_count, 1);
assert_eq!(status.edit_count, 1);
assert_eq!(status.prompt_count, 1);
}
#[test]
fn test_capture_hook_multiple_edits() {
let (dir, _repo) = create_test_repo();
let hook = CaptureHook::new(dir.path()).unwrap();
hook.on_file_change(HookInput {
tool: "Write".to_string(),
file_path: "test.rs".to_string(),
prompt: "Create file".to_string(),
old_content: None,
new_content: "line1\n".to_string(),
context: None,
})
.unwrap();
hook.on_file_change(HookInput {
tool: "Edit".to_string(),
file_path: "test.rs".to_string(),
prompt: "Add line".to_string(),
old_content: Some("line1\n".to_string()),
new_content: "line1\nline2\n".to_string(),
context: None,
})
.unwrap();
let status = hook.status().unwrap();
assert_eq!(status.file_count, 1);
assert_eq!(status.edit_count, 2);
assert_eq!(status.prompt_count, 2);
}
#[test]
fn test_capture_hook_status_empty() {
let (dir, _repo) = create_test_repo();
let hook = CaptureHook::new(dir.path()).unwrap();
let status = hook.status().unwrap();
assert!(!status.has_pending);
assert_eq!(status.file_count, 0);
}
#[test]
fn test_capture_hook_clear() {
let (dir, _repo) = create_test_repo();
let hook = CaptureHook::new(dir.path()).unwrap();
hook.on_file_change(HookInput {
tool: "Write".to_string(),
file_path: "test.rs".to_string(),
prompt: "test".to_string(),
old_content: None,
new_content: "content\n".to_string(),
context: None,
})
.unwrap();
assert!(hook.status().unwrap().has_pending);
hook.clear_pending().unwrap();
assert!(!hook.status().unwrap().has_pending);
}
#[test]
fn test_is_repo_initialized() {
let dir = TempDir::new().unwrap();
let hooks_dir = dir.path().join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
assert!(!is_repo_initialized(dir.path()));
std::fs::write(hooks_dir.join("post-commit"), "#!/bin/bash\necho hello").unwrap();
assert!(!is_repo_initialized(dir.path()));
std::fs::write(
hooks_dir.join("post-commit"),
"#!/bin/bash\nwhogitit commit",
)
.unwrap();
assert!(is_repo_initialized(dir.path()));
}
}