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) => (explicit, false),
226 None => (default_path, true),
227 }
228}
229
230pub fn promote_existing_checkout_path(
234 materialized_path: Option<&Path>,
235 execution_path: &Path,
236) -> PathBuf {
237 materialized_path
238 .filter(|p| !p.as_os_str().is_empty())
239 .map(Path::to_path_buf)
240 .unwrap_or_else(|| execution_path.to_path_buf())
241}
242
243pub fn promote_in_place_conversion_candidate(
249 using_default_path: bool,
250 mode: &ThreadMode,
251 existing: PathBuf,
252) -> Option<PathBuf> {
253 if using_default_path && matches!(mode, ThreadMode::Materialized | ThreadMode::Solid) {
254 Some(existing)
255 } else {
256 None
257 }
258}
259
260pub fn promote_confirm_in_place_removal(
265 candidate: Option<&Path>,
266 existing_has_heddle: bool,
267 same_as_target: bool,
268) -> bool {
269 let Some(existing) = candidate else {
270 return false;
271 };
272 !existing.as_os_str().is_empty() && existing_has_heddle && same_as_target
273}
274
275pub fn plan_thread_promote(options: &ThreadPromoteOptions) -> ThreadPromotePlan {
277 let (target_path, using_default_path) =
278 resolve_promote_target_path(options.path.clone(), options.default_path.clone());
279 let existing = promote_existing_checkout_path(
280 options.materialized_path.as_deref(),
281 &options.execution_path,
282 );
283 ThreadPromotePlan {
284 using_default_path,
285 target_path,
286 clean_worktree: plan_clean_worktree_guard(
287 options.force,
288 options.execution_path_exists,
289 options.execution_path_is_repo_root,
290 options.execution_path_has_heddle,
291 ),
292 unmount_virtualized: thread_mode_requires_unmount(&options.mode),
293 in_place_conversion_candidate: promote_in_place_conversion_candidate(
294 using_default_path,
295 &options.mode,
296 existing,
297 ),
298 resulting_mode: ThreadMode::Solid,
299 resulting_state: ThreadState::Promoted,
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct ThreadRefreshOptions {
310 pub has_target_thread: bool,
312 pub freshness: ThreadFreshness,
313 pub execution_path_empty: bool,
315 pub is_current_lane: bool,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ThreadRefreshPlan {
322 MissingTarget,
324 AlreadyCurrent,
326 RequiresCurrentCheckout,
328 ProceedOnCurrentRepo,
330 ProceedOnExecutionPath,
332}
333
334pub fn plan_thread_refresh(options: &ThreadRefreshOptions) -> ThreadRefreshPlan {
343 if !options.has_target_thread {
344 return ThreadRefreshPlan::MissingTarget;
345 }
346 if options.freshness == ThreadFreshness::Current {
347 return ThreadRefreshPlan::AlreadyCurrent;
348 }
349 if options.execution_path_empty {
350 if options.is_current_lane {
351 ThreadRefreshPlan::ProceedOnCurrentRepo
352 } else {
353 ThreadRefreshPlan::RequiresCurrentCheckout
354 }
355 } else {
356 ThreadRefreshPlan::ProceedOnExecutionPath
357 }
358}
359
360pub fn contains_conflict_marker_bytes(content: &[u8]) -> bool {
365 content
366 .windows("<<<<<<<".len())
367 .any(|window| window == b"<<<<<<<")
368 && content
369 .windows("=======".len())
370 .any(|window| window == b"=======")
371 && content
372 .windows(">>>>>>>".len())
373 .any(|window| window == b">>>>>>>")
374}
375
376pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
379 !contains_conflict_marker_bytes(existing)
380}
381
382pub fn format_refresh_conflict_markers(ours: &[u8], theirs: &[u8]) -> Vec<u8> {
387 let mut out = Vec::with_capacity(ours.len() + theirs.len() + 64);
388 out.extend_from_slice(b"<<<<<<< CURRENT\n");
389 out.extend_from_slice(ours);
390 if !ours.ends_with(b"\n") {
391 out.push(b'\n');
392 }
393 out.extend_from_slice(b"=======\n");
394 out.extend_from_slice(theirs);
395 if !theirs.ends_with(b"\n") {
396 out.push(b'\n');
397 }
398 out.extend_from_slice(b">>>>>>> INCOMING\n");
399 out
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn drop_opts(found: bool) -> ThreadDropOptions {
407 ThreadDropOptions {
408 thread_found: found,
409 is_current_lane: false,
410 delete_thread: false,
411 force: false,
412 mode: ThreadMode::Materialized,
413 execution_path_exists: true,
414 execution_path_is_repo_root: false,
415 execution_path_has_heddle: true,
416 }
417 }
418
419 #[test]
420 fn plan_thread_drop_refuses_missing_current_lane() {
421 let mut opts = drop_opts(false);
422 opts.is_current_lane = true;
423 assert_eq!(
424 plan_thread_drop(&opts),
425 ThreadDropDisposition::RefuseCurrentCheckout
426 );
427 }
428
429 #[test]
430 fn plan_thread_drop_refuses_found_current_lane() {
431 let mut opts = drop_opts(true);
432 opts.is_current_lane = true;
433 assert_eq!(
434 plan_thread_drop(&opts),
435 ThreadDropDisposition::RefuseCurrentCheckout
436 );
437 }
438
439 #[test]
440 fn plan_thread_drop_delete_found_current_lane_uses_branch_delete() {
441 let mut opts = drop_opts(true);
442 opts.is_current_lane = true;
443 opts.delete_thread = true;
444 assert_eq!(
445 plan_thread_drop(&opts),
446 ThreadDropDisposition::ProceedDeleteMissing
447 );
448 }
449
450 #[test]
451 fn plan_thread_drop_does_not_remove_shared_repo_root() {
452 let mut opts = drop_opts(true);
453 opts.execution_path_is_repo_root = true;
454 opts.delete_thread = true;
455 match plan_thread_drop(&opts) {
456 ThreadDropDisposition::Drop(plan) => {
457 assert!(!plan.remove_execution_path);
458 assert!(plan.delete_thread_ref);
459 assert!(plan.mark_abandoned);
460 }
461 other => panic!("expected Drop, got {other:?}"),
462 }
463 }
464
465 #[test]
466 fn plan_thread_drop_delete_missing_record() {
467 let mut opts = drop_opts(false);
468 opts.delete_thread = true;
469 assert_eq!(
470 plan_thread_drop(&opts),
471 ThreadDropDisposition::ProceedDeleteMissing
472 );
473 }
474
475 #[test]
476 fn plan_thread_drop_not_found() {
477 assert_eq!(
478 plan_thread_drop(&drop_opts(false)),
479 ThreadDropDisposition::NotFound
480 );
481 }
482
483 #[test]
484 fn plan_thread_drop_steps_for_virtualized_with_delete() {
485 let mut opts = drop_opts(true);
486 opts.mode = ThreadMode::Virtualized;
487 opts.delete_thread = true;
488 match plan_thread_drop(&opts) {
489 ThreadDropDisposition::Drop(plan) => {
490 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
491 assert!(plan.unmount_virtualized);
492 assert!(plan.remove_execution_path);
493 assert!(plan.remove_manifest);
494 assert!(plan.mark_abandoned);
495 assert!(plan.strip_actor_presence);
496 assert!(plan.delete_thread_ref);
497 }
498 other => panic!("expected Drop, got {other:?}"),
499 }
500 }
501
502 #[test]
503 fn plan_thread_drop_force_skips_clean_guard() {
504 let mut opts = drop_opts(true);
505 opts.force = true;
506 opts.execution_path_exists = false;
507 match plan_thread_drop(&opts) {
508 ThreadDropDisposition::Drop(plan) => {
509 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
510 assert!(!plan.remove_execution_path);
511 assert!(!plan.delete_thread_ref);
512 assert!(!plan.unmount_virtualized);
513 }
514 other => panic!("expected Drop, got {other:?}"),
515 }
516 }
517
518 #[test]
519 fn plan_cleanup_thread_drop_always_deletes_ref() {
520 let plan = plan_cleanup_thread_drop(&ThreadMode::Solid, true);
521 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
522 assert!(plan.delete_thread_ref);
523 assert!(plan.remove_execution_path);
524 assert!(!plan.unmount_virtualized);
525
526 let virt = plan_cleanup_thread_drop(&ThreadMode::Virtualized, false);
527 assert!(virt.unmount_virtualized);
528 assert!(!virt.remove_execution_path);
529 }
530
531 #[test]
532 fn plan_clean_worktree_guard_variants() {
533 assert_eq!(
534 plan_clean_worktree_guard(true, true, false, true),
535 CleanWorktreeGuard::Skip
536 );
537 assert_eq!(
538 plan_clean_worktree_guard(false, true, false, true),
539 CleanWorktreeGuard::OnExecutionPath
540 );
541 assert_eq!(
542 plan_clean_worktree_guard(false, true, true, true),
543 CleanWorktreeGuard::OnCallerRepo
544 );
545 assert_eq!(
546 plan_clean_worktree_guard(false, false, false, false),
547 CleanWorktreeGuard::OnCallerRepo
548 );
549 }
550
551 #[test]
552 fn plan_thread_promote_default_path_and_solid_result() {
553 let plan = plan_thread_promote(&ThreadPromoteOptions {
554 force: false,
555 path: None,
556 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
557 mode: ThreadMode::Materialized,
558 execution_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
559 materialized_path: Some(PathBuf::from("/repo/.heddle/threads/feat/repo")),
560 execution_path_exists: true,
561 execution_path_is_repo_root: false,
562 execution_path_has_heddle: true,
563 });
564 assert!(plan.using_default_path);
565 assert_eq!(
566 plan.target_path,
567 PathBuf::from("/repo/.heddle/threads/feat/repo")
568 );
569 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
570 assert!(!plan.unmount_virtualized);
571 assert_eq!(
572 plan.in_place_conversion_candidate.as_deref(),
573 Some(Path::new("/repo/.heddle/threads/feat/repo"))
574 );
575 assert_eq!(plan.resulting_mode, ThreadMode::Solid);
576 assert_eq!(plan.resulting_state, ThreadState::Promoted);
577 }
578
579 #[test]
580 fn plan_thread_promote_explicit_path_skips_in_place_candidate() {
581 let plan = plan_thread_promote(&ThreadPromoteOptions {
582 force: true,
583 path: Some(PathBuf::from("/tmp/out")),
584 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
585 mode: ThreadMode::Virtualized,
586 execution_path: PathBuf::from("/mnt/feat"),
587 materialized_path: None,
588 execution_path_exists: true,
589 execution_path_is_repo_root: false,
590 execution_path_has_heddle: false,
591 });
592 assert!(!plan.using_default_path);
593 assert_eq!(plan.target_path, PathBuf::from("/tmp/out"));
594 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
595 assert!(plan.unmount_virtualized);
596 assert!(plan.in_place_conversion_candidate.is_none());
597 }
598
599 #[test]
600 fn promote_existing_prefers_materialized_path() {
601 assert_eq!(
602 promote_existing_checkout_path(Some(Path::new("/mat")), Path::new("/exec")),
603 PathBuf::from("/mat")
604 );
605 assert_eq!(
606 promote_existing_checkout_path(Some(Path::new("")), Path::new("/exec")),
607 PathBuf::from("/exec")
608 );
609 assert_eq!(
610 promote_existing_checkout_path(None, Path::new("/exec")),
611 PathBuf::from("/exec")
612 );
613 }
614
615 #[test]
616 fn promote_confirm_in_place_removal_requires_identity() {
617 let candidate = PathBuf::from("/repo/.heddle/threads/feat/repo");
618 assert!(promote_confirm_in_place_removal(
619 Some(&candidate),
620 true,
621 true
622 ));
623 assert!(!promote_confirm_in_place_removal(
624 Some(&candidate),
625 false,
626 true
627 ));
628 assert!(!promote_confirm_in_place_removal(
629 Some(&candidate),
630 true,
631 false
632 ));
633 assert!(!promote_confirm_in_place_removal(None, true, true));
634 assert!(!promote_confirm_in_place_removal(
635 Some(Path::new("")),
636 true,
637 true
638 ));
639 }
640
641 #[test]
642 fn plan_thread_refresh_dispositions() {
643 let base = ThreadRefreshOptions {
644 has_target_thread: true,
645 freshness: ThreadFreshness::Stale,
646 execution_path_empty: false,
647 is_current_lane: false,
648 };
649 assert_eq!(
650 plan_thread_refresh(&ThreadRefreshOptions {
651 has_target_thread: false,
652 ..base.clone()
653 }),
654 ThreadRefreshPlan::MissingTarget
655 );
656 assert_eq!(
657 plan_thread_refresh(&ThreadRefreshOptions {
658 freshness: ThreadFreshness::Current,
659 ..base.clone()
660 }),
661 ThreadRefreshPlan::AlreadyCurrent
662 );
663 assert_eq!(
664 plan_thread_refresh(&ThreadRefreshOptions {
665 execution_path_empty: true,
666 is_current_lane: false,
667 ..base.clone()
668 }),
669 ThreadRefreshPlan::RequiresCurrentCheckout
670 );
671 assert_eq!(
672 plan_thread_refresh(&ThreadRefreshOptions {
673 execution_path_empty: true,
674 is_current_lane: true,
675 ..base.clone()
676 }),
677 ThreadRefreshPlan::ProceedOnCurrentRepo
678 );
679 assert_eq!(
680 plan_thread_refresh(&base),
681 ThreadRefreshPlan::ProceedOnExecutionPath
682 );
683 }
684
685 #[test]
686 fn conflict_marker_detection_and_format() {
687 let marked = b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n";
688 assert!(contains_conflict_marker_bytes(marked));
689 assert!(!should_materialize_refresh_conflict_markers(marked));
690 assert!(!contains_conflict_marker_bytes(b"clean content"));
691 assert!(should_materialize_refresh_conflict_markers(b"clean"));
692
693 let formatted = format_refresh_conflict_markers(b"ours-line", b"theirs-line\n");
694 assert_eq!(
695 formatted,
696 b"<<<<<<< CURRENT\nours-line\n=======\ntheirs-line\n>>>>>>> INCOMING\n"
697 );
698 let already_nl = format_refresh_conflict_markers(b"a\n", b"b\n");
699 assert_eq!(
700 already_nl,
701 b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n"
702 );
703 }
704}