Skip to main content

ironflow_ops_git/
submodule.rs

1//! Submodule operations.
2//!
3//! A submodule URL recorded in an operation's input or returned in its output
4//! has its credentials masked (`https://***@host/...`).
5
6use std::path::{Path, PathBuf};
7
8use async_trait::async_trait;
9use git2::{FetchOptions, Repository, SubmoduleUpdateOptions};
10use ironflow_core::error::OperationError;
11use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::helpers::{
16    GitAuth, auth_builders, blocking, blocking_authenticated, credentials_callbacks, redact_url,
17    to_value,
18};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SubmoduleAddOutput {
22    pub url: String,
23    pub path: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SubmoduleInitOutput {
28    pub name: String,
29    pub initialized: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SubmoduleUpdateOutput {
34    pub name: String,
35    pub updated: bool,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SubmoduleLookupOutput {
40    pub name: String,
41    pub url: String,
42    pub path: String,
43    pub head_id: Option<String>,
44}
45
46/// A single submodule entry.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SubmoduleEntry {
49    pub name: String,
50    pub url: String,
51    pub path: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SubmoduleListOutput {
56    pub submodules: Vec<SubmoduleEntry>,
57}
58
59/// Add a submodule.
60///
61/// # Examples
62///
63/// ```no_run
64/// use ironflow_ops_git::submodule::SubmoduleAdd;
65/// use ironflow_core::operation::Operation;
66///
67/// let op = SubmoduleAdd::new("/path/to/repo", "https://example.com/sub.git", "vendor/sub");
68/// assert_eq!(op.kind(), "git");
69/// ```
70pub struct SubmoduleAdd {
71    repo_path: PathBuf,
72    url: String,
73    path: String,
74}
75
76impl SubmoduleAdd {
77    /// Create a new submodule-add operation.
78    pub fn new(
79        repo_path: impl Into<PathBuf>,
80        url: impl Into<String>,
81        path: impl Into<String>,
82    ) -> Self {
83        Self {
84            repo_path: repo_path.into(),
85            url: url.into(),
86            path: path.into(),
87        }
88    }
89
90    /// Execute and return a typed result.
91    pub async fn run(&self, _ctx: &OperationContext) -> Result<SubmoduleAddOutput, OperationError> {
92        let repo_path = self.repo_path.clone();
93        let url = self.url.clone();
94        let path = self.path.clone();
95        blocking(move || {
96            let repo = Repository::open(&repo_path)?;
97            repo.submodule(&url, Path::new(&path), true)?;
98            Ok(SubmoduleAddOutput {
99                url: redact_url(&url),
100                path,
101            })
102        })
103        .await
104    }
105}
106
107#[async_trait]
108impl Operation for SubmoduleAdd {
109    fn kind(&self) -> &str {
110        "git"
111    }
112    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
113        to_value(&self.run(ctx).await?)
114    }
115    fn input(&self) -> Option<Value> {
116        Some(
117            serde_json::json!({ "repo_path": self.repo_path, "url": redact_url(&self.url), "path": self.path }),
118        )
119    }
120}
121
122impl TypedOperation for SubmoduleAdd {
123    type Output = SubmoduleAddOutput;
124}
125
126/// Initialize a submodule.
127///
128/// # Examples
129///
130/// ```no_run
131/// use ironflow_ops_git::submodule::SubmoduleInit;
132/// use ironflow_core::operation::Operation;
133///
134/// let op = SubmoduleInit::new("/path/to/repo", "vendor/sub");
135/// assert_eq!(op.kind(), "git");
136/// ```
137pub struct SubmoduleInit {
138    repo_path: PathBuf,
139    name: String,
140}
141
142impl SubmoduleInit {
143    /// Create a new submodule-init operation.
144    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
145        Self {
146            repo_path: repo_path.into(),
147            name: name.into(),
148        }
149    }
150
151    /// Execute and return a typed result.
152    pub async fn run(
153        &self,
154        _ctx: &OperationContext,
155    ) -> Result<SubmoduleInitOutput, OperationError> {
156        let repo_path = self.repo_path.clone();
157        let name = self.name.clone();
158        blocking(move || {
159            let repo = Repository::open(&repo_path)?;
160            let mut sub = repo.find_submodule(&name)?;
161            sub.init(false)?;
162            Ok(SubmoduleInitOutput {
163                name,
164                initialized: true,
165            })
166        })
167        .await
168    }
169}
170
171#[async_trait]
172impl Operation for SubmoduleInit {
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(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
181    }
182}
183
184impl TypedOperation for SubmoduleInit {
185    type Output = SubmoduleInitOutput;
186}
187
188/// Update a submodule (clone or fetch + checkout).
189///
190/// # Examples
191///
192/// ```no_run
193/// use ironflow_ops_git::submodule::SubmoduleUpdate;
194/// use ironflow_core::operation::Operation;
195///
196/// let op = SubmoduleUpdate::new("/path/to/repo", "vendor/sub");
197/// assert_eq!(op.kind(), "git");
198/// ```
199pub struct SubmoduleUpdate {
200    repo_path: PathBuf,
201    name: String,
202    auth: GitAuth,
203}
204
205impl SubmoduleUpdate {
206    /// Create a new submodule-update operation.
207    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
208        Self {
209            repo_path: repo_path.into(),
210            name: name.into(),
211            auth: GitAuth::default(),
212        }
213    }
214
215    /// Execute and return a typed result.
216    ///
217    /// Over HTTPS, the submodule is fetched with the token held in the
218    /// `git_token` secret (username `oauth2`), see
219    /// [`SubmoduleUpdate::token_secret`] and [`SubmoduleUpdate::username`].
220    ///
221    /// # Errors
222    ///
223    /// Returns [`OperationError::Secret`] if the secret store fails, and
224    /// [`OperationError::External`] if the submodule is unknown or the fetch
225    /// fails. The token never appears in the message.
226    pub async fn run(
227        &self,
228        ctx: &OperationContext,
229    ) -> Result<SubmoduleUpdateOutput, OperationError> {
230        let repo_path = self.repo_path.clone();
231        let name = self.name.clone();
232        blocking_authenticated(ctx, &self.auth, move |creds| {
233            let repo = Repository::open(&repo_path)?;
234            let mut sub = repo.find_submodule(&name)?;
235            let mut fetch_opts = FetchOptions::new();
236            fetch_opts.remote_callbacks(credentials_callbacks(creds));
237            let mut update_opts = SubmoduleUpdateOptions::new();
238            update_opts.fetch(fetch_opts);
239            sub.update(true, Some(&mut update_opts))?;
240            Ok(SubmoduleUpdateOutput {
241                name,
242                updated: true,
243            })
244        })
245        .await
246    }
247}
248
249#[async_trait]
250impl Operation for SubmoduleUpdate {
251    fn kind(&self) -> &str {
252        "git"
253    }
254    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
255        to_value(&self.run(ctx).await?)
256    }
257    fn input(&self) -> Option<Value> {
258        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
259    }
260}
261
262impl TypedOperation for SubmoduleUpdate {
263    type Output = SubmoduleUpdateOutput;
264}
265
266auth_builders!(
267    SubmoduleUpdate,
268    "submodule",
269    "\"/path/to/repo\", \"vendor/sub\""
270);
271
272/// Look up a submodule by name.
273///
274/// # Examples
275///
276/// ```no_run
277/// use ironflow_ops_git::submodule::SubmoduleLookup;
278/// use ironflow_core::operation::Operation;
279///
280/// let op = SubmoduleLookup::new("/path/to/repo", "vendor/sub");
281/// assert_eq!(op.kind(), "git");
282/// ```
283pub struct SubmoduleLookup {
284    repo_path: PathBuf,
285    name: String,
286}
287
288impl SubmoduleLookup {
289    /// Create a new submodule-lookup operation.
290    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
291        Self {
292            repo_path: repo_path.into(),
293            name: name.into(),
294        }
295    }
296
297    /// Execute and return a typed result.
298    pub async fn run(
299        &self,
300        _ctx: &OperationContext,
301    ) -> Result<SubmoduleLookupOutput, OperationError> {
302        let repo_path = self.repo_path.clone();
303        let name = self.name.clone();
304        blocking(move || {
305            let repo = Repository::open(&repo_path)?;
306            let sub = repo.find_submodule(&name)?;
307            Ok(SubmoduleLookupOutput {
308                name: sub.name().unwrap_or("").to_string(),
309                url: redact_url(sub.url().unwrap_or("")),
310                path: sub.path().to_string_lossy().into_owned(),
311                head_id: sub.head_id().map(|o| o.to_string()),
312            })
313        })
314        .await
315    }
316}
317
318#[async_trait]
319impl Operation for SubmoduleLookup {
320    fn kind(&self) -> &str {
321        "git"
322    }
323    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
324        to_value(&self.run(ctx).await?)
325    }
326    fn input(&self) -> Option<Value> {
327        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
328    }
329}
330
331impl TypedOperation for SubmoduleLookup {
332    type Output = SubmoduleLookupOutput;
333}
334
335/// List all submodules.
336///
337/// # Examples
338///
339/// ```no_run
340/// use ironflow_ops_git::submodule::SubmoduleList;
341/// use ironflow_core::operation::Operation;
342///
343/// let op = SubmoduleList::new("/path/to/repo");
344/// assert_eq!(op.kind(), "git");
345/// ```
346pub struct SubmoduleList {
347    repo_path: PathBuf,
348}
349
350impl SubmoduleList {
351    /// Create a new submodule-list operation.
352    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
353        Self {
354            repo_path: repo_path.into(),
355        }
356    }
357
358    /// Execute and return a typed result.
359    pub async fn run(
360        &self,
361        _ctx: &OperationContext,
362    ) -> Result<SubmoduleListOutput, OperationError> {
363        let repo_path = self.repo_path.clone();
364        blocking(move || {
365            let repo = Repository::open(&repo_path)?;
366            let subs = repo.submodules()?;
367            let list = subs
368                .iter()
369                .map(|s| SubmoduleEntry {
370                    name: s.name().unwrap_or("").to_string(),
371                    url: redact_url(s.url().unwrap_or("")),
372                    path: s.path().to_string_lossy().into_owned(),
373                })
374                .collect();
375            Ok(SubmoduleListOutput { submodules: list })
376        })
377        .await
378    }
379}
380
381#[async_trait]
382impl Operation for SubmoduleList {
383    fn kind(&self) -> &str {
384        "git"
385    }
386    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
387        to_value(&self.run(ctx).await?)
388    }
389    fn input(&self) -> Option<Value> {
390        Some(serde_json::json!({ "repo_path": self.repo_path }))
391    }
392}
393
394impl TypedOperation for SubmoduleList {
395    type Output = SubmoduleListOutput;
396}