Skip to main content

whogitit/capture/
hook.rs

1use std::collections::HashSet;
2use std::env;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use git2::{Delta, DiffFindOptions, DiffOptions, Repository};
7use serde::{Deserialize, Serialize};
8
9use crate::capture::pending::{PendingBuffer, PendingStore, PromptRecord};
10use crate::capture::threeway::ThreeWayAnalyzer;
11use crate::core::attribution::{AIAttribution, PromptInfo, SessionMetadata};
12use crate::privacy::{Redactor, RetentionConfig, WhogititConfig};
13use crate::retention::apply_retention_policy;
14use crate::storage::audit::AuditLog;
15use crate::storage::notes::NotesStore;
16
17/// Environment variable for session ID
18const ENV_SESSION_ID: &str = "WHOGITIT_SESSION_ID";
19/// Environment variable for model ID
20const ENV_MODEL_ID: &str = "WHOGITIT_MODEL_ID";
21/// Default model if not specified
22const DEFAULT_MODEL: &str = "claude-opus-4-5-20251101";
23
24/// Context from Claude Code transcript
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct HookContext {
27    /// Whether the edit was made in plan mode
28    #[serde(default)]
29    pub plan_mode: bool,
30    /// Whether this is from a subagent
31    #[serde(default)]
32    pub is_subagent: bool,
33    /// Agent nesting depth (0=main, 1+=subagent)
34    #[serde(default)]
35    pub agent_depth: u8,
36    /// Subagent ID if applicable
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub subagent_id: Option<String>,
39}
40
41/// Input from Claude Code hook for file changes
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HookInput {
44    /// The tool being called (Edit, Write)
45    pub tool: String,
46    /// File path being modified
47    pub file_path: String,
48    /// The current user prompt/context
49    pub prompt: String,
50    /// Old file content (None for new files)
51    pub old_content: Option<String>,
52    /// Whether old_content was provided (distinguish empty from missing)
53    #[serde(default)]
54    pub old_content_present: bool,
55    /// New file content
56    pub new_content: String,
57    /// Context from transcript (plan mode, subagent, etc.)
58    #[serde(default)]
59    pub context: Option<HookContext>,
60}
61
62/// Claude Code hook handler
63pub struct CaptureHook {
64    /// Repository root path
65    repo_root: std::path::PathBuf,
66    /// Privacy redactor
67    redactor: Redactor,
68    /// Whether audit logging is enabled
69    audit_enabled: bool,
70    /// Similarity threshold for AI-modified detection
71    similarity_threshold: f64,
72    /// Maximum pending buffer age in hours
73    max_pending_age_hours: i64,
74    /// Retention configuration
75    retention_config: RetentionConfig,
76}
77
78impl CaptureHook {
79    /// Create a new capture hook for a repository
80    pub fn new(repo_path: &Path) -> Result<Self> {
81        let repo_root = repo_path.to_path_buf();
82
83        // Load config and build redactor
84        let config = match WhogititConfig::load(&repo_root) {
85            Ok(config) => config,
86            Err(err) => {
87                eprintln!(
88                    "whogitit: Warning - failed to load config, using defaults: {}",
89                    err
90                );
91                WhogititConfig::default()
92            }
93        };
94        let redactor = config.privacy.build_redactor();
95        let audit_enabled = config.privacy.audit_log;
96        let similarity_threshold = config.analysis.similarity_threshold;
97        let max_pending_age_hours = config.analysis.max_pending_age_hours as i64;
98        let retention_config = config.retention.unwrap_or_default();
99
100        Ok(Self {
101            repo_root,
102            redactor,
103            audit_enabled,
104            similarity_threshold,
105            max_pending_age_hours,
106            retention_config,
107        })
108    }
109
110    /// Get or create session ID
111    fn get_session_id() -> String {
112        env::var(ENV_SESSION_ID).unwrap_or_else(|_| uuid::Uuid::new_v4().to_string())
113    }
114
115    /// Get model ID from environment
116    fn get_model_id() -> String {
117        env::var(ENV_MODEL_ID).unwrap_or_else(|_| DEFAULT_MODEL.to_string())
118    }
119
120    /// Handle a file change from Claude Code
121    pub fn on_file_change(&self, input: HookInput) -> Result<()> {
122        let store = PendingStore::new(&self.repo_root);
123
124        // Load or create pending buffer
125        let mut buffer = match store.load_with_max_age(self.max_pending_age_hours)? {
126            Some(b) => {
127                // Check if we should start a new session
128                // (different session ID in env means new session)
129                let current_session = Self::get_session_id();
130                if b.session.session_id != current_session && env::var(ENV_SESSION_ID).is_ok() {
131                    // New session ID explicitly set, start fresh
132                    // But first, warn about uncommitted changes
133                    if b.has_changes() {
134                        eprintln!(
135                            "whogitit: Warning - discarding {} uncommitted edits from previous session",
136                            b.total_edits()
137                        );
138                    }
139                    let mut buffer = PendingBuffer::new(&current_session, &Self::get_model_id());
140                    buffer.audit_logging_enabled = self.audit_enabled;
141                    buffer
142                } else {
143                    b
144                }
145            }
146            None => {
147                let mut buffer = PendingBuffer::new(&Self::get_session_id(), &Self::get_model_id());
148                buffer.audit_logging_enabled = self.audit_enabled;
149                buffer
150            }
151        };
152
153        // Make path relative to repo root
154        let relative_path = self.make_relative_path(&input.file_path)?;
155
156        // Validate input
157        if relative_path.is_empty() {
158            anyhow::bail!("Empty file path");
159        }
160
161        let rel_path = std::path::Path::new(&relative_path);
162
163        // Reject absolute paths (including Windows prefixes)
164        if rel_path.is_absolute()
165            || rel_path
166                .components()
167                .any(|c| matches!(c, std::path::Component::Prefix(_)))
168        {
169            anyhow::bail!(
170                "Path '{}' is outside repository root '{}'. Use a repository-relative path.",
171                relative_path,
172                self.repo_root.display()
173            );
174        }
175
176        // Check for path traversal attempts
177        if rel_path
178            .components()
179            .any(|c| matches!(c, std::path::Component::ParentDir))
180        {
181            anyhow::bail!(
182                "Path traversal detected in file path: '{}'. Paths containing '..' are not allowed.",
183                relative_path
184            );
185        }
186
187        if input.new_content.is_empty() && input.tool != "Delete" {
188            eprintln!("whogitit: Warning - empty new_content for non-delete operation");
189        }
190
191        // Determine old content: use provided value, or fall back to git HEAD
192        let old_content = if input.old_content_present {
193            Some(input.old_content.unwrap_or_default())
194        } else if let Some(content) = input.old_content.clone() {
195            Some(content)
196        } else {
197            // Try to get content from git HEAD for existing files
198            self.get_content_from_git_head(&relative_path)
199        };
200
201        // Build edit context from hook input
202        let edit_context =
203            input
204                .context
205                .as_ref()
206                .map(|ctx| crate::capture::snapshot::EditContext {
207                    plan_mode: ctx.plan_mode,
208                    subagent_id: ctx.subagent_id.clone(),
209                    agent_depth: ctx.agent_depth,
210                    plan_step: None,
211                });
212
213        // Record the edit with full content snapshots
214        buffer.record_edit_with_context(
215            &relative_path,
216            old_content.as_deref(),
217            &input.new_content,
218            &input.tool,
219            &input.prompt,
220            Some(&self.redactor),
221            edit_context,
222        );
223
224        // Log redaction audit events (if enabled)
225        if self.audit_enabled {
226            if let Some(prompt) = buffer.session.prompts.last() {
227                if !prompt.redaction_events.is_empty() {
228                    let audit_log = AuditLog::new(&self.repo_root);
229                    let mut counts: std::collections::HashMap<String, u32> =
230                        std::collections::HashMap::new();
231                    for event in &prompt.redaction_events {
232                        *counts.entry(event.pattern_name.clone()).or_insert(0) += 1;
233                    }
234                    for (pattern, count) in counts {
235                        if let Err(e) = audit_log.log_redaction(&pattern, count) {
236                            eprintln!("whogitit: Warning - failed to log redaction: {}", e);
237                        }
238                    }
239                }
240            }
241        }
242
243        // Save buffer with atomic write
244        store.save(&buffer)?;
245
246        Ok(())
247    }
248
249    /// Get file content from git HEAD (the last committed version)
250    ///
251    /// Returns None for new files or if git operations fail.
252    /// Logs warnings for unexpected failures to aid debugging.
253    fn get_content_from_git_head(&self, path: &str) -> Option<String> {
254        let repo = match Repository::open(&self.repo_root) {
255            Ok(r) => r,
256            Err(e) => {
257                eprintln!(
258                    "whogitit: Warning - failed to open repository at '{}': {}",
259                    self.repo_root.display(),
260                    e
261                );
262                return None;
263            }
264        };
265
266        let head = match repo.head() {
267            Ok(h) => h,
268            Err(e) => {
269                // HEAD not existing is normal for new repos with no commits
270                if e.code() != git2::ErrorCode::UnbornBranch {
271                    eprintln!("whogitit: Warning - failed to get HEAD: {}", e);
272                }
273                return None;
274            }
275        };
276
277        let commit = match head.peel_to_commit() {
278            Ok(c) => c,
279            Err(e) => {
280                eprintln!("whogitit: Warning - failed to peel HEAD to commit: {}", e);
281                return None;
282            }
283        };
284
285        let tree = match commit.tree() {
286            Ok(t) => t,
287            Err(e) => {
288                eprintln!("whogitit: Warning - failed to get commit tree: {}", e);
289                return None;
290            }
291        };
292
293        // File not existing in HEAD is normal for new files - don't warn
294        let entry = match tree.get_path(std::path::Path::new(path)) {
295            Ok(e) => e,
296            Err(_) => return None, // New file, not in HEAD
297        };
298
299        let blob = match repo.find_blob(entry.id()) {
300            Ok(b) => b,
301            Err(e) => {
302                eprintln!(
303                    "whogitit: Warning - failed to read blob for '{}': {}",
304                    path, e
305                );
306                return None;
307            }
308        };
309
310        // Non-UTF8 content is valid for binary files - don't treat as error
311        match std::str::from_utf8(blob.content()) {
312            Ok(content) => Some(content.to_string()),
313            Err(_) => None, // Binary file
314        }
315    }
316
317    /// Handle post-commit: perform three-way analysis, attach notes, and clean up
318    pub fn on_post_commit(&self) -> Result<Option<AIAttribution>> {
319        let store = PendingStore::new(&self.repo_root);
320
321        // Load pending buffer
322        let mut buffer = match store.load()? {
323            Some(b) if b.has_changes() => b,
324            _ => return Ok(None),
325        };
326
327        // Open repo and get HEAD commit
328        let repo = Repository::open(&self.repo_root).context("Failed to open repository")?;
329        let head = repo
330            .head()
331            .context("Failed to get HEAD")?
332            .peel_to_commit()
333            .context("Failed to get HEAD commit")?;
334
335        let tree = head.tree()?;
336
337        // Build rename map (old -> new) to preserve attribution across moves
338        let rename_map = build_rename_map(&repo, &head)?;
339        let changed_paths = build_changed_paths(&repo, &head)?;
340
341        // Preserve all prompt records before we split processed vs remaining histories.
342        let all_prompts = buffer.session.prompts.clone();
343
344        let mut file_results = Vec::new();
345        let mut remaining_histories = std::collections::HashMap::new();
346        let mut processed_prompt_indices = HashSet::new();
347        let mut remaining_prompt_indices = HashSet::new();
348        let mut used_plan_mode = false;
349        let mut subagent_count = 0u32;
350
351        for (path, history) in buffer.file_histories.drain() {
352            let Some(committed_path) = resolve_committed_path(&path, &changed_paths, &rename_map)
353            else {
354                for edit in &history.edits {
355                    remaining_prompt_indices.insert(edit.prompt_index);
356                }
357                remaining_histories.insert(path, history);
358                continue;
359            };
360
361            // Get the committed content for this file
362            let committed_content = match tree.get_path(std::path::Path::new(&committed_path)) {
363                Ok(entry) => {
364                    let blob = repo.find_blob(entry.id())?;
365                    String::from_utf8_lossy(blob.content()).to_string()
366                }
367                Err(_) => {
368                    // File was part of commit metadata but does not exist in final tree
369                    // (for example, deleted file). Consume it from pending state.
370                    continue;
371                }
372            };
373
374            // Perform three-way analysis
375            let mut result = ThreeWayAnalyzer::analyze_with_diff_with_threshold(
376                &history,
377                &committed_content,
378                self.similarity_threshold,
379            );
380            if committed_path != path {
381                result.path = committed_path;
382            }
383            file_results.push(result);
384
385            for edit in &history.edits {
386                processed_prompt_indices.insert(edit.prompt_index);
387                if edit.context.plan_mode {
388                    used_plan_mode = true;
389                }
390                if edit.context.agent_depth > 0 {
391                    subagent_count += 1;
392                }
393            }
394        }
395
396        // Nothing attributable for this commit; only update pending state.
397        if file_results.is_empty() {
398            if remaining_histories.is_empty() {
399                store.delete()?;
400            } else {
401                buffer.file_histories = remaining_histories;
402                buffer.session.prompts =
403                    filter_prompt_records(&all_prompts, &remaining_prompt_indices);
404                buffer.session.prompt_count = buffer.session.prompts.len() as u32;
405                buffer.prompt_counter = next_prompt_index(&buffer.session.prompts);
406                buffer.total_redactions = buffer
407                    .session
408                    .prompts
409                    .iter()
410                    .map(|p| p.redaction_events.len() as u32)
411                    .sum();
412                store.save(&buffer)?;
413            }
414            return Ok(None);
415        }
416
417        let attribution_prompts = filter_prompt_records(&all_prompts, &processed_prompt_indices);
418
419        // Create attribution with full analysis
420        let attribution = AIAttribution {
421            version: 3,
422            session: SessionMetadata {
423                session_id: buffer.session.session_id.clone(),
424                model: buffer.session.model.clone(),
425                started_at: buffer.session.started_at.clone(),
426                prompt_count: attribution_prompts.len() as u32,
427                used_plan_mode,
428                subagent_count,
429            },
430            prompts: attribution_prompts
431                .iter()
432                .map(|p| PromptInfo {
433                    index: p.index,
434                    text: p.text.clone(),
435                    timestamp: p.timestamp.clone(),
436                    affected_files: p.affected_files.clone(),
437                })
438                .collect(),
439            files: file_results,
440        };
441
442        // Store as git note
443        let notes_store = NotesStore::new(&repo)?;
444        notes_store.store_attribution(head.id(), &attribution)?;
445
446        if self.retention_config.auto_purge {
447            if let Err(e) = apply_retention_policy(
448                &repo,
449                &self.retention_config,
450                true,
451                "Auto purge (post-commit)",
452                self.audit_enabled,
453            ) {
454                eprintln!("whogitit: Warning - auto purge failed: {}", e);
455            }
456        }
457
458        // Persist any remaining pending edits only after attribution note is safely stored.
459        if remaining_histories.is_empty() {
460            store.delete()?;
461        } else {
462            buffer.file_histories = remaining_histories;
463            buffer.session.prompts = filter_prompt_records(&all_prompts, &remaining_prompt_indices);
464            buffer.session.prompt_count = buffer.session.prompts.len() as u32;
465            buffer.prompt_counter = next_prompt_index(&buffer.session.prompts);
466            buffer.total_redactions = buffer
467                .session
468                .prompts
469                .iter()
470                .map(|p| p.redaction_events.len() as u32)
471                .sum();
472            store.save(&buffer)?;
473        }
474
475        // Log summary
476        let total_ai = attribution
477            .files
478            .iter()
479            .map(|f| f.summary.ai_lines + f.summary.ai_modified_lines)
480            .sum::<usize>();
481        let total_human = attribution
482            .files
483            .iter()
484            .map(|f| f.summary.human_lines)
485            .sum::<usize>();
486
487        eprintln!(
488            "whogitit: Attached attribution - {} AI lines, {} human lines across {} files",
489            total_ai,
490            total_human,
491            attribution.files.len()
492        );
493
494        Ok(Some(attribution))
495    }
496
497    /// Make a path relative to the repo root
498    fn make_relative_path(&self, path: &str) -> Result<String> {
499        let input_path = Path::new(path);
500        if !input_path.is_absolute() {
501            return Ok(path.to_string());
502        }
503
504        // Fast path: exact prefix match against the repo root.
505        if let Ok(relative) = input_path.strip_prefix(&self.repo_root) {
506            return Ok(relative.to_string_lossy().to_string());
507        }
508
509        // Handle aliased absolute paths (e.g. /var vs /private/var on macOS)
510        // by canonicalizing both paths before prefix comparison.
511        let canonical_repo =
512            canonicalize_for_prefix(&self.repo_root).unwrap_or_else(|| self.repo_root.clone());
513        if let Some(canonical_input) = canonicalize_for_prefix(input_path) {
514            if let Ok(relative) = canonical_input.strip_prefix(&canonical_repo) {
515                return Ok(relative.to_string_lossy().to_string());
516            }
517        }
518
519        anyhow::bail!(
520            "Absolute path '{}' could not be mapped under repository root '{}'.",
521            path,
522            self.repo_root.display()
523        )
524    }
525
526    /// Get current pending status
527    pub fn status(&self) -> Result<PendingStatus> {
528        let store = PendingStore::new(&self.repo_root);
529
530        // Use quiet load to avoid spurious warnings during status check
531        match store.load_quiet()? {
532            Some(buffer) => {
533                let session_id = buffer.session.session_id.clone();
534                let file_count = buffer.file_count();
535                let line_count = buffer.total_lines();
536                let edit_count = buffer.total_edits();
537                let prompt_count = buffer.session.prompt_count;
538                let has_pending = buffer.has_changes();
539                let is_stale = buffer.is_stale_hours(self.max_pending_age_hours);
540                let age = buffer.age_string();
541                Ok(PendingStatus {
542                    has_pending,
543                    session_id: Some(session_id),
544                    file_count,
545                    line_count,
546                    edit_count,
547                    prompt_count,
548                    is_stale,
549                    age,
550                    max_pending_age_hours: self.max_pending_age_hours,
551                })
552            }
553            None => Ok(PendingStatus {
554                has_pending: false,
555                session_id: None,
556                file_count: 0,
557                line_count: 0,
558                edit_count: 0,
559                prompt_count: 0,
560                is_stale: false,
561                age: String::new(),
562                max_pending_age_hours: self.max_pending_age_hours,
563            }),
564        }
565    }
566
567    /// Clear pending changes without committing
568    pub fn clear_pending(&self) -> Result<()> {
569        let store = PendingStore::new(&self.repo_root);
570        store.delete()
571    }
572}
573
574/// Canonicalize a path for prefix comparison.
575///
576/// If the full path doesn't exist yet, this resolves the deepest existing ancestor
577/// and re-appends the missing suffix so new files can still be matched reliably.
578fn canonicalize_for_prefix(path: &Path) -> Option<std::path::PathBuf> {
579    if let Ok(canonical) = std::fs::canonicalize(path) {
580        return Some(canonical);
581    }
582
583    let mut current = path;
584    let mut missing_components = Vec::new();
585
586    while !current.exists() {
587        let file_name = current.file_name()?;
588        missing_components.push(file_name.to_os_string());
589        current = current.parent()?;
590    }
591
592    let mut canonical_base = std::fs::canonicalize(current).ok()?;
593    for component in missing_components.iter().rev() {
594        canonical_base.push(component);
595    }
596
597    Some(canonical_base)
598}
599
600fn build_rename_map(
601    repo: &Repository,
602    head: &git2::Commit,
603) -> Result<std::collections::HashMap<String, String>> {
604    let mut map = std::collections::HashMap::new();
605
606    let new_tree = head.tree()?;
607
608    for parent_idx in 0..head.parent_count() {
609        let parent = match head.parent(parent_idx) {
610            Ok(p) => p,
611            Err(_) => continue,
612        };
613
614        let old_tree = parent.tree()?;
615
616        let mut opts = DiffOptions::new();
617        let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), Some(&mut opts))?;
618
619        let mut find_opts = DiffFindOptions::new();
620        find_opts.renames_from_rewrites(true);
621        diff.find_similar(Some(&mut find_opts))?;
622
623        for delta in diff.deltas() {
624            if delta.status() == Delta::Renamed {
625                let old_path = delta
626                    .old_file()
627                    .path()
628                    .map(|p| p.to_string_lossy().to_string());
629                let new_path = delta
630                    .new_file()
631                    .path()
632                    .map(|p| p.to_string_lossy().to_string());
633                if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
634                    map.entry(old_path).or_insert(new_path);
635                }
636            }
637        }
638    }
639
640    Ok(map)
641}
642
643fn build_changed_paths(repo: &Repository, head: &git2::Commit) -> Result<HashSet<String>> {
644    let mut changed = HashSet::new();
645    let new_tree = head.tree()?;
646
647    if head.parent_count() == 0 {
648        collect_changed_paths(repo, None, &new_tree, &mut changed)?;
649        return Ok(changed);
650    }
651
652    for parent_idx in 0..head.parent_count() {
653        let parent = match head.parent(parent_idx) {
654            Ok(p) => p,
655            Err(_) => continue,
656        };
657        let old_tree = parent.tree()?;
658        collect_changed_paths(repo, Some(&old_tree), &new_tree, &mut changed)?;
659    }
660
661    Ok(changed)
662}
663
664fn collect_changed_paths(
665    repo: &Repository,
666    old_tree: Option<&git2::Tree<'_>>,
667    new_tree: &git2::Tree<'_>,
668    changed: &mut HashSet<String>,
669) -> Result<()> {
670    let mut opts = DiffOptions::new();
671    let diff = repo.diff_tree_to_tree(old_tree, Some(new_tree), Some(&mut opts))?;
672
673    for delta in diff.deltas() {
674        if let Some(path) = delta.old_file().path() {
675            changed.insert(path.to_string_lossy().to_string());
676        }
677        if let Some(path) = delta.new_file().path() {
678            changed.insert(path.to_string_lossy().to_string());
679        }
680    }
681
682    Ok(())
683}
684
685fn resolve_committed_path(
686    path: &str,
687    changed_paths: &HashSet<String>,
688    rename_map: &std::collections::HashMap<String, String>,
689) -> Option<String> {
690    if let Some(new_path) = rename_map.get(path) {
691        if changed_paths.contains(path) || changed_paths.contains(new_path) {
692            return Some(new_path.clone());
693        }
694    }
695
696    if changed_paths.contains(path) {
697        return Some(path.to_string());
698    }
699
700    None
701}
702
703fn filter_prompt_records(
704    prompts: &[PromptRecord],
705    prompt_indices: &HashSet<u32>,
706) -> Vec<PromptRecord> {
707    prompts
708        .iter()
709        .filter(|p| prompt_indices.contains(&p.index))
710        .cloned()
711        .collect()
712}
713
714fn next_prompt_index(prompts: &[PromptRecord]) -> u32 {
715    prompts
716        .iter()
717        .map(|p| p.index)
718        .max()
719        .map(|idx| idx.saturating_add(1))
720        .unwrap_or(0)
721}
722
723/// Status of pending changes
724#[derive(Debug)]
725pub struct PendingStatus {
726    pub has_pending: bool,
727    pub session_id: Option<String>,
728    pub file_count: usize,
729    pub line_count: u32,
730    pub edit_count: usize,
731    pub prompt_count: u32,
732    /// Whether the pending buffer is stale (older than configured hours)
733    pub is_stale: bool,
734    /// Human-readable age of the pending buffer
735    pub age: String,
736    /// Configured maximum pending buffer age in hours
737    pub max_pending_age_hours: i64,
738}
739
740/// Hook entry point for Claude Code integration
741pub fn run_capture_hook() -> Result<()> {
742    // Read input from stdin
743    let input: HookInput = serde_json::from_reader(std::io::stdin())
744        .context("Failed to read hook input from stdin")?;
745
746    // Find repo root
747    let repo_root = find_repo_root()?;
748
749    // Only capture in repos that have been initialized with `whogitit init`
750    if !is_repo_initialized(&repo_root) {
751        return Ok(());
752    }
753
754    // Process the change
755    let hook = CaptureHook::new(&repo_root)?;
756    hook.on_file_change(input)?;
757
758    Ok(())
759}
760
761/// Find the git repository root from current directory
762fn find_repo_root() -> Result<std::path::PathBuf> {
763    let current = env::current_dir()?;
764    let repo = Repository::discover(&current).context("Not in a git repository")?;
765
766    repo.workdir()
767        .map(|p| p.to_path_buf())
768        .context("Repository has no working directory")
769}
770
771/// Check if the repository has been initialized with `whogitit init`
772/// by looking for the whogitit marker in the post-commit hook
773fn is_repo_initialized(repo_root: &std::path::Path) -> bool {
774    let post_commit = repo_root.join(".git/hooks/post-commit");
775    if let Ok(content) = std::fs::read_to_string(&post_commit) {
776        content.contains("whogitit")
777    } else {
778        false
779    }
780}
781
782/// Git post-commit hook entry point
783pub fn run_post_commit_hook() -> Result<()> {
784    let repo_root = find_repo_root()?;
785    let hook = CaptureHook::new(&repo_root)?;
786
787    hook.on_post_commit()?;
788
789    Ok(())
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use git2::Signature;
796    use tempfile::TempDir;
797
798    fn create_test_repo() -> (TempDir, Repository) {
799        let dir = TempDir::new().unwrap();
800        let repo = Repository::init(dir.path()).unwrap();
801
802        // Create initial commit
803        {
804            let sig = Signature::now("Test", "test@test.com").unwrap();
805            let tree_id = repo.index().unwrap().write_tree().unwrap();
806            let tree = repo.find_tree(tree_id).unwrap();
807            repo.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])
808                .unwrap();
809        }
810
811        (dir, repo)
812    }
813
814    #[test]
815    fn test_capture_hook_on_file_change() {
816        let (dir, _repo) = create_test_repo();
817        let hook = CaptureHook::new(dir.path()).unwrap();
818
819        let input = HookInput {
820            tool: "Write".to_string(),
821            file_path: "test.rs".to_string(),
822            prompt: "Create a test file".to_string(),
823            old_content: None,
824            old_content_present: false,
825            new_content: "fn test() {}\n".to_string(),
826            context: None,
827        };
828
829        hook.on_file_change(input).unwrap();
830
831        let status = hook.status().unwrap();
832        assert!(status.has_pending);
833        assert_eq!(status.file_count, 1);
834        assert_eq!(status.edit_count, 1);
835        assert_eq!(status.prompt_count, 1);
836    }
837
838    #[test]
839    fn test_capture_hook_multiple_edits() {
840        let (dir, _repo) = create_test_repo();
841        let hook = CaptureHook::new(dir.path()).unwrap();
842
843        // First edit
844        hook.on_file_change(HookInput {
845            tool: "Write".to_string(),
846            file_path: "test.rs".to_string(),
847            prompt: "Create file".to_string(),
848            old_content: None,
849            old_content_present: false,
850            new_content: "line1\n".to_string(),
851            context: None,
852        })
853        .unwrap();
854
855        // Second edit to same file
856        hook.on_file_change(HookInput {
857            tool: "Edit".to_string(),
858            file_path: "test.rs".to_string(),
859            prompt: "Add line".to_string(),
860            old_content: Some("line1\n".to_string()),
861            old_content_present: true,
862            new_content: "line1\nline2\n".to_string(),
863            context: None,
864        })
865        .unwrap();
866
867        let status = hook.status().unwrap();
868        assert_eq!(status.file_count, 1);
869        assert_eq!(status.edit_count, 2);
870        assert_eq!(status.prompt_count, 2);
871    }
872
873    #[test]
874    fn test_capture_hook_status_empty() {
875        let (dir, _repo) = create_test_repo();
876        let hook = CaptureHook::new(dir.path()).unwrap();
877
878        let status = hook.status().unwrap();
879        assert!(!status.has_pending);
880        assert_eq!(status.file_count, 0);
881    }
882
883    #[test]
884    fn test_capture_hook_clear() {
885        let (dir, _repo) = create_test_repo();
886        let hook = CaptureHook::new(dir.path()).unwrap();
887
888        // Add a change
889        hook.on_file_change(HookInput {
890            tool: "Write".to_string(),
891            file_path: "test.rs".to_string(),
892            prompt: "test".to_string(),
893            old_content: None,
894            old_content_present: false,
895            new_content: "content\n".to_string(),
896            context: None,
897        })
898        .unwrap();
899
900        assert!(hook.status().unwrap().has_pending);
901
902        // Clear
903        hook.clear_pending().unwrap();
904        assert!(!hook.status().unwrap().has_pending);
905    }
906
907    #[test]
908    fn test_post_commit_rename_preserves_attribution_path() {
909        let (dir, repo) = create_test_repo();
910        let repo_root = dir.path();
911
912        // Create and commit initial file
913        let old_path = repo_root.join("old.rs");
914        std::fs::write(&old_path, "line1\n").unwrap();
915
916        {
917            let mut index = repo.index().unwrap();
918            index.add_path(std::path::Path::new("old.rs")).unwrap();
919            index.write().unwrap();
920            let tree_id = index.write_tree().unwrap();
921            let tree = repo.find_tree(tree_id).unwrap();
922            let sig = Signature::now("Test", "test@test.com").unwrap();
923            let head = repo.head().unwrap().peel_to_commit().unwrap();
924            repo.commit(Some("HEAD"), &sig, &sig, "Add old.rs", &tree, &[&head])
925                .unwrap();
926        }
927
928        let hook = CaptureHook::new(repo_root).unwrap();
929        hook.on_file_change(HookInput {
930            tool: "Edit".to_string(),
931            file_path: "old.rs".to_string(),
932            prompt: "Add line".to_string(),
933            old_content: Some("line1\n".to_string()),
934            old_content_present: true,
935            new_content: "line1\nline2\n".to_string(),
936            context: None,
937        })
938        .unwrap();
939
940        // Rename file and commit
941        let new_path = repo_root.join("new.rs");
942        std::fs::rename(&old_path, &new_path).unwrap();
943
944        {
945            let mut index = repo.index().unwrap();
946            index.remove_path(std::path::Path::new("old.rs")).unwrap();
947            index.add_path(std::path::Path::new("new.rs")).unwrap();
948            index.write().unwrap();
949            let tree_id = index.write_tree().unwrap();
950            let tree = repo.find_tree(tree_id).unwrap();
951            let sig = Signature::now("Test", "test@test.com").unwrap();
952            let head = repo.head().unwrap().peel_to_commit().unwrap();
953            repo.commit(
954                Some("HEAD"),
955                &sig,
956                &sig,
957                "Rename old.rs to new.rs",
958                &tree,
959                &[&head],
960            )
961            .unwrap();
962        }
963
964        let attribution = hook.on_post_commit().unwrap().unwrap();
965        assert_eq!(attribution.files.len(), 1);
966        assert_eq!(attribution.files[0].path, "new.rs");
967    }
968
969    #[test]
970    fn test_post_commit_preserves_pending_for_uncommitted_files() {
971        let (dir, repo) = create_test_repo();
972        let repo_root = dir.path();
973
974        // Add baseline files and commit them.
975        std::fs::write(repo_root.join("a.rs"), "a0\n").unwrap();
976        std::fs::write(repo_root.join("b.rs"), "b0\n").unwrap();
977        {
978            let mut index = repo.index().unwrap();
979            index.add_path(std::path::Path::new("a.rs")).unwrap();
980            index.add_path(std::path::Path::new("b.rs")).unwrap();
981            index.write().unwrap();
982            let tree_id = index.write_tree().unwrap();
983            let tree = repo.find_tree(tree_id).unwrap();
984            let sig = Signature::now("Test", "test@test.com").unwrap();
985            let head = repo.head().unwrap().peel_to_commit().unwrap();
986            repo.commit(
987                Some("HEAD"),
988                &sig,
989                &sig,
990                "Add baseline files",
991                &tree,
992                &[&head],
993            )
994            .unwrap();
995        }
996
997        let hook = CaptureHook::new(repo_root).unwrap();
998
999        // Capture edits for both files.
1000        hook.on_file_change(HookInput {
1001            tool: "Edit".to_string(),
1002            file_path: "a.rs".to_string(),
1003            prompt: "Update a".to_string(),
1004            old_content: Some("a0\n".to_string()),
1005            old_content_present: true,
1006            new_content: "a1\n".to_string(),
1007            context: None,
1008        })
1009        .unwrap();
1010
1011        hook.on_file_change(HookInput {
1012            tool: "Edit".to_string(),
1013            file_path: "b.rs".to_string(),
1014            prompt: "Update b".to_string(),
1015            old_content: Some("b0\n".to_string()),
1016            old_content_present: true,
1017            new_content: "b1\n".to_string(),
1018            context: None,
1019        })
1020        .unwrap();
1021
1022        std::fs::write(repo_root.join("a.rs"), "a1\n").unwrap();
1023        std::fs::write(repo_root.join("b.rs"), "b1\n").unwrap();
1024
1025        // Commit only a.rs.
1026        {
1027            let mut index = repo.index().unwrap();
1028            index.add_path(std::path::Path::new("a.rs")).unwrap();
1029            index.write().unwrap();
1030            let tree_id = index.write_tree().unwrap();
1031            let tree = repo.find_tree(tree_id).unwrap();
1032            let sig = Signature::now("Test", "test@test.com").unwrap();
1033            let head = repo.head().unwrap().peel_to_commit().unwrap();
1034            repo.commit(
1035                Some("HEAD"),
1036                &sig,
1037                &sig,
1038                "Commit only a.rs",
1039                &tree,
1040                &[&head],
1041            )
1042            .unwrap();
1043        }
1044
1045        let attribution = hook.on_post_commit().unwrap().unwrap();
1046        assert_eq!(attribution.files.len(), 1);
1047        assert_eq!(attribution.files[0].path, "a.rs");
1048
1049        // b.rs should remain pending for a later commit.
1050        let store = PendingStore::new(repo_root);
1051        let remaining = store.load_quiet().unwrap().unwrap();
1052        assert!(remaining.get_file_history("a.rs").is_none());
1053        assert!(remaining.get_file_history("b.rs").is_some());
1054
1055        let status = hook.status().unwrap();
1056        assert!(status.has_pending);
1057        assert_eq!(status.file_count, 1);
1058    }
1059
1060    #[cfg(unix)]
1061    #[test]
1062    fn test_make_relative_path_accepts_symlinked_absolute_path() {
1063        let (dir, _repo) = create_test_repo();
1064        let repo_root = dir.path();
1065        let hook = CaptureHook::new(repo_root).unwrap();
1066
1067        let alias_parent = TempDir::new().unwrap();
1068        let alias_root = alias_parent.path().join("repo-alias");
1069        std::os::unix::fs::symlink(repo_root, &alias_root).unwrap();
1070
1071        let file_via_alias = alias_root.join("src").join("main.rs");
1072        std::fs::create_dir_all(file_via_alias.parent().unwrap()).unwrap();
1073        std::fs::write(&file_via_alias, "fn main() {}\n").unwrap();
1074
1075        let relative = hook
1076            .make_relative_path(file_via_alias.to_str().unwrap())
1077            .unwrap();
1078        assert_eq!(relative, "src/main.rs");
1079    }
1080
1081    #[cfg(unix)]
1082    #[test]
1083    fn test_make_relative_path_accepts_nonexistent_file_under_symlinked_root() {
1084        let (dir, _repo) = create_test_repo();
1085        let repo_root = dir.path();
1086        let hook = CaptureHook::new(repo_root).unwrap();
1087
1088        let alias_parent = TempDir::new().unwrap();
1089        let alias_root = alias_parent.path().join("repo-alias");
1090        std::os::unix::fs::symlink(repo_root, &alias_root).unwrap();
1091
1092        let nested_dir = alias_root.join("newdir");
1093        std::fs::create_dir_all(&nested_dir).unwrap();
1094        let missing_file_via_alias = nested_dir.join("created_later.rs");
1095
1096        let relative = hook
1097            .make_relative_path(missing_file_via_alias.to_str().unwrap())
1098            .unwrap();
1099        assert_eq!(relative, "newdir/created_later.rs");
1100    }
1101
1102    #[test]
1103    fn test_is_repo_initialized() {
1104        let dir = TempDir::new().unwrap();
1105        let hooks_dir = dir.path().join(".git/hooks");
1106        std::fs::create_dir_all(&hooks_dir).unwrap();
1107
1108        // Not initialized - no hook file
1109        assert!(!is_repo_initialized(dir.path()));
1110
1111        // Not initialized - hook exists but no whogitit marker
1112        std::fs::write(hooks_dir.join("post-commit"), "#!/bin/bash\necho hello").unwrap();
1113        assert!(!is_repo_initialized(dir.path()));
1114
1115        // Initialized - hook contains whogitit
1116        std::fs::write(
1117            hooks_dir.join("post-commit"),
1118            "#!/bin/bash\nwhogitit commit",
1119        )
1120        .unwrap();
1121        assert!(is_repo_initialized(dir.path()));
1122    }
1123}