capsula_capture_git_repo/
lib.rs

1mod 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/// Configuration for GitHook
13#[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, // TODO: Use more suitable type
31    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        // Check if repository is dirty
106        let statuses = repo.statuses(None).map_err(GitHookError::from)?;
107        let is_dirty = !statuses.is_empty();
108
109        // If dirty and not allowed, we'll signal abort through the Captured trait
110        // rather than returning an error, so other hooks can still be captured
111        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        // Output diff content if dirty
118        if is_dirty {
119            let run_dir = &metadata.run_dir;
120            let diff_content = GitHook::diff_content(&repo)?;
121            // Output to a patch file in the run directory
122            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}