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