1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct CommitOutput {
18 pub oid: String,
20 pub message: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CommitSignedOutput {
27 pub oid: String,
29 pub message: String,
31 pub signed: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CommitAuthor {
38 pub name: String,
40 pub email: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CommitFindOutput {
47 pub oid: String,
49 pub message: String,
51 pub author: CommitAuthor,
53 pub time: i64,
55}
56
57pub struct CommitCreate {
72 repo_path: PathBuf,
73 message: String,
74 author_name: String,
75 author_email: String,
76}
77
78impl CommitCreate {
79 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 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
139pub struct CommitFind {
151 repo_path: PathBuf,
152 oid: String,
153}
154
155impl CommitFind {
156 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 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
205pub struct CommitAmend {
217 repo_path: PathBuf,
218 message: String,
219 author_name: String,
220 author_email: String,
221}
222
223impl CommitAmend {
224 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 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
292pub 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 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 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}