Skip to main content

ironflow_ops_git/
worktree.rs

1//! Worktree operations.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use git2::{Error, Repository, WorktreeAddOptions, WorktreePruneOptions};
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 WorktreeAddOutput {
16    pub name: String,
17    pub path: PathBuf,
18    /// SHA the worktree `HEAD` is detached at, when added with
19    /// [`WorktreeAdd::detached_at`].
20    pub detached_at: Option<String>,
21}
22
23/// Output of [`WorktreeRemove`].
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct WorktreeRemoveOutput {
26    /// Name of the worktree.
27    pub name: String,
28    /// `false` when no worktree of that name existed.
29    pub removed: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WorktreeListOutput {
34    pub worktrees: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct WorktreeValidateOutput {
39    pub name: String,
40    pub valid: bool,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct WorktreePruneOutput {
45    pub name: String,
46    pub pruned: bool,
47}
48
49/// Add a new worktree.
50///
51/// # Examples
52///
53/// ```no_run
54/// use ironflow_ops_git::worktree::WorktreeAdd;
55/// use ironflow_core::operation::Operation;
56///
57/// let op = WorktreeAdd::new("/path/to/repo", "wt-feature", "/path/to/worktree");
58/// assert_eq!(op.kind(), "git");
59/// ```
60pub struct WorktreeAdd {
61    repo_path: PathBuf,
62    name: String,
63    path: PathBuf,
64    detached_at: Option<String>,
65}
66
67impl WorktreeAdd {
68    /// Create a new worktree-add operation.
69    ///
70    /// The worktree is checked out on a new branch named `name`, created from
71    /// the repository `HEAD`, unless [`WorktreeAdd::detached_at`] is set.
72    pub fn new(
73        repo_path: impl Into<PathBuf>,
74        name: impl Into<String>,
75        path: impl Into<PathBuf>,
76    ) -> Self {
77        Self {
78            repo_path: repo_path.into(),
79            name: name.into(),
80            path: path.into(),
81            detached_at: None,
82        }
83    }
84
85    /// Check the worktree out with a detached `HEAD` at `commit` (a SHA or
86    /// any revspec), without creating a branch.
87    ///
88    /// # Examples
89    ///
90    /// ```no_run
91    /// use ironflow_ops_git::worktree::WorktreeAdd;
92    ///
93    /// let op = WorktreeAdd::new("/srv/repo.git", "mr-42", "/tmp/mr-42")
94    ///     .detached_at("5f1c0e2a9b7d4c3e8f6a1b2c3d4e5f60718293a4");
95    /// ```
96    pub fn detached_at(mut self, commit: impl Into<String>) -> Self {
97        self.detached_at = Some(commit.into());
98        self
99    }
100
101    /// Execute and return a typed result.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`OperationError::External`] if the repository cannot be
106    /// opened, the commit does not resolve, or the worktree cannot be added.
107    pub async fn run(&self, _ctx: &OperationContext) -> Result<WorktreeAddOutput, OperationError> {
108        let repo_path = self.repo_path.clone();
109        let name = self.name.clone();
110        let path = self.path.clone();
111        let detached_at = self.detached_at.clone();
112        blocking(move || {
113            let repo = Repository::open(&repo_path)?;
114            let detached_at = match detached_at {
115                Some(spec) => Some(add_detached(&repo, &name, &path, &spec)?),
116                None => {
117                    repo.worktree(&name, &path, None)?;
118                    None
119                }
120            };
121            Ok(WorktreeAddOutput {
122                name,
123                path,
124                detached_at,
125            })
126        })
127        .await
128    }
129}
130
131/// Add a worktree with a detached `HEAD` at `spec` and return the SHA.
132///
133/// libgit2 only adds a worktree on a branch, so the worktree is added on a
134/// temporary branch pointing at the commit, its `HEAD` is detached, then the
135/// branch is deleted, on success and on failure alike.
136fn add_detached(repo: &Repository, name: &str, path: &Path, spec: &str) -> Result<String, Error> {
137    let commit = repo.revparse_single(spec)?.peel_to_commit()?;
138    let mut temp = repo.branch(&format!("ironflow-detached/{name}"), &commit, true)?;
139    let added = (|| {
140        let mut opts = WorktreeAddOptions::new();
141        opts.reference(Some(temp.get()));
142        let worktree = repo.worktree(name, path, Some(&opts))?;
143        Repository::open_from_worktree(&worktree)?.set_head_detached(commit.id())
144    })();
145    let deleted = temp.delete();
146    added?;
147    deleted?;
148    Ok(commit.id().to_string())
149}
150
151#[async_trait]
152impl Operation for WorktreeAdd {
153    fn kind(&self) -> &str {
154        "git"
155    }
156    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
157        to_value(&self.run(ctx).await?)
158    }
159    fn input(&self) -> Option<Value> {
160        Some(
161            serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "path": self.path, "detached_at": self.detached_at }),
162        )
163    }
164}
165
166impl TypedOperation for WorktreeAdd {
167    type Output = WorktreeAddOutput;
168}
169
170/// List all worktrees.
171///
172/// # Examples
173///
174/// ```no_run
175/// use ironflow_ops_git::worktree::WorktreeList;
176/// use ironflow_core::operation::Operation;
177///
178/// let op = WorktreeList::new("/path/to/repo");
179/// assert_eq!(op.kind(), "git");
180/// ```
181pub struct WorktreeList {
182    repo_path: PathBuf,
183}
184
185impl WorktreeList {
186    /// Create a new worktree-list operation.
187    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
188        Self {
189            repo_path: repo_path.into(),
190        }
191    }
192
193    /// Execute and return a typed result.
194    pub async fn run(&self, _ctx: &OperationContext) -> Result<WorktreeListOutput, OperationError> {
195        let repo_path = self.repo_path.clone();
196        blocking(move || {
197            let repo = Repository::open(&repo_path)?;
198            let worktrees = repo.worktrees()?;
199            let list = worktrees
200                .iter()
201                .filter_map(|w| w.map(String::from))
202                .collect();
203            Ok(WorktreeListOutput { worktrees: list })
204        })
205        .await
206    }
207}
208
209#[async_trait]
210impl Operation for WorktreeList {
211    fn kind(&self) -> &str {
212        "git"
213    }
214    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
215        to_value(&self.run(ctx).await?)
216    }
217    fn input(&self) -> Option<Value> {
218        Some(serde_json::json!({ "repo_path": self.repo_path }))
219    }
220}
221
222impl TypedOperation for WorktreeList {
223    type Output = WorktreeListOutput;
224}
225
226/// Validate a worktree.
227///
228/// # Examples
229///
230/// ```no_run
231/// use ironflow_ops_git::worktree::WorktreeValidate;
232/// use ironflow_core::operation::Operation;
233///
234/// let op = WorktreeValidate::new("/path/to/repo", "wt-feature");
235/// assert_eq!(op.kind(), "git");
236/// ```
237pub struct WorktreeValidate {
238    repo_path: PathBuf,
239    name: String,
240}
241
242impl WorktreeValidate {
243    /// Create a new worktree-validate operation.
244    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
245        Self {
246            repo_path: repo_path.into(),
247            name: name.into(),
248        }
249    }
250
251    /// Execute and return a typed result.
252    pub async fn run(
253        &self,
254        _ctx: &OperationContext,
255    ) -> Result<WorktreeValidateOutput, OperationError> {
256        let repo_path = self.repo_path.clone();
257        let name = self.name.clone();
258        blocking(move || {
259            let repo = Repository::open(&repo_path)?;
260            let wt = repo.find_worktree(&name)?;
261            let valid = wt.validate().is_ok();
262            Ok(WorktreeValidateOutput { name, valid })
263        })
264        .await
265    }
266}
267
268#[async_trait]
269impl Operation for WorktreeValidate {
270    fn kind(&self) -> &str {
271        "git"
272    }
273    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
274        to_value(&self.run(ctx).await?)
275    }
276    fn input(&self) -> Option<Value> {
277        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
278    }
279}
280
281impl TypedOperation for WorktreeValidate {
282    type Output = WorktreeValidateOutput;
283}
284
285/// Prune a worktree.
286///
287/// # Examples
288///
289/// ```no_run
290/// use ironflow_ops_git::worktree::WorktreePrune;
291/// use ironflow_core::operation::Operation;
292///
293/// let op = WorktreePrune::new("/path/to/repo", "wt-feature");
294/// assert_eq!(op.kind(), "git");
295/// ```
296pub struct WorktreePrune {
297    repo_path: PathBuf,
298    name: String,
299}
300
301impl WorktreePrune {
302    /// Create a new worktree-prune operation.
303    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
304        Self {
305            repo_path: repo_path.into(),
306            name: name.into(),
307        }
308    }
309
310    /// Execute and return a typed result.
311    pub async fn run(
312        &self,
313        _ctx: &OperationContext,
314    ) -> Result<WorktreePruneOutput, OperationError> {
315        let repo_path = self.repo_path.clone();
316        let name = self.name.clone();
317        blocking(move || {
318            let repo = Repository::open(&repo_path)?;
319            let wt = repo.find_worktree(&name)?;
320            wt.prune(None)?;
321            Ok(WorktreePruneOutput { name, pruned: true })
322        })
323        .await
324    }
325}
326
327#[async_trait]
328impl Operation for WorktreePrune {
329    fn kind(&self) -> &str {
330        "git"
331    }
332    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
333        to_value(&self.run(ctx).await?)
334    }
335    fn input(&self) -> Option<Value> {
336        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
337    }
338}
339
340impl TypedOperation for WorktreePrune {
341    type Output = WorktreePruneOutput;
342}
343
344/// Remove a worktree: delete its directory and prune its entry.
345///
346/// Idempotent, so a retried step does not fail: an unknown worktree name
347/// returns `removed: false`, and a worktree whose directory is already gone
348/// only has its entry pruned. A locked worktree is not removed.
349///
350/// # Examples
351///
352/// ```no_run
353/// use ironflow_ops_git::worktree::WorktreeRemove;
354/// use ironflow_core::operation::Operation;
355///
356/// let op = WorktreeRemove::new("/path/to/repo", "wt-feature");
357/// assert_eq!(op.kind(), "git");
358/// ```
359pub struct WorktreeRemove {
360    repo_path: PathBuf,
361    name: String,
362}
363
364impl WorktreeRemove {
365    /// Create a new worktree-remove operation.
366    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
367        Self {
368            repo_path: repo_path.into(),
369            name: name.into(),
370        }
371    }
372
373    /// Execute and return a typed result.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`OperationError::External`] if the repository cannot be
378    /// opened, or the worktree is locked or cannot be deleted.
379    pub async fn run(
380        &self,
381        _ctx: &OperationContext,
382    ) -> Result<WorktreeRemoveOutput, OperationError> {
383        let repo_path = self.repo_path.clone();
384        let name = self.name.clone();
385        blocking(move || {
386            let repo = Repository::open(&repo_path)?;
387            let exists = repo.worktrees()?.iter().flatten().any(|n| n == name);
388            if !exists {
389                return Ok(WorktreeRemoveOutput {
390                    name,
391                    removed: false,
392                });
393            }
394            let mut opts = WorktreePruneOptions::new();
395            opts.valid(true).working_tree(true);
396            repo.find_worktree(&name)?.prune(Some(&mut opts))?;
397            Ok(WorktreeRemoveOutput {
398                name,
399                removed: true,
400            })
401        })
402        .await
403    }
404}
405
406#[async_trait]
407impl Operation for WorktreeRemove {
408    fn kind(&self) -> &str {
409        "git"
410    }
411    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
412        to_value(&self.run(ctx).await?)
413    }
414    fn input(&self) -> Option<Value> {
415        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
416    }
417}
418
419impl TypedOperation for WorktreeRemove {
420    type Output = WorktreeRemoveOutput;
421}
422
423#[cfg(test)]
424mod tests {
425    use std::fs;
426
427    use git2::BranchType;
428    use ironflow_core::operation::Operation;
429
430    use super::*;
431    use crate::test_helpers::{ctx, init_repo, make_two_commits};
432
433    #[tokio::test]
434    async fn add_and_list() {
435        let tmp = tempfile::tempdir().unwrap();
436        init_repo(tmp.path());
437        let wt_path = tmp.path().join("wt-test");
438        let result = WorktreeAdd::new(tmp.path(), "wt-test", &wt_path)
439            .run(&ctx())
440            .await
441            .unwrap();
442        assert_eq!(result.name, "wt-test");
443        let list = WorktreeList::new(tmp.path()).run(&ctx()).await.unwrap();
444        assert!(list.worktrees.contains(&"wt-test".to_string()));
445    }
446
447    #[tokio::test]
448    async fn validate_existing_worktree() {
449        let tmp = tempfile::tempdir().unwrap();
450        init_repo(tmp.path());
451        let wt_path = tmp.path().join("wt-val");
452        WorktreeAdd::new(tmp.path(), "wt-val", &wt_path)
453            .run(&ctx())
454            .await
455            .unwrap();
456        let result = WorktreeValidate::new(tmp.path(), "wt-val")
457            .run(&ctx())
458            .await
459            .unwrap();
460        assert!(result.valid);
461    }
462
463    #[tokio::test]
464    async fn prune_worktree() {
465        let tmp = tempfile::tempdir().unwrap();
466        init_repo(tmp.path());
467        let wt_path = tmp.path().join("wt-prune");
468        WorktreeAdd::new(tmp.path(), "wt-prune", &wt_path)
469            .run(&ctx())
470            .await
471            .unwrap();
472        fs::remove_dir_all(&wt_path).unwrap();
473        let result = WorktreePrune::new(tmp.path(), "wt-prune")
474            .run(&ctx())
475            .await
476            .unwrap();
477        assert!(result.pruned);
478    }
479
480    #[tokio::test]
481    async fn add_detached_at_commit_checks_out_sha_without_creating_branch() {
482        let tmp = tempfile::tempdir().unwrap();
483        let (first, _second) = make_two_commits(tmp.path());
484        let wt_path = tmp.path().join("wt-review");
485
486        let result = WorktreeAdd::new(tmp.path(), "wt-review", &wt_path)
487            .detached_at(&first)
488            .run(&ctx())
489            .await
490            .unwrap();
491
492        assert_eq!(result.detached_at.as_deref(), Some(first.as_str()));
493        let wt_repo = Repository::open(&wt_path).unwrap();
494        assert!(wt_repo.head_detached().unwrap());
495        assert_eq!(wt_repo.head().unwrap().target().unwrap().to_string(), first);
496        // `other.txt` only exists in the second commit.
497        assert!(wt_path.join("file.txt").exists());
498        assert!(!wt_path.join("other.txt").exists());
499        let repo = Repository::open(tmp.path()).unwrap();
500        let branches: Vec<String> = repo
501            .branches(Some(BranchType::Local))
502            .unwrap()
503            .map(|b| b.unwrap().0.name().unwrap().unwrap().to_string())
504            .collect();
505        assert_eq!(branches.len(), 1, "unexpected branches: {branches:?}");
506    }
507
508    #[tokio::test]
509    async fn add_detached_at_accepts_a_revspec() {
510        let tmp = tempfile::tempdir().unwrap();
511        let (first, _second) = make_two_commits(tmp.path());
512        let wt_path = tmp.path().join("wt-rev");
513
514        let result = WorktreeAdd::new(tmp.path(), "wt-rev", &wt_path)
515            .detached_at("HEAD~1")
516            .run(&ctx())
517            .await
518            .unwrap();
519
520        assert_eq!(result.detached_at.as_deref(), Some(first.as_str()));
521    }
522
523    #[tokio::test]
524    async fn add_detached_at_unknown_commit_fails_and_leaves_nothing() {
525        let tmp = tempfile::tempdir().unwrap();
526        make_two_commits(tmp.path());
527        let wt_path = tmp.path().join("wt-bad");
528
529        let result = WorktreeAdd::new(tmp.path(), "wt-bad", &wt_path)
530            .detached_at("0123456789abcdef0123456789abcdef01234567")
531            .run(&ctx())
532            .await;
533
534        assert!(result.is_err());
535        assert!(!wt_path.exists());
536        let repo = Repository::open(tmp.path()).unwrap();
537        assert_eq!(repo.branches(Some(BranchType::Local)).unwrap().count(), 1);
538        assert!(repo.worktrees().unwrap().is_empty());
539    }
540
541    #[tokio::test]
542    async fn add_detached_on_existing_worktree_name_fails_and_leaves_no_branch() {
543        let tmp = tempfile::tempdir().unwrap();
544        let (first, _second) = make_two_commits(tmp.path());
545        WorktreeAdd::new(tmp.path(), "wt-dup", tmp.path().join("wt-dup"))
546            .detached_at(&first)
547            .run(&ctx())
548            .await
549            .unwrap();
550
551        let result = WorktreeAdd::new(tmp.path(), "wt-dup", tmp.path().join("wt-dup-2"))
552            .detached_at(&first)
553            .run(&ctx())
554            .await;
555
556        assert!(result.is_err());
557        let repo = Repository::open(tmp.path()).unwrap();
558        assert_eq!(repo.branches(Some(BranchType::Local)).unwrap().count(), 1);
559    }
560
561    #[tokio::test]
562    async fn worktree_remove_deletes_directory_and_entry() {
563        let tmp = tempfile::tempdir().unwrap();
564        init_repo(tmp.path());
565        let wt_path = tmp.path().join("wt-rm");
566        WorktreeAdd::new(tmp.path(), "wt-rm", &wt_path)
567            .run(&ctx())
568            .await
569            .unwrap();
570
571        let result = WorktreeRemove::new(tmp.path(), "wt-rm")
572            .run(&ctx())
573            .await
574            .unwrap();
575
576        assert!(result.removed);
577        assert!(!wt_path.exists());
578        let list = WorktreeList::new(tmp.path()).run(&ctx()).await.unwrap();
579        assert!(list.worktrees.is_empty());
580    }
581
582    #[tokio::test]
583    async fn worktree_remove_after_directory_deleted_prunes_entry() {
584        let tmp = tempfile::tempdir().unwrap();
585        init_repo(tmp.path());
586        let wt_path = tmp.path().join("wt-gone");
587        WorktreeAdd::new(tmp.path(), "wt-gone", &wt_path)
588            .run(&ctx())
589            .await
590            .unwrap();
591        fs::remove_dir_all(&wt_path).unwrap();
592
593        let result = WorktreeRemove::new(tmp.path(), "wt-gone")
594            .run(&ctx())
595            .await
596            .unwrap();
597
598        assert!(result.removed);
599        let list = WorktreeList::new(tmp.path()).run(&ctx()).await.unwrap();
600        assert!(list.worktrees.is_empty());
601    }
602
603    #[tokio::test]
604    async fn worktree_remove_unknown_name_is_a_noop() {
605        let tmp = tempfile::tempdir().unwrap();
606        init_repo(tmp.path());
607
608        let result = WorktreeRemove::new(tmp.path(), "never-added")
609            .run(&ctx())
610            .await
611            .unwrap();
612
613        assert!(!result.removed);
614    }
615
616    #[tokio::test]
617    async fn worktree_remove_twice_is_idempotent() {
618        let tmp = tempfile::tempdir().unwrap();
619        init_repo(tmp.path());
620        let wt_path = tmp.path().join("wt-twice");
621        WorktreeAdd::new(tmp.path(), "wt-twice", &wt_path)
622            .run(&ctx())
623            .await
624            .unwrap();
625        let remove = WorktreeRemove::new(tmp.path(), "wt-twice");
626
627        assert!(remove.run(&ctx()).await.unwrap().removed);
628        assert!(!remove.run(&ctx()).await.unwrap().removed);
629    }
630
631    #[tokio::test]
632    async fn worktree_remove_on_missing_repo_fails() {
633        let tmp = tempfile::tempdir().unwrap();
634        let result = WorktreeRemove::new(tmp.path().join("nope"), "wt")
635            .run(&ctx())
636            .await;
637        assert!(result.is_err());
638    }
639
640    #[tokio::test]
641    async fn list_empty_worktrees() {
642        let tmp = tempfile::tempdir().unwrap();
643        init_repo(tmp.path());
644        let result = WorktreeList::new(tmp.path()).run(&ctx()).await.unwrap();
645        assert!(result.worktrees.is_empty());
646    }
647
648    #[tokio::test]
649    async fn execute_serializes_correctly() {
650        let tmp = tempfile::tempdir().unwrap();
651        init_repo(tmp.path());
652        let value = WorktreeList::new(tmp.path()).execute(&ctx()).await.unwrap();
653        assert!(value["worktrees"].is_array());
654    }
655}