Skip to main content

ironflow_ops_git/
status.rs

1//! Status operations.
2
3use 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/// A single status entry.
65#[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
82/// Get the status of a single file.
83///
84/// # Examples
85///
86/// ```no_run
87/// use ironflow_ops_git::status::StatusFile;
88/// use ironflow_core::operation::Operation;
89///
90/// let op = StatusFile::new("/path/to/repo", "file.txt");
91/// assert_eq!(op.kind(), "git");
92/// ```
93pub struct StatusFile {
94    repo_path: PathBuf,
95    path: String,
96}
97
98impl StatusFile {
99    /// Create a new status-file operation.
100    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    /// Execute and return a typed result.
108    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
140/// List the status of all files in the repository.
141///
142/// # Examples
143///
144/// ```no_run
145/// use ironflow_ops_git::status::StatusList;
146/// use ironflow_core::operation::Operation;
147///
148/// let op = StatusList::new("/path/to/repo");
149/// assert_eq!(op.kind(), "git");
150/// ```
151pub struct StatusList {
152    repo_path: PathBuf,
153}
154
155impl StatusList {
156    /// Create a new status-list operation.
157    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
158        Self {
159            repo_path: repo_path.into(),
160        }
161    }
162
163    /// Execute and return a typed result.
164    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
201/// Check if a path should be ignored.
202///
203/// # Examples
204///
205/// ```no_run
206/// use ironflow_ops_git::status::StatusShouldIgnore;
207/// use ironflow_core::operation::Operation;
208///
209/// let op = StatusShouldIgnore::new("/path/to/repo", "target/");
210/// assert_eq!(op.kind(), "git");
211/// ```
212pub struct StatusShouldIgnore {
213    repo_path: PathBuf,
214    path: String,
215}
216
217impl StatusShouldIgnore {
218    /// Create a new should-ignore check operation.
219    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    /// Execute and return a typed result.
227    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}