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) => (explicit, false),
226        None => (default_path, true),
227    }
228}
229
230/// Existing checkout path preferred for identity / in-place conversion checks.
231///
232/// Prefers a non-empty `materialized_path`, else falls back to `execution_path`.
233pub 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
243/// Whether promote should consider tearing down the thread's own existing
244/// checkout before writing a solid tree at the default path.
245///
246/// Final removal still requires FS checks (`.heddle` exists, same directory as
247/// target) via [`promote_confirm_in_place_removal`].
248pub 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
260/// Confirm in-place conversion teardown after FS identity facts are known.
261///
262/// `same_as_target` should be true when the candidate and promote target
263/// resolve to the same directory (canonicalized when both exist).
264pub 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
275/// Pure option preflight for `heddle thread promote`.
276pub 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// ---------------------------------------------------------------------------
304// Refresh
305// ---------------------------------------------------------------------------
306
307/// Caller-supplied facts for pure refresh preflight (no I/O).
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct ThreadRefreshOptions {
310    /// Whether the thread record has a `target_thread` configured.
311    pub has_target_thread: bool,
312    pub freshness: ThreadFreshness,
313    /// True when `execution_path` is empty (branch-like / in-repo checkout).
314    pub execution_path_empty: bool,
315    /// Whether the caller's current lane matches this thread.
316    pub is_current_lane: bool,
317}
318
319/// Pure disposition for `heddle thread refresh` before rebase/merge I/O.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ThreadRefreshPlan {
322    /// No integration target configured on the thread record.
323    MissingTarget,
324    /// Already current relative to target — no rebase/merge needed.
325    AlreadyCurrent,
326    /// Branch-like thread (empty execution path) but not the current checkout.
327    RequiresCurrentCheckout,
328    /// Rebase/merge against the caller's open repository (current lane).
329    ProceedOnCurrentRepo,
330    /// Open `execution_path` and refresh that isolated checkout.
331    ProceedOnExecutionPath,
332}
333
334/// Pure preflight for refresh checkout selection and no-op / refusal gates.
335///
336/// Rules (matching CLI `refresh_thread`):
337/// 1. No target → missing target
338/// 2. Freshness current → already current
339/// 3. Empty execution path + current lane → proceed on caller repo
340/// 4. Empty execution path + not current → requires current checkout
341/// 5. Non-empty execution path → proceed on that path
342pub 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
360/// Whether existing file bytes already contain full conflict-marker triplets.
361///
362/// Used so refresh does not overwrite a user-edited conflicted file when
363/// materializing markers after a conflicted 3-way merge.
364pub 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
376/// Whether refresh should write conflict markers for a path given its current
377/// on-disk content.
378pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
379    !contains_conflict_marker_bytes(existing)
380}
381
382/// Format conflict markers for a refresh conflict (CURRENT / INCOMING).
383///
384/// Ensures each side ends with a newline before the next marker line so tools
385/// that parse line-based conflict markers see clean boundaries.
386pub 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}