ironflow_ops_git/
status.rs1use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use git2::{Repository, Status, StatusOptions};
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
14fn status_label(status: Status) -> String {
15 let mut parts = Vec::new();
16 if status.is_index_new() {
17 parts.push("index_new");
18 }
19 if status.is_index_modified() {
20 parts.push("index_modified");
21 }
22 if status.is_index_deleted() {
23 parts.push("index_deleted");
24 }
25 if status.is_index_renamed() {
26 parts.push("index_renamed");
27 }
28 if status.is_index_typechange() {
29 parts.push("index_typechange");
30 }
31 if status.is_wt_new() {
32 parts.push("wt_new");
33 }
34 if status.is_wt_modified() {
35 parts.push("wt_modified");
36 }
37 if status.is_wt_deleted() {
38 parts.push("wt_deleted");
39 }
40 if status.is_wt_typechange() {
41 parts.push("wt_typechange");
42 }
43 if status.is_wt_renamed() {
44 parts.push("wt_renamed");
45 }
46 if status.is_ignored() {
47 parts.push("ignored");
48 }
49 if status.is_conflicted() {
50 parts.push("conflicted");
51 }
52 if parts.is_empty() {
53 parts.push("current");
54 }
55 parts.join(",")
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct StatusFileOutput {
60 pub path: String,
61 pub status: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct StatusEntry {
67 pub path: String,
68 pub status: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct StatusListOutput {
73 pub entries: Vec<StatusEntry>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StatusShouldIgnoreOutput {
78 pub path: String,
79 pub ignored: bool,
80}
81
82pub struct StatusFile {
94 repo_path: PathBuf,
95 path: String,
96}
97
98impl StatusFile {
99 pub fn new(repo_path: impl Into<PathBuf>, path: impl Into<String>) -> Self {
101 Self {
102 repo_path: repo_path.into(),
103 path: path.into(),
104 }
105 }
106
107 pub async fn run(&self, _ctx: &OperationContext) -> Result<StatusFileOutput, OperationError> {
109 let repo_path = self.repo_path.clone();
110 let path = self.path.clone();
111 blocking(move || {
112 let repo = Repository::open(&repo_path)?;
113 let status = repo.status_file(Path::new(&path))?;
114 Ok(StatusFileOutput {
115 path,
116 status: status_label(status),
117 })
118 })
119 .await
120 }
121}
122
123#[async_trait]
124impl Operation for StatusFile {
125 fn kind(&self) -> &str {
126 "git"
127 }
128 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
129 to_value(&self.run(ctx).await?)
130 }
131 fn input(&self) -> Option<Value> {
132 Some(serde_json::json!({ "repo_path": self.repo_path, "path": self.path }))
133 }
134}
135
136impl TypedOperation for StatusFile {
137 type Output = StatusFileOutput;
138}
139
140pub struct StatusList {
152 repo_path: PathBuf,
153}
154
155impl StatusList {
156 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
158 Self {
159 repo_path: repo_path.into(),
160 }
161 }
162
163 pub async fn run(&self, _ctx: &OperationContext) -> Result<StatusListOutput, OperationError> {
165 let repo_path = self.repo_path.clone();
166 blocking(move || {
167 let repo = Repository::open(&repo_path)?;
168 let mut opts = StatusOptions::new();
169 opts.include_untracked(true);
170 let statuses = repo.statuses(Some(&mut opts))?;
171 let entries = statuses
172 .iter()
173 .map(|entry| StatusEntry {
174 path: entry.path().unwrap_or("").to_string(),
175 status: status_label(entry.status()),
176 })
177 .collect();
178 Ok(StatusListOutput { entries })
179 })
180 .await
181 }
182}
183
184#[async_trait]
185impl Operation for StatusList {
186 fn kind(&self) -> &str {
187 "git"
188 }
189 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
190 to_value(&self.run(ctx).await?)
191 }
192 fn input(&self) -> Option<Value> {
193 Some(serde_json::json!({ "repo_path": self.repo_path }))
194 }
195}
196
197impl TypedOperation for StatusList {
198 type Output = StatusListOutput;
199}
200
201pub struct StatusShouldIgnore {
213 repo_path: PathBuf,
214 path: String,
215}
216
217impl StatusShouldIgnore {
218 pub fn new(repo_path: impl Into<PathBuf>, path: impl Into<String>) -> Self {
220 Self {
221 repo_path: repo_path.into(),
222 path: path.into(),
223 }
224 }
225
226 pub async fn run(
228 &self,
229 _ctx: &OperationContext,
230 ) -> Result<StatusShouldIgnoreOutput, OperationError> {
231 let repo_path = self.repo_path.clone();
232 let path = self.path.clone();
233 blocking(move || {
234 let repo = Repository::open(&repo_path)?;
235 let ignored = repo.status_should_ignore(Path::new(&path))?;
236 Ok(StatusShouldIgnoreOutput { path, ignored })
237 })
238 .await
239 }
240}
241
242#[async_trait]
243impl Operation for StatusShouldIgnore {
244 fn kind(&self) -> &str {
245 "git"
246 }
247 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
248 to_value(&self.run(ctx).await?)
249 }
250 fn input(&self) -> Option<Value> {
251 Some(serde_json::json!({ "repo_path": self.repo_path, "path": self.path }))
252 }
253}
254
255impl TypedOperation for StatusShouldIgnore {
256 type Output = StatusShouldIgnoreOutput;
257}
258
259#[cfg(test)]
260mod tests {
261 use std::fs;
262
263 use ironflow_core::operation::Operation;
264
265 use super::*;
266 use crate::test_helpers::{ctx, init_repo};
267
268 #[tokio::test]
269 async fn status_file_clean() {
270 let tmp = tempfile::tempdir().unwrap();
271 init_repo(tmp.path());
272 let result = StatusFile::new(tmp.path(), "file.txt")
273 .run(&ctx())
274 .await
275 .unwrap();
276 assert_eq!(result.path, "file.txt");
277 assert_eq!(result.status, "current");
278 }
279
280 #[tokio::test]
281 async fn status_file_modified() {
282 let tmp = tempfile::tempdir().unwrap();
283 init_repo(tmp.path());
284 fs::write(tmp.path().join("file.txt"), "changed").unwrap();
285 let result = StatusFile::new(tmp.path(), "file.txt")
286 .run(&ctx())
287 .await
288 .unwrap();
289 assert!(result.status.contains("wt_modified"));
290 }
291
292 #[tokio::test]
293 async fn status_list_detects_new_files() {
294 let tmp = tempfile::tempdir().unwrap();
295 init_repo(tmp.path());
296 fs::write(tmp.path().join("new.txt"), "n").unwrap();
297 let result = StatusList::new(tmp.path()).run(&ctx()).await.unwrap();
298 assert!(result.entries.iter().any(|e| e.path == "new.txt"));
299 }
300
301 #[tokio::test]
302 async fn should_ignore_gitignore() {
303 let tmp = tempfile::tempdir().unwrap();
304 init_repo(tmp.path());
305 fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
306 let result = StatusShouldIgnore::new(tmp.path(), "debug.log")
307 .run(&ctx())
308 .await
309 .unwrap();
310 assert!(result.ignored);
311 let result = StatusShouldIgnore::new(tmp.path(), "file.txt")
312 .run(&ctx())
313 .await
314 .unwrap();
315 assert!(!result.ignored);
316 }
317
318 #[tokio::test]
319 async fn execute_serializes_correctly() {
320 let tmp = tempfile::tempdir().unwrap();
321 init_repo(tmp.path());
322 let value = StatusList::new(tmp.path()).execute(&ctx()).await.unwrap();
323 assert!(value["entries"].is_array());
324 }
325}