Skip to main content

verbs/
thread_lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure thread drop / promote / refresh planning.
3//!
4//! Owns decision logic shared by `heddle thread drop`, `heddle thread promote`,
5//! `heddle thread refresh`, and cleanup sweeps:
6//! - drop disposition (refuse current / missing / delete-missing / drop steps)
7//! - what a drop removes (unmount? checkout? ref? registry?)
8//! - promote path defaults and in-place conversion preconditions
9//! - refresh checkout selection and conflict-marker materialization (pure)
10//!
11//! FS materialization, merge apply, mount RPCs, registry I/O, and recovery
12//! advice strings stay CLI-owned. Callers resolve path/mode/freshness facts
13//! first, then invoke these helpers.
14
15use std::path::{Path, PathBuf};
16
17use repo::{ThreadFreshness, ThreadMode, ThreadState};
18
19// ---------------------------------------------------------------------------
20// Shared clean-worktree guard (drop + promote)
21// ---------------------------------------------------------------------------
22
23/// Where the clean-worktree preflight should look before mutating a thread.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CleanWorktreeGuard {
26    /// `--force` (or equivalent): skip the clean-worktree check.
27    Skip,
28    /// Open the isolated execution path (has its own `.heddle`) and check there.
29    OnExecutionPath,
30    /// Check the caller's repository worktree.
31    OnCallerRepo,
32}
33
34/// Select the clean-worktree guard from force + execution-path facts.
35///
36/// Matches CLI drop/promote: when the thread has an isolated checkout that is
37/// not the repo root and contains `.heddle`, guard that tree; otherwise guard
38/// the caller's repo. Force always skips.
39pub 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
55/// Whether a thread mode owns a FUSE/virtual mount that must be torn down
56/// before the checkout directory is removed or replaced.
57pub fn thread_mode_requires_unmount(mode: &ThreadMode) -> bool {
58    matches!(mode, ThreadMode::Virtualized)
59}
60
61// ---------------------------------------------------------------------------
62// Drop
63// ---------------------------------------------------------------------------
64
65/// Caller-supplied facts for pure drop preflight (no I/O).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ThreadDropOptions {
68    /// Whether a managed thread record was loaded for the requested id/name.
69    pub thread_found: bool,
70    /// True when the request names the attached current lane.
71    pub is_current_lane: bool,
72    /// `heddle thread drop --delete-thread` (or cleanup-equivalent).
73    pub delete_thread: bool,
74    /// Skip clean-worktree preflight.
75    pub force: bool,
76    /// Record mode when found; ignored when missing.
77    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/// Pure plan describing what a successful drop should remove / update.
84///
85/// FS, mount, registry, and ref mutations remain with the caller.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ThreadDropPlan {
88    pub clean_worktree: CleanWorktreeGuard,
89    /// Tear down a virtualized mount before removing the execution path.
90    pub unmount_virtualized: bool,
91    /// Remove `execution_path` when it exists on disk.
92    pub remove_execution_path: bool,
93    /// Always drop the per-thread manifest sidecar.
94    pub remove_manifest: bool,
95    /// Mark the manager record [`ThreadState::Abandoned`].
96    pub mark_abandoned: bool,
97    /// Strip agent-registry entries matching thread name or id.
98    pub strip_actor_presence: bool,
99    /// Delete the live thread ref when present (ordinary drop only with
100    /// `--delete-thread`; cleanup always requests this).
101    pub delete_thread_ref: bool,
102}
103
104/// Outcome of pure drop planning before any mutation.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ThreadDropDisposition {
107    /// Missing record, attached as current checkout, no `--delete-thread`.
108    RefuseCurrentCheckout,
109    /// Missing record with `--delete-thread`: fall through to thread delete.
110    ProceedDeleteMissing,
111    /// Missing record and not recoverable via delete.
112    NotFound,
113    /// Record exists: perform the planned tear-down steps.
114    Drop(ThreadDropPlan),
115}
116
117/// Pure preflight for `heddle thread drop` / `drop_thread_silent`.
118///
119/// Rules (matching CLI):
120/// 1. Current lane + no delete flag → refuse
121/// 2. Current lane + delete flag → proceed to delete command (typed
122///    `branch_delete_current` advice; do not tear down the checkout)
123/// 3. Missing + delete flag → proceed to delete command
124/// 4. Missing otherwise → not found
125/// 5. Found → drop plan (unmount if virtualized, remove a dedicated
126///    checkout if present, abandon record, strip agents, optionally
127///    delete ref). Never remove the shared repo root.
128pub 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
159/// Pure plan for a cleanup sweep drop (`thread cleanup`).
160///
161/// Stronger than ordinary drop: always deletes the live thread ref when
162/// present. Clean-worktree is skipped (cleanup already selected merged/stale
163/// threads and never runs the force gate).
164pub 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// ---------------------------------------------------------------------------
177// Promote
178// ---------------------------------------------------------------------------
179
180/// Caller-supplied facts for pure promote preflight (no I/O).
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ThreadPromoteOptions {
183    pub force: bool,
184    /// Explicit `--path` from the caller, if any.
185    pub path: Option<PathBuf>,
186    /// Canonical managed checkout path (`repo.managed_checkout_path(id)`).
187    /// Used when `path` is `None` so promote lands under the same layout as
188    /// `start` / the per-thread manifest (heddle#572).
189    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/// Pure plan for `heddle thread promote`.
199///
200/// Materialization, mount teardown RPCs, and same-inode path confirmation
201/// remain with the caller.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ThreadPromotePlan {
204    /// True when the caller did not supply `--path`.
205    pub using_default_path: bool,
206    pub target_path: PathBuf,
207    pub clean_worktree: CleanWorktreeGuard,
208    pub unmount_virtualized: bool,
209    /// Candidate checkout to tear down before rematerializing into the default
210    /// path (in-place Materialized/Solid conversion). Caller must still confirm
211    /// `.heddle` presence and path identity before removing.
212    pub in_place_conversion_candidate: Option<PathBuf>,
213    /// Resulting workspace mode after a successful promote.
214    pub resulting_mode: ThreadMode,
215    /// Resulting lifecycle state after a successful promote.
216    pub resulting_state: ThreadState,
217}
218
219/// Resolve the promote target path and whether the default was used.
220pub 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
233/// Existing checkout path preferred for identity / in-place conversion checks.
234///
235/// Prefers a non-empty `materialized_path`, else falls back to `execution_path`.
236pub 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
246/// Whether promote should consider tearing down the thread's own existing
247/// checkout before writing a solid tree at the default path.
248///
249/// Final removal still requires FS checks (`.heddle` exists, same directory as
250/// target) via [`promote_confirm_in_place_removal`].
251pub 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
263/// Confirm in-place conversion teardown after FS identity facts are known.
264///
265/// `same_as_target` should be true when the candidate and promote target
266/// resolve to the same directory (canonicalized when both exist).
267pub 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
278/// Pure option preflight for `heddle thread promote`.
279pub 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// ---------------------------------------------------------------------------
307// Refresh
308// ---------------------------------------------------------------------------
309
310/// Caller-supplied facts for pure refresh preflight (no I/O).
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct ThreadRefreshOptions {
313    /// Whether the thread record has a `target_thread` configured.
314    pub has_target_thread: bool,
315    pub freshness: ThreadFreshness,
316    /// True when `execution_path` is empty (branch-like / in-repo checkout).
317    pub execution_path_empty: bool,
318    /// Whether the caller's current lane matches this thread.
319    pub is_current_lane: bool,
320}
321
322/// Pure disposition for `heddle thread refresh` before rebase/merge I/O.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum ThreadRefreshPlan {
325    /// No integration target configured on the thread record.
326    MissingTarget,
327    /// Already current relative to target — no rebase/merge needed.
328    AlreadyCurrent,
329    /// Branch-like thread (empty execution path) but not the current checkout.
330    RequiresCurrentCheckout,
331    /// Rebase/merge against the caller's open repository (current lane).
332    ProceedOnCurrentRepo,
333    /// Open `execution_path` and refresh that isolated checkout.
334    ProceedOnExecutionPath,
335}
336
337/// Pure preflight for refresh checkout selection and no-op / refusal gates.
338///
339/// Rules (matching CLI `refresh_thread`):
340/// 1. No target → missing target
341/// 2. Freshness current → already current
342/// 3. Empty execution path + current lane → proceed on caller repo
343/// 4. Empty execution path + not current → requires current checkout
344/// 5. Non-empty execution path → proceed on that path
345pub 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
363/// Whether existing file bytes already contain full conflict-marker triplets.
364///
365/// Used so refresh does not overwrite a user-edited conflicted file when
366/// materializing markers after a conflicted 3-way merge.
367pub 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
379/// Whether refresh should write conflict markers for a path given its current
380/// on-disk content.
381pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
382    !contains_conflict_marker_bytes(existing)
383}
384
385/// Format conflict markers for a refresh conflict (CURRENT / INCOMING).
386///
387/// Ensures each side ends with a newline before the next marker line so tools
388/// that parse line-based conflict markers see clean boundaries.
389pub 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}