Skip to main content

ironflow_ops_git/
log.rs

1//! Log / history operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{Repository, Sort};
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/// A commit entry in a revwalk.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LogCommitEntry {
17    pub oid: String,
18    pub message: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub author: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub time: Option<i64>,
23}
24
25/// Output of revwalk operations.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RevwalkOutput {
28    pub commits: Vec<LogCommitEntry>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub range: Option<String>,
31}
32
33/// Create a new revision walk from HEAD.
34///
35/// Returns the first `limit` commits reachable from HEAD.
36///
37/// # Examples
38///
39/// ```no_run
40/// use ironflow_ops_git::log::RevwalkNew;
41/// use ironflow_core::operation::Operation;
42///
43/// let op = RevwalkNew::new("/path/to/repo", 50);
44/// assert_eq!(op.kind(), "git");
45/// ```
46pub struct RevwalkNew {
47    repo_path: PathBuf,
48    limit: usize,
49}
50
51impl RevwalkNew {
52    /// Create a new revwalk operation.
53    pub fn new(repo_path: impl Into<PathBuf>, limit: usize) -> Self {
54        Self {
55            repo_path: repo_path.into(),
56            limit,
57        }
58    }
59
60    /// Execute and return a typed result.
61    pub async fn run(&self, _ctx: &OperationContext) -> Result<RevwalkOutput, OperationError> {
62        let repo_path = self.repo_path.clone();
63        let limit = self.limit;
64        blocking(move || {
65            let repo = Repository::open(&repo_path)?;
66            let mut revwalk = repo.revwalk()?;
67            revwalk.push_head()?;
68            revwalk.set_sorting(Sort::TIME)?;
69            let commits = revwalk
70                .take(limit)
71                .filter_map(|oid| oid.ok())
72                .filter_map(|oid| repo.find_commit(oid).ok())
73                .map(|c| LogCommitEntry {
74                    oid: c.id().to_string(),
75                    message: c.message().unwrap_or("").to_string(),
76                    author: Some(c.author().name().unwrap_or("").to_string()),
77                    time: Some(c.time().seconds()),
78                })
79                .collect();
80            Ok(RevwalkOutput {
81                commits,
82                range: None,
83            })
84        })
85        .await
86    }
87}
88
89#[async_trait]
90impl Operation for RevwalkNew {
91    fn kind(&self) -> &str {
92        "git"
93    }
94    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
95        to_value(&self.run(ctx).await?)
96    }
97    fn input(&self) -> Option<Value> {
98        Some(serde_json::json!({ "repo_path": self.repo_path, "limit": self.limit }))
99    }
100}
101
102impl TypedOperation for RevwalkNew {
103    type Output = RevwalkOutput;
104}
105
106/// Walk commits in a range (from..to).
107///
108/// # Examples
109///
110/// ```no_run
111/// use ironflow_ops_git::log::RevwalkPushRange;
112/// use ironflow_core::operation::Operation;
113///
114/// let op = RevwalkPushRange::new("/path/to/repo", "abc123..def456", 100);
115/// assert_eq!(op.kind(), "git");
116/// ```
117pub struct RevwalkPushRange {
118    repo_path: PathBuf,
119    range: String,
120    limit: usize,
121}
122
123impl RevwalkPushRange {
124    /// Create a new range-walk operation.
125    pub fn new(repo_path: impl Into<PathBuf>, range: impl Into<String>, limit: usize) -> Self {
126        Self {
127            repo_path: repo_path.into(),
128            range: range.into(),
129            limit,
130        }
131    }
132
133    /// Execute and return a typed result.
134    pub async fn run(&self, _ctx: &OperationContext) -> Result<RevwalkOutput, OperationError> {
135        let repo_path = self.repo_path.clone();
136        let range = self.range.clone();
137        let limit = self.limit;
138        blocking(move || {
139            let repo = Repository::open(&repo_path)?;
140            let mut revwalk = repo.revwalk()?;
141            revwalk.push_range(&range)?;
142            let commits = revwalk
143                .take(limit)
144                .filter_map(|oid| oid.ok())
145                .filter_map(|oid| repo.find_commit(oid).ok())
146                .map(|c| LogCommitEntry {
147                    oid: c.id().to_string(),
148                    message: c.message().unwrap_or("").to_string(),
149                    author: Some(c.author().name().unwrap_or("").to_string()),
150                    time: Some(c.time().seconds()),
151                })
152                .collect();
153            Ok(RevwalkOutput {
154                commits,
155                range: Some(range),
156            })
157        })
158        .await
159    }
160}
161
162#[async_trait]
163impl Operation for RevwalkPushRange {
164    fn kind(&self) -> &str {
165        "git"
166    }
167    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
168        to_value(&self.run(ctx).await?)
169    }
170    fn input(&self) -> Option<Value> {
171        Some(serde_json::json!({ "repo_path": self.repo_path, "range": self.range }))
172    }
173}
174
175impl TypedOperation for RevwalkPushRange {
176    type Output = RevwalkOutput;
177}
178
179/// Walk commits following only first parents (no merge parents).
180///
181/// # Examples
182///
183/// ```no_run
184/// use ironflow_ops_git::log::RevwalkSimplifyFirstParent;
185/// use ironflow_core::operation::Operation;
186///
187/// let op = RevwalkSimplifyFirstParent::new("/path/to/repo", 50);
188/// assert_eq!(op.kind(), "git");
189/// ```
190pub struct RevwalkSimplifyFirstParent {
191    repo_path: PathBuf,
192    limit: usize,
193}
194
195impl RevwalkSimplifyFirstParent {
196    /// Create a new first-parent walk operation.
197    pub fn new(repo_path: impl Into<PathBuf>, limit: usize) -> Self {
198        Self {
199            repo_path: repo_path.into(),
200            limit,
201        }
202    }
203
204    /// Execute and return a typed result.
205    pub async fn run(&self, _ctx: &OperationContext) -> Result<RevwalkOutput, OperationError> {
206        let repo_path = self.repo_path.clone();
207        let limit = self.limit;
208        blocking(move || {
209            let repo = Repository::open(&repo_path)?;
210            let mut revwalk = repo.revwalk()?;
211            revwalk.push_head()?;
212            revwalk.simplify_first_parent()?;
213            let commits = revwalk
214                .take(limit)
215                .filter_map(|oid| oid.ok())
216                .filter_map(|oid| repo.find_commit(oid).ok())
217                .map(|c| LogCommitEntry {
218                    oid: c.id().to_string(),
219                    message: c.message().unwrap_or("").to_string(),
220                    author: Some(c.author().name().unwrap_or("").to_string()),
221                    time: Some(c.time().seconds()),
222                })
223                .collect();
224            Ok(RevwalkOutput {
225                commits,
226                range: None,
227            })
228        })
229        .await
230    }
231}
232
233#[async_trait]
234impl Operation for RevwalkSimplifyFirstParent {
235    fn kind(&self) -> &str {
236        "git"
237    }
238    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
239        to_value(&self.run(ctx).await?)
240    }
241    fn input(&self) -> Option<Value> {
242        Some(serde_json::json!({ "repo_path": self.repo_path, "limit": self.limit }))
243    }
244}
245
246impl TypedOperation for RevwalkSimplifyFirstParent {
247    type Output = RevwalkOutput;
248}
249
250#[cfg(test)]
251mod tests {
252    use std::fs;
253    use std::path::Path;
254
255    use git2::{Commit, Repository, Signature};
256    use ironflow_core::operation::Operation;
257
258    use super::*;
259    use crate::test_helpers::ctx;
260
261    fn init_repo_n_commits(path: &Path, n: usize) {
262        let repo = Repository::init(path).unwrap();
263        let sig = Signature::now("Test", "test@test.com").unwrap();
264        let mut parent = None;
265        for i in 0..n {
266            fs::write(path.join("file.txt"), format!("v{i}")).unwrap();
267            let mut index = repo.index().unwrap();
268            index.add_path(Path::new("file.txt")).unwrap();
269            index.write().unwrap();
270            let oid = index.write_tree().unwrap();
271            let tree = repo.find_tree(oid).unwrap();
272            let parents: Vec<&Commit<'_>> = parent.iter().collect();
273            let c = repo
274                .commit(
275                    Some("HEAD"),
276                    &sig,
277                    &sig,
278                    &format!("commit {i}"),
279                    &tree,
280                    &parents,
281                )
282                .unwrap();
283            parent = Some(repo.find_commit(c).unwrap());
284        }
285    }
286
287    #[tokio::test]
288    async fn revwalk_returns_commits() {
289        let tmp = tempfile::tempdir().unwrap();
290        init_repo_n_commits(tmp.path(), 3);
291        let result = RevwalkNew::new(tmp.path(), 10).run(&ctx()).await.unwrap();
292        assert_eq!(result.commits.len(), 3);
293        assert_eq!(result.commits[0].message, "commit 2");
294        assert!(result.commits[0].author.is_some());
295    }
296
297    #[tokio::test]
298    async fn revwalk_respects_limit() {
299        let tmp = tempfile::tempdir().unwrap();
300        init_repo_n_commits(tmp.path(), 5);
301        let result = RevwalkNew::new(tmp.path(), 2).run(&ctx()).await.unwrap();
302        assert_eq!(result.commits.len(), 2);
303    }
304
305    #[tokio::test]
306    async fn revwalk_push_range() {
307        let tmp = tempfile::tempdir().unwrap();
308        init_repo_n_commits(tmp.path(), 3);
309        let repo = Repository::open(tmp.path()).unwrap();
310        let mut revwalk = repo.revwalk().unwrap();
311        revwalk.push_head().unwrap();
312        let oids: Vec<_> = revwalk.filter_map(|o| o.ok()).collect();
313        let range = format!("{}..{}", oids[2], oids[0]);
314        let result = RevwalkPushRange::new(tmp.path(), &range, 100)
315            .run(&ctx())
316            .await
317            .unwrap();
318        assert_eq!(result.commits.len(), 2);
319        assert_eq!(result.range.as_deref(), Some(range.as_str()));
320    }
321
322    #[tokio::test]
323    async fn simplify_first_parent() {
324        let tmp = tempfile::tempdir().unwrap();
325        init_repo_n_commits(tmp.path(), 3);
326        let result = RevwalkSimplifyFirstParent::new(tmp.path(), 10)
327            .run(&ctx())
328            .await
329            .unwrap();
330        assert_eq!(result.commits.len(), 3);
331    }
332
333    #[tokio::test]
334    async fn execute_serializes_correctly() {
335        let tmp = tempfile::tempdir().unwrap();
336        init_repo_n_commits(tmp.path(), 2);
337        let value = RevwalkNew::new(tmp.path(), 10)
338            .execute(&ctx())
339            .await
340            .unwrap();
341        assert_eq!(value["commits"].as_array().unwrap().len(), 2);
342    }
343}