Skip to main content

ironflow_ops_git/
branch.rs

1//! Branch operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::build::CheckoutBuilder;
7use git2::{BranchType, Repository};
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, to_value};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BranchCreateOutput {
17    pub name: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BranchDeleteOutput {
22    pub name: String,
23    pub deleted: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct BranchRenameOutput {
28    pub old_name: String,
29    pub new_name: String,
30}
31
32/// A single branch entry.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BranchEntry {
35    pub name: String,
36    pub is_head: bool,
37    #[serde(rename = "type")]
38    pub branch_type: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BranchListOutput {
43    pub branches: Vec<BranchEntry>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BranchLookupOutput {
48    pub name: String,
49    pub is_head: bool,
50    pub target: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct BranchIsHeadOutput {
55    pub name: String,
56    pub is_head: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct BranchSetUpstreamOutput {
61    pub branch: String,
62    pub upstream: String,
63}
64
65/// Create a new branch pointing at HEAD.
66///
67/// # Examples
68///
69/// ```no_run
70/// use ironflow_ops_git::branch::BranchCreate;
71/// use ironflow_core::operation::Operation;
72///
73/// let op = BranchCreate::new("/path/to/repo", "feature-x");
74/// assert_eq!(op.kind(), "git");
75/// ```
76pub struct BranchCreate {
77    repo_path: PathBuf,
78    name: String,
79}
80
81impl BranchCreate {
82    /// Create a new branch-create operation.
83    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
84        Self {
85            repo_path: repo_path.into(),
86            name: name.into(),
87        }
88    }
89
90    /// Execute and return a typed result.
91    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchCreateOutput, OperationError> {
92        let repo_path = self.repo_path.clone();
93        let name = self.name.clone();
94        blocking(move || {
95            let repo = Repository::open(&repo_path)?;
96            let head = repo.head()?.peel_to_commit()?;
97            repo.branch(&name, &head, false)?;
98            Ok(BranchCreateOutput { name })
99        })
100        .await
101    }
102}
103
104#[async_trait]
105impl Operation for BranchCreate {
106    fn kind(&self) -> &str {
107        "git"
108    }
109    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
110        to_value(&self.run(ctx).await?)
111    }
112    fn input(&self) -> Option<Value> {
113        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
114    }
115}
116
117impl TypedOperation for BranchCreate {
118    type Output = BranchCreateOutput;
119}
120
121/// Delete a branch.
122///
123/// # Examples
124///
125/// ```no_run
126/// use ironflow_ops_git::branch::BranchDelete;
127/// use ironflow_core::operation::Operation;
128///
129/// let op = BranchDelete::new("/path/to/repo", "feature-x", false);
130/// assert_eq!(op.kind(), "git");
131/// ```
132pub struct BranchDelete {
133    repo_path: PathBuf,
134    name: String,
135    remote: bool,
136}
137
138impl BranchDelete {
139    /// Create a new branch-delete operation.
140    ///
141    /// Set `remote` to `true` to delete a remote-tracking branch.
142    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>, remote: bool) -> Self {
143        Self {
144            repo_path: repo_path.into(),
145            name: name.into(),
146            remote,
147        }
148    }
149
150    /// Execute and return a typed result.
151    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchDeleteOutput, OperationError> {
152        let repo_path = self.repo_path.clone();
153        let name = self.name.clone();
154        let branch_type = if self.remote {
155            BranchType::Remote
156        } else {
157            BranchType::Local
158        };
159        blocking(move || {
160            let repo = Repository::open(&repo_path)?;
161            let mut branch = repo.find_branch(&name, branch_type)?;
162            branch.delete()?;
163            Ok(BranchDeleteOutput {
164                name,
165                deleted: true,
166            })
167        })
168        .await
169    }
170}
171
172#[async_trait]
173impl Operation for BranchDelete {
174    fn kind(&self) -> &str {
175        "git"
176    }
177    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
178        to_value(&self.run(ctx).await?)
179    }
180    fn input(&self) -> Option<Value> {
181        Some(
182            serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "remote": self.remote }),
183        )
184    }
185}
186
187impl TypedOperation for BranchDelete {
188    type Output = BranchDeleteOutput;
189}
190
191/// Rename a branch.
192///
193/// # Examples
194///
195/// ```no_run
196/// use ironflow_ops_git::branch::BranchRename;
197/// use ironflow_core::operation::Operation;
198///
199/// let op = BranchRename::new("/path/to/repo", "old-name", "new-name", false);
200/// assert_eq!(op.kind(), "git");
201/// ```
202pub struct BranchRename {
203    repo_path: PathBuf,
204    old_name: String,
205    new_name: String,
206    force: bool,
207}
208
209impl BranchRename {
210    /// Create a new branch-rename operation.
211    pub fn new(
212        repo_path: impl Into<PathBuf>,
213        old_name: impl Into<String>,
214        new_name: impl Into<String>,
215        force: bool,
216    ) -> Self {
217        Self {
218            repo_path: repo_path.into(),
219            old_name: old_name.into(),
220            new_name: new_name.into(),
221            force,
222        }
223    }
224
225    /// Execute and return a typed result.
226    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchRenameOutput, OperationError> {
227        let repo_path = self.repo_path.clone();
228        let old = self.old_name.clone();
229        let new = self.new_name.clone();
230        let force = self.force;
231        blocking(move || {
232            let repo = Repository::open(&repo_path)?;
233            let mut branch = repo.find_branch(&old, BranchType::Local)?;
234            branch.rename(&new, force)?;
235            Ok(BranchRenameOutput {
236                old_name: old,
237                new_name: new,
238            })
239        })
240        .await
241    }
242}
243
244#[async_trait]
245impl Operation for BranchRename {
246    fn kind(&self) -> &str {
247        "git"
248    }
249    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
250        to_value(&self.run(ctx).await?)
251    }
252    fn input(&self) -> Option<Value> {
253        Some(serde_json::json!({
254            "repo_path": self.repo_path,
255            "old_name": self.old_name,
256            "new_name": self.new_name,
257        }))
258    }
259}
260
261impl TypedOperation for BranchRename {
262    type Output = BranchRenameOutput;
263}
264
265/// List all branches.
266///
267/// # Examples
268///
269/// ```no_run
270/// use ironflow_ops_git::branch::BranchList;
271/// use ironflow_core::operation::Operation;
272///
273/// let op = BranchList::local("/path/to/repo");
274/// assert_eq!(op.kind(), "git");
275/// ```
276pub struct BranchList {
277    repo_path: PathBuf,
278    filter: Option<BranchType>,
279}
280
281impl BranchList {
282    /// List local branches only.
283    pub fn local(repo_path: impl Into<PathBuf>) -> Self {
284        Self {
285            repo_path: repo_path.into(),
286            filter: Some(BranchType::Local),
287        }
288    }
289
290    /// List remote-tracking branches only.
291    pub fn remote(repo_path: impl Into<PathBuf>) -> Self {
292        Self {
293            repo_path: repo_path.into(),
294            filter: Some(BranchType::Remote),
295        }
296    }
297
298    /// List all branches (local and remote).
299    pub fn all(repo_path: impl Into<PathBuf>) -> Self {
300        Self {
301            repo_path: repo_path.into(),
302            filter: None,
303        }
304    }
305
306    /// Execute and return a typed result.
307    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchListOutput, OperationError> {
308        let repo_path = self.repo_path.clone();
309        let filter = self.filter;
310        blocking(move || {
311            let repo = Repository::open(&repo_path)?;
312            let branches = repo.branches(filter)?;
313            let list = branches
314                .filter_map(|b| b.ok())
315                .map(|(branch, bt)| BranchEntry {
316                    name: branch.name().ok().flatten().unwrap_or("").to_string(),
317                    is_head: branch.is_head(),
318                    branch_type: match bt {
319                        BranchType::Local => "local".to_string(),
320                        BranchType::Remote => "remote".to_string(),
321                    },
322                })
323                .collect();
324            Ok(BranchListOutput { branches: list })
325        })
326        .await
327    }
328}
329
330#[async_trait]
331impl Operation for BranchList {
332    fn kind(&self) -> &str {
333        "git"
334    }
335    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
336        to_value(&self.run(ctx).await?)
337    }
338    fn input(&self) -> Option<Value> {
339        Some(serde_json::json!({ "repo_path": self.repo_path }))
340    }
341}
342
343impl TypedOperation for BranchList {
344    type Output = BranchListOutput;
345}
346
347/// Look up a branch by name.
348///
349/// # Examples
350///
351/// ```no_run
352/// use ironflow_ops_git::branch::BranchLookup;
353/// use ironflow_core::operation::Operation;
354///
355/// let op = BranchLookup::new("/path/to/repo", "main", false);
356/// assert_eq!(op.kind(), "git");
357/// ```
358pub struct BranchLookup {
359    repo_path: PathBuf,
360    name: String,
361    remote: bool,
362}
363
364impl BranchLookup {
365    /// Create a new branch-lookup operation.
366    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>, remote: bool) -> Self {
367        Self {
368            repo_path: repo_path.into(),
369            name: name.into(),
370            remote,
371        }
372    }
373
374    /// Execute and return a typed result.
375    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchLookupOutput, OperationError> {
376        let repo_path = self.repo_path.clone();
377        let name = self.name.clone();
378        let bt = if self.remote {
379            BranchType::Remote
380        } else {
381            BranchType::Local
382        };
383        blocking(move || {
384            let repo = Repository::open(&repo_path)?;
385            let branch = repo.find_branch(&name, bt)?;
386            let target = branch.get().target().map(|o| o.to_string());
387            Ok(BranchLookupOutput {
388                name,
389                is_head: branch.is_head(),
390                target,
391            })
392        })
393        .await
394    }
395}
396
397#[async_trait]
398impl Operation for BranchLookup {
399    fn kind(&self) -> &str {
400        "git"
401    }
402    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
403        to_value(&self.run(ctx).await?)
404    }
405    fn input(&self) -> Option<Value> {
406        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
407    }
408}
409
410impl TypedOperation for BranchLookup {
411    type Output = BranchLookupOutput;
412}
413
414/// Check if a branch is the current HEAD.
415///
416/// # Examples
417///
418/// ```no_run
419/// use ironflow_ops_git::branch::BranchIsHead;
420/// use ironflow_core::operation::Operation;
421///
422/// let op = BranchIsHead::new("/path/to/repo", "main");
423/// assert_eq!(op.kind(), "git");
424/// ```
425pub struct BranchIsHead {
426    repo_path: PathBuf,
427    name: String,
428}
429
430impl BranchIsHead {
431    /// Create a new is-head check operation.
432    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
433        Self {
434            repo_path: repo_path.into(),
435            name: name.into(),
436        }
437    }
438
439    /// Execute and return a typed result.
440    pub async fn run(&self, _ctx: &OperationContext) -> Result<BranchIsHeadOutput, OperationError> {
441        let repo_path = self.repo_path.clone();
442        let name = self.name.clone();
443        blocking(move || {
444            let repo = Repository::open(&repo_path)?;
445            let branch = repo.find_branch(&name, BranchType::Local)?;
446            Ok(BranchIsHeadOutput {
447                name,
448                is_head: branch.is_head(),
449            })
450        })
451        .await
452    }
453}
454
455#[async_trait]
456impl Operation for BranchIsHead {
457    fn kind(&self) -> &str {
458        "git"
459    }
460    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
461        to_value(&self.run(ctx).await?)
462    }
463    fn input(&self) -> Option<Value> {
464        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
465    }
466}
467
468impl TypedOperation for BranchIsHead {
469    type Output = BranchIsHeadOutput;
470}
471
472/// Set the upstream for a branch.
473///
474/// # Examples
475///
476/// ```no_run
477/// use ironflow_ops_git::branch::BranchSetUpstream;
478/// use ironflow_core::operation::Operation;
479///
480/// let op = BranchSetUpstream::new("/path/to/repo", "main", "origin/main");
481/// assert_eq!(op.kind(), "git");
482/// ```
483pub struct BranchSetUpstream {
484    repo_path: PathBuf,
485    branch_name: String,
486    upstream_name: String,
487}
488
489impl BranchSetUpstream {
490    /// Create a new set-upstream operation.
491    pub fn new(
492        repo_path: impl Into<PathBuf>,
493        branch_name: impl Into<String>,
494        upstream_name: impl Into<String>,
495    ) -> Self {
496        Self {
497            repo_path: repo_path.into(),
498            branch_name: branch_name.into(),
499            upstream_name: upstream_name.into(),
500        }
501    }
502
503    /// Execute and return a typed result.
504    pub async fn run(
505        &self,
506        _ctx: &OperationContext,
507    ) -> Result<BranchSetUpstreamOutput, OperationError> {
508        let repo_path = self.repo_path.clone();
509        let branch_name = self.branch_name.clone();
510        let upstream = self.upstream_name.clone();
511        blocking(move || {
512            let repo = Repository::open(&repo_path)?;
513            let mut branch = repo.find_branch(&branch_name, BranchType::Local)?;
514            branch.set_upstream(Some(&upstream))?;
515            Ok(BranchSetUpstreamOutput {
516                branch: branch_name,
517                upstream,
518            })
519        })
520        .await
521    }
522}
523
524#[async_trait]
525impl Operation for BranchSetUpstream {
526    fn kind(&self) -> &str {
527        "git"
528    }
529    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
530        to_value(&self.run(ctx).await?)
531    }
532    fn input(&self) -> Option<Value> {
533        Some(serde_json::json!({
534            "repo_path": self.repo_path,
535            "branch": self.branch_name,
536            "upstream": self.upstream_name,
537        }))
538    }
539}
540
541impl TypedOperation for BranchSetUpstream {
542    type Output = BranchSetUpstreamOutput;
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct BranchCheckoutOutput {
547    pub name: String,
548}
549
550/// Checkout an existing local branch (set HEAD + update working directory).
551///
552/// # Examples
553///
554/// ```no_run
555/// use ironflow_ops_git::branch::BranchCheckout;
556/// use ironflow_core::operation::Operation;
557///
558/// let op = BranchCheckout::new("/path/to/repo", "feature-x");
559/// assert_eq!(op.kind(), "git");
560/// ```
561pub struct BranchCheckout {
562    repo_path: PathBuf,
563    name: String,
564}
565
566impl BranchCheckout {
567    /// Create a new `BranchCheckout` operation.
568    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
569        Self {
570            repo_path: repo_path.into(),
571            name: name.into(),
572        }
573    }
574
575    /// Execute and return a typed result.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`OperationError`] if the repository cannot be opened, the
580    /// branch does not exist, or the working directory checkout fails.
581    pub async fn run(
582        &self,
583        _ctx: &OperationContext,
584    ) -> Result<BranchCheckoutOutput, OperationError> {
585        let repo_path = self.repo_path.clone();
586        let name = self.name.clone();
587        blocking(move || {
588            let repo = Repository::open(&repo_path)?;
589            repo.find_branch(&name, BranchType::Local)?;
590            let refname = format!("refs/heads/{name}");
591            repo.set_head(&refname)?;
592            repo.checkout_head(Some(CheckoutBuilder::new().force()))?;
593            Ok(BranchCheckoutOutput { name })
594        })
595        .await
596    }
597}
598
599#[async_trait]
600impl Operation for BranchCheckout {
601    fn kind(&self) -> &str {
602        "git"
603    }
604    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
605        to_value(&self.run(ctx).await?)
606    }
607    fn input(&self) -> Option<Value> {
608        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
609    }
610}
611
612impl TypedOperation for BranchCheckout {
613    type Output = BranchCheckoutOutput;
614}