Skip to main content

ironflow_ops_git/
cherrypick.rs

1//! Cherry-pick and revert operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{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 CherrypickOutput {
16    pub oid: String,
17    pub applied: bool,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CherrypickConflictOutput {
22    pub has_conflicts: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RevertOutput {
27    pub oid: String,
28    pub reverted: bool,
29}
30
31/// Cherry-pick a commit onto the working directory and index.
32///
33/// # Examples
34///
35/// ```no_run
36/// use ironflow_ops_git::cherrypick::Cherrypick;
37/// use ironflow_core::operation::Operation;
38///
39/// let op = Cherrypick::new("/path/to/repo", "abc123");
40/// assert_eq!(op.kind(), "git");
41/// ```
42pub struct Cherrypick {
43    repo_path: PathBuf,
44    oid: String,
45}
46
47impl Cherrypick {
48    /// Create a new cherry-pick operation.
49    pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
50        Self {
51            repo_path: repo_path.into(),
52            oid: oid.into(),
53        }
54    }
55
56    /// Execute and return a typed result.
57    pub async fn run(&self, _ctx: &OperationContext) -> Result<CherrypickOutput, OperationError> {
58        let repo_path = self.repo_path.clone();
59        let oid_str = self.oid.clone();
60        blocking(move || {
61            let repo = Repository::open(&repo_path)?;
62            let oid = Oid::from_str(&oid_str)?;
63            let commit = repo.find_commit(oid)?;
64            repo.cherrypick(&commit, None)?;
65            Ok(CherrypickOutput {
66                oid: oid_str,
67                applied: true,
68            })
69        })
70        .await
71    }
72}
73
74#[async_trait]
75impl Operation for Cherrypick {
76    fn kind(&self) -> &str {
77        "git"
78    }
79    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
80        to_value(&self.run(ctx).await?)
81    }
82    fn input(&self) -> Option<Value> {
83        Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
84    }
85}
86
87impl TypedOperation for Cherrypick {
88    type Output = CherrypickOutput;
89}
90
91/// Cherry-pick a commit as a tree merge (without touching the working directory).
92///
93/// # Examples
94///
95/// ```no_run
96/// use ironflow_ops_git::cherrypick::CherrypickCommit;
97/// use ironflow_core::operation::Operation;
98///
99/// let op = CherrypickCommit::new("/path/to/repo", "abc123", "def456");
100/// assert_eq!(op.kind(), "git");
101/// ```
102pub struct CherrypickCommit {
103    repo_path: PathBuf,
104    cherrypick_oid: String,
105    our_oid: String,
106}
107
108impl CherrypickCommit {
109    /// Create a new cherry-pick-commit operation.
110    pub fn new(
111        repo_path: impl Into<PathBuf>,
112        cherrypick_oid: impl Into<String>,
113        our_oid: impl Into<String>,
114    ) -> Self {
115        Self {
116            repo_path: repo_path.into(),
117            cherrypick_oid: cherrypick_oid.into(),
118            our_oid: our_oid.into(),
119        }
120    }
121
122    /// Execute and return a typed result.
123    pub async fn run(
124        &self,
125        _ctx: &OperationContext,
126    ) -> Result<CherrypickConflictOutput, OperationError> {
127        let repo_path = self.repo_path.clone();
128        let cp_oid = self.cherrypick_oid.clone();
129        let our_oid = self.our_oid.clone();
130        blocking(move || {
131            let repo = Repository::open(&repo_path)?;
132            let cp_commit = repo.find_commit(Oid::from_str(&cp_oid)?)?;
133            let our_commit = repo.find_commit(Oid::from_str(&our_oid)?)?;
134            let index = repo.cherrypick_commit(&cp_commit, &our_commit, 0, None)?;
135            Ok(CherrypickConflictOutput {
136                has_conflicts: index.has_conflicts(),
137            })
138        })
139        .await
140    }
141}
142
143#[async_trait]
144impl Operation for CherrypickCommit {
145    fn kind(&self) -> &str {
146        "git"
147    }
148    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
149        to_value(&self.run(ctx).await?)
150    }
151    fn input(&self) -> Option<Value> {
152        Some(
153            serde_json::json!({ "repo_path": self.repo_path, "cherrypick": self.cherrypick_oid, "our": self.our_oid }),
154        )
155    }
156}
157
158impl TypedOperation for CherrypickCommit {
159    type Output = CherrypickConflictOutput;
160}
161
162/// Revert a commit onto the working directory and index.
163///
164/// # Examples
165///
166/// ```no_run
167/// use ironflow_ops_git::cherrypick::Revert;
168/// use ironflow_core::operation::Operation;
169///
170/// let op = Revert::new("/path/to/repo", "abc123");
171/// assert_eq!(op.kind(), "git");
172/// ```
173pub struct Revert {
174    repo_path: PathBuf,
175    oid: String,
176}
177
178impl Revert {
179    /// Create a new revert operation.
180    pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
181        Self {
182            repo_path: repo_path.into(),
183            oid: oid.into(),
184        }
185    }
186
187    /// Execute and return a typed result.
188    pub async fn run(&self, _ctx: &OperationContext) -> Result<RevertOutput, OperationError> {
189        let repo_path = self.repo_path.clone();
190        let oid_str = self.oid.clone();
191        blocking(move || {
192            let repo = Repository::open(&repo_path)?;
193            let oid = Oid::from_str(&oid_str)?;
194            let commit = repo.find_commit(oid)?;
195            repo.revert(&commit, None)?;
196            Ok(RevertOutput {
197                oid: oid_str,
198                reverted: true,
199            })
200        })
201        .await
202    }
203}
204
205#[async_trait]
206impl Operation for Revert {
207    fn kind(&self) -> &str {
208        "git"
209    }
210    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
211        to_value(&self.run(ctx).await?)
212    }
213    fn input(&self) -> Option<Value> {
214        Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
215    }
216}
217
218impl TypedOperation for Revert {
219    type Output = RevertOutput;
220}
221
222/// Revert a commit as a tree merge (without touching the working directory).
223///
224/// # Examples
225///
226/// ```no_run
227/// use ironflow_ops_git::cherrypick::RevertCommit;
228/// use ironflow_core::operation::Operation;
229///
230/// let op = RevertCommit::new("/path/to/repo", "abc123", "def456");
231/// assert_eq!(op.kind(), "git");
232/// ```
233pub struct RevertCommit {
234    repo_path: PathBuf,
235    revert_oid: String,
236    our_oid: String,
237}
238
239impl RevertCommit {
240    /// Create a new revert-commit operation.
241    pub fn new(
242        repo_path: impl Into<PathBuf>,
243        revert_oid: impl Into<String>,
244        our_oid: impl Into<String>,
245    ) -> Self {
246        Self {
247            repo_path: repo_path.into(),
248            revert_oid: revert_oid.into(),
249            our_oid: our_oid.into(),
250        }
251    }
252
253    /// Execute and return a typed result.
254    pub async fn run(
255        &self,
256        _ctx: &OperationContext,
257    ) -> Result<CherrypickConflictOutput, OperationError> {
258        let repo_path = self.repo_path.clone();
259        let rv_oid = self.revert_oid.clone();
260        let our_oid = self.our_oid.clone();
261        blocking(move || {
262            let repo = Repository::open(&repo_path)?;
263            let rv_commit = repo.find_commit(Oid::from_str(&rv_oid)?)?;
264            let our_commit = repo.find_commit(Oid::from_str(&our_oid)?)?;
265            let index = repo.revert_commit(&rv_commit, &our_commit, 0, None)?;
266            Ok(CherrypickConflictOutput {
267                has_conflicts: index.has_conflicts(),
268            })
269        })
270        .await
271    }
272}
273
274#[async_trait]
275impl Operation for RevertCommit {
276    fn kind(&self) -> &str {
277        "git"
278    }
279    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
280        to_value(&self.run(ctx).await?)
281    }
282    fn input(&self) -> Option<Value> {
283        Some(
284            serde_json::json!({ "repo_path": self.repo_path, "revert": self.revert_oid, "our": self.our_oid }),
285        )
286    }
287}
288
289impl TypedOperation for RevertCommit {
290    type Output = CherrypickConflictOutput;
291}
292
293#[cfg(test)]
294mod tests {
295    use ironflow_core::operation::Operation;
296
297    use super::*;
298    use crate::test_helpers::{ctx, make_two_commits};
299
300    #[tokio::test]
301    async fn cherrypick_commit_no_conflict() {
302        let tmp = tempfile::tempdir().unwrap();
303        let (c1, c2) = make_two_commits(tmp.path());
304        let result = CherrypickCommit::new(tmp.path(), &c2, &c1)
305            .run(&ctx())
306            .await
307            .unwrap();
308        assert!(!result.has_conflicts);
309    }
310
311    #[tokio::test]
312    async fn revert_commit_no_conflict() {
313        let tmp = tempfile::tempdir().unwrap();
314        let (_c1, c2) = make_two_commits(tmp.path());
315        let result = RevertCommit::new(tmp.path(), &c2, &c2)
316            .run(&ctx())
317            .await
318            .unwrap();
319        assert!(!result.has_conflicts);
320    }
321
322    #[tokio::test]
323    async fn cherrypick_applies_to_workdir() {
324        let tmp = tempfile::tempdir().unwrap();
325        let (_, c2) = make_two_commits(tmp.path());
326        let result = Cherrypick::new(tmp.path(), &c2).run(&ctx()).await.unwrap();
327        assert!(result.applied);
328        assert_eq!(result.oid, c2);
329    }
330
331    #[tokio::test]
332    async fn cherrypick_invalid_oid_fails() {
333        let tmp = tempfile::tempdir().unwrap();
334        make_two_commits(tmp.path());
335        assert!(
336            Cherrypick::new(tmp.path(), "bad-oid")
337                .run(&ctx())
338                .await
339                .is_err()
340        );
341    }
342
343    #[tokio::test]
344    async fn execute_serializes_correctly() {
345        let tmp = tempfile::tempdir().unwrap();
346        let (c1, c2) = make_two_commits(tmp.path());
347        let value = CherrypickCommit::new(tmp.path(), &c2, &c1)
348            .execute(&ctx())
349            .await
350            .unwrap();
351        assert!(!value["has_conflicts"].as_bool().unwrap());
352    }
353}