1use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{Repository, Signature};
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 StashSaveOutput {
16 pub oid: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StashApplyOutput {
21 pub index: usize,
22 pub applied: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct StashPopOutput {
27 pub index: usize,
28 pub popped: bool,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct StashDropOutput {
33 pub index: usize,
34 pub dropped: bool,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct StashEntry {
40 pub index: usize,
41 pub message: String,
42 pub oid: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct StashListOutput {
47 pub stashes: Vec<StashEntry>,
48}
49
50pub struct StashSave {
62 repo_path: PathBuf,
63 stasher_name: String,
64 stasher_email: String,
65 message: Option<String>,
66}
67
68impl StashSave {
69 pub fn new(
71 repo_path: impl Into<PathBuf>,
72 stasher_name: impl Into<String>,
73 stasher_email: impl Into<String>,
74 message: impl Into<String>,
75 ) -> Self {
76 Self {
77 repo_path: repo_path.into(),
78 stasher_name: stasher_name.into(),
79 stasher_email: stasher_email.into(),
80 message: Some(message.into()),
81 }
82 }
83
84 pub async fn run(&self, _ctx: &OperationContext) -> Result<StashSaveOutput, OperationError> {
86 let repo_path = self.repo_path.clone();
87 let name = self.stasher_name.clone();
88 let email = self.stasher_email.clone();
89 let message = self.message.clone();
90 blocking(move || {
91 let mut repo = Repository::open(&repo_path)?;
92 let sig = Signature::now(&name, &email)?;
93 let oid = repo.stash_save(&sig, message.as_deref().unwrap_or(""), None)?;
94 Ok(StashSaveOutput {
95 oid: oid.to_string(),
96 })
97 })
98 .await
99 }
100}
101
102#[async_trait]
103impl Operation for StashSave {
104 fn kind(&self) -> &str {
105 "git"
106 }
107 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
108 to_value(&self.run(ctx).await?)
109 }
110 fn input(&self) -> Option<Value> {
111 Some(serde_json::json!({ "repo_path": self.repo_path, "message": self.message }))
112 }
113}
114
115impl TypedOperation for StashSave {
116 type Output = StashSaveOutput;
117}
118
119pub struct StashApply {
131 repo_path: PathBuf,
132 index: usize,
133}
134
135impl StashApply {
136 pub fn new(repo_path: impl Into<PathBuf>, index: usize) -> Self {
138 Self {
139 repo_path: repo_path.into(),
140 index,
141 }
142 }
143
144 pub async fn run(&self, _ctx: &OperationContext) -> Result<StashApplyOutput, OperationError> {
146 let repo_path = self.repo_path.clone();
147 let index = self.index;
148 blocking(move || {
149 let mut repo = Repository::open(&repo_path)?;
150 repo.stash_apply(index, None)?;
151 Ok(StashApplyOutput {
152 index,
153 applied: true,
154 })
155 })
156 .await
157 }
158}
159
160#[async_trait]
161impl Operation for StashApply {
162 fn kind(&self) -> &str {
163 "git"
164 }
165 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
166 to_value(&self.run(ctx).await?)
167 }
168 fn input(&self) -> Option<Value> {
169 Some(serde_json::json!({ "repo_path": self.repo_path, "index": self.index }))
170 }
171}
172
173impl TypedOperation for StashApply {
174 type Output = StashApplyOutput;
175}
176
177pub struct StashPop {
189 repo_path: PathBuf,
190 index: usize,
191}
192
193impl StashPop {
194 pub fn new(repo_path: impl Into<PathBuf>, index: usize) -> Self {
196 Self {
197 repo_path: repo_path.into(),
198 index,
199 }
200 }
201
202 pub async fn run(&self, _ctx: &OperationContext) -> Result<StashPopOutput, OperationError> {
204 let repo_path = self.repo_path.clone();
205 let index = self.index;
206 blocking(move || {
207 let mut repo = Repository::open(&repo_path)?;
208 repo.stash_pop(index, None)?;
209 Ok(StashPopOutput {
210 index,
211 popped: true,
212 })
213 })
214 .await
215 }
216}
217
218#[async_trait]
219impl Operation for StashPop {
220 fn kind(&self) -> &str {
221 "git"
222 }
223 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
224 to_value(&self.run(ctx).await?)
225 }
226 fn input(&self) -> Option<Value> {
227 Some(serde_json::json!({ "repo_path": self.repo_path, "index": self.index }))
228 }
229}
230
231impl TypedOperation for StashPop {
232 type Output = StashPopOutput;
233}
234
235pub struct StashDrop {
247 repo_path: PathBuf,
248 index: usize,
249}
250
251impl StashDrop {
252 pub fn new(repo_path: impl Into<PathBuf>, index: usize) -> Self {
254 Self {
255 repo_path: repo_path.into(),
256 index,
257 }
258 }
259
260 pub async fn run(&self, _ctx: &OperationContext) -> Result<StashDropOutput, OperationError> {
262 let repo_path = self.repo_path.clone();
263 let index = self.index;
264 blocking(move || {
265 let mut repo = Repository::open(&repo_path)?;
266 repo.stash_drop(index)?;
267 Ok(StashDropOutput {
268 index,
269 dropped: true,
270 })
271 })
272 .await
273 }
274}
275
276#[async_trait]
277impl Operation for StashDrop {
278 fn kind(&self) -> &str {
279 "git"
280 }
281 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
282 to_value(&self.run(ctx).await?)
283 }
284 fn input(&self) -> Option<Value> {
285 Some(serde_json::json!({ "repo_path": self.repo_path, "index": self.index }))
286 }
287}
288
289impl TypedOperation for StashDrop {
290 type Output = StashDropOutput;
291}
292
293pub struct StashList {
305 repo_path: PathBuf,
306}
307
308impl StashList {
309 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
311 Self {
312 repo_path: repo_path.into(),
313 }
314 }
315
316 pub async fn run(&self, _ctx: &OperationContext) -> Result<StashListOutput, OperationError> {
318 let repo_path = self.repo_path.clone();
319 blocking(move || {
320 let mut repo = Repository::open(&repo_path)?;
321 let mut entries = Vec::new();
322 repo.stash_foreach(|index, message, oid| {
323 entries.push(StashEntry {
324 index,
325 message: message.to_string(),
326 oid: oid.to_string(),
327 });
328 true
329 })?;
330 Ok(StashListOutput { stashes: entries })
331 })
332 .await
333 }
334}
335
336#[async_trait]
337impl Operation for StashList {
338 fn kind(&self) -> &str {
339 "git"
340 }
341 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
342 to_value(&self.run(ctx).await?)
343 }
344 fn input(&self) -> Option<Value> {
345 Some(serde_json::json!({ "repo_path": self.repo_path }))
346 }
347}
348
349impl TypedOperation for StashList {
350 type Output = StashListOutput;
351}
352
353#[cfg(test)]
354mod tests {
355 use std::fs;
356
357 use ironflow_core::operation::Operation;
358
359 use super::*;
360 use crate::test_helpers::{ctx, init_repo};
361
362 #[tokio::test]
363 async fn save_and_list() {
364 let tmp = tempfile::tempdir().unwrap();
365 init_repo(tmp.path());
366 fs::write(tmp.path().join("file.txt"), "dirty").unwrap();
367 let result = StashSave::new(tmp.path(), "Test", "t@t.com", "WIP")
368 .run(&ctx())
369 .await
370 .unwrap();
371 assert!(!result.oid.is_empty());
372 assert_eq!(
373 fs::read_to_string(tmp.path().join("file.txt")).unwrap(),
374 "content"
375 );
376 let list = StashList::new(tmp.path()).run(&ctx()).await.unwrap();
377 assert_eq!(list.stashes.len(), 1);
378 }
379
380 #[tokio::test]
381 async fn save_nothing_to_stash_fails() {
382 let tmp = tempfile::tempdir().unwrap();
383 init_repo(tmp.path());
384 assert!(
385 StashSave::new(tmp.path(), "Test", "t@t.com", "WIP")
386 .run(&ctx())
387 .await
388 .is_err()
389 );
390 }
391
392 #[tokio::test]
393 async fn apply_restores_changes() {
394 let tmp = tempfile::tempdir().unwrap();
395 init_repo(tmp.path());
396 fs::write(tmp.path().join("file.txt"), "dirty").unwrap();
397 StashSave::new(tmp.path(), "Test", "t@t.com", "WIP")
398 .run(&ctx())
399 .await
400 .unwrap();
401 let result = StashApply::new(tmp.path(), 0).run(&ctx()).await.unwrap();
402 assert!(result.applied);
403 assert_eq!(
404 fs::read_to_string(tmp.path().join("file.txt")).unwrap(),
405 "dirty"
406 );
407 }
408
409 #[tokio::test]
410 async fn pop_removes_from_list() {
411 let tmp = tempfile::tempdir().unwrap();
412 init_repo(tmp.path());
413 fs::write(tmp.path().join("file.txt"), "dirty").unwrap();
414 StashSave::new(tmp.path(), "Test", "t@t.com", "WIP")
415 .run(&ctx())
416 .await
417 .unwrap();
418 StashPop::new(tmp.path(), 0).run(&ctx()).await.unwrap();
419 let list = StashList::new(tmp.path()).run(&ctx()).await.unwrap();
420 assert!(list.stashes.is_empty());
421 }
422
423 #[tokio::test]
424 async fn drop_removes_entry() {
425 let tmp = tempfile::tempdir().unwrap();
426 init_repo(tmp.path());
427 fs::write(tmp.path().join("file.txt"), "dirty").unwrap();
428 StashSave::new(tmp.path(), "Test", "t@t.com", "WIP")
429 .run(&ctx())
430 .await
431 .unwrap();
432 StashDrop::new(tmp.path(), 0).run(&ctx()).await.unwrap();
433 let list = StashList::new(tmp.path()).run(&ctx()).await.unwrap();
434 assert!(list.stashes.is_empty());
435 }
436
437 #[tokio::test]
438 async fn execute_serializes_correctly() {
439 let tmp = tempfile::tempdir().unwrap();
440 init_repo(tmp.path());
441 let value = StashList::new(tmp.path()).execute(&ctx()).await.unwrap();
442 assert!(value["stashes"].as_array().unwrap().is_empty());
443 }
444}