Skip to main content

ironflow_ops_git/
stash.rs

1//! Stash operations.
2
3use 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/// A single stash entry.
38#[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
50/// Save changes to the stash.
51///
52/// # Examples
53///
54/// ```no_run
55/// use ironflow_ops_git::stash::StashSave;
56/// use ironflow_core::operation::Operation;
57///
58/// let op = StashSave::new("/path/to/repo", "Test", "test@example.com", "WIP");
59/// assert_eq!(op.kind(), "git");
60/// ```
61pub struct StashSave {
62    repo_path: PathBuf,
63    stasher_name: String,
64    stasher_email: String,
65    message: Option<String>,
66}
67
68impl StashSave {
69    /// Create a new stash-save operation.
70    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    /// Execute and return a typed result.
85    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
119/// Apply a stash entry.
120///
121/// # Examples
122///
123/// ```no_run
124/// use ironflow_ops_git::stash::StashApply;
125/// use ironflow_core::operation::Operation;
126///
127/// let op = StashApply::new("/path/to/repo", 0);
128/// assert_eq!(op.kind(), "git");
129/// ```
130pub struct StashApply {
131    repo_path: PathBuf,
132    index: usize,
133}
134
135impl StashApply {
136    /// Create a new stash-apply operation.
137    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    /// Execute and return a typed result.
145    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
177/// Pop a stash entry (apply and drop).
178///
179/// # Examples
180///
181/// ```no_run
182/// use ironflow_ops_git::stash::StashPop;
183/// use ironflow_core::operation::Operation;
184///
185/// let op = StashPop::new("/path/to/repo", 0);
186/// assert_eq!(op.kind(), "git");
187/// ```
188pub struct StashPop {
189    repo_path: PathBuf,
190    index: usize,
191}
192
193impl StashPop {
194    /// Create a new stash-pop operation.
195    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    /// Execute and return a typed result.
203    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
235/// Drop a stash entry.
236///
237/// # Examples
238///
239/// ```no_run
240/// use ironflow_ops_git::stash::StashDrop;
241/// use ironflow_core::operation::Operation;
242///
243/// let op = StashDrop::new("/path/to/repo", 0);
244/// assert_eq!(op.kind(), "git");
245/// ```
246pub struct StashDrop {
247    repo_path: PathBuf,
248    index: usize,
249}
250
251impl StashDrop {
252    /// Create a new stash-drop operation.
253    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    /// Execute and return a typed result.
261    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
293/// List all stash entries.
294///
295/// # Examples
296///
297/// ```no_run
298/// use ironflow_ops_git::stash::StashList;
299/// use ironflow_core::operation::Operation;
300///
301/// let op = StashList::new("/path/to/repo");
302/// assert_eq!(op.kind(), "git");
303/// ```
304pub struct StashList {
305    repo_path: PathBuf,
306}
307
308impl StashList {
309    /// Create a new stash-list operation.
310    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
311        Self {
312            repo_path: repo_path.into(),
313        }
314    }
315
316    /// Execute and return a typed result.
317    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}