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::{Repository, RepositoryState};
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 repo_state_label(state: RepositoryState) -> &'static str {
15    match state {
16        RepositoryState::Clean => "clean",
17        RepositoryState::Merge => "merge",
18        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
19        RepositoryState::CherryPickSequence | RepositoryState::CherryPick => "cherrypick",
20        RepositoryState::Bisect => "bisect",
21        RepositoryState::Rebase
22        | RepositoryState::RebaseInteractive
23        | RepositoryState::RebaseMerge => "rebase",
24        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
25    }
26}
27
28/// Output of [`RepoInit`].
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct RepoInitOutput {
31    /// Path where the repository was created.
32    pub path: PathBuf,
33    /// Whether this is a bare repository.
34    pub bare: bool,
35}
36
37/// Initialize a new Git repository.
38///
39/// Creates a new repository at the given path. If `bare` is true, creates
40/// a bare repository (no working directory).
41///
42/// # Examples
43///
44/// ```no_run
45/// use ironflow_ops_git::repository::RepoInit;
46/// use ironflow_core::operation::Operation;
47///
48/// let op = RepoInit::new("/tmp/my-repo", false);
49/// assert_eq!(op.kind(), "git");
50/// ```
51pub struct RepoInit {
52    path: PathBuf,
53    bare: bool,
54}
55
56impl RepoInit {
57    /// Create a new init operation.
58    pub fn new(path: impl Into<PathBuf>, bare: bool) -> Self {
59        Self {
60            path: path.into(),
61            bare,
62        }
63    }
64
65    /// Execute and return a typed result.
66    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoInitOutput, OperationError> {
67        let path = self.path.clone();
68        let bare = self.bare;
69        blocking(move || {
70            if bare {
71                Repository::init_bare(&path)?;
72            } else {
73                Repository::init(&path)?;
74            }
75            Ok(RepoInitOutput { path, bare })
76        })
77        .await
78    }
79}
80
81#[async_trait]
82impl Operation for RepoInit {
83    fn kind(&self) -> &str {
84        "git"
85    }
86
87    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
88        to_value(&self.run(ctx).await?)
89    }
90
91    fn input(&self) -> Option<Value> {
92        Some(serde_json::json!({ "path": self.path, "bare": self.bare }))
93    }
94}
95
96impl TypedOperation for RepoInit {
97    type Output = RepoInitOutput;
98}
99
100/// Output of [`RepoOpen`].
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RepoOpenOutput {
103    /// Working directory path (or the bare repo path).
104    pub path: PathBuf,
105    /// Whether this is a bare repository.
106    pub bare: bool,
107}
108
109/// Open an existing Git repository.
110///
111/// Returns the repository path and whether it is bare.
112///
113/// # Examples
114///
115/// ```no_run
116/// use ironflow_ops_git::repository::RepoOpen;
117/// use ironflow_core::operation::Operation;
118///
119/// let op = RepoOpen::new("/path/to/repo");
120/// assert_eq!(op.kind(), "git");
121/// ```
122pub struct RepoOpen {
123    path: PathBuf,
124}
125
126impl RepoOpen {
127    /// Create a new open operation.
128    pub fn new(path: impl Into<PathBuf>) -> Self {
129        Self { path: path.into() }
130    }
131
132    /// Execute and return a typed result.
133    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoOpenOutput, OperationError> {
134        let path = self.path.clone();
135        blocking(move || {
136            let repo = Repository::open(&path)?;
137            let is_bare = repo.is_bare();
138            let workdir = repo.workdir().map(Path::to_path_buf);
139            Ok(RepoOpenOutput {
140                path: workdir.unwrap_or(path),
141                bare: is_bare,
142            })
143        })
144        .await
145    }
146}
147
148#[async_trait]
149impl Operation for RepoOpen {
150    fn kind(&self) -> &str {
151        "git"
152    }
153
154    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
155        to_value(&self.run(ctx).await?)
156    }
157
158    fn input(&self) -> Option<Value> {
159        Some(serde_json::json!({ "path": self.path }))
160    }
161}
162
163impl TypedOperation for RepoOpen {
164    type Output = RepoOpenOutput;
165}
166
167/// Output of [`RepoClone`].
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct RepoCloneOutput {
170    /// The cloned URL.
171    pub url: String,
172    /// Local path of the clone.
173    pub path: PathBuf,
174}
175
176/// Clone a remote or local repository.
177///
178/// # Examples
179///
180/// ```no_run
181/// use ironflow_ops_git::repository::RepoClone;
182/// use ironflow_core::operation::Operation;
183///
184/// let op = RepoClone::new("https://github.com/user/repo.git", "/tmp/clone");
185/// assert_eq!(op.kind(), "git");
186/// ```
187pub struct RepoClone {
188    url: String,
189    path: PathBuf,
190}
191
192impl RepoClone {
193    /// Create a new clone operation.
194    pub fn new(url: impl Into<String>, path: impl Into<PathBuf>) -> Self {
195        Self {
196            url: url.into(),
197            path: path.into(),
198        }
199    }
200
201    /// Execute and return a typed result.
202    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoCloneOutput, OperationError> {
203        let url = self.url.clone();
204        let path = self.path.clone();
205        blocking(move || {
206            Repository::clone(&url, &path)?;
207            Ok(RepoCloneOutput { url, path })
208        })
209        .await
210    }
211}
212
213#[async_trait]
214impl Operation for RepoClone {
215    fn kind(&self) -> &str {
216        "git"
217    }
218
219    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
220        to_value(&self.run(ctx).await?)
221    }
222
223    fn input(&self) -> Option<Value> {
224        Some(serde_json::json!({ "url": self.url, "path": self.path }))
225    }
226}
227
228impl TypedOperation for RepoClone {
229    type Output = RepoCloneOutput;
230}
231
232/// Output of [`RepoDiscover`].
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct RepoDiscoverOutput {
235    /// Working directory path (if not bare).
236    pub path: Option<PathBuf>,
237    /// Whether this is a bare repository.
238    pub bare: bool,
239}
240
241/// Discover a repository by walking parent directories.
242///
243/// Starts from `start_path` and walks upward until a `.git` directory is found.
244///
245/// # Examples
246///
247/// ```no_run
248/// use ironflow_ops_git::repository::RepoDiscover;
249/// use ironflow_core::operation::Operation;
250///
251/// let op = RepoDiscover::new("/path/to/subdir");
252/// assert_eq!(op.kind(), "git");
253/// ```
254pub struct RepoDiscover {
255    start_path: PathBuf,
256}
257
258impl RepoDiscover {
259    /// Create a new discover operation.
260    pub fn new(start_path: impl Into<PathBuf>) -> Self {
261        Self {
262            start_path: start_path.into(),
263        }
264    }
265
266    /// Execute and return a typed result.
267    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoDiscoverOutput, OperationError> {
268        let start = self.start_path.clone();
269        blocking(move || {
270            let repo = Repository::discover(&start)?;
271            let workdir = repo.workdir().map(Path::to_path_buf);
272            let is_bare = repo.is_bare();
273            Ok(RepoDiscoverOutput {
274                path: workdir,
275                bare: is_bare,
276            })
277        })
278        .await
279    }
280}
281
282#[async_trait]
283impl Operation for RepoDiscover {
284    fn kind(&self) -> &str {
285        "git"
286    }
287
288    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
289        to_value(&self.run(ctx).await?)
290    }
291
292    fn input(&self) -> Option<Value> {
293        Some(serde_json::json!({ "start_path": self.start_path }))
294    }
295}
296
297impl TypedOperation for RepoDiscover {
298    type Output = RepoDiscoverOutput;
299}
300
301/// Output of [`RepoState`].
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct RepoStateOutput {
304    /// Repository state (e.g. "Clean", "Merge", "Rebase").
305    pub state: String,
306}
307
308/// Query the current state of the repository.
309///
310/// Returns the repository state (clean, merge, rebase, etc.).
311///
312/// # Examples
313///
314/// ```no_run
315/// use ironflow_ops_git::repository::RepoState;
316/// use ironflow_core::operation::Operation;
317///
318/// let op = RepoState::new("/path/to/repo");
319/// assert_eq!(op.kind(), "git");
320/// ```
321pub struct RepoState {
322    repo_path: PathBuf,
323}
324
325impl RepoState {
326    /// Create a new state query operation.
327    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
328        Self {
329            repo_path: repo_path.into(),
330        }
331    }
332
333    /// Execute and return a typed result.
334    pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoStateOutput, OperationError> {
335        let path = self.repo_path.clone();
336        blocking(move || {
337            let repo = Repository::open(&path)?;
338            let state = repo_state_label(repo.state()).to_string();
339            Ok(RepoStateOutput { state })
340        })
341        .await
342    }
343}
344
345#[async_trait]
346impl Operation for RepoState {
347    fn kind(&self) -> &str {
348        "git"
349    }
350
351    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
352        to_value(&self.run(ctx).await?)
353    }
354
355    fn input(&self) -> Option<Value> {
356        Some(serde_json::json!({ "repo_path": self.repo_path }))
357    }
358}
359
360impl TypedOperation for RepoState {
361    type Output = RepoStateOutput;
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::test_helpers::ctx;
368
369    #[tokio::test]
370    async fn init_creates_repo() {
371        let tmp = tempfile::tempdir().unwrap();
372        let target = tmp.path().join("new-repo");
373        let op = RepoInit::new(&target, false);
374        let result = op.run(&ctx()).await.unwrap();
375        assert!(!result.bare);
376        assert!(target.join(".git").exists());
377    }
378
379    #[tokio::test]
380    async fn init_creates_bare_repo() {
381        let tmp = tempfile::tempdir().unwrap();
382        let target = tmp.path().join("bare-repo");
383        let op = RepoInit::new(&target, true);
384        let result = op.run(&ctx()).await.unwrap();
385        assert!(result.bare);
386        assert!(target.join("HEAD").exists());
387    }
388
389    #[tokio::test]
390    async fn clone_local() {
391        let tmp = tempfile::tempdir().unwrap();
392        let origin = tmp.path().join("origin");
393        Repository::init(&origin).unwrap();
394
395        let target = tmp.path().join("clone");
396        let url = origin.to_str().unwrap();
397        let op = RepoClone::new(url, &target);
398        let result = op.run(&ctx()).await.unwrap();
399        assert_eq!(result.path, target);
400        assert!(target.join(".git").exists());
401    }
402
403    #[tokio::test]
404    async fn discover_finds_repo() {
405        let tmp = tempfile::tempdir().unwrap();
406        Repository::init(tmp.path()).unwrap();
407        let subdir = tmp.path().join("a").join("b");
408        std::fs::create_dir_all(&subdir).unwrap();
409
410        let op = RepoDiscover::new(&subdir);
411        let result = op.run(&ctx()).await.unwrap();
412        assert!(!result.bare);
413    }
414
415    #[tokio::test]
416    async fn state_on_clean_repo() {
417        let tmp = tempfile::tempdir().unwrap();
418        Repository::init(tmp.path()).unwrap();
419
420        let op = RepoState::new(tmp.path());
421        let result = op.run(&ctx()).await.unwrap();
422        assert_eq!(result.state, "clean");
423    }
424}