1use std::path::{Path, PathBuf};
16
17use repo::{ThreadFreshness, ThreadMode, ThreadState};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CleanWorktreeGuard {
26 Skip,
28 OnExecutionPath,
30 OnCallerRepo,
32}
33
34pub fn plan_clean_worktree_guard(
40 force: bool,
41 execution_path_exists: bool,
42 execution_path_is_repo_root: bool,
43 execution_path_has_heddle: bool,
44) -> CleanWorktreeGuard {
45 if force {
46 return CleanWorktreeGuard::Skip;
47 }
48 if execution_path_exists && !execution_path_is_repo_root && execution_path_has_heddle {
49 CleanWorktreeGuard::OnExecutionPath
50 } else {
51 CleanWorktreeGuard::OnCallerRepo
52 }
53}
54
55pub fn thread_mode_requires_unmount(mode: &ThreadMode) -> bool {
58 matches!(mode, ThreadMode::Virtualized)
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ThreadDropOptions {
68 pub thread_found: bool,
70 pub is_current_lane: bool,
72 pub delete_thread: bool,
74 pub force: bool,
76 pub mode: ThreadMode,
78 pub execution_path_exists: bool,
79 pub execution_path_is_repo_root: bool,
80 pub execution_path_has_heddle: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ThreadDropPlan {
88 pub clean_worktree: CleanWorktreeGuard,
89 pub unmount_virtualized: bool,
91 pub remove_execution_path: bool,
93 pub remove_manifest: bool,
95 pub mark_abandoned: bool,
97 pub strip_actor_presence: bool,
99 pub delete_thread_ref: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ThreadDropDisposition {
107 RefuseCurrentCheckout,
109 ProceedDeleteMissing,
111 NotFound,
113 Drop(ThreadDropPlan),
115}
116
117pub fn plan_thread_drop(options: &ThreadDropOptions) -> ThreadDropDisposition {
129 if options.is_current_lane {
130 if options.delete_thread {
131 return ThreadDropDisposition::ProceedDeleteMissing;
132 }
133 return ThreadDropDisposition::RefuseCurrentCheckout;
134 }
135 if !options.thread_found {
136 if options.delete_thread {
137 return ThreadDropDisposition::ProceedDeleteMissing;
138 }
139 return ThreadDropDisposition::NotFound;
140 }
141
142 ThreadDropDisposition::Drop(ThreadDropPlan {
143 clean_worktree: plan_clean_worktree_guard(
144 options.force,
145 options.execution_path_exists,
146 options.execution_path_is_repo_root,
147 options.execution_path_has_heddle,
148 ),
149 unmount_virtualized: thread_mode_requires_unmount(&options.mode),
150 remove_execution_path: options.execution_path_exists
151 && !options.execution_path_is_repo_root,
152 remove_manifest: true,
153 mark_abandoned: true,
154 strip_actor_presence: true,
155 delete_thread_ref: options.delete_thread,
156 })
157}
158
159pub fn plan_cleanup_thread_drop(mode: &ThreadMode, execution_path_exists: bool) -> ThreadDropPlan {
165 ThreadDropPlan {
166 clean_worktree: CleanWorktreeGuard::Skip,
167 unmount_virtualized: thread_mode_requires_unmount(mode),
168 remove_execution_path: execution_path_exists,
169 remove_manifest: true,
170 mark_abandoned: true,
171 strip_actor_presence: true,
172 delete_thread_ref: true,
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ThreadPromoteOptions {
183 pub force: bool,
184 pub path: Option<PathBuf>,
186 pub default_path: PathBuf,
190 pub mode: ThreadMode,
191 pub execution_path: PathBuf,
192 pub materialized_path: Option<PathBuf>,
193 pub execution_path_exists: bool,
194 pub execution_path_is_repo_root: bool,
195 pub execution_path_has_heddle: bool,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ThreadPromotePlan {
204 pub using_default_path: bool,
206 pub target_path: PathBuf,
207 pub clean_worktree: CleanWorktreeGuard,
208 pub unmount_virtualized: bool,
209 pub in_place_conversion_candidate: Option<PathBuf>,
213 pub resulting_mode: ThreadMode,
215 pub resulting_state: ThreadState,
217}
218
219pub fn resolve_promote_target_path(
221 path: Option<PathBuf>,
222 default_path: PathBuf,
223) -> (PathBuf, bool) {
224 match path {
225 Some(explicit) => {
226 let is_default = explicit == default_path;
227 (explicit, is_default)
228 }
229 None => (default_path, true),
230 }
231}
232
233pub fn promote_existing_checkout_path(
237 materialized_path: Option<&Path>,
238 execution_path: &Path,
239) -> PathBuf {
240 materialized_path
241 .filter(|p| !p.as_os_str().is_empty())
242 .map(Path::to_path_buf)
243 .unwrap_or_else(|| execution_path.to_path_buf())
244}
245
246pub fn promote_in_place_conversion_candidate(
252 using_default_path: bool,
253 mode: &ThreadMode,
254 existing: PathBuf,
255) -> Option<PathBuf> {
256 if using_default_path && matches!(mode, ThreadMode::Materialized | ThreadMode::Solid) {
257 Some(existing)
258 } else {
259 None
260 }
261}
262
263pub fn promote_confirm_in_place_removal(
268 candidate: Option<&Path>,
269 existing_has_heddle: bool,
270 same_as_target: bool,
271) -> bool {
272 let Some(existing) = candidate else {
273 return false;
274 };
275 !existing.as_os_str().is_empty() && existing_has_heddle && same_as_target
276}
277
278pub fn plan_thread_promote(options: &ThreadPromoteOptions) -> ThreadPromotePlan {
280 let (target_path, using_default_path) =
281 resolve_promote_target_path(options.path.clone(), options.default_path.clone());
282 let existing = promote_existing_checkout_path(
283 options.materialized_path.as_deref(),
284 &options.execution_path,
285 );
286 ThreadPromotePlan {
287 using_default_path,
288 target_path,
289 clean_worktree: plan_clean_worktree_guard(
290 options.force,
291 options.execution_path_exists,
292 options.execution_path_is_repo_root,
293 options.execution_path_has_heddle,
294 ),
295 unmount_virtualized: thread_mode_requires_unmount(&options.mode),
296 in_place_conversion_candidate: promote_in_place_conversion_candidate(
297 using_default_path,
298 &options.mode,
299 existing,
300 ),
301 resulting_mode: ThreadMode::Solid,
302 resulting_state: ThreadState::Promoted,
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct ThreadRefreshOptions {
313 pub has_target_thread: bool,
315 pub freshness: ThreadFreshness,
316 pub execution_path_empty: bool,
318 pub is_current_lane: bool,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum ThreadRefreshPlan {
325 MissingTarget,
327 AlreadyCurrent,
329 RequiresCurrentCheckout,
331 ProceedOnCurrentRepo,
333 ProceedOnExecutionPath,
335}
336
337pub fn plan_thread_refresh(options: &ThreadRefreshOptions) -> ThreadRefreshPlan {
346 if !options.has_target_thread {
347 return ThreadRefreshPlan::MissingTarget;
348 }
349 if options.freshness == ThreadFreshness::Current {
350 return ThreadRefreshPlan::AlreadyCurrent;
351 }
352 if options.execution_path_empty {
353 if options.is_current_lane {
354 ThreadRefreshPlan::ProceedOnCurrentRepo
355 } else {
356 ThreadRefreshPlan::RequiresCurrentCheckout
357 }
358 } else {
359 ThreadRefreshPlan::ProceedOnExecutionPath
360 }
361}
362
363pub fn contains_conflict_marker_bytes(content: &[u8]) -> bool {
368 content
369 .windows("<<<<<<<".len())
370 .any(|window| window == b"<<<<<<<")
371 && content
372 .windows("=======".len())
373 .any(|window| window == b"=======")
374 && content
375 .windows(">>>>>>>".len())
376 .any(|window| window == b">>>>>>>")
377}
378
379pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
382 !contains_conflict_marker_bytes(existing)
383}
384
385pub fn format_refresh_conflict_markers(ours: &[u8], theirs: &[u8]) -> Vec<u8> {
390 let mut out = Vec::with_capacity(ours.len() + theirs.len() + 64);
391 out.extend_from_slice(b"<<<<<<< CURRENT\n");
392 out.extend_from_slice(ours);
393 if !ours.ends_with(b"\n") {
394 out.push(b'\n');
395 }
396 out.extend_from_slice(b"=======\n");
397 out.extend_from_slice(theirs);
398 if !theirs.ends_with(b"\n") {
399 out.push(b'\n');
400 }
401 out.extend_from_slice(b">>>>>>> INCOMING\n");
402 out
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn drop_opts(found: bool) -> ThreadDropOptions {
410 ThreadDropOptions {
411 thread_found: found,
412 is_current_lane: false,
413 delete_thread: false,
414 force: false,
415 mode: ThreadMode::Materialized,
416 execution_path_exists: true,
417 execution_path_is_repo_root: false,
418 execution_path_has_heddle: true,
419 }
420 }
421
422 #[test]
423 fn plan_thread_drop_refuses_missing_current_lane() {
424 let mut opts = drop_opts(false);
425 opts.is_current_lane = true;
426 assert_eq!(
427 plan_thread_drop(&opts),
428 ThreadDropDisposition::RefuseCurrentCheckout
429 );
430 }
431
432 #[test]
433 fn plan_thread_drop_refuses_found_current_lane() {
434 let mut opts = drop_opts(true);
435 opts.is_current_lane = true;
436 assert_eq!(
437 plan_thread_drop(&opts),
438 ThreadDropDisposition::RefuseCurrentCheckout
439 );
440 }
441
442 #[test]
443 fn plan_thread_drop_delete_found_current_lane_uses_branch_delete() {
444 let mut opts = drop_opts(true);
445 opts.is_current_lane = true;
446 opts.delete_thread = true;
447 assert_eq!(
448 plan_thread_drop(&opts),
449 ThreadDropDisposition::ProceedDeleteMissing
450 );
451 }
452
453 #[test]
454 fn plan_thread_drop_does_not_remove_shared_repo_root() {
455 let mut opts = drop_opts(true);
456 opts.execution_path_is_repo_root = true;
457 opts.delete_thread = true;
458 match plan_thread_drop(&opts) {
459 ThreadDropDisposition::Drop(plan) => {
460 assert!(!plan.remove_execution_path);
461 assert!(plan.delete_thread_ref);
462 assert!(plan.mark_abandoned);
463 }
464 other => panic!("expected Drop, got {other:?}"),
465 }
466 }
467
468 #[test]
469 fn plan_thread_drop_delete_missing_record() {
470 let mut opts = drop_opts(false);
471 opts.delete_thread = true;
472 assert_eq!(
473 plan_thread_drop(&opts),
474 ThreadDropDisposition::ProceedDeleteMissing
475 );
476 }
477
478 #[test]
479 fn plan_thread_drop_not_found() {
480 assert_eq!(
481 plan_thread_drop(&drop_opts(false)),
482 ThreadDropDisposition::NotFound
483 );
484 }
485
486 #[test]
487 fn plan_thread_drop_steps_for_virtualized_with_delete() {
488 let mut opts = drop_opts(true);
489 opts.mode = ThreadMode::Virtualized;
490 opts.delete_thread = true;
491 match plan_thread_drop(&opts) {
492 ThreadDropDisposition::Drop(plan) => {
493 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
494 assert!(plan.unmount_virtualized);
495 assert!(plan.remove_execution_path);
496 assert!(plan.remove_manifest);
497 assert!(plan.mark_abandoned);
498 assert!(plan.strip_actor_presence);
499 assert!(plan.delete_thread_ref);
500 }
501 other => panic!("expected Drop, got {other:?}"),
502 }
503 }
504
505 #[test]
506 fn plan_thread_drop_force_skips_clean_guard() {
507 let mut opts = drop_opts(true);
508 opts.force = true;
509 opts.execution_path_exists = false;
510 match plan_thread_drop(&opts) {
511 ThreadDropDisposition::Drop(plan) => {
512 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
513 assert!(!plan.remove_execution_path);
514 assert!(!plan.delete_thread_ref);
515 assert!(!plan.unmount_virtualized);
516 }
517 other => panic!("expected Drop, got {other:?}"),
518 }
519 }
520
521 #[test]
522 fn plan_cleanup_thread_drop_always_deletes_ref() {
523 let plan = plan_cleanup_thread_drop(&ThreadMode::Solid, true);
524 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
525 assert!(plan.delete_thread_ref);
526 assert!(plan.remove_execution_path);
527 assert!(!plan.unmount_virtualized);
528
529 let virt = plan_cleanup_thread_drop(&ThreadMode::Virtualized, false);
530 assert!(virt.unmount_virtualized);
531 assert!(!virt.remove_execution_path);
532 }
533
534 #[test]
535 fn plan_clean_worktree_guard_variants() {
536 assert_eq!(
537 plan_clean_worktree_guard(true, true, false, true),
538 CleanWorktreeGuard::Skip
539 );
540 assert_eq!(
541 plan_clean_worktree_guard(false, true, false, true),
542 CleanWorktreeGuard::OnExecutionPath
543 );
544 assert_eq!(
545 plan_clean_worktree_guard(false, true, true, true),
546 CleanWorktreeGuard::OnCallerRepo
547 );
548 assert_eq!(
549 plan_clean_worktree_guard(false, false, false, false),
550 CleanWorktreeGuard::OnCallerRepo
551 );
552 }
553
554 #[test]
555 fn plan_thread_promote_default_path_and_solid_result() {
556 let plan = plan_thread_promote(&ThreadPromoteOptions {
557 force: false,
558 path: None,
559 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
560 mode: ThreadMode::Materialized,
561 execution_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
562 materialized_path: Some(PathBuf::from("/repo/.heddle/threads/feat/repo")),
563 execution_path_exists: true,
564 execution_path_is_repo_root: false,
565 execution_path_has_heddle: true,
566 });
567 assert!(plan.using_default_path);
568 assert_eq!(
569 plan.target_path,
570 PathBuf::from("/repo/.heddle/threads/feat/repo")
571 );
572 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
573 assert!(!plan.unmount_virtualized);
574 assert_eq!(
575 plan.in_place_conversion_candidate.as_deref(),
576 Some(Path::new("/repo/.heddle/threads/feat/repo"))
577 );
578 assert_eq!(plan.resulting_mode, ThreadMode::Solid);
579 assert_eq!(plan.resulting_state, ThreadState::Promoted);
580 }
581
582 #[test]
583 fn explicit_default_checkout_path_can_convert_in_place() {
584 let path = PathBuf::from("/repo/.heddle/threads/feat/repo");
585 let plan = plan_thread_promote(&ThreadPromoteOptions {
586 force: false,
587 path: Some(path.clone()),
588 default_path: path.clone(),
589 mode: ThreadMode::Materialized,
590 execution_path: path.clone(),
591 materialized_path: Some(path.clone()),
592 execution_path_exists: true,
593 execution_path_is_repo_root: false,
594 execution_path_has_heddle: true,
595 });
596 assert_eq!(plan.in_place_conversion_candidate, Some(path));
597 }
598
599 #[test]
600 fn plan_thread_promote_explicit_path_skips_in_place_candidate() {
601 let plan = plan_thread_promote(&ThreadPromoteOptions {
602 force: true,
603 path: Some(PathBuf::from("/tmp/out")),
604 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
605 mode: ThreadMode::Virtualized,
606 execution_path: PathBuf::from("/mnt/feat"),
607 materialized_path: None,
608 execution_path_exists: true,
609 execution_path_is_repo_root: false,
610 execution_path_has_heddle: false,
611 });
612 assert!(!plan.using_default_path);
613 assert_eq!(plan.target_path, PathBuf::from("/tmp/out"));
614 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
615 assert!(plan.unmount_virtualized);
616 assert!(plan.in_place_conversion_candidate.is_none());
617 }
618
619 #[test]
620 fn promote_existing_prefers_materialized_path() {
621 assert_eq!(
622 promote_existing_checkout_path(Some(Path::new("/mat")), Path::new("/exec")),
623 PathBuf::from("/mat")
624 );
625 assert_eq!(
626 promote_existing_checkout_path(Some(Path::new("")), Path::new("/exec")),
627 PathBuf::from("/exec")
628 );
629 assert_eq!(
630 promote_existing_checkout_path(None, Path::new("/exec")),
631 PathBuf::from("/exec")
632 );
633 }
634
635 #[test]
636 fn promote_confirm_in_place_removal_requires_identity() {
637 let candidate = PathBuf::from("/repo/.heddle/threads/feat/repo");
638 assert!(promote_confirm_in_place_removal(
639 Some(&candidate),
640 true,
641 true
642 ));
643 assert!(!promote_confirm_in_place_removal(
644 Some(&candidate),
645 false,
646 true
647 ));
648 assert!(!promote_confirm_in_place_removal(
649 Some(&candidate),
650 true,
651 false
652 ));
653 assert!(!promote_confirm_in_place_removal(None, true, true));
654 assert!(!promote_confirm_in_place_removal(
655 Some(Path::new("")),
656 true,
657 true
658 ));
659 }
660
661 #[test]
662 fn plan_thread_refresh_dispositions() {
663 let base = ThreadRefreshOptions {
664 has_target_thread: true,
665 freshness: ThreadFreshness::Stale,
666 execution_path_empty: false,
667 is_current_lane: false,
668 };
669 assert_eq!(
670 plan_thread_refresh(&ThreadRefreshOptions {
671 has_target_thread: false,
672 ..base.clone()
673 }),
674 ThreadRefreshPlan::MissingTarget
675 );
676 assert_eq!(
677 plan_thread_refresh(&ThreadRefreshOptions {
678 freshness: ThreadFreshness::Current,
679 ..base.clone()
680 }),
681 ThreadRefreshPlan::AlreadyCurrent
682 );
683 assert_eq!(
684 plan_thread_refresh(&ThreadRefreshOptions {
685 execution_path_empty: true,
686 is_current_lane: false,
687 ..base.clone()
688 }),
689 ThreadRefreshPlan::RequiresCurrentCheckout
690 );
691 assert_eq!(
692 plan_thread_refresh(&ThreadRefreshOptions {
693 execution_path_empty: true,
694 is_current_lane: true,
695 ..base.clone()
696 }),
697 ThreadRefreshPlan::ProceedOnCurrentRepo
698 );
699 assert_eq!(
700 plan_thread_refresh(&base),
701 ThreadRefreshPlan::ProceedOnExecutionPath
702 );
703 }
704
705 #[test]
706 fn conflict_marker_detection_and_format() {
707 let marked = b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n";
708 assert!(contains_conflict_marker_bytes(marked));
709 assert!(!should_materialize_refresh_conflict_markers(marked));
710 assert!(!contains_conflict_marker_bytes(b"clean content"));
711 assert!(should_materialize_refresh_conflict_markers(b"clean"));
712
713 let formatted = format_refresh_conflict_markers(b"ours-line", b"theirs-line\n");
714 assert_eq!(
715 formatted,
716 b"<<<<<<< CURRENT\nours-line\n=======\ntheirs-line\n>>>>>>> INCOMING\n"
717 );
718 let already_nl = format_refresh_conflict_markers(b"a\n", b"b\n");
719 assert_eq!(
720 already_nl,
721 b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n"
722 );
723 }
724}