Skip to main content

ironflow_ops_git/
reset.rs

1//! Reset operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{Oid, Repository, ResetType};
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 ResetOutput {
16    pub target: String,
17    #[serde(rename = "type")]
18    pub reset_type: String,
19}
20
21/// Reset HEAD to a target commit.
22///
23/// Three convenience constructors select the reset mode:
24/// - [`Reset::soft`] -- move HEAD only (keep index and working directory)
25/// - [`Reset::mixed`] -- move HEAD and reset index (keep working directory)
26/// - [`Reset::hard`] -- move HEAD, reset index and working directory
27///
28/// # Examples
29///
30/// ```no_run
31/// use ironflow_ops_git::reset::Reset;
32/// use ironflow_core::operation::Operation;
33///
34/// let soft = Reset::soft("/path/to/repo", "abc123");
35/// let mixed = Reset::mixed("/path/to/repo", "abc123");
36/// let hard = Reset::hard("/path/to/repo", "abc123");
37/// assert_eq!(soft.kind(), "git");
38/// ```
39pub struct Reset {
40    repo_path: PathBuf,
41    target: String,
42    reset_type: ResetType,
43}
44
45impl Reset {
46    /// Soft reset (move HEAD, keep index and working directory).
47    pub fn soft(repo_path: impl Into<PathBuf>, target: impl Into<String>) -> Self {
48        Self {
49            repo_path: repo_path.into(),
50            target: target.into(),
51            reset_type: ResetType::Soft,
52        }
53    }
54
55    /// Mixed reset (move HEAD and reset index, keep working directory).
56    pub fn mixed(repo_path: impl Into<PathBuf>, target: impl Into<String>) -> Self {
57        Self {
58            repo_path: repo_path.into(),
59            target: target.into(),
60            reset_type: ResetType::Mixed,
61        }
62    }
63
64    /// Hard reset (move HEAD, reset index and working directory).
65    pub fn hard(repo_path: impl Into<PathBuf>, target: impl Into<String>) -> Self {
66        Self {
67            repo_path: repo_path.into(),
68            target: target.into(),
69            reset_type: ResetType::Hard,
70        }
71    }
72
73    /// Execute and return a typed result.
74    pub async fn run(&self, _ctx: &OperationContext) -> Result<ResetOutput, OperationError> {
75        let repo_path = self.repo_path.clone();
76        let target = self.target.clone();
77        let reset_type = self.reset_type;
78        blocking(move || {
79            let repo = Repository::open(&repo_path)?;
80            let oid = Oid::from_str(&target)?;
81            let obj = repo.find_object(oid, None)?;
82            repo.reset(&obj, reset_type, None)?;
83            Ok(ResetOutput {
84                target,
85                reset_type: reset_type_label(reset_type).to_string(),
86            })
87        })
88        .await
89    }
90}
91
92fn reset_type_label(rt: ResetType) -> &'static str {
93    match rt {
94        ResetType::Soft => "soft",
95        ResetType::Mixed => "mixed",
96        ResetType::Hard => "hard",
97    }
98}
99
100#[async_trait]
101impl Operation for Reset {
102    fn kind(&self) -> &str {
103        "git"
104    }
105    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
106        to_value(&self.run(ctx).await?)
107    }
108    fn input(&self) -> Option<Value> {
109        Some(serde_json::json!({
110            "repo_path": self.repo_path,
111            "target": self.target,
112            "type": reset_type_label(self.reset_type),
113        }))
114    }
115}
116
117impl TypedOperation for Reset {
118    type Output = ResetOutput;
119}
120
121#[cfg(test)]
122mod tests {
123    use std::fs;
124    use std::path::Path;
125
126    use git2::{Repository, Signature};
127    use ironflow_core::operation::Operation;
128
129    use super::*;
130    use crate::test_helpers::ctx;
131
132    fn init_two_commits(path: &Path) -> String {
133        let repo = Repository::init(path).unwrap();
134        let sig = Signature::now("Test", "test@test.com").unwrap();
135        fs::write(path.join("file.txt"), "v1").unwrap();
136        let mut index = repo.index().unwrap();
137        index.add_path(Path::new("file.txt")).unwrap();
138        index.write().unwrap();
139        let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
140        let c1 = repo
141            .commit(Some("HEAD"), &sig, &sig, "first", &tree, &[])
142            .unwrap();
143        let parent = repo.find_commit(c1).unwrap();
144
145        fs::write(path.join("file.txt"), "v2").unwrap();
146        let mut index = repo.index().unwrap();
147        index.add_path(Path::new("file.txt")).unwrap();
148        index.write().unwrap();
149        let tree2 = repo.find_tree(index.write_tree().unwrap()).unwrap();
150        repo.commit(Some("HEAD"), &sig, &sig, "second", &tree2, &[&parent])
151            .unwrap();
152        c1.to_string()
153    }
154
155    #[tokio::test]
156    async fn soft_reset_moves_head() {
157        let tmp = tempfile::tempdir().unwrap();
158        let first_oid = init_two_commits(tmp.path());
159        let result = Reset::soft(tmp.path(), &first_oid)
160            .run(&ctx())
161            .await
162            .unwrap();
163        assert_eq!(result.target, first_oid);
164        assert_eq!(result.reset_type, "soft");
165        let repo = Repository::open(tmp.path()).unwrap();
166        assert_eq!(
167            repo.head()
168                .unwrap()
169                .peel_to_commit()
170                .unwrap()
171                .id()
172                .to_string(),
173            first_oid
174        );
175        assert_eq!(
176            fs::read_to_string(tmp.path().join("file.txt")).unwrap(),
177            "v2"
178        );
179    }
180
181    #[tokio::test]
182    async fn hard_reset_restores_workdir() {
183        let tmp = tempfile::tempdir().unwrap();
184        let first_oid = init_two_commits(tmp.path());
185        let result = Reset::hard(tmp.path(), &first_oid)
186            .run(&ctx())
187            .await
188            .unwrap();
189        assert_eq!(result.reset_type, "hard");
190        assert_eq!(
191            fs::read_to_string(tmp.path().join("file.txt")).unwrap(),
192            "v1"
193        );
194    }
195
196    #[tokio::test]
197    async fn reset_invalid_target_fails() {
198        let tmp = tempfile::tempdir().unwrap();
199        init_two_commits(tmp.path());
200        assert!(
201            Reset::soft(tmp.path(), "0000000000000000000000000000000000000000")
202                .run(&ctx())
203                .await
204                .is_err()
205        );
206    }
207
208    #[tokio::test]
209    async fn execute_serializes_correctly() {
210        let tmp = tempfile::tempdir().unwrap();
211        let first_oid = init_two_commits(tmp.path());
212        let op = Reset::mixed(tmp.path(), &first_oid);
213        let value = op.execute(&ctx()).await.unwrap();
214        assert_eq!(value["type"], "mixed");
215        assert_eq!(value["target"], first_oid);
216    }
217}