Skip to main content

ironflow_ops_git/
graph.rs

1//! Graph operations (ahead/behind, descendant, describe).
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{DescribeFormatOptions, DescribeOptions, Oid, 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 GraphAheadBehindOutput {
16    pub ahead: usize,
17    pub behind: usize,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GraphDescendantOfOutput {
22    pub is_descendant: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GraphDescribeOutput {
27    pub description: String,
28}
29
30/// Count commits ahead and behind between two references.
31///
32/// # Examples
33///
34/// ```no_run
35/// use ironflow_ops_git::graph::GraphAheadBehind;
36/// use ironflow_core::operation::Operation;
37///
38/// let op = GraphAheadBehind::new("/path/to/repo", "abc123", "def456");
39/// assert_eq!(op.kind(), "git");
40/// ```
41pub struct GraphAheadBehind {
42    repo_path: PathBuf,
43    local: String,
44    upstream: String,
45}
46
47impl GraphAheadBehind {
48    /// Create a new ahead-behind operation.
49    pub fn new(
50        repo_path: impl Into<PathBuf>,
51        local: impl Into<String>,
52        upstream: impl Into<String>,
53    ) -> Self {
54        Self {
55            repo_path: repo_path.into(),
56            local: local.into(),
57            upstream: upstream.into(),
58        }
59    }
60
61    /// Execute and return a typed result.
62    pub async fn run(
63        &self,
64        _ctx: &OperationContext,
65    ) -> Result<GraphAheadBehindOutput, OperationError> {
66        let repo_path = self.repo_path.clone();
67        let local = self.local.clone();
68        let upstream = self.upstream.clone();
69        blocking(move || {
70            let repo = Repository::open(&repo_path)?;
71            let local_oid = Oid::from_str(&local)?;
72            let upstream_oid = Oid::from_str(&upstream)?;
73            let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
74            Ok(GraphAheadBehindOutput { ahead, behind })
75        })
76        .await
77    }
78}
79
80#[async_trait]
81impl Operation for GraphAheadBehind {
82    fn kind(&self) -> &str {
83        "git"
84    }
85    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
86        to_value(&self.run(ctx).await?)
87    }
88    fn input(&self) -> Option<Value> {
89        Some(
90            serde_json::json!({ "repo_path": self.repo_path, "local": self.local, "upstream": self.upstream }),
91        )
92    }
93}
94
95impl TypedOperation for GraphAheadBehind {
96    type Output = GraphAheadBehindOutput;
97}
98
99/// Check if a commit is a descendant of another.
100///
101/// # Examples
102///
103/// ```no_run
104/// use ironflow_ops_git::graph::GraphDescendantOf;
105/// use ironflow_core::operation::Operation;
106///
107/// let op = GraphDescendantOf::new("/path/to/repo", "abc123", "def456");
108/// assert_eq!(op.kind(), "git");
109/// ```
110pub struct GraphDescendantOf {
111    repo_path: PathBuf,
112    commit: String,
113    ancestor: String,
114}
115
116impl GraphDescendantOf {
117    /// Create a new descendant-of check operation.
118    pub fn new(
119        repo_path: impl Into<PathBuf>,
120        commit: impl Into<String>,
121        ancestor: impl Into<String>,
122    ) -> Self {
123        Self {
124            repo_path: repo_path.into(),
125            commit: commit.into(),
126            ancestor: ancestor.into(),
127        }
128    }
129
130    /// Execute and return a typed result.
131    pub async fn run(
132        &self,
133        _ctx: &OperationContext,
134    ) -> Result<GraphDescendantOfOutput, OperationError> {
135        let repo_path = self.repo_path.clone();
136        let commit = self.commit.clone();
137        let ancestor = self.ancestor.clone();
138        blocking(move || {
139            let repo = Repository::open(&repo_path)?;
140            let commit_oid = Oid::from_str(&commit)?;
141            let ancestor_oid = Oid::from_str(&ancestor)?;
142            let is_descendant = repo.graph_descendant_of(commit_oid, ancestor_oid)?;
143            Ok(GraphDescendantOfOutput { is_descendant })
144        })
145        .await
146    }
147}
148
149#[async_trait]
150impl Operation for GraphDescendantOf {
151    fn kind(&self) -> &str {
152        "git"
153    }
154    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
155        to_value(&self.run(ctx).await?)
156    }
157    fn input(&self) -> Option<Value> {
158        Some(
159            serde_json::json!({ "repo_path": self.repo_path, "commit": self.commit, "ancestor": self.ancestor }),
160        )
161    }
162}
163
164impl TypedOperation for GraphDescendantOf {
165    type Output = GraphDescendantOfOutput;
166}
167
168/// Describe a commit using the most recent tag reachable from it.
169///
170/// # Examples
171///
172/// ```no_run
173/// use ironflow_ops_git::graph::GraphDescribe;
174/// use ironflow_core::operation::Operation;
175///
176/// let op = GraphDescribe::new("/path/to/repo");
177/// assert_eq!(op.kind(), "git");
178/// ```
179pub struct GraphDescribe {
180    repo_path: PathBuf,
181}
182
183impl GraphDescribe {
184    /// Create a new describe operation.
185    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
186        Self {
187            repo_path: repo_path.into(),
188        }
189    }
190
191    /// Execute and return a typed result.
192    pub async fn run(
193        &self,
194        _ctx: &OperationContext,
195    ) -> Result<GraphDescribeOutput, OperationError> {
196        let repo_path = self.repo_path.clone();
197        blocking(move || {
198            let repo = Repository::open(&repo_path)?;
199            let describe = repo.describe(DescribeOptions::new().describe_tags())?;
200            let formatted =
201                describe.format(Some(DescribeFormatOptions::new().dirty_suffix("-dirty")))?;
202            Ok(GraphDescribeOutput {
203                description: formatted,
204            })
205        })
206        .await
207    }
208}
209
210#[async_trait]
211impl Operation for GraphDescribe {
212    fn kind(&self) -> &str {
213        "git"
214    }
215    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
216        to_value(&self.run(ctx).await?)
217    }
218    fn input(&self) -> Option<Value> {
219        Some(serde_json::json!({ "repo_path": self.repo_path }))
220    }
221}
222
223impl TypedOperation for GraphDescribe {
224    type Output = GraphDescribeOutput;
225}
226
227#[cfg(test)]
228mod tests {
229    use git2::Repository;
230    use ironflow_core::operation::Operation;
231
232    use super::*;
233    use crate::test_helpers::{ctx, make_two_commits};
234
235    #[tokio::test]
236    async fn ahead_behind_counts() {
237        let tmp = tempfile::tempdir().unwrap();
238        let (c1, c2) = make_two_commits(tmp.path());
239        let result = GraphAheadBehind::new(tmp.path(), &c2, &c1)
240            .run(&ctx())
241            .await
242            .unwrap();
243        assert_eq!(result.ahead, 1);
244        assert_eq!(result.behind, 0);
245
246        let result = GraphAheadBehind::new(tmp.path(), &c1, &c2)
247            .run(&ctx())
248            .await
249            .unwrap();
250        assert_eq!(result.ahead, 0);
251        assert_eq!(result.behind, 1);
252    }
253
254    #[tokio::test]
255    async fn descendant_of() {
256        let tmp = tempfile::tempdir().unwrap();
257        let (c1, c2) = make_two_commits(tmp.path());
258        let result = GraphDescendantOf::new(tmp.path(), &c2, &c1)
259            .run(&ctx())
260            .await
261            .unwrap();
262        assert!(result.is_descendant);
263        let result = GraphDescendantOf::new(tmp.path(), &c1, &c2)
264            .run(&ctx())
265            .await
266            .unwrap();
267        assert!(!result.is_descendant);
268    }
269
270    #[tokio::test]
271    async fn describe_with_tag() {
272        let tmp = tempfile::tempdir().unwrap();
273        make_two_commits(tmp.path());
274        let repo = Repository::open(tmp.path()).unwrap();
275        let head = repo.head().unwrap().peel_to_commit().unwrap();
276        repo.tag_lightweight("v1.0", head.as_object(), false)
277            .unwrap();
278        let result = GraphDescribe::new(tmp.path()).run(&ctx()).await.unwrap();
279        assert!(result.description.contains("v1.0"));
280    }
281
282    #[tokio::test]
283    async fn execute_serializes_correctly() {
284        let tmp = tempfile::tempdir().unwrap();
285        let (c1, c2) = make_two_commits(tmp.path());
286        let value = GraphAheadBehind::new(tmp.path(), &c2, &c1)
287            .execute(&ctx())
288            .await
289            .unwrap();
290        assert_eq!(value["ahead"], 1);
291    }
292}