Skip to main content

ironflow_ops_git/
diff.rs

1//! Diff operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{ApplyLocation, Delta, Diff, Error, 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/// A single delta entry in a diff.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DiffDelta {
17    pub status: String,
18    pub old_file: Option<String>,
19    pub new_file: Option<String>,
20}
21
22/// Output of diff operations that return full diff info.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DiffOutput {
25    pub files_changed: usize,
26    pub insertions: usize,
27    pub deletions: usize,
28    pub deltas: Vec<DiffDelta>,
29}
30
31/// Output of [`DiffStats`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DiffStatsOutput {
34    pub files_changed: usize,
35    pub insertions: usize,
36    pub deletions: usize,
37}
38
39/// Output of [`DiffApply`].
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DiffApplyOutput {
42    pub applied: bool,
43    pub to_index: bool,
44}
45
46fn delta_status_label(status: Delta) -> &'static str {
47    match status {
48        Delta::Unmodified => "unmodified",
49        Delta::Added => "added",
50        Delta::Deleted => "deleted",
51        Delta::Modified => "modified",
52        Delta::Renamed => "renamed",
53        Delta::Copied => "copied",
54        Delta::Ignored => "ignored",
55        Delta::Untracked => "untracked",
56        Delta::Typechange => "typechange",
57        Delta::Unreadable => "unreadable",
58        Delta::Conflicted => "conflicted",
59    }
60}
61
62fn diff_to_output(diff: &Diff<'_>) -> Result<DiffOutput, Error> {
63    let stats = diff.stats()?;
64    let deltas: Vec<DiffDelta> = diff
65        .deltas()
66        .map(|delta| DiffDelta {
67            status: delta_status_label(delta.status()).to_string(),
68            old_file: delta
69                .old_file()
70                .path()
71                .map(|p| p.to_string_lossy().into_owned()),
72            new_file: delta
73                .new_file()
74                .path()
75                .map(|p| p.to_string_lossy().into_owned()),
76        })
77        .collect();
78    Ok(DiffOutput {
79        files_changed: stats.files_changed(),
80        insertions: stats.insertions(),
81        deletions: stats.deletions(),
82        deltas,
83    })
84}
85
86/// Diff between two trees.
87///
88/// # Examples
89///
90/// ```no_run
91/// use ironflow_ops_git::diff::DiffTreeToTree;
92/// use ironflow_core::operation::Operation;
93///
94/// let op = DiffTreeToTree::new("/path/to/repo", "abc123", "def456");
95/// assert_eq!(op.kind(), "git");
96/// ```
97pub struct DiffTreeToTree {
98    repo_path: PathBuf,
99    old_tree: String,
100    new_tree: String,
101}
102
103impl DiffTreeToTree {
104    /// Create a new tree-to-tree diff operation.
105    pub fn new(
106        repo_path: impl Into<PathBuf>,
107        old_tree: impl Into<String>,
108        new_tree: impl Into<String>,
109    ) -> Self {
110        Self {
111            repo_path: repo_path.into(),
112            old_tree: old_tree.into(),
113            new_tree: new_tree.into(),
114        }
115    }
116
117    /// Execute and return a typed result.
118    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffOutput, OperationError> {
119        let repo_path = self.repo_path.clone();
120        let old = self.old_tree.clone();
121        let new = self.new_tree.clone();
122        blocking(move || {
123            let repo = Repository::open(&repo_path)?;
124            let old_tree = repo.find_tree(Oid::from_str(&old)?)?;
125            let new_tree = repo.find_tree(Oid::from_str(&new)?)?;
126            let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
127            diff_to_output(&diff)
128        })
129        .await
130    }
131}
132
133#[async_trait]
134impl Operation for DiffTreeToTree {
135    fn kind(&self) -> &str {
136        "git"
137    }
138    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
139        to_value(&self.run(ctx).await?)
140    }
141    fn input(&self) -> Option<Value> {
142        Some(
143            serde_json::json!({ "repo_path": self.repo_path, "old_tree": self.old_tree, "new_tree": self.new_tree }),
144        )
145    }
146}
147
148impl TypedOperation for DiffTreeToTree {
149    type Output = DiffOutput;
150}
151
152/// Diff between a tree and the index.
153///
154/// # Examples
155///
156/// ```no_run
157/// use ironflow_ops_git::diff::DiffTreeToIndex;
158/// use ironflow_core::operation::Operation;
159///
160/// let op = DiffTreeToIndex::new("/path/to/repo", "abc123");
161/// assert_eq!(op.kind(), "git");
162/// ```
163pub struct DiffTreeToIndex {
164    repo_path: PathBuf,
165    tree_oid: String,
166}
167
168impl DiffTreeToIndex {
169    /// Create a new tree-to-index diff operation.
170    pub fn new(repo_path: impl Into<PathBuf>, tree_oid: impl Into<String>) -> Self {
171        Self {
172            repo_path: repo_path.into(),
173            tree_oid: tree_oid.into(),
174        }
175    }
176
177    /// Execute and return a typed result.
178    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffOutput, OperationError> {
179        let repo_path = self.repo_path.clone();
180        let tree_oid = self.tree_oid.clone();
181        blocking(move || {
182            let repo = Repository::open(&repo_path)?;
183            let tree = repo.find_tree(Oid::from_str(&tree_oid)?)?;
184            let diff = repo.diff_tree_to_index(Some(&tree), None, None)?;
185            diff_to_output(&diff)
186        })
187        .await
188    }
189}
190
191#[async_trait]
192impl Operation for DiffTreeToIndex {
193    fn kind(&self) -> &str {
194        "git"
195    }
196    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
197        to_value(&self.run(ctx).await?)
198    }
199    fn input(&self) -> Option<Value> {
200        Some(serde_json::json!({ "repo_path": self.repo_path, "tree_oid": self.tree_oid }))
201    }
202}
203
204impl TypedOperation for DiffTreeToIndex {
205    type Output = DiffOutput;
206}
207
208/// Diff between the index and the working directory.
209///
210/// # Examples
211///
212/// ```no_run
213/// use ironflow_ops_git::diff::DiffIndexToWorkdir;
214/// use ironflow_core::operation::Operation;
215///
216/// let op = DiffIndexToWorkdir::new("/path/to/repo");
217/// assert_eq!(op.kind(), "git");
218/// ```
219pub struct DiffIndexToWorkdir {
220    repo_path: PathBuf,
221}
222
223impl DiffIndexToWorkdir {
224    /// Create a new index-to-workdir diff operation.
225    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
226        Self {
227            repo_path: repo_path.into(),
228        }
229    }
230
231    /// Execute and return a typed result.
232    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffOutput, OperationError> {
233        let repo_path = self.repo_path.clone();
234        blocking(move || {
235            let repo = Repository::open(&repo_path)?;
236            let diff = repo.diff_index_to_workdir(None, None)?;
237            diff_to_output(&diff)
238        })
239        .await
240    }
241}
242
243#[async_trait]
244impl Operation for DiffIndexToWorkdir {
245    fn kind(&self) -> &str {
246        "git"
247    }
248    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
249        to_value(&self.run(ctx).await?)
250    }
251    fn input(&self) -> Option<Value> {
252        Some(serde_json::json!({ "repo_path": self.repo_path }))
253    }
254}
255
256impl TypedOperation for DiffIndexToWorkdir {
257    type Output = DiffOutput;
258}
259
260/// Get diff statistics.
261///
262/// # Examples
263///
264/// ```no_run
265/// use ironflow_ops_git::diff::DiffStats;
266/// use ironflow_core::operation::Operation;
267///
268/// let op = DiffStats::new("/path/to/repo");
269/// assert_eq!(op.kind(), "git");
270/// ```
271pub struct DiffStats {
272    repo_path: PathBuf,
273}
274
275impl DiffStats {
276    /// Create a new diff-stats operation (index to workdir).
277    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
278        Self {
279            repo_path: repo_path.into(),
280        }
281    }
282
283    /// Execute and return a typed result.
284    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffStatsOutput, OperationError> {
285        let repo_path = self.repo_path.clone();
286        blocking(move || {
287            let repo = Repository::open(&repo_path)?;
288            let diff = repo.diff_index_to_workdir(None, None)?;
289            let stats = diff.stats()?;
290            Ok(DiffStatsOutput {
291                files_changed: stats.files_changed(),
292                insertions: stats.insertions(),
293                deletions: stats.deletions(),
294            })
295        })
296        .await
297    }
298}
299
300#[async_trait]
301impl Operation for DiffStats {
302    fn kind(&self) -> &str {
303        "git"
304    }
305    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
306        to_value(&self.run(ctx).await?)
307    }
308    fn input(&self) -> Option<Value> {
309        Some(serde_json::json!({ "repo_path": self.repo_path }))
310    }
311}
312
313impl TypedOperation for DiffStats {
314    type Output = DiffStatsOutput;
315}
316
317/// Find renamed/copied files in a diff.
318///
319/// # Examples
320///
321/// ```no_run
322/// use ironflow_ops_git::diff::DiffFindSimilar;
323/// use ironflow_core::operation::Operation;
324///
325/// let op = DiffFindSimilar::new("/path/to/repo");
326/// assert_eq!(op.kind(), "git");
327/// ```
328pub struct DiffFindSimilar {
329    repo_path: PathBuf,
330}
331
332impl DiffFindSimilar {
333    /// Create a new find-similar operation.
334    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
335        Self {
336            repo_path: repo_path.into(),
337        }
338    }
339
340    /// Execute and return a typed result.
341    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffOutput, OperationError> {
342        let repo_path = self.repo_path.clone();
343        blocking(move || {
344            let repo = Repository::open(&repo_path)?;
345            let mut diff = repo.diff_index_to_workdir(None, None)?;
346            diff.find_similar(None)?;
347            diff_to_output(&diff)
348        })
349        .await
350    }
351}
352
353#[async_trait]
354impl Operation for DiffFindSimilar {
355    fn kind(&self) -> &str {
356        "git"
357    }
358    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
359        to_value(&self.run(ctx).await?)
360    }
361    fn input(&self) -> Option<Value> {
362        Some(serde_json::json!({ "repo_path": self.repo_path }))
363    }
364}
365
366impl TypedOperation for DiffFindSimilar {
367    type Output = DiffOutput;
368}
369
370/// Apply a diff to the working directory or index.
371///
372/// # Examples
373///
374/// ```no_run
375/// use ironflow_ops_git::diff::DiffApply;
376/// use ironflow_core::operation::Operation;
377///
378/// let op = DiffApply::new("/path/to/repo", true);
379/// assert_eq!(op.kind(), "git");
380/// ```
381pub struct DiffApply {
382    repo_path: PathBuf,
383    to_index: bool,
384}
385
386impl DiffApply {
387    /// Create a new diff-apply operation.
388    ///
389    /// If `to_index` is true, applies to the index. Otherwise, applies to the working directory.
390    pub fn new(repo_path: impl Into<PathBuf>, to_index: bool) -> Self {
391        Self {
392            repo_path: repo_path.into(),
393            to_index,
394        }
395    }
396
397    /// Execute and return a typed result.
398    pub async fn run(&self, _ctx: &OperationContext) -> Result<DiffApplyOutput, OperationError> {
399        let repo_path = self.repo_path.clone();
400        let to_index = self.to_index;
401        blocking(move || {
402            let repo = Repository::open(&repo_path)?;
403            let diff = repo.diff_index_to_workdir(None, None)?;
404            let location = if to_index {
405                ApplyLocation::Index
406            } else {
407                ApplyLocation::WorkDir
408            };
409            repo.apply(&diff, location, None)?;
410            Ok(DiffApplyOutput {
411                applied: true,
412                to_index,
413            })
414        })
415        .await
416    }
417}
418
419#[async_trait]
420impl Operation for DiffApply {
421    fn kind(&self) -> &str {
422        "git"
423    }
424    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
425        to_value(&self.run(ctx).await?)
426    }
427    fn input(&self) -> Option<Value> {
428        Some(serde_json::json!({ "repo_path": self.repo_path, "to_index": self.to_index }))
429    }
430}
431
432impl TypedOperation for DiffApply {
433    type Output = DiffApplyOutput;
434}
435
436#[cfg(test)]
437mod tests {
438    use std::fs;
439    use std::path::Path;
440
441    use git2::{Repository, Signature};
442    use ironflow_core::operation::Operation;
443
444    use super::*;
445    use crate::test_helpers::ctx;
446
447    fn init_diff_repo(path: &Path) -> String {
448        let repo = Repository::init(path).unwrap();
449        fs::write(path.join("f.txt"), "original").unwrap();
450        let mut idx = repo.index().unwrap();
451        idx.add_path(Path::new("f.txt")).unwrap();
452        idx.write().unwrap();
453        let tree_oid = idx.write_tree().unwrap();
454        let tree = repo.find_tree(tree_oid).unwrap();
455        let sig = Signature::now("Test", "t@t.com").unwrap();
456        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
457            .unwrap();
458        tree_oid.to_string()
459    }
460
461    #[tokio::test]
462    async fn diff_stats_no_changes() {
463        let tmp = tempfile::tempdir().unwrap();
464        init_diff_repo(tmp.path());
465        let result = DiffStats::new(tmp.path()).run(&ctx()).await.unwrap();
466        assert_eq!(result.files_changed, 0);
467        assert_eq!(result.insertions, 0);
468        assert_eq!(result.deletions, 0);
469    }
470
471    #[tokio::test]
472    async fn diff_stats_with_changes() {
473        let tmp = tempfile::tempdir().unwrap();
474        init_diff_repo(tmp.path());
475        fs::write(tmp.path().join("f.txt"), "modified").unwrap();
476        let result = DiffStats::new(tmp.path()).run(&ctx()).await.unwrap();
477        assert!(result.files_changed > 0);
478    }
479
480    #[tokio::test]
481    async fn diff_tree_to_tree() {
482        let tmp = tempfile::tempdir().unwrap();
483        let tree1_oid = init_diff_repo(tmp.path());
484        let repo = Repository::open(tmp.path()).unwrap();
485        let head = repo.head().unwrap().peel_to_commit().unwrap();
486
487        fs::write(tmp.path().join("f.txt"), "v2").unwrap();
488        let mut idx = repo.index().unwrap();
489        idx.add_path(Path::new("f.txt")).unwrap();
490        idx.write().unwrap();
491        let tree2_oid = idx.write_tree().unwrap();
492        let tree2 = repo.find_tree(tree2_oid).unwrap();
493        let sig = Signature::now("Test", "t@t.com").unwrap();
494        repo.commit(Some("HEAD"), &sig, &sig, "second", &tree2, &[&head])
495            .unwrap();
496
497        let result = DiffTreeToTree::new(tmp.path(), &tree1_oid, tree2_oid.to_string())
498            .run(&ctx())
499            .await
500            .unwrap();
501        assert!(result.files_changed > 0);
502        assert!(!result.deltas.is_empty());
503    }
504
505    #[tokio::test]
506    async fn diff_tree_to_index() {
507        let tmp = tempfile::tempdir().unwrap();
508        let tree_oid = init_diff_repo(tmp.path());
509        fs::write(tmp.path().join("f.txt"), "staged").unwrap();
510        let repo = Repository::open(tmp.path()).unwrap();
511        let mut idx = repo.index().unwrap();
512        idx.add_path(Path::new("f.txt")).unwrap();
513        idx.write().unwrap();
514        let result = DiffTreeToIndex::new(tmp.path(), &tree_oid)
515            .run(&ctx())
516            .await
517            .unwrap();
518        assert!(result.files_changed > 0);
519    }
520
521    #[tokio::test]
522    async fn find_similar_empty() {
523        let tmp = tempfile::tempdir().unwrap();
524        init_diff_repo(tmp.path());
525        let result = DiffFindSimilar::new(tmp.path()).run(&ctx()).await.unwrap();
526        assert_eq!(result.files_changed, 0);
527    }
528
529    #[tokio::test]
530    async fn execute_serializes_correctly() {
531        let tmp = tempfile::tempdir().unwrap();
532        init_diff_repo(tmp.path());
533        let value = DiffStats::new(tmp.path()).execute(&ctx()).await.unwrap();
534        assert_eq!(value["files_changed"], 0);
535    }
536}