Skip to main content

ironflow_ops_git/
merge.rs

1//! Merge operations.
2
3use 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
45/// Merge a branch into HEAD.
46///
47/// # Examples
48///
49/// ```no_run
50/// use ironflow_ops_git::merge::MergeBranch;
51/// use ironflow_core::operation::Operation;
52///
53/// let op = MergeBranch::new("/path/to/repo", "feature");
54/// assert_eq!(op.kind(), "git");
55/// ```
56pub struct MergeBranch {
57    repo_path: PathBuf,
58    branch_name: String,
59}
60
61impl MergeBranch {
62    /// Create a new merge operation.
63    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    /// Execute and return a typed result.
71    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
106/// Analyze what kind of merge is needed.
107///
108/// # Examples
109///
110/// ```no_run
111/// use ironflow_ops_git::merge::MergeAnalysisOp;
112/// use ironflow_core::operation::Operation;
113///
114/// let op = MergeAnalysisOp::new("/path/to/repo", "abc123");
115/// assert_eq!(op.kind(), "git");
116/// ```
117pub struct MergeAnalysisOp {
118    repo_path: PathBuf,
119    oid: String,
120}
121
122impl MergeAnalysisOp {
123    /// Create a new merge-analysis operation.
124    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    /// Execute and return a typed result.
132    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
173/// Merge two commits as trees (without touching the working directory).
174///
175/// # Examples
176///
177/// ```no_run
178/// use ironflow_ops_git::merge::MergeCommits;
179/// use ironflow_core::operation::Operation;
180///
181/// let op = MergeCommits::new("/path/to/repo", "abc123", "def456");
182/// assert_eq!(op.kind(), "git");
183/// ```
184pub struct MergeCommits {
185    repo_path: PathBuf,
186    our_oid: String,
187    their_oid: String,
188}
189
190impl MergeCommits {
191    /// Create a new merge-commits operation.
192    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    /// Execute and return a typed result.
205    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
244/// Find the merge base between two commits.
245///
246/// # Examples
247///
248/// ```no_run
249/// use ironflow_ops_git::merge::MergeBaseOp;
250/// use ironflow_core::operation::Operation;
251///
252/// let op = MergeBaseOp::new("/path/to/repo", "abc123", "def456");
253/// assert_eq!(op.kind(), "git");
254/// ```
255pub struct MergeBaseOp {
256    repo_path: PathBuf,
257    one: String,
258    two: String,
259}
260
261impl MergeBaseOp {
262    /// Create a new merge-base operation.
263    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    /// Execute and return a typed result.
276    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
310/// Clean up merge state files.
311///
312/// # Examples
313///
314/// ```no_run
315/// use ironflow_ops_git::merge::MergeCleanupState;
316/// use ironflow_core::operation::Operation;
317///
318/// let op = MergeCleanupState::new("/path/to/repo");
319/// assert_eq!(op.kind(), "git");
320/// ```
321pub struct MergeCleanupState {
322    repo_path: PathBuf,
323}
324
325impl MergeCleanupState {
326    /// Create a new cleanup-state operation.
327    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
328        Self {
329            repo_path: repo_path.into(),
330        }
331    }
332
333    /// Execute and return a typed result.
334    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}