Skip to main content

ironflow_ops_git/
index.rs

1//! Index / staging area operations.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use git2::{IndexAddOption, Repository};
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 IndexPathOutput {
16    pub path: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct IndexPathspecsOutput {
21    pub pathspecs: Vec<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct IndexUpdateAllOutput {
26    pub updated: bool,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct IndexWriteTreeOutput {
31    pub tree_oid: String,
32}
33
34/// Add a single file to the index.
35///
36/// # Examples
37///
38/// ```no_run
39/// use ironflow_ops_git::index::IndexAdd;
40/// use ironflow_core::operation::Operation;
41///
42/// let op = IndexAdd::new("/path/to/repo", "file.txt");
43/// assert_eq!(op.kind(), "git");
44/// ```
45pub struct IndexAdd {
46    repo_path: PathBuf,
47    path: String,
48}
49
50impl IndexAdd {
51    /// Create a new add operation.
52    pub fn new(repo_path: impl Into<PathBuf>, path: impl Into<String>) -> Self {
53        Self {
54            repo_path: repo_path.into(),
55            path: path.into(),
56        }
57    }
58
59    /// Execute and return a typed result.
60    pub async fn run(&self, _ctx: &OperationContext) -> Result<IndexPathOutput, OperationError> {
61        let repo_path = self.repo_path.clone();
62        let file_path = self.path.clone();
63        blocking(move || {
64            let repo = Repository::open(&repo_path)?;
65            let mut index = repo.index()?;
66            index.add_path(Path::new(&file_path))?;
67            index.write()?;
68            Ok(IndexPathOutput { path: file_path })
69        })
70        .await
71    }
72}
73
74#[async_trait]
75impl Operation for IndexAdd {
76    fn kind(&self) -> &str {
77        "git"
78    }
79    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
80        to_value(&self.run(ctx).await?)
81    }
82    fn input(&self) -> Option<Value> {
83        Some(serde_json::json!({ "repo_path": self.repo_path, "path": self.path }))
84    }
85}
86
87impl TypedOperation for IndexAdd {
88    type Output = IndexPathOutput;
89}
90
91/// Add all files matching a pathspec to the index.
92///
93/// Equivalent to `git add .` when called with `["*"]` or `["."]`.
94///
95/// # Examples
96///
97/// ```no_run
98/// use ironflow_ops_git::index::IndexAddAll;
99/// use ironflow_core::operation::Operation;
100///
101/// let op = IndexAddAll::new("/path/to/repo", vec!["*.rs"]);
102/// assert_eq!(op.kind(), "git");
103/// ```
104pub struct IndexAddAll {
105    repo_path: PathBuf,
106    pathspecs: Vec<String>,
107}
108
109impl IndexAddAll {
110    /// Create a new add-all operation.
111    pub fn new(repo_path: impl Into<PathBuf>, pathspecs: Vec<impl Into<String>>) -> Self {
112        Self {
113            repo_path: repo_path.into(),
114            pathspecs: pathspecs.into_iter().map(Into::into).collect(),
115        }
116    }
117
118    /// Execute and return a typed result.
119    pub async fn run(
120        &self,
121        _ctx: &OperationContext,
122    ) -> Result<IndexPathspecsOutput, OperationError> {
123        let repo_path = self.repo_path.clone();
124        let pathspecs = self.pathspecs.clone();
125        blocking(move || {
126            let repo = Repository::open(&repo_path)?;
127            let mut index = repo.index()?;
128            index.add_all(&pathspecs, IndexAddOption::DEFAULT, None)?;
129            index.write()?;
130            Ok(IndexPathspecsOutput { pathspecs })
131        })
132        .await
133    }
134}
135
136#[async_trait]
137impl Operation for IndexAddAll {
138    fn kind(&self) -> &str {
139        "git"
140    }
141    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
142        to_value(&self.run(ctx).await?)
143    }
144    fn input(&self) -> Option<Value> {
145        Some(serde_json::json!({ "repo_path": self.repo_path, "pathspecs": self.pathspecs }))
146    }
147}
148
149impl TypedOperation for IndexAddAll {
150    type Output = IndexPathspecsOutput;
151}
152
153/// Remove a file from the index.
154///
155/// # Examples
156///
157/// ```no_run
158/// use ironflow_ops_git::index::IndexRemove;
159/// use ironflow_core::operation::Operation;
160///
161/// let op = IndexRemove::new("/path/to/repo", "file.txt");
162/// assert_eq!(op.kind(), "git");
163/// ```
164pub struct IndexRemove {
165    repo_path: PathBuf,
166    path: String,
167}
168
169impl IndexRemove {
170    /// Create a new remove operation.
171    pub fn new(repo_path: impl Into<PathBuf>, path: impl Into<String>) -> Self {
172        Self {
173            repo_path: repo_path.into(),
174            path: path.into(),
175        }
176    }
177
178    /// Execute and return a typed result.
179    pub async fn run(&self, _ctx: &OperationContext) -> Result<IndexPathOutput, OperationError> {
180        let repo_path = self.repo_path.clone();
181        let file_path = self.path.clone();
182        blocking(move || {
183            let repo = Repository::open(&repo_path)?;
184            let mut index = repo.index()?;
185            index.remove_path(Path::new(&file_path))?;
186            index.write()?;
187            Ok(IndexPathOutput { path: file_path })
188        })
189        .await
190    }
191}
192
193#[async_trait]
194impl Operation for IndexRemove {
195    fn kind(&self) -> &str {
196        "git"
197    }
198    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
199        to_value(&self.run(ctx).await?)
200    }
201    fn input(&self) -> Option<Value> {
202        Some(serde_json::json!({ "repo_path": self.repo_path, "path": self.path }))
203    }
204}
205
206impl TypedOperation for IndexRemove {
207    type Output = IndexPathOutput;
208}
209
210/// Remove all files matching a pathspec from the index.
211///
212/// # Examples
213///
214/// ```no_run
215/// use ironflow_ops_git::index::IndexRemoveAll;
216/// use ironflow_core::operation::Operation;
217///
218/// let op = IndexRemoveAll::new("/path/to/repo", vec!["*.tmp"]);
219/// assert_eq!(op.kind(), "git");
220/// ```
221pub struct IndexRemoveAll {
222    repo_path: PathBuf,
223    pathspecs: Vec<String>,
224}
225
226impl IndexRemoveAll {
227    /// Create a new remove-all operation.
228    pub fn new(repo_path: impl Into<PathBuf>, pathspecs: Vec<impl Into<String>>) -> Self {
229        Self {
230            repo_path: repo_path.into(),
231            pathspecs: pathspecs.into_iter().map(Into::into).collect(),
232        }
233    }
234
235    /// Execute and return a typed result.
236    pub async fn run(
237        &self,
238        _ctx: &OperationContext,
239    ) -> Result<IndexPathspecsOutput, OperationError> {
240        let repo_path = self.repo_path.clone();
241        let pathspecs = self.pathspecs.clone();
242        blocking(move || {
243            let repo = Repository::open(&repo_path)?;
244            let mut index = repo.index()?;
245            index.remove_all(&pathspecs, None)?;
246            index.write()?;
247            Ok(IndexPathspecsOutput { pathspecs })
248        })
249        .await
250    }
251}
252
253#[async_trait]
254impl Operation for IndexRemoveAll {
255    fn kind(&self) -> &str {
256        "git"
257    }
258    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
259        to_value(&self.run(ctx).await?)
260    }
261    fn input(&self) -> Option<Value> {
262        Some(serde_json::json!({ "repo_path": self.repo_path, "pathspecs": self.pathspecs }))
263    }
264}
265
266impl TypedOperation for IndexRemoveAll {
267    type Output = IndexPathspecsOutput;
268}
269
270/// Update all tracked files in the index.
271///
272/// Updates the index with the current content of tracked files.
273/// Equivalent to `git add -u`.
274///
275/// # Examples
276///
277/// ```no_run
278/// use ironflow_ops_git::index::IndexUpdateAll;
279/// use ironflow_core::operation::Operation;
280///
281/// let op = IndexUpdateAll::new("/path/to/repo");
282/// assert_eq!(op.kind(), "git");
283/// ```
284pub struct IndexUpdateAll {
285    repo_path: PathBuf,
286}
287
288impl IndexUpdateAll {
289    /// Create a new update-all operation.
290    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
291        Self {
292            repo_path: repo_path.into(),
293        }
294    }
295
296    /// Execute and return a typed result.
297    pub async fn run(
298        &self,
299        _ctx: &OperationContext,
300    ) -> Result<IndexUpdateAllOutput, OperationError> {
301        let repo_path = self.repo_path.clone();
302        blocking(move || {
303            let repo = Repository::open(&repo_path)?;
304            let mut index = repo.index()?;
305            index.update_all(["*"], None)?;
306            index.write()?;
307            Ok(IndexUpdateAllOutput { updated: true })
308        })
309        .await
310    }
311}
312
313#[async_trait]
314impl Operation for IndexUpdateAll {
315    fn kind(&self) -> &str {
316        "git"
317    }
318    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
319        to_value(&self.run(ctx).await?)
320    }
321    fn input(&self) -> Option<Value> {
322        Some(serde_json::json!({ "repo_path": self.repo_path }))
323    }
324}
325
326impl TypedOperation for IndexUpdateAll {
327    type Output = IndexUpdateAllOutput;
328}
329
330/// Write the index as a tree object.
331///
332/// Converts the current index into a tree object in the object database.
333/// Returns the tree OID.
334///
335/// # Examples
336///
337/// ```no_run
338/// use ironflow_ops_git::index::IndexWriteTree;
339/// use ironflow_core::operation::Operation;
340///
341/// let op = IndexWriteTree::new("/path/to/repo");
342/// assert_eq!(op.kind(), "git");
343/// ```
344pub struct IndexWriteTree {
345    repo_path: PathBuf,
346}
347
348impl IndexWriteTree {
349    /// Create a new write-tree operation.
350    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
351        Self {
352            repo_path: repo_path.into(),
353        }
354    }
355
356    /// Execute and return a typed result.
357    pub async fn run(
358        &self,
359        _ctx: &OperationContext,
360    ) -> Result<IndexWriteTreeOutput, OperationError> {
361        let repo_path = self.repo_path.clone();
362        blocking(move || {
363            let repo = Repository::open(&repo_path)?;
364            let mut index = repo.index()?;
365            let oid = index.write_tree()?;
366            Ok(IndexWriteTreeOutput {
367                tree_oid: oid.to_string(),
368            })
369        })
370        .await
371    }
372}
373
374#[async_trait]
375impl Operation for IndexWriteTree {
376    fn kind(&self) -> &str {
377        "git"
378    }
379    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
380        to_value(&self.run(ctx).await?)
381    }
382    fn input(&self) -> Option<Value> {
383        Some(serde_json::json!({ "repo_path": self.repo_path }))
384    }
385}
386
387impl TypedOperation for IndexWriteTree {
388    type Output = IndexWriteTreeOutput;
389}
390
391#[cfg(test)]
392mod tests {
393    use std::fs;
394
395    use ironflow_core::operation::Operation;
396
397    use super::*;
398    use crate::test_helpers::{ctx, init_repo};
399
400    #[tokio::test]
401    async fn add_and_remove() {
402        let tmp = tempfile::tempdir().unwrap();
403        init_repo(tmp.path());
404        fs::write(tmp.path().join("new.txt"), "n").unwrap();
405        let result = IndexAdd::new(tmp.path(), "new.txt")
406            .run(&ctx())
407            .await
408            .unwrap();
409        assert_eq!(result.path, "new.txt");
410        let result = IndexRemove::new(tmp.path(), "new.txt")
411            .run(&ctx())
412            .await
413            .unwrap();
414        assert_eq!(result.path, "new.txt");
415    }
416
417    #[tokio::test]
418    async fn add_all_with_glob() {
419        let tmp = tempfile::tempdir().unwrap();
420        init_repo(tmp.path());
421        fs::write(tmp.path().join("a.rs"), "a").unwrap();
422        fs::write(tmp.path().join("b.rs"), "b").unwrap();
423        let result = IndexAddAll::new(tmp.path(), vec!["*.rs"])
424            .run(&ctx())
425            .await
426            .unwrap();
427        assert_eq!(result.pathspecs, vec!["*.rs"]);
428    }
429
430    #[tokio::test]
431    async fn update_all() {
432        let tmp = tempfile::tempdir().unwrap();
433        init_repo(tmp.path());
434        fs::write(tmp.path().join("file.txt"), "modified").unwrap();
435        let result = IndexUpdateAll::new(tmp.path()).run(&ctx()).await.unwrap();
436        assert!(result.updated);
437    }
438
439    #[tokio::test]
440    async fn write_tree_returns_oid() {
441        let tmp = tempfile::tempdir().unwrap();
442        init_repo(tmp.path());
443        let result = IndexWriteTree::new(tmp.path()).run(&ctx()).await.unwrap();
444        assert!(!result.tree_oid.is_empty());
445    }
446
447    #[tokio::test]
448    async fn add_nonexistent_file_fails() {
449        let tmp = tempfile::tempdir().unwrap();
450        init_repo(tmp.path());
451        assert!(
452            IndexAdd::new(tmp.path(), "nope.txt")
453                .run(&ctx())
454                .await
455                .is_err()
456        );
457    }
458
459    #[tokio::test]
460    async fn execute_serializes_correctly() {
461        let tmp = tempfile::tempdir().unwrap();
462        init_repo(tmp.path());
463        let value = IndexWriteTree::new(tmp.path())
464            .execute(&ctx())
465            .await
466            .unwrap();
467        assert!(value["tree_oid"].is_string());
468    }
469}