Skip to main content

ironflow_ops_git/
rebase.rs

1//! Rebase operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{BranchType, RebaseOperationType, Repository, Signature};
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
14fn rebase_op_label(kind: Option<RebaseOperationType>) -> &'static str {
15    match kind {
16        Some(RebaseOperationType::Pick) => "pick",
17        Some(RebaseOperationType::Reword) => "reword",
18        Some(RebaseOperationType::Edit) => "edit",
19        Some(RebaseOperationType::Squash) => "squash",
20        Some(RebaseOperationType::Fixup) => "fixup",
21        Some(RebaseOperationType::Exec) => "exec",
22        None => "unknown",
23    }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RebaseInitOutput {
28    pub branch: String,
29    pub upstream: String,
30    pub operations: usize,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RebaseNextOutput {
35    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
36    pub op_type: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub id: Option<String>,
39    pub has_more: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RebaseCommitOutput {
44    pub oid: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct RebaseAbortOutput {
49    pub aborted: bool,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RebaseFinishOutput {
54    pub finished: bool,
55}
56
57/// Start a rebase operation.
58///
59/// # Examples
60///
61/// ```no_run
62/// use ironflow_ops_git::rebase::RebaseInit;
63/// use ironflow_core::operation::Operation;
64///
65/// let op = RebaseInit::new("/path/to/repo", "feature", "main");
66/// assert_eq!(op.kind(), "git");
67/// ```
68pub struct RebaseInit {
69    repo_path: PathBuf,
70    branch: String,
71    upstream: String,
72}
73
74impl RebaseInit {
75    /// Create a new rebase-init operation.
76    pub fn new(
77        repo_path: impl Into<PathBuf>,
78        branch: impl Into<String>,
79        upstream: impl Into<String>,
80    ) -> Self {
81        Self {
82            repo_path: repo_path.into(),
83            branch: branch.into(),
84            upstream: upstream.into(),
85        }
86    }
87
88    /// Execute and return a typed result.
89    pub async fn run(&self, _ctx: &OperationContext) -> Result<RebaseInitOutput, OperationError> {
90        let repo_path = self.repo_path.clone();
91        let branch = self.branch.clone();
92        let upstream = self.upstream.clone();
93        blocking(move || {
94            let repo = Repository::open(&repo_path)?;
95            let branch_ref = repo.find_branch(&branch, BranchType::Local)?;
96            let branch_commit = branch_ref.get().peel_to_commit()?;
97            let branch_annotated = repo.find_annotated_commit(branch_commit.id())?;
98            let upstream_ref = repo.find_branch(&upstream, BranchType::Local)?;
99            let upstream_commit = upstream_ref.get().peel_to_commit()?;
100            let upstream_annotated = repo.find_annotated_commit(upstream_commit.id())?;
101            let rebase = repo.rebase(
102                Some(&branch_annotated),
103                Some(&upstream_annotated),
104                None,
105                None,
106            )?;
107            let count = rebase.len();
108            Ok(RebaseInitOutput {
109                branch,
110                upstream,
111                operations: count,
112            })
113        })
114        .await
115    }
116}
117
118#[async_trait]
119impl Operation for RebaseInit {
120    fn kind(&self) -> &str {
121        "git"
122    }
123    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
124        to_value(&self.run(ctx).await?)
125    }
126    fn input(&self) -> Option<Value> {
127        Some(
128            serde_json::json!({ "repo_path": self.repo_path, "branch": self.branch, "upstream": self.upstream }),
129        )
130    }
131}
132
133impl TypedOperation for RebaseInit {
134    type Output = RebaseInitOutput;
135}
136
137/// Apply the next rebase operation.
138///
139/// # Examples
140///
141/// ```no_run
142/// use ironflow_ops_git::rebase::RebaseNext;
143/// use ironflow_core::operation::Operation;
144///
145/// let op = RebaseNext::new("/path/to/repo");
146/// assert_eq!(op.kind(), "git");
147/// ```
148pub struct RebaseNext {
149    repo_path: PathBuf,
150}
151
152impl RebaseNext {
153    /// Create a new rebase-next operation.
154    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
155        Self {
156            repo_path: repo_path.into(),
157        }
158    }
159
160    /// Execute and return a typed result.
161    pub async fn run(&self, _ctx: &OperationContext) -> Result<RebaseNextOutput, OperationError> {
162        let repo_path = self.repo_path.clone();
163        blocking(move || {
164            let repo = Repository::open(&repo_path)?;
165            let mut rebase = repo.open_rebase(None)?;
166            let op = rebase.next();
167            match op {
168                Some(Ok(operation)) => Ok(RebaseNextOutput {
169                    op_type: Some(rebase_op_label(operation.kind()).to_string()),
170                    id: Some(operation.id().to_string()),
171                    has_more: true,
172                }),
173                Some(Err(e)) => Err(e),
174                None => Ok(RebaseNextOutput {
175                    op_type: None,
176                    id: None,
177                    has_more: false,
178                }),
179            }
180        })
181        .await
182    }
183}
184
185#[async_trait]
186impl Operation for RebaseNext {
187    fn kind(&self) -> &str {
188        "git"
189    }
190    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
191        to_value(&self.run(ctx).await?)
192    }
193    fn input(&self) -> Option<Value> {
194        Some(serde_json::json!({ "repo_path": self.repo_path }))
195    }
196}
197
198impl TypedOperation for RebaseNext {
199    type Output = RebaseNextOutput;
200}
201
202/// Commit the current rebase operation.
203///
204/// # Examples
205///
206/// ```no_run
207/// use ironflow_ops_git::rebase::RebaseCommit;
208/// use ironflow_core::operation::Operation;
209///
210/// let op = RebaseCommit::new("/path/to/repo", "Author", "author@example.com");
211/// assert_eq!(op.kind(), "git");
212/// ```
213pub struct RebaseCommit {
214    repo_path: PathBuf,
215    author_name: String,
216    author_email: String,
217}
218
219impl RebaseCommit {
220    /// Create a new rebase-commit operation.
221    pub fn new(
222        repo_path: impl Into<PathBuf>,
223        author_name: impl Into<String>,
224        author_email: impl Into<String>,
225    ) -> Self {
226        Self {
227            repo_path: repo_path.into(),
228            author_name: author_name.into(),
229            author_email: author_email.into(),
230        }
231    }
232
233    /// Execute and return a typed result.
234    pub async fn run(&self, _ctx: &OperationContext) -> Result<RebaseCommitOutput, OperationError> {
235        let repo_path = self.repo_path.clone();
236        let name = self.author_name.clone();
237        let email = self.author_email.clone();
238        blocking(move || {
239            let repo = Repository::open(&repo_path)?;
240            let mut rebase = repo.open_rebase(None)?;
241            let sig = Signature::now(&name, &email)?;
242            let oid = rebase.commit(None, &sig, None)?;
243            Ok(RebaseCommitOutput {
244                oid: oid.to_string(),
245            })
246        })
247        .await
248    }
249}
250
251#[async_trait]
252impl Operation for RebaseCommit {
253    fn kind(&self) -> &str {
254        "git"
255    }
256    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
257        to_value(&self.run(ctx).await?)
258    }
259    fn input(&self) -> Option<Value> {
260        Some(serde_json::json!({ "repo_path": self.repo_path }))
261    }
262}
263
264impl TypedOperation for RebaseCommit {
265    type Output = RebaseCommitOutput;
266}
267
268/// Abort a rebase in progress.
269///
270/// # Examples
271///
272/// ```no_run
273/// use ironflow_ops_git::rebase::RebaseAbort;
274/// use ironflow_core::operation::Operation;
275///
276/// let op = RebaseAbort::new("/path/to/repo");
277/// assert_eq!(op.kind(), "git");
278/// ```
279pub struct RebaseAbort {
280    repo_path: PathBuf,
281}
282
283impl RebaseAbort {
284    /// Create a new rebase-abort operation.
285    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
286        Self {
287            repo_path: repo_path.into(),
288        }
289    }
290
291    /// Execute and return a typed result.
292    pub async fn run(&self, _ctx: &OperationContext) -> Result<RebaseAbortOutput, OperationError> {
293        let repo_path = self.repo_path.clone();
294        blocking(move || {
295            let repo = Repository::open(&repo_path)?;
296            let mut rebase = repo.open_rebase(None)?;
297            rebase.abort()?;
298            Ok(RebaseAbortOutput { aborted: true })
299        })
300        .await
301    }
302}
303
304#[async_trait]
305impl Operation for RebaseAbort {
306    fn kind(&self) -> &str {
307        "git"
308    }
309    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
310        to_value(&self.run(ctx).await?)
311    }
312    fn input(&self) -> Option<Value> {
313        Some(serde_json::json!({ "repo_path": self.repo_path }))
314    }
315}
316
317impl TypedOperation for RebaseAbort {
318    type Output = RebaseAbortOutput;
319}
320
321/// Finish a rebase.
322///
323/// # Examples
324///
325/// ```no_run
326/// use ironflow_ops_git::rebase::RebaseFinish;
327/// use ironflow_core::operation::Operation;
328///
329/// let op = RebaseFinish::new("/path/to/repo", "Author", "author@example.com");
330/// assert_eq!(op.kind(), "git");
331/// ```
332pub struct RebaseFinish {
333    repo_path: PathBuf,
334    author_name: String,
335    author_email: String,
336}
337
338impl RebaseFinish {
339    /// Create a new rebase-finish operation.
340    pub fn new(
341        repo_path: impl Into<PathBuf>,
342        author_name: impl Into<String>,
343        author_email: impl Into<String>,
344    ) -> Self {
345        Self {
346            repo_path: repo_path.into(),
347            author_name: author_name.into(),
348            author_email: author_email.into(),
349        }
350    }
351
352    /// Execute and return a typed result.
353    pub async fn run(&self, _ctx: &OperationContext) -> Result<RebaseFinishOutput, OperationError> {
354        let repo_path = self.repo_path.clone();
355        let name = self.author_name.clone();
356        let email = self.author_email.clone();
357        blocking(move || {
358            let repo = Repository::open(&repo_path)?;
359            let mut rebase = repo.open_rebase(None)?;
360            let sig = Signature::now(&name, &email)?;
361            rebase.finish(Some(&sig))?;
362            Ok(RebaseFinishOutput { finished: true })
363        })
364        .await
365    }
366}
367
368#[async_trait]
369impl Operation for RebaseFinish {
370    fn kind(&self) -> &str {
371        "git"
372    }
373    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
374        to_value(&self.run(ctx).await?)
375    }
376    fn input(&self) -> Option<Value> {
377        Some(serde_json::json!({ "repo_path": self.repo_path }))
378    }
379}
380
381impl TypedOperation for RebaseFinish {
382    type Output = RebaseFinishOutput;
383}
384
385#[cfg(test)]
386mod tests {
387    use std::fs;
388    use std::path::Path;
389
390    use git2::{Repository, Signature};
391    use ironflow_core::operation::Operation;
392
393    use super::*;
394    use crate::test_helpers::ctx;
395
396    fn init_with_branch(path: &Path) {
397        use git2::build::CheckoutBuilder;
398        let repo = Repository::init(path).unwrap();
399        let sig = Signature::now("Test", "t@t.com").unwrap();
400        fs::write(path.join("f.txt"), "base").unwrap();
401        let mut idx = repo.index().unwrap();
402        idx.add_path(Path::new("f.txt")).unwrap();
403        idx.write().unwrap();
404        let tree = repo.find_tree(idx.write_tree().unwrap()).unwrap();
405        let c1 = repo
406            .commit(Some("HEAD"), &sig, &sig, "base", &tree, &[])
407            .unwrap();
408        let base = repo.find_commit(c1).unwrap();
409        repo.branch("feature", &base, false).unwrap();
410
411        let mut tb = repo.treebuilder(Some(&tree)).unwrap();
412        tb.insert("main.txt", repo.blob(b"main-only").unwrap(), 0o100644)
413            .unwrap();
414        let main_tree = repo.find_tree(tb.write().unwrap()).unwrap();
415        repo.commit(
416            Some("HEAD"),
417            &sig,
418            &sig,
419            "main commit",
420            &main_tree,
421            &[&base],
422        )
423        .unwrap();
424
425        let mut tb2 = repo.treebuilder(Some(&tree)).unwrap();
426        tb2.insert("feat.txt", repo.blob(b"feat-only").unwrap(), 0o100644)
427            .unwrap();
428        let feat_tree = repo.find_tree(tb2.write().unwrap()).unwrap();
429        repo.commit(
430            Some("refs/heads/feature"),
431            &sig,
432            &sig,
433            "feat commit",
434            &feat_tree,
435            &[&base],
436        )
437        .unwrap();
438
439        repo.checkout_head(Some(CheckoutBuilder::new().force()))
440            .unwrap();
441    }
442
443    #[tokio::test]
444    async fn rebase_init_counts_operations() {
445        let tmp = tempfile::tempdir().unwrap();
446        init_with_branch(tmp.path());
447        let result = RebaseInit::new(tmp.path(), "feature", "master")
448            .run(&ctx())
449            .await
450            .unwrap();
451        assert_eq!(result.branch, "feature");
452        assert!(result.operations > 0);
453    }
454
455    #[tokio::test]
456    async fn rebase_init_and_abort() {
457        let tmp = tempfile::tempdir().unwrap();
458        init_with_branch(tmp.path());
459        RebaseInit::new(tmp.path(), "feature", "master")
460            .run(&ctx())
461            .await
462            .unwrap();
463        let result = RebaseAbort::new(tmp.path()).run(&ctx()).await.unwrap();
464        assert!(result.aborted);
465    }
466
467    #[tokio::test]
468    async fn execute_serializes_correctly() {
469        let tmp = tempfile::tempdir().unwrap();
470        init_with_branch(tmp.path());
471        let value = RebaseInit::new(tmp.path(), "feature", "master")
472            .execute(&ctx())
473            .await
474            .unwrap();
475        assert_eq!(value["branch"], "feature");
476        assert!(value["operations"].is_number());
477    }
478}