1use 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 pub detached_at: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct WorktreeRemoveOutput {
26 pub name: String,
28 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
49pub struct WorktreeAdd {
61 repo_path: PathBuf,
62 name: String,
63 path: PathBuf,
64 detached_at: Option<String>,
65}
66
67impl WorktreeAdd {
68 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 pub fn detached_at(mut self, commit: impl Into<String>) -> Self {
97 self.detached_at = Some(commit.into());
98 self
99 }
100
101 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
131fn 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
170pub struct WorktreeList {
182 repo_path: PathBuf,
183}
184
185impl WorktreeList {
186 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
188 Self {
189 repo_path: repo_path.into(),
190 }
191 }
192
193 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
226pub struct WorktreeValidate {
238 repo_path: PathBuf,
239 name: String,
240}
241
242impl WorktreeValidate {
243 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 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
285pub struct WorktreePrune {
297 repo_path: PathBuf,
298 name: String,
299}
300
301impl WorktreePrune {
302 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 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
344pub struct WorktreeRemove {
360 repo_path: PathBuf,
361 name: String,
362}
363
364impl WorktreeRemove {
365 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 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 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}