1use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{BranchType, MergeOptions, Oid, Repository};
7use ironflow_core::error::OperationError;
8use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::helpers::{blocking, to_value};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct MergeBranchOutput {
16 pub branch: String,
17 pub merged: bool,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct MergeAnalysisOutput {
22 pub up_to_date: bool,
23 pub fast_forward: bool,
24 pub normal: bool,
25 pub none: bool,
26 pub no_fast_forward: bool,
27 pub fastforward_only: bool,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MergeConflictOutput {
32 pub has_conflicts: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct MergeBaseOutput {
37 pub merge_base: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct MergeCleanupOutput {
42 pub cleaned: bool,
43}
44
45pub struct MergeBranch {
57 repo_path: PathBuf,
58 branch_name: String,
59}
60
61impl MergeBranch {
62 pub fn new(repo_path: impl Into<PathBuf>, branch_name: impl Into<String>) -> Self {
64 Self {
65 repo_path: repo_path.into(),
66 branch_name: branch_name.into(),
67 }
68 }
69
70 pub async fn run(&self, _ctx: &OperationContext) -> Result<MergeBranchOutput, OperationError> {
72 let repo_path = self.repo_path.clone();
73 let branch_name = self.branch_name.clone();
74 blocking(move || {
75 let repo = Repository::open(&repo_path)?;
76 let reference = repo.find_branch(&branch_name, BranchType::Local)?;
77 let commit = reference.get().peel_to_commit()?;
78 let annotated = repo.find_annotated_commit(commit.id())?;
79 repo.merge(&[&annotated], Some(&mut MergeOptions::new()), None)?;
80 Ok(MergeBranchOutput {
81 branch: branch_name,
82 merged: true,
83 })
84 })
85 .await
86 }
87}
88
89#[async_trait]
90impl Operation for MergeBranch {
91 fn kind(&self) -> &str {
92 "git"
93 }
94 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
95 to_value(&self.run(ctx).await?)
96 }
97 fn input(&self) -> Option<Value> {
98 Some(serde_json::json!({ "repo_path": self.repo_path, "branch": self.branch_name }))
99 }
100}
101
102impl TypedOperation for MergeBranch {
103 type Output = MergeBranchOutput;
104}
105
106pub struct MergeAnalysisOp {
118 repo_path: PathBuf,
119 oid: String,
120}
121
122impl MergeAnalysisOp {
123 pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
125 Self {
126 repo_path: repo_path.into(),
127 oid: oid.into(),
128 }
129 }
130
131 pub async fn run(
133 &self,
134 _ctx: &OperationContext,
135 ) -> Result<MergeAnalysisOutput, OperationError> {
136 let repo_path = self.repo_path.clone();
137 let oid_str = self.oid.clone();
138 blocking(move || {
139 let repo = Repository::open(&repo_path)?;
140 let oid = Oid::from_str(&oid_str)?;
141 let annotated = repo.find_annotated_commit(oid)?;
142 let (analysis, preference) = repo.merge_analysis(&[&annotated])?;
143 Ok(MergeAnalysisOutput {
144 up_to_date: analysis.is_up_to_date(),
145 fast_forward: analysis.is_fast_forward(),
146 normal: analysis.is_normal(),
147 none: analysis.is_none(),
148 no_fast_forward: preference.is_no_fast_forward(),
149 fastforward_only: preference.is_fastforward_only(),
150 })
151 })
152 .await
153 }
154}
155
156#[async_trait]
157impl Operation for MergeAnalysisOp {
158 fn kind(&self) -> &str {
159 "git"
160 }
161 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
162 to_value(&self.run(ctx).await?)
163 }
164 fn input(&self) -> Option<Value> {
165 Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
166 }
167}
168
169impl TypedOperation for MergeAnalysisOp {
170 type Output = MergeAnalysisOutput;
171}
172
173pub struct MergeCommits {
185 repo_path: PathBuf,
186 our_oid: String,
187 their_oid: String,
188}
189
190impl MergeCommits {
191 pub fn new(
193 repo_path: impl Into<PathBuf>,
194 our_oid: impl Into<String>,
195 their_oid: impl Into<String>,
196 ) -> Self {
197 Self {
198 repo_path: repo_path.into(),
199 our_oid: our_oid.into(),
200 their_oid: their_oid.into(),
201 }
202 }
203
204 pub async fn run(
206 &self,
207 _ctx: &OperationContext,
208 ) -> Result<MergeConflictOutput, OperationError> {
209 let repo_path = self.repo_path.clone();
210 let our = self.our_oid.clone();
211 let their = self.their_oid.clone();
212 blocking(move || {
213 let repo = Repository::open(&repo_path)?;
214 let our_commit = repo.find_commit(Oid::from_str(&our)?)?;
215 let their_commit = repo.find_commit(Oid::from_str(&their)?)?;
216 let index = repo.merge_commits(&our_commit, &their_commit, None)?;
217 Ok(MergeConflictOutput {
218 has_conflicts: index.has_conflicts(),
219 })
220 })
221 .await
222 }
223}
224
225#[async_trait]
226impl Operation for MergeCommits {
227 fn kind(&self) -> &str {
228 "git"
229 }
230 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
231 to_value(&self.run(ctx).await?)
232 }
233 fn input(&self) -> Option<Value> {
234 Some(
235 serde_json::json!({ "repo_path": self.repo_path, "ours": self.our_oid, "theirs": self.their_oid }),
236 )
237 }
238}
239
240impl TypedOperation for MergeCommits {
241 type Output = MergeConflictOutput;
242}
243
244pub struct MergeBaseOp {
256 repo_path: PathBuf,
257 one: String,
258 two: String,
259}
260
261impl MergeBaseOp {
262 pub fn new(
264 repo_path: impl Into<PathBuf>,
265 one: impl Into<String>,
266 two: impl Into<String>,
267 ) -> Self {
268 Self {
269 repo_path: repo_path.into(),
270 one: one.into(),
271 two: two.into(),
272 }
273 }
274
275 pub async fn run(&self, _ctx: &OperationContext) -> Result<MergeBaseOutput, OperationError> {
277 let repo_path = self.repo_path.clone();
278 let one = self.one.clone();
279 let two = self.two.clone();
280 blocking(move || {
281 let repo = Repository::open(&repo_path)?;
282 let oid1 = Oid::from_str(&one)?;
283 let oid2 = Oid::from_str(&two)?;
284 let base = repo.merge_base(oid1, oid2)?;
285 Ok(MergeBaseOutput {
286 merge_base: base.to_string(),
287 })
288 })
289 .await
290 }
291}
292
293#[async_trait]
294impl Operation for MergeBaseOp {
295 fn kind(&self) -> &str {
296 "git"
297 }
298 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
299 to_value(&self.run(ctx).await?)
300 }
301 fn input(&self) -> Option<Value> {
302 Some(serde_json::json!({ "repo_path": self.repo_path, "one": self.one, "two": self.two }))
303 }
304}
305
306impl TypedOperation for MergeBaseOp {
307 type Output = MergeBaseOutput;
308}
309
310pub struct MergeCleanupState {
322 repo_path: PathBuf,
323}
324
325impl MergeCleanupState {
326 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
328 Self {
329 repo_path: repo_path.into(),
330 }
331 }
332
333 pub async fn run(&self, _ctx: &OperationContext) -> Result<MergeCleanupOutput, OperationError> {
335 let repo_path = self.repo_path.clone();
336 blocking(move || {
337 let repo = Repository::open(&repo_path)?;
338 repo.cleanup_state()?;
339 Ok(MergeCleanupOutput { cleaned: true })
340 })
341 .await
342 }
343}
344
345#[async_trait]
346impl Operation for MergeCleanupState {
347 fn kind(&self) -> &str {
348 "git"
349 }
350 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
351 to_value(&self.run(ctx).await?)
352 }
353 fn input(&self) -> Option<Value> {
354 Some(serde_json::json!({ "repo_path": self.repo_path }))
355 }
356}
357
358impl TypedOperation for MergeCleanupState {
359 type Output = MergeCleanupOutput;
360}
361
362#[cfg(test)]
363mod tests {
364 use std::fs;
365 use std::path::Path;
366
367 use git2::{Repository, Signature};
368 use ironflow_core::operation::Operation;
369
370 use super::*;
371 use crate::test_helpers::ctx;
372
373 fn init_with_branch(path: &Path) -> (String, String) {
374 use git2::build::CheckoutBuilder;
375 let repo = Repository::init(path).unwrap();
376 let sig = Signature::now("Test", "t@t.com").unwrap();
377 fs::write(path.join("f.txt"), "base").unwrap();
378 let mut idx = repo.index().unwrap();
379 idx.add_path(Path::new("f.txt")).unwrap();
380 idx.write().unwrap();
381 let tree = repo.find_tree(idx.write_tree().unwrap()).unwrap();
382 let c1 = repo
383 .commit(Some("HEAD"), &sig, &sig, "base", &tree, &[])
384 .unwrap();
385 let base = repo.find_commit(c1).unwrap();
386 repo.branch("feature", &base, false).unwrap();
387
388 let mut tb = repo.treebuilder(Some(&tree)).unwrap();
389 let blob_oid = repo.blob(b"feature-only").unwrap();
390 tb.insert("g.txt", blob_oid, 0o100644).unwrap();
391 let feat_tree = repo.find_tree(tb.write().unwrap()).unwrap();
392 repo.commit(
393 Some("refs/heads/feature"),
394 &sig,
395 &sig,
396 "feature commit",
397 &feat_tree,
398 &[&base],
399 )
400 .unwrap();
401
402 repo.checkout_head(Some(CheckoutBuilder::new().force()))
403 .unwrap();
404 let head_oid = repo
405 .head()
406 .unwrap()
407 .peel_to_commit()
408 .unwrap()
409 .id()
410 .to_string();
411 let feat_oid = repo
412 .find_branch("feature", BranchType::Local)
413 .unwrap()
414 .get()
415 .peel_to_commit()
416 .unwrap()
417 .id()
418 .to_string();
419 (head_oid, feat_oid)
420 }
421
422 #[tokio::test]
423 async fn merge_analysis_fast_forward() {
424 let tmp = tempfile::tempdir().unwrap();
425 let (_, feat_oid) = init_with_branch(tmp.path());
426 let result = MergeAnalysisOp::new(tmp.path(), &feat_oid)
427 .run(&ctx())
428 .await
429 .unwrap();
430 assert!(result.fast_forward);
431 assert!(!result.up_to_date);
432 }
433
434 #[tokio::test]
435 async fn merge_base_found() {
436 let tmp = tempfile::tempdir().unwrap();
437 let (head_oid, feat_oid) = init_with_branch(tmp.path());
438 let result = MergeBaseOp::new(tmp.path(), &head_oid, &feat_oid)
439 .run(&ctx())
440 .await
441 .unwrap();
442 assert_eq!(result.merge_base, head_oid);
443 }
444
445 #[tokio::test]
446 async fn merge_branch_succeeds() {
447 let tmp = tempfile::tempdir().unwrap();
448 init_with_branch(tmp.path());
449 let result = MergeBranch::new(tmp.path(), "feature")
450 .run(&ctx())
451 .await
452 .unwrap();
453 assert!(result.merged);
454 assert_eq!(result.branch, "feature");
455 }
456
457 #[tokio::test]
458 async fn merge_commits_no_conflict() {
459 let tmp = tempfile::tempdir().unwrap();
460 let (head_oid, feat_oid) = init_with_branch(tmp.path());
461 let result = MergeCommits::new(tmp.path(), &head_oid, &feat_oid)
462 .run(&ctx())
463 .await
464 .unwrap();
465 assert!(!result.has_conflicts);
466 }
467
468 #[tokio::test]
469 async fn cleanup_state() {
470 let tmp = tempfile::tempdir().unwrap();
471 init_with_branch(tmp.path());
472 let result = MergeCleanupState::new(tmp.path())
473 .run(&ctx())
474 .await
475 .unwrap();
476 assert!(result.cleaned);
477 }
478
479 #[tokio::test]
480 async fn execute_serializes_correctly() {
481 let tmp = tempfile::tempdir().unwrap();
482 let (_, feat_oid) = init_with_branch(tmp.path());
483 let value = MergeAnalysisOp::new(tmp.path(), &feat_oid)
484 .execute(&ctx())
485 .await
486 .unwrap();
487 assert!(value["fast_forward"].as_bool().unwrap());
488 }
489}