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,
73 pub delete_thread: bool,
75 pub force: bool,
77 pub mode: ThreadMode,
79 pub execution_path_exists: bool,
80 pub execution_path_is_repo_root: bool,
81 pub execution_path_has_heddle: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ThreadDropPlan {
89 pub clean_worktree: CleanWorktreeGuard,
90 pub unmount_virtualized: bool,
92 pub remove_execution_path: bool,
94 pub remove_manifest: bool,
96 pub mark_abandoned: bool,
98 pub strip_actor_presence: bool,
100 pub delete_thread_ref: bool,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum ThreadDropDisposition {
108 RefuseCurrentCheckout,
110 ProceedDeleteMissing,
112 NotFound,
114 Drop(ThreadDropPlan),
116}
117
118pub fn plan_thread_drop(options: &ThreadDropOptions) -> ThreadDropDisposition {
127 if !options.thread_found {
128 if !options.delete_thread && options.is_current_lane {
129 return ThreadDropDisposition::RefuseCurrentCheckout;
130 }
131 if options.delete_thread {
132 return ThreadDropDisposition::ProceedDeleteMissing;
133 }
134 return ThreadDropDisposition::NotFound;
135 }
136
137 ThreadDropDisposition::Drop(ThreadDropPlan {
138 clean_worktree: plan_clean_worktree_guard(
139 options.force,
140 options.execution_path_exists,
141 options.execution_path_is_repo_root,
142 options.execution_path_has_heddle,
143 ),
144 unmount_virtualized: thread_mode_requires_unmount(&options.mode),
145 remove_execution_path: options.execution_path_exists,
146 remove_manifest: true,
147 mark_abandoned: true,
148 strip_actor_presence: true,
149 delete_thread_ref: options.delete_thread,
150 })
151}
152
153pub fn plan_cleanup_thread_drop(mode: &ThreadMode, execution_path_exists: bool) -> ThreadDropPlan {
159 ThreadDropPlan {
160 clean_worktree: CleanWorktreeGuard::Skip,
161 unmount_virtualized: thread_mode_requires_unmount(mode),
162 remove_execution_path: execution_path_exists,
163 remove_manifest: true,
164 mark_abandoned: true,
165 strip_actor_presence: true,
166 delete_thread_ref: true,
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ThreadPromoteOptions {
177 pub force: bool,
178 pub path: Option<PathBuf>,
180 pub default_path: PathBuf,
184 pub mode: ThreadMode,
185 pub execution_path: PathBuf,
186 pub materialized_path: Option<PathBuf>,
187 pub execution_path_exists: bool,
188 pub execution_path_is_repo_root: bool,
189 pub execution_path_has_heddle: bool,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct ThreadPromotePlan {
198 pub using_default_path: bool,
200 pub target_path: PathBuf,
201 pub clean_worktree: CleanWorktreeGuard,
202 pub unmount_virtualized: bool,
203 pub in_place_conversion_candidate: Option<PathBuf>,
207 pub resulting_mode: ThreadMode,
209 pub resulting_state: ThreadState,
211}
212
213pub fn resolve_promote_target_path(
215 path: Option<PathBuf>,
216 default_path: PathBuf,
217) -> (PathBuf, bool) {
218 match path {
219 Some(explicit) => (explicit, false),
220 None => (default_path, true),
221 }
222}
223
224pub fn promote_existing_checkout_path(
228 materialized_path: Option<&Path>,
229 execution_path: &Path,
230) -> PathBuf {
231 materialized_path
232 .filter(|p| !p.as_os_str().is_empty())
233 .map(Path::to_path_buf)
234 .unwrap_or_else(|| execution_path.to_path_buf())
235}
236
237pub fn promote_in_place_conversion_candidate(
243 using_default_path: bool,
244 mode: &ThreadMode,
245 existing: PathBuf,
246) -> Option<PathBuf> {
247 if using_default_path && matches!(mode, ThreadMode::Materialized | ThreadMode::Solid) {
248 Some(existing)
249 } else {
250 None
251 }
252}
253
254pub fn promote_confirm_in_place_removal(
259 candidate: Option<&Path>,
260 existing_has_heddle: bool,
261 same_as_target: bool,
262) -> bool {
263 let Some(existing) = candidate else {
264 return false;
265 };
266 !existing.as_os_str().is_empty() && existing_has_heddle && same_as_target
267}
268
269pub fn plan_thread_promote(options: &ThreadPromoteOptions) -> ThreadPromotePlan {
271 let (target_path, using_default_path) =
272 resolve_promote_target_path(options.path.clone(), options.default_path.clone());
273 let existing = promote_existing_checkout_path(
274 options.materialized_path.as_deref(),
275 &options.execution_path,
276 );
277 ThreadPromotePlan {
278 using_default_path,
279 target_path,
280 clean_worktree: plan_clean_worktree_guard(
281 options.force,
282 options.execution_path_exists,
283 options.execution_path_is_repo_root,
284 options.execution_path_has_heddle,
285 ),
286 unmount_virtualized: thread_mode_requires_unmount(&options.mode),
287 in_place_conversion_candidate: promote_in_place_conversion_candidate(
288 using_default_path,
289 &options.mode,
290 existing,
291 ),
292 resulting_mode: ThreadMode::Solid,
293 resulting_state: ThreadState::Promoted,
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct ThreadRefreshOptions {
304 pub has_target_thread: bool,
306 pub freshness: ThreadFreshness,
307 pub execution_path_empty: bool,
309 pub is_current_lane: bool,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum ThreadRefreshPlan {
316 MissingTarget,
318 AlreadyCurrent,
320 RequiresCurrentCheckout,
322 ProceedOnCurrentRepo,
324 ProceedOnExecutionPath,
326}
327
328pub fn plan_thread_refresh(options: &ThreadRefreshOptions) -> ThreadRefreshPlan {
337 if !options.has_target_thread {
338 return ThreadRefreshPlan::MissingTarget;
339 }
340 if options.freshness == ThreadFreshness::Current {
341 return ThreadRefreshPlan::AlreadyCurrent;
342 }
343 if options.execution_path_empty {
344 if options.is_current_lane {
345 ThreadRefreshPlan::ProceedOnCurrentRepo
346 } else {
347 ThreadRefreshPlan::RequiresCurrentCheckout
348 }
349 } else {
350 ThreadRefreshPlan::ProceedOnExecutionPath
351 }
352}
353
354pub fn contains_conflict_marker_bytes(content: &[u8]) -> bool {
359 content
360 .windows("<<<<<<<".len())
361 .any(|window| window == b"<<<<<<<")
362 && content
363 .windows("=======".len())
364 .any(|window| window == b"=======")
365 && content
366 .windows(">>>>>>>".len())
367 .any(|window| window == b">>>>>>>")
368}
369
370pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
373 !contains_conflict_marker_bytes(existing)
374}
375
376pub fn format_refresh_conflict_markers(ours: &[u8], theirs: &[u8]) -> Vec<u8> {
381 let mut out = Vec::with_capacity(ours.len() + theirs.len() + 64);
382 out.extend_from_slice(b"<<<<<<< CURRENT\n");
383 out.extend_from_slice(ours);
384 if !ours.ends_with(b"\n") {
385 out.push(b'\n');
386 }
387 out.extend_from_slice(b"=======\n");
388 out.extend_from_slice(theirs);
389 if !theirs.ends_with(b"\n") {
390 out.push(b'\n');
391 }
392 out.extend_from_slice(b">>>>>>> INCOMING\n");
393 out
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn drop_opts(found: bool) -> ThreadDropOptions {
401 ThreadDropOptions {
402 thread_found: found,
403 is_current_lane: false,
404 delete_thread: false,
405 force: false,
406 mode: ThreadMode::Materialized,
407 execution_path_exists: true,
408 execution_path_is_repo_root: false,
409 execution_path_has_heddle: true,
410 }
411 }
412
413 #[test]
414 fn plan_thread_drop_refuses_missing_current_lane() {
415 let mut opts = drop_opts(false);
416 opts.is_current_lane = true;
417 assert_eq!(
418 plan_thread_drop(&opts),
419 ThreadDropDisposition::RefuseCurrentCheckout
420 );
421 }
422
423 #[test]
424 fn plan_thread_drop_delete_missing_record() {
425 let mut opts = drop_opts(false);
426 opts.delete_thread = true;
427 assert_eq!(
428 plan_thread_drop(&opts),
429 ThreadDropDisposition::ProceedDeleteMissing
430 );
431 }
432
433 #[test]
434 fn plan_thread_drop_not_found() {
435 assert_eq!(
436 plan_thread_drop(&drop_opts(false)),
437 ThreadDropDisposition::NotFound
438 );
439 }
440
441 #[test]
442 fn plan_thread_drop_steps_for_virtualized_with_delete() {
443 let mut opts = drop_opts(true);
444 opts.mode = ThreadMode::Virtualized;
445 opts.delete_thread = true;
446 match plan_thread_drop(&opts) {
447 ThreadDropDisposition::Drop(plan) => {
448 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
449 assert!(plan.unmount_virtualized);
450 assert!(plan.remove_execution_path);
451 assert!(plan.remove_manifest);
452 assert!(plan.mark_abandoned);
453 assert!(plan.strip_actor_presence);
454 assert!(plan.delete_thread_ref);
455 }
456 other => panic!("expected Drop, got {other:?}"),
457 }
458 }
459
460 #[test]
461 fn plan_thread_drop_force_skips_clean_guard() {
462 let mut opts = drop_opts(true);
463 opts.force = true;
464 opts.execution_path_exists = false;
465 match plan_thread_drop(&opts) {
466 ThreadDropDisposition::Drop(plan) => {
467 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
468 assert!(!plan.remove_execution_path);
469 assert!(!plan.delete_thread_ref);
470 assert!(!plan.unmount_virtualized);
471 }
472 other => panic!("expected Drop, got {other:?}"),
473 }
474 }
475
476 #[test]
477 fn plan_cleanup_thread_drop_always_deletes_ref() {
478 let plan = plan_cleanup_thread_drop(&ThreadMode::Solid, true);
479 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
480 assert!(plan.delete_thread_ref);
481 assert!(plan.remove_execution_path);
482 assert!(!plan.unmount_virtualized);
483
484 let virt = plan_cleanup_thread_drop(&ThreadMode::Virtualized, false);
485 assert!(virt.unmount_virtualized);
486 assert!(!virt.remove_execution_path);
487 }
488
489 #[test]
490 fn plan_clean_worktree_guard_variants() {
491 assert_eq!(
492 plan_clean_worktree_guard(true, true, false, true),
493 CleanWorktreeGuard::Skip
494 );
495 assert_eq!(
496 plan_clean_worktree_guard(false, true, false, true),
497 CleanWorktreeGuard::OnExecutionPath
498 );
499 assert_eq!(
500 plan_clean_worktree_guard(false, true, true, true),
501 CleanWorktreeGuard::OnCallerRepo
502 );
503 assert_eq!(
504 plan_clean_worktree_guard(false, false, false, false),
505 CleanWorktreeGuard::OnCallerRepo
506 );
507 }
508
509 #[test]
510 fn plan_thread_promote_default_path_and_solid_result() {
511 let plan = plan_thread_promote(&ThreadPromoteOptions {
512 force: false,
513 path: None,
514 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
515 mode: ThreadMode::Materialized,
516 execution_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
517 materialized_path: Some(PathBuf::from("/repo/.heddle/threads/feat/repo")),
518 execution_path_exists: true,
519 execution_path_is_repo_root: false,
520 execution_path_has_heddle: true,
521 });
522 assert!(plan.using_default_path);
523 assert_eq!(
524 plan.target_path,
525 PathBuf::from("/repo/.heddle/threads/feat/repo")
526 );
527 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::OnExecutionPath);
528 assert!(!plan.unmount_virtualized);
529 assert_eq!(
530 plan.in_place_conversion_candidate.as_deref(),
531 Some(Path::new("/repo/.heddle/threads/feat/repo"))
532 );
533 assert_eq!(plan.resulting_mode, ThreadMode::Solid);
534 assert_eq!(plan.resulting_state, ThreadState::Promoted);
535 }
536
537 #[test]
538 fn plan_thread_promote_explicit_path_skips_in_place_candidate() {
539 let plan = plan_thread_promote(&ThreadPromoteOptions {
540 force: true,
541 path: Some(PathBuf::from("/tmp/out")),
542 default_path: PathBuf::from("/repo/.heddle/threads/feat/repo"),
543 mode: ThreadMode::Virtualized,
544 execution_path: PathBuf::from("/mnt/feat"),
545 materialized_path: None,
546 execution_path_exists: true,
547 execution_path_is_repo_root: false,
548 execution_path_has_heddle: false,
549 });
550 assert!(!plan.using_default_path);
551 assert_eq!(plan.target_path, PathBuf::from("/tmp/out"));
552 assert_eq!(plan.clean_worktree, CleanWorktreeGuard::Skip);
553 assert!(plan.unmount_virtualized);
554 assert!(plan.in_place_conversion_candidate.is_none());
555 }
556
557 #[test]
558 fn promote_existing_prefers_materialized_path() {
559 assert_eq!(
560 promote_existing_checkout_path(Some(Path::new("/mat")), Path::new("/exec")),
561 PathBuf::from("/mat")
562 );
563 assert_eq!(
564 promote_existing_checkout_path(Some(Path::new("")), Path::new("/exec")),
565 PathBuf::from("/exec")
566 );
567 assert_eq!(
568 promote_existing_checkout_path(None, Path::new("/exec")),
569 PathBuf::from("/exec")
570 );
571 }
572
573 #[test]
574 fn promote_confirm_in_place_removal_requires_identity() {
575 let candidate = PathBuf::from("/repo/.heddle/threads/feat/repo");
576 assert!(promote_confirm_in_place_removal(
577 Some(&candidate),
578 true,
579 true
580 ));
581 assert!(!promote_confirm_in_place_removal(
582 Some(&candidate),
583 false,
584 true
585 ));
586 assert!(!promote_confirm_in_place_removal(
587 Some(&candidate),
588 true,
589 false
590 ));
591 assert!(!promote_confirm_in_place_removal(None, true, true));
592 assert!(!promote_confirm_in_place_removal(
593 Some(Path::new("")),
594 true,
595 true
596 ));
597 }
598
599 #[test]
600 fn plan_thread_refresh_dispositions() {
601 let base = ThreadRefreshOptions {
602 has_target_thread: true,
603 freshness: ThreadFreshness::Stale,
604 execution_path_empty: false,
605 is_current_lane: false,
606 };
607 assert_eq!(
608 plan_thread_refresh(&ThreadRefreshOptions {
609 has_target_thread: false,
610 ..base.clone()
611 }),
612 ThreadRefreshPlan::MissingTarget
613 );
614 assert_eq!(
615 plan_thread_refresh(&ThreadRefreshOptions {
616 freshness: ThreadFreshness::Current,
617 ..base.clone()
618 }),
619 ThreadRefreshPlan::AlreadyCurrent
620 );
621 assert_eq!(
622 plan_thread_refresh(&ThreadRefreshOptions {
623 execution_path_empty: true,
624 is_current_lane: false,
625 ..base.clone()
626 }),
627 ThreadRefreshPlan::RequiresCurrentCheckout
628 );
629 assert_eq!(
630 plan_thread_refresh(&ThreadRefreshOptions {
631 execution_path_empty: true,
632 is_current_lane: true,
633 ..base.clone()
634 }),
635 ThreadRefreshPlan::ProceedOnCurrentRepo
636 );
637 assert_eq!(
638 plan_thread_refresh(&base),
639 ThreadRefreshPlan::ProceedOnExecutionPath
640 );
641 }
642
643 #[test]
644 fn conflict_marker_detection_and_format() {
645 let marked = b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n";
646 assert!(contains_conflict_marker_bytes(marked));
647 assert!(!should_materialize_refresh_conflict_markers(marked));
648 assert!(!contains_conflict_marker_bytes(b"clean content"));
649 assert!(should_materialize_refresh_conflict_markers(b"clean"));
650
651 let formatted = format_refresh_conflict_markers(b"ours-line", b"theirs-line\n");
652 assert_eq!(
653 formatted,
654 b"<<<<<<< CURRENT\nours-line\n=======\ntheirs-line\n>>>>>>> INCOMING\n"
655 );
656 let already_nl = format_refresh_conflict_markers(b"a\n", b"b\n");
657 assert_eq!(
658 already_nl,
659 b"<<<<<<< CURRENT\na\n=======\nb\n>>>>>>> INCOMING\n"
660 );
661 }
662}