1use 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DiffStatsOutput {
34 pub files_changed: usize,
35 pub insertions: usize,
36 pub deletions: usize,
37}
38
39#[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
86pub struct DiffTreeToTree {
98 repo_path: PathBuf,
99 old_tree: String,
100 new_tree: String,
101}
102
103impl DiffTreeToTree {
104 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 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
152pub struct DiffTreeToIndex {
164 repo_path: PathBuf,
165 tree_oid: String,
166}
167
168impl DiffTreeToIndex {
169 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 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
208pub struct DiffIndexToWorkdir {
220 repo_path: PathBuf,
221}
222
223impl DiffIndexToWorkdir {
224 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
226 Self {
227 repo_path: repo_path.into(),
228 }
229 }
230
231 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
260pub struct DiffStats {
272 repo_path: PathBuf,
273}
274
275impl DiffStats {
276 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
278 Self {
279 repo_path: repo_path.into(),
280 }
281 }
282
283 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
317pub struct DiffFindSimilar {
329 repo_path: PathBuf,
330}
331
332impl DiffFindSimilar {
333 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
335 Self {
336 repo_path: repo_path.into(),
337 }
338 }
339
340 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
370pub struct DiffApply {
382 repo_path: PathBuf,
383 to_index: bool,
384}
385
386impl DiffApply {
387 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 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}