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 (only consulted
71    /// when the record is missing).
72    pub is_current_lane: bool,
73    /// `heddle thread drop --delete-thread` (or cleanup-equivalent).
74    pub delete_thread: bool,
75    /// Skip clean-worktree preflight.
76    pub force: bool,
77    /// Record mode when found; ignored when missing.
78    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/// Pure plan describing what a successful drop should remove / update.
85///
86/// FS, mount, registry, and ref mutations remain with the caller.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ThreadDropPlan {
89    pub clean_worktree: CleanWorktreeGuard,
90    /// Tear down a virtualized mount before removing the execution path.
91    pub unmount_virtualized: bool,
92    /// Remove `execution_path` when it exists on disk.
93    pub remove_execution_path: bool,
94    /// Always drop the per-thread manifest sidecar.
95    pub remove_manifest: bool,
96    /// Mark the manager record [`ThreadState::Abandoned`].
97    pub mark_abandoned: bool,
98    /// Strip agent-registry entries matching thread name or id.
99    pub strip_actor_presence: bool,
100    /// Delete the live thread ref when present (ordinary drop only with
101    /// `--delete-thread`; cleanup always requests this).
102    pub delete_thread_ref: bool,
103}
104
105/// Outcome of pure drop planning before any mutation.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum ThreadDropDisposition {
108    /// Missing record, attached as current checkout, no `--delete-thread`.
109    RefuseCurrentCheckout,
110    /// Missing record with `--delete-thread`: fall through to thread delete.
111    ProceedDeleteMissing,
112    /// Missing record and not recoverable via delete.
113    NotFound,
114    /// Record exists: perform the planned tear-down steps.
115    Drop(ThreadDropPlan),
116}
117
118/// Pure preflight for `heddle thread drop` / `drop_thread_silent`.
119///
120/// Rules (matching CLI):
121/// 1. Missing + current lane + no delete flag → refuse
122/// 2. Missing + delete flag → proceed to delete command
123/// 3. Missing otherwise → not found
124/// 4. Found → drop plan (unmount if virtualized, remove checkout if present,
125///    abandon record, strip agents, optionally delete ref)
126pub 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
153/// Pure plan for a cleanup sweep drop (`thread cleanup`).
154///
155/// Stronger than ordinary drop: always deletes the live thread ref when
156/// present. Clean-worktree is skipped (cleanup already selected merged/stale
157/// threads and never runs the force gate).
158pub 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// ---------------------------------------------------------------------------
171// Promote
172// ---------------------------------------------------------------------------
173
174/// Caller-supplied facts for pure promote preflight (no I/O).
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ThreadPromoteOptions {
177    pub force: bool,
178    /// Explicit `--path` from the caller, if any.
179    pub path: Option<PathBuf>,
180    /// Canonical managed checkout path (`repo.managed_checkout_path(id)`).
181    /// Used when `path` is `None` so promote lands under the same layout as
182    /// `start` / the per-thread manifest (heddle#572).
183    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/// Pure plan for `heddle thread promote`.
193///
194/// Materialization, mount teardown RPCs, and same-inode path confirmation
195/// remain with the caller.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct ThreadPromotePlan {
198    /// True when the caller did not supply `--path`.
199    pub using_default_path: bool,
200    pub target_path: PathBuf,
201    pub clean_worktree: CleanWorktreeGuard,
202    pub unmount_virtualized: bool,
203    /// Candidate checkout to tear down before rematerializing into the default
204    /// path (in-place Materialized/Solid conversion). Caller must still confirm
205    /// `.heddle` presence and path identity before removing.
206    pub in_place_conversion_candidate: Option<PathBuf>,
207    /// Resulting workspace mode after a successful promote.
208    pub resulting_mode: ThreadMode,
209    /// Resulting lifecycle state after a successful promote.
210    pub resulting_state: ThreadState,
211}
212
213/// Resolve the promote target path and whether the default was used.
214pub 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
224/// Existing checkout path preferred for identity / in-place conversion checks.
225///
226/// Prefers a non-empty `materialized_path`, else falls back to `execution_path`.
227pub 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
237/// Whether promote should consider tearing down the thread's own existing
238/// checkout before writing a solid tree at the default path.
239///
240/// Final removal still requires FS checks (`.heddle` exists, same directory as
241/// target) via [`promote_confirm_in_place_removal`].
242pub 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
254/// Confirm in-place conversion teardown after FS identity facts are known.
255///
256/// `same_as_target` should be true when the candidate and promote target
257/// resolve to the same directory (canonicalized when both exist).
258pub 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
269/// Pure option preflight for `heddle thread promote`.
270pub 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// ---------------------------------------------------------------------------
298// Refresh
299// ---------------------------------------------------------------------------
300
301/// Caller-supplied facts for pure refresh preflight (no I/O).
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct ThreadRefreshOptions {
304    /// Whether the thread record has a `target_thread` configured.
305    pub has_target_thread: bool,
306    pub freshness: ThreadFreshness,
307    /// True when `execution_path` is empty (branch-like / in-repo checkout).
308    pub execution_path_empty: bool,
309    /// Whether the caller's current lane matches this thread.
310    pub is_current_lane: bool,
311}
312
313/// Pure disposition for `heddle thread refresh` before rebase/merge I/O.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum ThreadRefreshPlan {
316    /// No integration target configured on the thread record.
317    MissingTarget,
318    /// Already current relative to target — no rebase/merge needed.
319    AlreadyCurrent,
320    /// Branch-like thread (empty execution path) but not the current checkout.
321    RequiresCurrentCheckout,
322    /// Rebase/merge against the caller's open repository (current lane).
323    ProceedOnCurrentRepo,
324    /// Open `execution_path` and refresh that isolated checkout.
325    ProceedOnExecutionPath,
326}
327
328/// Pure preflight for refresh checkout selection and no-op / refusal gates.
329///
330/// Rules (matching CLI `refresh_thread`):
331/// 1. No target → missing target
332/// 2. Freshness current → already current
333/// 3. Empty execution path + current lane → proceed on caller repo
334/// 4. Empty execution path + not current → requires current checkout
335/// 5. Non-empty execution path → proceed on that path
336pub 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
354/// Whether existing file bytes already contain full conflict-marker triplets.
355///
356/// Used so refresh does not overwrite a user-edited conflicted file when
357/// materializing markers after a conflicted 3-way merge.
358pub 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
370/// Whether refresh should write conflict markers for a path given its current
371/// on-disk content.
372pub fn should_materialize_refresh_conflict_markers(existing: &[u8]) -> bool {
373    !contains_conflict_marker_bytes(existing)
374}
375
376/// Format conflict markers for a refresh conflict (CURRENT / INCOMING).
377///
378/// Ensures each side ends with a newline before the next marker line so tools
379/// that parse line-based conflict markers see clean boundaries.
380pub 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}