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
17const ENV_SESSION_ID: &str = "WHOGITIT_SESSION_ID";
19const ENV_MODEL_ID: &str = "WHOGITIT_MODEL_ID";
21const DEFAULT_MODEL: &str = "claude-opus-4-5-20251101";
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct HookContext {
27 #[serde(default)]
29 pub plan_mode: bool,
30 #[serde(default)]
32 pub is_subagent: bool,
33 #[serde(default)]
35 pub agent_depth: u8,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub subagent_id: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HookInput {
44 pub tool: String,
46 pub file_path: String,
48 pub prompt: String,
50 pub old_content: Option<String>,
52 #[serde(default)]
54 pub old_content_present: bool,
55 pub new_content: String,
57 #[serde(default)]
59 pub context: Option<HookContext>,
60}
61
62pub struct CaptureHook {
64 repo_root: std::path::PathBuf,
66 redactor: Redactor,
68 audit_enabled: bool,
70 similarity_threshold: f64,
72 max_pending_age_hours: i64,
74 retention_config: RetentionConfig,
76}
77
78impl CaptureHook {
79 pub fn new(repo_path: &Path) -> Result<Self> {
81 let repo_root = repo_path.to_path_buf();
82
83 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 fn get_session_id() -> String {
112 env::var(ENV_SESSION_ID).unwrap_or_else(|_| uuid::Uuid::new_v4().to_string())
113 }
114
115 fn get_model_id() -> String {
117 env::var(ENV_MODEL_ID).unwrap_or_else(|_| DEFAULT_MODEL.to_string())
118 }
119
120 pub fn on_file_change(&self, input: HookInput) -> Result<()> {
122 let store = PendingStore::new(&self.repo_root);
123
124 let mut buffer = match store.load_with_max_age(self.max_pending_age_hours)? {
126 Some(b) => {
127 let current_session = Self::get_session_id();
130 if b.session.session_id != current_session && env::var(ENV_SESSION_ID).is_ok() {
131 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(¤t_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 let relative_path = self.make_relative_path(&input.file_path)?;
155
156 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 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 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 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 self.get_content_from_git_head(&relative_path)
199 };
200
201 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 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 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 store.save(&buffer)?;
245
246 Ok(())
247 }
248
249 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 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 let entry = match tree.get_path(std::path::Path::new(path)) {
295 Ok(e) => e,
296 Err(_) => return None, };
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 match std::str::from_utf8(blob.content()) {
312 Ok(content) => Some(content.to_string()),
313 Err(_) => None, }
315 }
316
317 pub fn on_post_commit(&self) -> Result<Option<AIAttribution>> {
319 let store = PendingStore::new(&self.repo_root);
320
321 let mut buffer = match store.load()? {
323 Some(b) if b.has_changes() => b,
324 _ => return Ok(None),
325 };
326
327 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 let rename_map = build_rename_map(&repo, &head)?;
339 let changed_paths = build_changed_paths(&repo, &head)?;
340
341 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 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 continue;
371 }
372 };
373
374 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 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 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 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 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 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 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 if let Ok(relative) = input_path.strip_prefix(&self.repo_root) {
506 return Ok(relative.to_string_lossy().to_string());
507 }
508
509 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 pub fn status(&self) -> Result<PendingStatus> {
528 let store = PendingStore::new(&self.repo_root);
529
530 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 pub fn clear_pending(&self) -> Result<()> {
569 let store = PendingStore::new(&self.repo_root);
570 store.delete()
571 }
572}
573
574fn 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#[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 pub is_stale: bool,
734 pub age: String,
736 pub max_pending_age_hours: i64,
738}
739
740pub fn run_capture_hook() -> Result<()> {
742 let input: HookInput = serde_json::from_reader(std::io::stdin())
744 .context("Failed to read hook input from stdin")?;
745
746 let repo_root = find_repo_root()?;
748
749 if !is_repo_initialized(&repo_root) {
751 return Ok(());
752 }
753
754 let hook = CaptureHook::new(&repo_root)?;
756 hook.on_file_change(input)?;
757
758 Ok(())
759}
760
761fn find_repo_root() -> Result<std::path::PathBuf> {
763 let current = env::current_dir()?;
764 let repo = Repository::discover(¤t).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
771fn 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
782pub 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 {
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 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 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 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 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 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 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 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 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 {
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 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 assert!(!is_repo_initialized(dir.path()));
1110
1111 std::fs::write(hooks_dir.join("post-commit"), "#!/bin/bash\necho hello").unwrap();
1113 assert!(!is_repo_initialized(dir.path()));
1114
1115 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}