capsula_capture_git_repo/
lib.rs1mod error;
2
3use crate::error::GitHookError;
4use capsula_core::captured::Captured;
5use capsula_core::error::CapsulaResult;
6use capsula_core::hook::{Hook, PhaseMarker, RuntimeParams};
7use capsula_core::run::PreparedRun;
8use git2::Repository;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct GitHookConfig {
15 name: String,
16 path: PathBuf,
17 #[serde(default)]
18 allow_dirty: bool,
19}
20
21#[derive(Debug)]
22pub struct GitHook {
23 config: GitHookConfig,
24 working_dir: PathBuf,
25}
26
27#[derive(Debug, Serialize)]
28pub struct GitCaptured {
29 working_dir: PathBuf,
30 sha: String, is_dirty: bool,
32 #[serde(skip)]
33 abort_requested: bool,
34}
35
36impl Captured for GitCaptured {
37 fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
38 serde_json::to_value(self)
39 }
40
41 fn abort_requested(&self) -> bool {
42 self.abort_requested
43 }
44}
45
46impl<P> Hook<P> for GitHook
47where
48 P: PhaseMarker,
49{
50 const ID: &'static str = "capture-git-repo";
51
52 type Config = GitHookConfig;
53 type Output = GitCaptured;
54
55 fn from_config(
56 config: &serde_json::Value,
57 project_root: &std::path::Path,
58 ) -> CapsulaResult<Self> {
59 let config: GitHookConfig = serde_json::from_value(config.clone()).map_err(|e| {
60 capsula_core::error::CapsulaError::Configuration {
61 message: format!("Invalid git hook configuration: {}", e),
62 }
63 })?;
64
65 let working_dir = if config.path.is_absolute() {
66 config.path.clone()
67 } else {
68 project_root.join(&config.path).canonicalize()?
69 };
70
71 Ok(GitHook {
72 working_dir,
73 config,
74 })
75 }
76
77 fn config(&self) -> &Self::Config {
78 &self.config
79 }
80
81 fn run(
82 &self,
83 metadata: &PreparedRun,
84 _params: &RuntimeParams<P>,
85 ) -> CapsulaResult<Self::Output> {
86 let repo_path = if self.working_dir.as_os_str().is_empty() {
87 std::env::current_dir()?
88 } else {
89 self.working_dir.clone()
90 };
91
92 let repo = Repository::discover(&repo_path).map_err(|e| {
93 if e.code() == git2::ErrorCode::NotFound {
94 GitHookError::NotARepository
95 } else {
96 GitHookError::GitOperation(e)
97 }
98 })?;
99
100 let head = repo.head().map_err(GitHookError::from)?;
101 let oid = head.target().ok_or_else(|| GitHookError::HeadNotFound {
102 message: "HEAD does not point to a valid commit".to_string(),
103 })?;
104
105 let statuses = repo.statuses(None).map_err(GitHookError::from)?;
107 let is_dirty = !statuses.is_empty();
108
109 if is_dirty && !self.config.allow_dirty {
112 eprintln!(
113 "Warning: Repository has uncommitted changes. Run will be aborted after hooks capture."
114 );
115 }
116
117 if is_dirty {
119 let run_dir = &metadata.run_dir;
120 let diff_content = GitHook::diff_content(&repo)?;
121 let patch_file_path = run_dir.join(format!("{}.patch", self.config.name));
123 std::fs::write(&patch_file_path, diff_content).map_err(GitHookError::IoError)?;
124 }
125
126 Ok(GitCaptured {
127 working_dir: repo_path,
128 sha: oid.to_string(),
129 is_dirty,
130 abort_requested: is_dirty && !self.config.allow_dirty,
131 })
132 }
133}
134
135impl GitHook {
136 fn diff_content(repo: &Repository) -> CapsulaResult<String> {
137 let mut diff_opts = git2::DiffOptions::new();
138 diff_opts.include_untracked(true);
139 let diff = repo
140 .diff_index_to_workdir(None, Some(&mut diff_opts))
141 .map_err(GitHookError::from)?;
142
143 let mut diff_content = String::new();
144 diff.print(git2::DiffFormat::Patch, |_, _, line| {
145 diff_content.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
146 true
147 })
148 .map_err(GitHookError::from)?;
149
150 Ok(diff_content)
151 }
152}