ironflow_ops_git/
reset.rs1use 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
21pub struct Reset {
40 repo_path: PathBuf,
41 target: String,
42 reset_type: ResetType,
43}
44
45impl Reset {
46 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 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 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 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}