Skip to main content

ironflow_ops_git/
fetch.rs

1//! Fetch and push operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::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, credentials_callbacks, to_value};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FetchPushOutput {
16    pub remote: String,
17    pub refspecs: Vec<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RemotePruneOutput {
22    pub remote: String,
23    pub pruned: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RemoteDefaultBranchOutput {
28    pub remote: String,
29    pub default_branch: Option<String>,
30}
31
32/// Fetch from a remote.
33///
34/// # Examples
35///
36/// ```no_run
37/// use ironflow_ops_git::fetch::FetchRemote;
38/// use ironflow_core::operation::Operation;
39///
40/// let op = FetchRemote::new("/path/to/repo", "origin", vec!["main"]);
41/// assert_eq!(op.kind(), "git");
42/// ```
43pub struct FetchRemote {
44    repo_path: PathBuf,
45    remote_name: String,
46    refspecs: Vec<String>,
47}
48
49impl FetchRemote {
50    /// Create a new fetch operation.
51    pub fn new(
52        repo_path: impl Into<PathBuf>,
53        remote_name: impl Into<String>,
54        refspecs: Vec<impl Into<String>>,
55    ) -> Self {
56        Self {
57            repo_path: repo_path.into(),
58            remote_name: remote_name.into(),
59            refspecs: refspecs.into_iter().map(Into::into).collect(),
60        }
61    }
62
63    /// Execute and return a typed result.
64    pub async fn run(&self, _ctx: &OperationContext) -> Result<FetchPushOutput, OperationError> {
65        let repo_path = self.repo_path.clone();
66        let remote_name = self.remote_name.clone();
67        let refspecs = self.refspecs.clone();
68        blocking(move || {
69            let repo = Repository::open(&repo_path)?;
70            let mut remote = repo.find_remote(&remote_name)?;
71            let refs: Vec<&str> = refspecs.iter().map(String::as_str).collect();
72            let mut fetch_opts = git2::FetchOptions::new();
73            fetch_opts.remote_callbacks(credentials_callbacks());
74            remote.fetch(&refs, Some(&mut fetch_opts), None)?;
75            Ok(FetchPushOutput {
76                remote: remote_name,
77                refspecs,
78            })
79        })
80        .await
81    }
82}
83
84#[async_trait]
85impl Operation for FetchRemote {
86    fn kind(&self) -> &str {
87        "git"
88    }
89    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
90        to_value(&self.run(ctx).await?)
91    }
92    fn input(&self) -> Option<Value> {
93        Some(serde_json::json!({ "repo_path": self.repo_path, "remote": self.remote_name }))
94    }
95}
96
97impl TypedOperation for FetchRemote {
98    type Output = FetchPushOutput;
99}
100
101/// Push to a remote.
102///
103/// # Examples
104///
105/// ```no_run
106/// use ironflow_ops_git::fetch::PushRemote;
107/// use ironflow_core::operation::Operation;
108///
109/// let op = PushRemote::new("/path/to/repo", "origin", vec!["refs/heads/main"]);
110/// assert_eq!(op.kind(), "git");
111/// ```
112pub struct PushRemote {
113    repo_path: PathBuf,
114    remote_name: String,
115    refspecs: Vec<String>,
116}
117
118impl PushRemote {
119    /// Create a new push operation.
120    pub fn new(
121        repo_path: impl Into<PathBuf>,
122        remote_name: impl Into<String>,
123        refspecs: Vec<impl Into<String>>,
124    ) -> Self {
125        Self {
126            repo_path: repo_path.into(),
127            remote_name: remote_name.into(),
128            refspecs: refspecs.into_iter().map(Into::into).collect(),
129        }
130    }
131
132    /// Execute and return a typed result.
133    pub async fn run(&self, _ctx: &OperationContext) -> Result<FetchPushOutput, OperationError> {
134        let repo_path = self.repo_path.clone();
135        let remote_name = self.remote_name.clone();
136        let refspecs = self.refspecs.clone();
137        blocking(move || {
138            let repo = Repository::open(&repo_path)?;
139            let mut remote = repo.find_remote(&remote_name)?;
140            let refs: Vec<&str> = refspecs.iter().map(String::as_str).collect();
141            let mut push_opts = git2::PushOptions::new();
142            push_opts.remote_callbacks(credentials_callbacks());
143            remote.push(&refs, Some(&mut push_opts))?;
144            Ok(FetchPushOutput {
145                remote: remote_name,
146                refspecs,
147            })
148        })
149        .await
150    }
151}
152
153#[async_trait]
154impl Operation for PushRemote {
155    fn kind(&self) -> &str {
156        "git"
157    }
158    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
159        to_value(&self.run(ctx).await?)
160    }
161    fn input(&self) -> Option<Value> {
162        Some(serde_json::json!({ "repo_path": self.repo_path, "remote": self.remote_name }))
163    }
164}
165
166impl TypedOperation for PushRemote {
167    type Output = FetchPushOutput;
168}
169
170/// Prune stale remote-tracking branches.
171///
172/// # Examples
173///
174/// ```no_run
175/// use ironflow_ops_git::fetch::RemotePrune;
176/// use ironflow_core::operation::Operation;
177///
178/// let op = RemotePrune::new("/path/to/repo", "origin");
179/// assert_eq!(op.kind(), "git");
180/// ```
181pub struct RemotePrune {
182    repo_path: PathBuf,
183    remote_name: String,
184}
185
186impl RemotePrune {
187    /// Create a new prune operation.
188    pub fn new(repo_path: impl Into<PathBuf>, remote_name: impl Into<String>) -> Self {
189        Self {
190            repo_path: repo_path.into(),
191            remote_name: remote_name.into(),
192        }
193    }
194
195    /// Execute and return a typed result.
196    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemotePruneOutput, OperationError> {
197        let repo_path = self.repo_path.clone();
198        let remote_name = self.remote_name.clone();
199        blocking(move || {
200            let repo = Repository::open(&repo_path)?;
201            let mut remote = repo.find_remote(&remote_name)?;
202            remote.prune(None)?;
203            Ok(RemotePruneOutput {
204                remote: remote_name,
205                pruned: true,
206            })
207        })
208        .await
209    }
210}
211
212#[async_trait]
213impl Operation for RemotePrune {
214    fn kind(&self) -> &str {
215        "git"
216    }
217    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
218        to_value(&self.run(ctx).await?)
219    }
220    fn input(&self) -> Option<Value> {
221        Some(serde_json::json!({ "repo_path": self.repo_path, "remote": self.remote_name }))
222    }
223}
224
225impl TypedOperation for RemotePrune {
226    type Output = RemotePruneOutput;
227}
228
229/// Get the default branch of a remote.
230///
231/// # Examples
232///
233/// ```no_run
234/// use ironflow_ops_git::fetch::RemoteDefaultBranch;
235/// use ironflow_core::operation::Operation;
236///
237/// let op = RemoteDefaultBranch::new("/path/to/repo", "origin");
238/// assert_eq!(op.kind(), "git");
239/// ```
240pub struct RemoteDefaultBranch {
241    repo_path: PathBuf,
242    remote_name: String,
243}
244
245impl RemoteDefaultBranch {
246    /// Create a new default-branch query operation.
247    pub fn new(repo_path: impl Into<PathBuf>, remote_name: impl Into<String>) -> Self {
248        Self {
249            repo_path: repo_path.into(),
250            remote_name: remote_name.into(),
251        }
252    }
253
254    /// Execute and return a typed result.
255    pub async fn run(
256        &self,
257        _ctx: &OperationContext,
258    ) -> Result<RemoteDefaultBranchOutput, OperationError> {
259        let repo_path = self.repo_path.clone();
260        let remote_name = self.remote_name.clone();
261        blocking(move || {
262            let repo = Repository::open(&repo_path)?;
263            let remote = repo.find_remote(&remote_name)?;
264            let default = remote.default_branch()?;
265            let name = default.as_str().map(String::from);
266            Ok(RemoteDefaultBranchOutput {
267                remote: remote_name,
268                default_branch: name,
269            })
270        })
271        .await
272    }
273}
274
275#[async_trait]
276impl Operation for RemoteDefaultBranch {
277    fn kind(&self) -> &str {
278        "git"
279    }
280    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
281        to_value(&self.run(ctx).await?)
282    }
283    fn input(&self) -> Option<Value> {
284        Some(serde_json::json!({ "repo_path": self.repo_path, "remote": self.remote_name }))
285    }
286}
287
288impl TypedOperation for RemoteDefaultBranch {
289    type Output = RemoteDefaultBranchOutput;
290}
291
292#[cfg(test)]
293mod tests {
294    use git2::Repository;
295
296    use super::*;
297    use crate::test_helpers::{ctx, init_repo};
298
299    // Push and fetch over a local bare remote (file://) never hit the
300    // credentials callback, but exercising them proves that wiring
301    // PushOptions/FetchOptions with RemoteCallbacks does not break the normal
302    // flow. The SSH-agent path (the literal issue symptom) needs a live SSH
303    // server and is covered in Out of Test Scope.
304    fn setup_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir) {
305        let work = tempfile::tempdir().unwrap();
306        init_repo(work.path());
307        let bare = tempfile::tempdir().unwrap();
308        Repository::init_bare(bare.path()).unwrap();
309        let repo = Repository::open(work.path()).unwrap();
310        repo.remote("origin", bare.path().to_str().unwrap())
311            .unwrap();
312        (work, bare)
313    }
314
315    #[tokio::test]
316    async fn push_to_local_bare_remote() {
317        let (work, bare) = setup_with_bare_remote();
318        let result = PushRemote::new(
319            work.path(),
320            "origin",
321            vec!["refs/heads/master:refs/heads/master"],
322        )
323        .run(&ctx())
324        .await
325        .unwrap();
326        assert_eq!(result.remote, "origin");
327        // The ref must exist on the remote side after the push.
328        let bare_repo = Repository::open(bare.path()).unwrap();
329        assert!(bare_repo.find_reference("refs/heads/master").is_ok());
330    }
331
332    #[tokio::test]
333    async fn fetch_from_local_bare_remote() {
334        let (work, bare) = setup_with_bare_remote();
335        PushRemote::new(
336            work.path(),
337            "origin",
338            vec!["refs/heads/master:refs/heads/master"],
339        )
340        .run(&ctx())
341        .await
342        .unwrap();
343        // A second clone fetching from the same bare remote must succeed.
344        let clone_dir = tempfile::tempdir().unwrap();
345        let clone = Repository::init(clone_dir.path()).unwrap();
346        clone
347            .remote("origin", bare.path().to_str().unwrap())
348            .unwrap();
349        let result = FetchRemote::new(clone_dir.path(), "origin", vec!["master"])
350            .run(&ctx())
351            .await
352            .unwrap();
353        assert_eq!(result.remote, "origin");
354    }
355
356    #[tokio::test]
357    async fn push_missing_remote_fails() {
358        let work = tempfile::tempdir().unwrap();
359        init_repo(work.path());
360        let result = PushRemote::new(work.path(), "nope", vec!["refs/heads/master"])
361            .run(&ctx())
362            .await;
363        assert!(result.is_err());
364    }
365}