Skip to main content

ironflow_ops_git/
repository.rs

1//! Repository-level operations: init, open, clone, discover, state.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use git2::build::RepoBuilder;
7use git2::{FetchOptions, Repository, RepositoryState};
8use ironflow_core::error::OperationError;
9use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::helpers::{
14    GitAuth, auth_builders, blocking, blocking_authenticated, credentials_callbacks, redact_url,
15    to_value,
16};
17
18fn repo_state_label(state: RepositoryState) -> &'static str {
19    match state {
20        RepositoryState::Clean => "clean",
21        RepositoryState::Merge => "merge",
22        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
23        RepositoryState::CherryPickSequence | RepositoryState::CherryPick => "cherrypick",
24        RepositoryState::Bisect => "bisect",
25        RepositoryState::Rebase
26        | RepositoryState::RebaseInteractive
27        | RepositoryState::RebaseMerge => "rebase",
28        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
29    }
30}
31
32/// Output of [`RepoInit`].
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RepoInitOutput {
35    /// Path where the repository was created.
36    pub path: PathBuf,
37    /// Whether this is a bare repository.
38    pub bare: bool,
39}
40
41/// Initialize a new Git repository.
42///
43/// Creates a new repository at the given path. If `bare` is true, creates
44/// a bare repository (no working directory).
45///
46/// # Examples
47///
48/// ```no_run
49/// use ironflow_ops_git::repository::RepoInit;
50/// use ironflow_core::operation::Operation;
51///
52/// let op = RepoInit::new("/tmp/my-repo", false);
53/// assert_eq!(op.kind(), "git");
54/// ```
55pub struct RepoInit {
56    path: PathBuf,
57    bare: bool,
58}
59
60impl RepoInit {
61    /// Create a new init operation.
62    pub fn new(path: impl Into<PathBuf>, bare: bool) -> Self {
63        Self {
64            path: path.into(),
65            bare,
66        }
67    }
68
69    /// Execute and return a typed result.
70    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoInitOutput, OperationError> {
71        let path = self.path.clone();
72        let bare = self.bare;
73        blocking(move || {
74            if bare {
75                Repository::init_bare(&path)?;
76            } else {
77                Repository::init(&path)?;
78            }
79            Ok(RepoInitOutput { path, bare })
80        })
81        .await
82    }
83}
84
85#[async_trait]
86impl Operation for RepoInit {
87    fn kind(&self) -> &str {
88        "git"
89    }
90
91    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
92        to_value(&self.run(ctx).await?)
93    }
94
95    fn input(&self) -> Option<Value> {
96        Some(serde_json::json!({ "path": self.path, "bare": self.bare }))
97    }
98}
99
100impl TypedOperation for RepoInit {
101    type Output = RepoInitOutput;
102}
103
104/// Output of [`RepoOpen`].
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct RepoOpenOutput {
107    /// Working directory path (or the bare repo path).
108    pub path: PathBuf,
109    /// Whether this is a bare repository.
110    pub bare: bool,
111}
112
113/// Open an existing Git repository.
114///
115/// Returns the repository path and whether it is bare.
116///
117/// # Examples
118///
119/// ```no_run
120/// use ironflow_ops_git::repository::RepoOpen;
121/// use ironflow_core::operation::Operation;
122///
123/// let op = RepoOpen::new("/path/to/repo");
124/// assert_eq!(op.kind(), "git");
125/// ```
126pub struct RepoOpen {
127    path: PathBuf,
128}
129
130impl RepoOpen {
131    /// Create a new open operation.
132    pub fn new(path: impl Into<PathBuf>) -> Self {
133        Self { path: path.into() }
134    }
135
136    /// Execute and return a typed result.
137    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoOpenOutput, OperationError> {
138        let path = self.path.clone();
139        blocking(move || {
140            let repo = Repository::open(&path)?;
141            let is_bare = repo.is_bare();
142            let workdir = repo.workdir().map(Path::to_path_buf);
143            Ok(RepoOpenOutput {
144                path: workdir.unwrap_or(path),
145                bare: is_bare,
146            })
147        })
148        .await
149    }
150}
151
152#[async_trait]
153impl Operation for RepoOpen {
154    fn kind(&self) -> &str {
155        "git"
156    }
157
158    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
159        to_value(&self.run(ctx).await?)
160    }
161
162    fn input(&self) -> Option<Value> {
163        Some(serde_json::json!({ "path": self.path }))
164    }
165}
166
167impl TypedOperation for RepoOpen {
168    type Output = RepoOpenOutput;
169}
170
171/// Output of [`RepoClone`].
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct RepoCloneOutput {
174    /// The cloned URL, with any userinfo masked as `***`.
175    pub url: String,
176    /// Local path of the clone.
177    pub path: PathBuf,
178    /// Whether the clone is a bare repository.
179    pub bare: bool,
180}
181
182/// Clone a remote or local repository.
183///
184/// Over HTTPS, the clone authenticates with the token held in the `git_token`
185/// secret (username `oauth2`), see [`RepoClone::token_secret`] and
186/// [`RepoClone::username`]. Without that secret, it falls back to the SSH
187/// agent and the git credential helper. Any credentials embedded in the URL
188/// are masked in [`Operation::input`] and in the output.
189///
190/// # Examples
191///
192/// ```no_run
193/// use ironflow_ops_git::repository::RepoClone;
194/// use ironflow_core::operation::Operation;
195///
196/// let op = RepoClone::new("https://github.com/user/repo.git", "/tmp/clone");
197/// assert_eq!(op.kind(), "git");
198/// ```
199pub struct RepoClone {
200    url: String,
201    path: PathBuf,
202    bare: bool,
203    auth: GitAuth,
204}
205
206impl RepoClone {
207    /// Create a new clone operation.
208    pub fn new(url: impl Into<String>, path: impl Into<PathBuf>) -> Self {
209        Self {
210            url: url.into(),
211            path: path.into(),
212            bare: false,
213            auth: GitAuth::default(),
214        }
215    }
216
217    /// Clone as a bare repository (no working directory).
218    ///
219    /// # Examples
220    ///
221    /// ```no_run
222    /// use ironflow_ops_git::repository::RepoClone;
223    ///
224    /// let op = RepoClone::new("https://gitlab.com/group/repo.git", "/tmp/repo.git").bare(true);
225    /// ```
226    pub fn bare(mut self, bare: bool) -> Self {
227        self.bare = bare;
228        self
229    }
230
231    /// Execute and return a typed result.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`OperationError::Secret`] if the secret store fails, and
236    /// [`OperationError::External`] if the clone fails. Neither the token nor
237    /// URL credentials appear in the message.
238    pub async fn run(&self, ctx: &OperationContext) -> Result<RepoCloneOutput, OperationError> {
239        let url = self.url.clone();
240        let path = self.path.clone();
241        let bare = self.bare;
242        blocking_authenticated(ctx, &self.auth, move |creds| {
243            let mut fetch_opts = FetchOptions::new();
244            fetch_opts.remote_callbacks(credentials_callbacks(creds));
245            RepoBuilder::new()
246                .bare(bare)
247                .fetch_options(fetch_opts)
248                .clone(&url, &path)?;
249            Ok(RepoCloneOutput {
250                url: redact_url(&url),
251                path,
252                bare,
253            })
254        })
255        .await
256    }
257}
258
259#[async_trait]
260impl Operation for RepoClone {
261    fn kind(&self) -> &str {
262        "git"
263    }
264
265    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
266        to_value(&self.run(ctx).await?)
267    }
268
269    fn input(&self) -> Option<Value> {
270        Some(serde_json::json!({
271            "url": redact_url(&self.url),
272            "path": self.path,
273            "bare": self.bare,
274        }))
275    }
276}
277
278impl TypedOperation for RepoClone {
279    type Output = RepoCloneOutput;
280}
281
282auth_builders!(
283    RepoClone,
284    "repository",
285    "\"https://gitlab.com/group/repo.git\", \"/tmp/repo\""
286);
287
288/// Output of [`RepoDiscover`].
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct RepoDiscoverOutput {
291    /// Working directory path (if not bare).
292    pub path: Option<PathBuf>,
293    /// Whether this is a bare repository.
294    pub bare: bool,
295}
296
297/// Discover a repository by walking parent directories.
298///
299/// Starts from `start_path` and walks upward until a `.git` directory is found.
300///
301/// # Examples
302///
303/// ```no_run
304/// use ironflow_ops_git::repository::RepoDiscover;
305/// use ironflow_core::operation::Operation;
306///
307/// let op = RepoDiscover::new("/path/to/subdir");
308/// assert_eq!(op.kind(), "git");
309/// ```
310pub struct RepoDiscover {
311    start_path: PathBuf,
312}
313
314impl RepoDiscover {
315    /// Create a new discover operation.
316    pub fn new(start_path: impl Into<PathBuf>) -> Self {
317        Self {
318            start_path: start_path.into(),
319        }
320    }
321
322    /// Execute and return a typed result.
323    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoDiscoverOutput, OperationError> {
324        let start = self.start_path.clone();
325        blocking(move || {
326            let repo = Repository::discover(&start)?;
327            let workdir = repo.workdir().map(Path::to_path_buf);
328            let is_bare = repo.is_bare();
329            Ok(RepoDiscoverOutput {
330                path: workdir,
331                bare: is_bare,
332            })
333        })
334        .await
335    }
336}
337
338#[async_trait]
339impl Operation for RepoDiscover {
340    fn kind(&self) -> &str {
341        "git"
342    }
343
344    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
345        to_value(&self.run(ctx).await?)
346    }
347
348    fn input(&self) -> Option<Value> {
349        Some(serde_json::json!({ "start_path": self.start_path }))
350    }
351}
352
353impl TypedOperation for RepoDiscover {
354    type Output = RepoDiscoverOutput;
355}
356
357/// Output of [`RepoState`].
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct RepoStateOutput {
360    /// Repository state (e.g. "Clean", "Merge", "Rebase").
361    pub state: String,
362}
363
364/// Query the current state of the repository.
365///
366/// Returns the repository state (clean, merge, rebase, etc.).
367///
368/// # Examples
369///
370/// ```no_run
371/// use ironflow_ops_git::repository::RepoState;
372/// use ironflow_core::operation::Operation;
373///
374/// let op = RepoState::new("/path/to/repo");
375/// assert_eq!(op.kind(), "git");
376/// ```
377pub struct RepoState {
378    repo_path: PathBuf,
379}
380
381impl RepoState {
382    /// Create a new state query operation.
383    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
384        Self {
385            repo_path: repo_path.into(),
386        }
387    }
388
389    /// Execute and return a typed result.
390    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoStateOutput, OperationError> {
391        let path = self.repo_path.clone();
392        blocking(move || {
393            let repo = Repository::open(&path)?;
394            let state = repo_state_label(repo.state()).to_string();
395            Ok(RepoStateOutput { state })
396        })
397        .await
398    }
399}
400
401#[async_trait]
402impl Operation for RepoState {
403    fn kind(&self) -> &str {
404        "git"
405    }
406
407    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
408        to_value(&self.run(ctx).await?)
409    }
410
411    fn input(&self) -> Option<Value> {
412        Some(serde_json::json!({ "repo_path": self.repo_path }))
413    }
414}
415
416impl TypedOperation for RepoState {
417    type Output = RepoStateOutput;
418}
419
420#[cfg(test)]
421mod tests {
422    use std::fs;
423
424    use super::*;
425    use crate::test_helpers::{ctx, init_repo};
426
427    #[tokio::test]
428    async fn init_creates_repo() {
429        let tmp = tempfile::tempdir().unwrap();
430        let target = tmp.path().join("new-repo");
431        let op = RepoInit::new(&target, false);
432        let result = op.run(&ctx()).await.unwrap();
433        assert!(!result.bare);
434        assert!(target.join(".git").exists());
435    }
436
437    #[tokio::test]
438    async fn init_creates_bare_repo() {
439        let tmp = tempfile::tempdir().unwrap();
440        let target = tmp.path().join("bare-repo");
441        let op = RepoInit::new(&target, true);
442        let result = op.run(&ctx()).await.unwrap();
443        assert!(result.bare);
444        assert!(target.join("HEAD").exists());
445    }
446
447    #[tokio::test]
448    async fn clone_local() {
449        let tmp = tempfile::tempdir().unwrap();
450        let origin = tmp.path().join("origin");
451        Repository::init(&origin).unwrap();
452
453        let target = tmp.path().join("clone");
454        let url = origin.to_str().unwrap();
455        let op = RepoClone::new(url, &target);
456        let result = op.run(&ctx()).await.unwrap();
457        assert_eq!(result.path, target);
458        assert!(target.join(".git").exists());
459    }
460
461    #[tokio::test]
462    async fn clone_bare_creates_repo_without_workdir() {
463        let tmp = tempfile::tempdir().unwrap();
464        let origin = tmp.path().join("origin");
465        init_repo(&origin);
466
467        let target = tmp.path().join("clone.git");
468        let result = RepoClone::new(origin.to_str().unwrap(), &target)
469            .bare(true)
470            .run(&ctx())
471            .await
472            .unwrap();
473
474        assert!(result.bare);
475        let repo = Repository::open(&target).unwrap();
476        assert!(repo.is_bare());
477        assert!(target.join("HEAD").exists());
478        assert!(!target.join("file.txt").exists());
479    }
480
481    #[tokio::test]
482    async fn clone_into_non_empty_directory_fails() {
483        let tmp = tempfile::tempdir().unwrap();
484        let origin = tmp.path().join("origin");
485        init_repo(&origin);
486        let target = tmp.path().join("busy");
487        fs::create_dir_all(&target).unwrap();
488        fs::write(target.join("keep.txt"), "x").unwrap();
489
490        let result = RepoClone::new(origin.to_str().unwrap(), &target)
491            .run(&ctx())
492            .await;
493
494        assert!(result.is_err());
495    }
496
497    #[tokio::test]
498    async fn discover_finds_repo() {
499        let tmp = tempfile::tempdir().unwrap();
500        Repository::init(tmp.path()).unwrap();
501        let subdir = tmp.path().join("a").join("b");
502        std::fs::create_dir_all(&subdir).unwrap();
503
504        let op = RepoDiscover::new(&subdir);
505        let result = op.run(&ctx()).await.unwrap();
506        assert!(!result.bare);
507    }
508
509    #[tokio::test]
510    async fn state_on_clean_repo() {
511        let tmp = tempfile::tempdir().unwrap();
512        Repository::init(tmp.path()).unwrap();
513
514        let op = RepoState::new(tmp.path());
515        let result = op.run(&ctx()).await.unwrap();
516        assert_eq!(result.state, "clean");
517    }
518}