Skip to main content

ironflow_ops_git/
commit.rs

1//! Commit operations.
2
3use std::path::PathBuf;
4use std::str::from_utf8;
5
6use async_trait::async_trait;
7use git2::{Commit, Error, Oid, Repository, Signature};
8use ironflow_core::error::OperationError;
9use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::helpers::{blocking, prepare_commit, to_value};
14
15/// Output of [`CommitCreate`] and [`CommitAmend`].
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct CommitOutput {
18    /// The OID of the created/amended commit.
19    pub oid: String,
20    /// The commit message.
21    pub message: String,
22}
23
24/// Output of [`CommitSigned`].
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CommitSignedOutput {
27    /// The OID of the signed commit.
28    pub oid: String,
29    /// The commit message.
30    pub message: String,
31    /// Whether the commit is signed.
32    pub signed: bool,
33}
34
35/// Author information returned by [`CommitFind`].
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CommitAuthor {
38    /// Author name.
39    pub name: String,
40    /// Author email.
41    pub email: String,
42}
43
44/// Output of [`CommitFind`].
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CommitFindOutput {
47    /// The commit OID.
48    pub oid: String,
49    /// The commit message.
50    pub message: String,
51    /// Author information.
52    pub author: CommitAuthor,
53    /// Unix timestamp of the commit.
54    pub time: i64,
55}
56
57/// Create a new commit on HEAD.
58///
59/// Stages the current index as a tree, creates a commit with the given
60/// message and author, and updates HEAD to point to the new commit.
61///
62/// # Examples
63///
64/// ```no_run
65/// use ironflow_ops_git::commit::CommitCreate;
66/// use ironflow_core::operation::Operation;
67///
68/// let op = CommitCreate::new("/path/to/repo", "Initial commit", "Alice", "alice@example.com");
69/// assert_eq!(op.kind(), "git");
70/// ```
71pub struct CommitCreate {
72    repo_path: PathBuf,
73    message: String,
74    author_name: String,
75    author_email: String,
76}
77
78impl CommitCreate {
79    /// Create a new commit operation.
80    pub fn new(
81        repo_path: impl Into<PathBuf>,
82        message: impl Into<String>,
83        author_name: impl Into<String>,
84        author_email: impl Into<String>,
85    ) -> Self {
86        Self {
87            repo_path: repo_path.into(),
88            message: message.into(),
89            author_name: author_name.into(),
90            author_email: author_email.into(),
91        }
92    }
93
94    /// Execute and return a typed result.
95    pub async fn run(&self, _ctx: &OperationContext) -> Result<CommitOutput, OperationError> {
96        let repo_path = self.repo_path.clone();
97        let message = self.message.clone();
98        let name = self.author_name.clone();
99        let email = self.author_email.clone();
100        blocking(move || {
101            let repo = Repository::open(&repo_path)?;
102            let sig = Signature::now(&name, &email)?;
103            let (tree, parents) = prepare_commit(&repo)?;
104            let parent_refs: Vec<&Commit<'_>> = parents.iter().collect();
105
106            let oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)?;
107            Ok(CommitOutput {
108                oid: oid.to_string(),
109                message,
110            })
111        })
112        .await
113    }
114}
115
116#[async_trait]
117impl Operation for CommitCreate {
118    fn kind(&self) -> &str {
119        "git"
120    }
121
122    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
123        to_value(&self.run(ctx).await?)
124    }
125
126    fn input(&self) -> Option<Value> {
127        Some(serde_json::json!({
128            "repo_path": self.repo_path,
129            "message": self.message,
130            "author": format!("{} <{}>", self.author_name, self.author_email),
131        }))
132    }
133}
134
135impl TypedOperation for CommitCreate {
136    type Output = CommitOutput;
137}
138
139/// Find a commit by its OID.
140///
141/// # Examples
142///
143/// ```no_run
144/// use ironflow_ops_git::commit::CommitFind;
145/// use ironflow_core::operation::Operation;
146///
147/// let op = CommitFind::new("/path/to/repo", "abc123");
148/// assert_eq!(op.kind(), "git");
149/// ```
150pub struct CommitFind {
151    repo_path: PathBuf,
152    oid: String,
153}
154
155impl CommitFind {
156    /// Create a new find-commit operation.
157    pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
158        Self {
159            repo_path: repo_path.into(),
160            oid: oid.into(),
161        }
162    }
163
164    /// Execute and return a typed result.
165    pub async fn run(&self, _ctx: &OperationContext) -> Result<CommitFindOutput, OperationError> {
166        let repo_path = self.repo_path.clone();
167        let oid_str = self.oid.clone();
168        blocking(move || {
169            let repo = Repository::open(&repo_path)?;
170            let oid = Oid::from_str(&oid_str)?;
171            let commit = repo.find_commit(oid)?;
172            Ok(CommitFindOutput {
173                oid: commit.id().to_string(),
174                message: commit.message().unwrap_or("").to_string(),
175                author: CommitAuthor {
176                    name: commit.author().name().unwrap_or("").to_string(),
177                    email: commit.author().email().unwrap_or("").to_string(),
178                },
179                time: commit.time().seconds(),
180            })
181        })
182        .await
183    }
184}
185
186#[async_trait]
187impl Operation for CommitFind {
188    fn kind(&self) -> &str {
189        "git"
190    }
191
192    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
193        to_value(&self.run(ctx).await?)
194    }
195
196    fn input(&self) -> Option<Value> {
197        Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
198    }
199}
200
201impl TypedOperation for CommitFind {
202    type Output = CommitFindOutput;
203}
204
205/// Amend the most recent commit.
206///
207/// # Examples
208///
209/// ```no_run
210/// use ironflow_ops_git::commit::CommitAmend;
211/// use ironflow_core::operation::Operation;
212///
213/// let op = CommitAmend::new("/path/to/repo", "Updated message", "Alice", "alice@example.com");
214/// assert_eq!(op.kind(), "git");
215/// ```
216pub struct CommitAmend {
217    repo_path: PathBuf,
218    message: String,
219    author_name: String,
220    author_email: String,
221}
222
223impl CommitAmend {
224    /// Create a new amend operation.
225    pub fn new(
226        repo_path: impl Into<PathBuf>,
227        message: impl Into<String>,
228        author_name: impl Into<String>,
229        author_email: impl Into<String>,
230    ) -> Self {
231        Self {
232            repo_path: repo_path.into(),
233            message: message.into(),
234            author_name: author_name.into(),
235            author_email: author_email.into(),
236        }
237    }
238
239    /// Execute and return a typed result.
240    pub async fn run(&self, _ctx: &OperationContext) -> Result<CommitOutput, OperationError> {
241        let repo_path = self.repo_path.clone();
242        let message = self.message.clone();
243        let name = self.author_name.clone();
244        let email = self.author_email.clone();
245        blocking(move || {
246            let repo = Repository::open(&repo_path)?;
247            let head = repo.head()?.peel_to_commit()?;
248            let sig = Signature::now(&name, &email)?;
249            let mut index = repo.index()?;
250            let tree_oid = index.write_tree()?;
251            let tree = repo.find_tree(tree_oid)?;
252
253            let oid = head.amend(
254                Some("HEAD"),
255                Some(&sig),
256                Some(&sig),
257                None,
258                Some(&message),
259                Some(&tree),
260            )?;
261            Ok(CommitOutput {
262                oid: oid.to_string(),
263                message,
264            })
265        })
266        .await
267    }
268}
269
270#[async_trait]
271impl Operation for CommitAmend {
272    fn kind(&self) -> &str {
273        "git"
274    }
275
276    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
277        to_value(&self.run(ctx).await?)
278    }
279
280    fn input(&self) -> Option<Value> {
281        Some(serde_json::json!({
282            "repo_path": self.repo_path,
283            "message": self.message,
284        }))
285    }
286}
287
288impl TypedOperation for CommitAmend {
289    type Output = CommitOutput;
290}
291
292/// Create a signed commit (GPG/SSH).
293///
294/// The signature must be provided as a string. The caller is responsible
295/// for generating the signature externally.
296///
297/// # Examples
298///
299/// ```no_run
300/// use ironflow_ops_git::commit::CommitSigned;
301/// use ironflow_core::operation::Operation;
302///
303/// let op = CommitSigned::new(
304///     "/path/to/repo",
305///     "Signed commit",
306///     "Alice",
307///     "alice@example.com",
308///     "-----BEGIN PGP SIGNATURE-----\n...",
309/// );
310/// assert_eq!(op.kind(), "git");
311/// ```
312pub struct CommitSigned {
313    repo_path: PathBuf,
314    message: String,
315    author_name: String,
316    author_email: String,
317    signature: String,
318}
319
320impl CommitSigned {
321    /// Create a new signed-commit operation.
322    pub fn new(
323        repo_path: impl Into<PathBuf>,
324        message: impl Into<String>,
325        author_name: impl Into<String>,
326        author_email: impl Into<String>,
327        signature: impl Into<String>,
328    ) -> Self {
329        Self {
330            repo_path: repo_path.into(),
331            message: message.into(),
332            author_name: author_name.into(),
333            author_email: author_email.into(),
334            signature: signature.into(),
335        }
336    }
337
338    /// Execute and return a typed result.
339    pub async fn run(&self, _ctx: &OperationContext) -> Result<CommitSignedOutput, OperationError> {
340        let repo_path = self.repo_path.clone();
341        let message = self.message.clone();
342        let name = self.author_name.clone();
343        let email = self.author_email.clone();
344        let signature = self.signature.clone();
345        blocking(move || {
346            let repo = Repository::open(&repo_path)?;
347            let sig = Signature::now(&name, &email)?;
348            let (tree, parents) = prepare_commit(&repo)?;
349            let parent_refs: Vec<&Commit<'_>> = parents.iter().collect();
350
351            let buf = repo.commit_create_buffer(&sig, &sig, &message, &tree, &parent_refs)?;
352            let content = from_utf8(&buf)
353                .map_err(|e| Error::from_str(&format!("invalid UTF-8 in commit buffer: {e}")))?;
354            let oid = repo.commit_signed(content, &signature, None)?;
355
356            let head_ref = repo.head()?;
357            let mut resolved = head_ref.resolve()?;
358            resolved.set_target(oid, "commit signed")?;
359
360            Ok(CommitSignedOutput {
361                oid: oid.to_string(),
362                message,
363                signed: true,
364            })
365        })
366        .await
367    }
368}
369
370#[async_trait]
371impl Operation for CommitSigned {
372    fn kind(&self) -> &str {
373        "git"
374    }
375
376    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
377        to_value(&self.run(ctx).await?)
378    }
379
380    fn input(&self) -> Option<Value> {
381        Some(serde_json::json!({
382            "repo_path": self.repo_path,
383            "message": self.message,
384            "signed": true,
385        }))
386    }
387}
388
389impl TypedOperation for CommitSigned {
390    type Output = CommitSignedOutput;
391}
392
393#[cfg(test)]
394mod tests {
395    use std::fs;
396    use std::path::Path;
397
398    use super::*;
399    use crate::test_helpers::ctx;
400
401    fn init_repo_with_file(tmp: &Path) -> Repository {
402        let repo = Repository::init(tmp).unwrap();
403        fs::write(tmp.join("file.txt"), "hello").unwrap();
404        let mut index = repo.index().unwrap();
405        index.add_path(Path::new("file.txt")).unwrap();
406        index.write().unwrap();
407        repo
408    }
409
410    #[tokio::test]
411    async fn add_and_commit() {
412        let tmp = tempfile::tempdir().unwrap();
413        init_repo_with_file(tmp.path());
414
415        let op = CommitCreate::new(tmp.path(), "test commit", "Test", "test@example.com");
416        let result = op.run(&ctx()).await.unwrap();
417        assert!(!result.oid.is_empty());
418        assert_eq!(result.message, "test commit");
419
420        let repo = Repository::open(tmp.path()).unwrap();
421        let head = repo.head().unwrap().peel_to_commit().unwrap();
422        assert_eq!(head.message().unwrap(), "test commit");
423    }
424
425    #[tokio::test]
426    async fn find_commit_after_create() {
427        let tmp = tempfile::tempdir().unwrap();
428        init_repo_with_file(tmp.path());
429
430        let create = CommitCreate::new(tmp.path(), "find me", "Test", "test@example.com");
431        let result = create.run(&ctx()).await.unwrap();
432
433        let find = CommitFind::new(tmp.path(), &result.oid);
434        let found = find.run(&ctx()).await.unwrap();
435        assert_eq!(found.message, "find me");
436        assert_eq!(found.author.name, "Test");
437    }
438
439    #[tokio::test]
440    async fn amend_updates_message() {
441        let tmp = tempfile::tempdir().unwrap();
442        init_repo_with_file(tmp.path());
443
444        let create = CommitCreate::new(tmp.path(), "original", "Test", "test@example.com");
445        create.run(&ctx()).await.unwrap();
446
447        let amend = CommitAmend::new(tmp.path(), "amended", "Test", "test@example.com");
448        let result = amend.run(&ctx()).await.unwrap();
449        assert_eq!(result.message, "amended");
450
451        let repo = Repository::open(tmp.path()).unwrap();
452        let head = repo.head().unwrap().peel_to_commit().unwrap();
453        assert_eq!(head.message().unwrap(), "amended");
454    }
455}