Skip to main content

cli/cli/commands/
thread_shaping.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Heddle-native thread shaping helpers.
3
4use std::{fs, path::Path};
5
6use anyhow::{Result, anyhow};
7use objects::{
8    fs_ops::remove_path_recursively,
9    object::{ChangeId, ThreadName},
10    store::ObjectStore,
11};
12use repo::{GitOverlayImportHint, GitRemoteTrackingStatus, Repository, RepositoryOperationStatus};
13use serde::Serialize;
14
15use super::{
16    action_line::print_next,
17    advice::RecoveryAdvice,
18    git_overlay_health::{RepositoryVerificationState, build_repository_verification_state},
19    merge::merge_thread_into_current,
20    next_action::{NextActionValidationContext, write_command_json},
21    operator_core::{OperatorAction, OperatorCommandOutput},
22    operator_loop::primary_next_action,
23    ready_cmd::worktree_dirty,
24    snapshot::{SnapshotAgentOverrides, create_snapshot},
25    thread_cmd::{
26        capture_thread_update_before, current_thread_ref_state, load_thread, refresh_thread,
27        refresh_thread_freshness, save_thread_update_with_oplog, thread_not_found_advice,
28    },
29    thread_landing::{land_command_for_thread, land_command_with_push_target},
30};
31use crate::{
32    cli::{
33        Cli, output_is_compact, render::shell_quote, should_output_json, style,
34        worktree_status_options,
35    },
36    config::UserConfig,
37};
38
39#[derive(Debug, Serialize)]
40pub struct ThreadMoveOutput {
41    pub from_thread: String,
42    pub to_thread: String,
43    pub moved_paths: Vec<String>,
44    pub source_change_id: Option<String>,
45    pub target_change_id: String,
46    pub message: String,
47}
48
49#[derive(Debug, Serialize)]
50pub struct ThreadResolveOutput {
51    #[serde(flatten)]
52    pub operator: OperatorCommandOutput,
53    pub thread: String,
54}
55
56impl super::compact::CompactProjection for ThreadResolveOutput {
57    fn compact(&self) -> super::compact::CompactOutput {
58        <OperatorCommandOutput as super::compact::CompactProjection>::compact(&self.operator)
59    }
60}
61
62#[derive(Debug, Serialize)]
63pub struct ThreadAbsorbOutput {
64    pub thread: String,
65    pub into: String,
66    pub preview_only: bool,
67    pub conflicts: Vec<String>,
68    pub merge_state: Option<String>,
69    pub message: String,
70}
71
72pub fn cmd_capture_split(
73    cli: &Cli,
74    into: String,
75    prefixes: Vec<String>,
76    intent: Option<String>,
77) -> Result<()> {
78    let repo = cli.open_repo()?;
79    let current = super::thread_cmd::current_thread(&repo)?.ok_or_else(|| {
80        anyhow!(RecoveryAdvice::no_current_thread(
81            "capture --split",
82            None,
83            "heddle thread switch <name>",
84        ))
85    })?;
86    let target = load_thread(&repo, &into)?;
87    let moved_paths = collect_worktree_split_paths(&repo, &prefixes)?;
88    if moved_paths.is_empty() {
89        return Err(anyhow!(no_paths_matched_advice(
90            "capture split",
91            "No dirty paths matched the requested split prefixes",
92            "the worktree has no dirty paths under the requested prefixes",
93            "capture --split would not move any work into the target thread",
94            "heddle status",
95        )));
96    }
97
98    let target_repo = Repository::open(&target.execution_path)?;
99    apply_selected_worktree_paths(&repo, &target_repo, &moved_paths)?;
100    let user_config = UserConfig::load_default().unwrap_or_default();
101    let target_snapshot = create_snapshot(
102        &target_repo,
103        &user_config,
104        Some(intent.unwrap_or_else(|| format!("Split paths from {}", current.id))),
105        None,
106        SnapshotAgentOverrides {
107            provider: None,
108            model: None,
109            session: None,
110            segment: None,
111            policy: None,
112            no_policy: false,
113            no_agent: false,
114        },
115    )?;
116
117    restore_paths_from_state(&repo, repo.head()?, &moved_paths)?;
118
119    let output = ThreadMoveOutput {
120        from_thread: current.id,
121        to_thread: target.id,
122        moved_paths,
123        source_change_id: None,
124        target_change_id: target_snapshot.change_id,
125        message: "Split selected paths into target thread".to_string(),
126    };
127    emit(cli, &output)
128}
129
130pub fn cmd_thread_move(
131    cli: &Cli,
132    from: String,
133    to: String,
134    prefixes: Vec<String>,
135    message: Option<String>,
136) -> Result<()> {
137    let repo = cli.open_repo()?;
138    let source = load_thread(&repo, &from)?;
139    let target = load_thread(&repo, &to)?;
140    let source_repo = Repository::open(&source.execution_path)?;
141    let target_repo = Repository::open(&target.execution_path)?;
142
143    let source_current = resolve_required_state(
144        &source_repo,
145        source.current_state.as_deref(),
146        "source thread has no current state",
147    )?;
148    let source_base = resolve_required_state(
149        &source_repo,
150        Some(&source.base_state),
151        "source thread has no base state",
152    )?;
153    let moved_paths =
154        collect_state_move_paths(&source_repo, &source_base, &source_current, &prefixes)?;
155    if moved_paths.is_empty() {
156        return Err(anyhow!(no_paths_matched_advice(
157            "thread move",
158            "No captured paths matched the requested prefixes",
159            "the source thread has no captured paths under the requested prefixes",
160            "thread move would not move any captured files into the target thread",
161            "heddle thread show",
162        )));
163    }
164
165    apply_selected_state_paths(&source_repo, &source_current, &target_repo, &moved_paths)?;
166    let user_config = UserConfig::load_default().unwrap_or_default();
167    let target_snapshot = create_snapshot(
168        &target_repo,
169        &user_config,
170        Some(
171            message
172                .clone()
173                .unwrap_or_else(|| format!("Move paths from {}", source.id)),
174        ),
175        None,
176        SnapshotAgentOverrides {
177            provider: None,
178            model: None,
179            session: None,
180            segment: None,
181            policy: None,
182            no_policy: false,
183            no_agent: false,
184        },
185    )?;
186
187    restore_paths_from_state(&source_repo, Some(source_base), &moved_paths)?;
188    let source_snapshot = create_snapshot(
189        &source_repo,
190        &user_config,
191        Some(message.unwrap_or_else(|| format!("Move paths to {}", target.id))),
192        None,
193        SnapshotAgentOverrides {
194            provider: None,
195            model: None,
196            session: None,
197            segment: None,
198            policy: None,
199            no_policy: false,
200            no_agent: false,
201        },
202    )?;
203
204    let output = ThreadMoveOutput {
205        from_thread: source.id,
206        to_thread: target.id,
207        moved_paths,
208        source_change_id: Some(source_snapshot.change_id),
209        target_change_id: target_snapshot.change_id,
210        message: "Moved selected paths between threads".to_string(),
211    };
212    emit(cli, &output)
213}
214
215pub fn cmd_thread_absorb(
216    cli: &Cli,
217    thread: String,
218    into: Option<String>,
219    message: Option<String>,
220    preview: bool,
221) -> Result<()> {
222    let repo = cli.open_repo()?;
223    let child = load_thread(&repo, &thread)?;
224    let parent_id = into
225        .or(child.parent_thread.clone())
226        .ok_or_else(|| anyhow!(RecoveryAdvice::thread_absorb_parent_required(&child.id)))?;
227    let parent = load_thread(&repo, &parent_id)?;
228    let parent_repo = Repository::open(&parent.execution_path)?;
229    let user_config = UserConfig::load_default().unwrap_or_default();
230    let status_options = worktree_status_options(Some(parent_repo.config()));
231    if worktree_dirty(&parent_repo, &status_options)? {
232        create_snapshot(
233            &parent_repo,
234            &user_config,
235            Some(format!("Prepare absorb of {}", child.id)),
236            None,
237            SnapshotAgentOverrides {
238                provider: None,
239                model: None,
240                session: None,
241                segment: None,
242                policy: None,
243                no_policy: false,
244                no_agent: false,
245            },
246        )?;
247    }
248    let output = merge_thread_into_current(
249        &parent_repo,
250        &child.thread,
251        message,
252        false,
253        preview,
254        false,
255        false,
256        false,
257    )?;
258    emit(
259        cli,
260        &ThreadAbsorbOutput {
261            thread: child.id,
262            into: parent_id,
263            preview_only: output.preview_only,
264            conflicts: output.conflicts,
265            merge_state: output.merge_state,
266            message: output.operator.message,
267        },
268    )
269}
270
271pub fn cmd_thread_resolve(cli: &Cli, thread_id: String) -> Result<()> {
272    let repo = cli.open_repo()?;
273    let mut thread = load_thread(&repo, &thread_id)?;
274    refresh_thread_freshness(&repo, &mut thread)?;
275    let source_root = if thread.execution_path.as_os_str().is_empty() {
276        repo.root().to_path_buf()
277    } else {
278        thread.execution_path.clone()
279    };
280    let source_repo = Repository::open(&source_root)?;
281    let rebase_state_path = source_repo.heddle_dir().join("REBASE_STATE");
282
283    if thread.freshness == repo::ThreadFreshness::Stale {
284        match refresh_thread(&repo, &thread_id, cli) {
285            Ok(_) => {
286                let manager = super::thread_cmd::thread_manager(&repo);
287                let mut refreshed_thread = manager.load(&thread_id)?.ok_or_else(|| {
288                    anyhow!(thread_not_found_advice(&thread_id, "resolve thread"))
289                })?;
290                let before_update =
291                    capture_thread_update_before(&repo, &manager, &refreshed_thread)?;
292                let resolved_state = repo
293                    .refs()
294                    .get_thread(&ThreadName::new(&refreshed_thread.thread))?
295                    .map(|id| id.short());
296                let new_state = current_thread_ref_state(&repo, &refreshed_thread)?;
297                refreshed_thread.integration_policy_result.status =
298                    Some("manual_resolved".to_string());
299                refreshed_thread.integration_policy_result.reason =
300                    Some("manual integration resolution captured".to_string());
301                refreshed_thread
302                    .integration_policy_result
303                    .manual_resolution_state = resolved_state;
304                // The stale thread refreshed cleanly (no conflicts surfaced
305                // for the user to resolve), so the land message must not
306                // claim a manual resolution.
307                refreshed_thread
308                    .integration_policy_result
309                    .conflicts_resolved_manually = false;
310                save_thread_update_with_oplog(
311                    &repo,
312                    &manager,
313                    &refreshed_thread,
314                    before_update,
315                    new_state,
316                )?;
317                let operator = if rebase_state_path.exists() {
318                    thread_resolve_rebase_followup_operator(
319                        &source_repo,
320                        &rebase_state_path,
321                        &thread.id,
322                    )?
323                } else {
324                    let trust = build_repository_verification_state(&repo);
325                    thread_resolve_refresh_operator(&thread.id, &trust)
326                };
327                return emit_thread_resolve(
328                    cli,
329                    &repo,
330                    &ThreadResolveOutput {
331                        operator,
332                        thread: thread_id,
333                    },
334                );
335            }
336            Err(err) => {
337                if rebase_state_path.exists() {
338                    let operator = thread_resolve_rebase_followup_operator(
339                        &source_repo,
340                        &rebase_state_path,
341                        &thread.id,
342                    )?;
343                    return emit_thread_resolve(
344                        cli,
345                        &repo,
346                        &ThreadResolveOutput {
347                            operator,
348                            thread: thread_id,
349                        },
350                    );
351                }
352                if let Some(operator) =
353                    thread_resolve_conflict_recovery_operator(&source_repo, &thread.id)?
354                {
355                    return emit_thread_resolve(
356                        cli,
357                        &repo,
358                        &ThreadResolveOutput {
359                            operator,
360                            thread: thread_id,
361                        },
362                    );
363                }
364                return Err(err);
365            }
366        }
367    }
368
369    let summary = super::thread::find_thread_summary(&repo, &thread.id)?
370        .ok_or_else(|| anyhow!(thread_not_found_advice(&thread.id, "resolve thread")))?;
371    let mut blockers = if rebase_state_path.exists() {
372        Vec::new()
373    } else {
374        summary.blockers.clone()
375    };
376    let mut warnings = Vec::new();
377    if !blockers.is_empty()
378        && blockers
379            .iter()
380            .all(|blocker| is_manual_review_blocker(blocker))
381    {
382        warnings = blockers.clone();
383        blockers.clear();
384    }
385    let mut recommended_action = summary.recommended_action.clone();
386    if blockers.is_empty() && rebase_state_path.exists() {
387        let rebase_state = super::rebase::load_persisted_rebase_state(&rebase_state_path)?;
388        let current_state = source_repo
389            .current_state()?
390            .ok_or_else(|| anyhow!("Thread '{}' has no current state", thread.id))?;
391        if rebase_state
392            .pre_conflict_head
393            .is_some_and(|head| head != current_state.change_id)
394        {
395            recommended_action = "heddle rebase --continue".to_string();
396        } else {
397            blockers.push(
398                "refresh has a rebase in progress; capture a manual resolution in the thread checkout, then run `heddle rebase --continue`".to_string(),
399            );
400        }
401    }
402    if blockers.is_empty()
403        && !rebase_state_path.exists()
404        && thread
405            .integration_policy_result
406            .manual_resolution_state
407            .is_none()
408    {
409        let preview =
410            merge_thread_into_current(&repo, &thread.id, None, false, true, false, false, false)?;
411        if preview.conflict_count > 0 {
412            blockers.push(format!(
413                "Thread '{}' still has merge conflicts: {}",
414                thread.id,
415                preview.conflicts.join(", ")
416            ));
417            recommended_action = "heddle resolve --list".to_string();
418        }
419    }
420    if blockers.is_empty() {
421        let manager = super::thread_cmd::thread_manager(&repo);
422        let before_update = capture_thread_update_before(&repo, &manager, &thread)?;
423        let thread_state = before_update.state;
424        thread.integration_policy_result.status = Some("manual_resolved".to_string());
425        thread.integration_policy_result.reason =
426            Some("manual integration resolution captured".to_string());
427        thread.integration_policy_result.manual_resolution_state = repo
428            .refs()
429            .get_thread(&ThreadName::new(&thread.thread))?
430            .map(|id| id.short());
431        // Reached only after the conflict preview above came back clean
432        // because the operator had captured a resolution in their checkout —
433        // this is the genuine `heddle resolve` manual-resolution path.
434        thread.integration_policy_result.conflicts_resolved_manually = true;
435        save_thread_update_with_oplog(&repo, &manager, &thread, before_update, thread_state)?;
436    }
437    let recommended_action = if blockers.is_empty() {
438        if rebase_state_path.exists() {
439            recommended_action
440        } else {
441            land_command_for_thread(&repo, &summary.name)
442        }
443    } else {
444        recommended_action
445    };
446    let operation = repo.operation_status()?;
447    let remote_tracking = repo.git_remote_tracking_status()?;
448    let import_hint = repo.git_overlay_import_hint()?;
449    let recommended_action = thread_resolve_next_action(
450        &blockers,
451        operation.as_ref(),
452        remote_tracking.as_ref(),
453        import_hint.as_ref(),
454        &recommended_action,
455    );
456    emit_thread_resolve(
457        cli,
458        &repo,
459        &ThreadResolveOutput {
460            operator: OperatorCommandOutput {
461                status: if blockers.is_empty() {
462                    "completed".to_string()
463                } else {
464                    "blocked".to_string()
465                },
466                action: OperatorAction::ThreadResolve,
467                message: if blockers.is_empty() {
468                    if warnings.is_empty() {
469                        "Thread manual resolution recorded".to_string()
470                    } else {
471                        "Thread manual review recorded".to_string()
472                    }
473                } else {
474                    "Thread requires a manual follow-up".to_string()
475                },
476                blockers: blockers.clone(),
477                warnings,
478                next_action: recommended_action.clone(),
479                recommended_action,
480            },
481            thread: summary.name.clone(),
482        },
483    )
484}
485
486fn is_manual_review_blocker(blocker: &str) -> bool {
487    blocker.starts_with("Heavy-impact change:")
488}
489
490fn thread_resolve_next_action(
491    blockers: &[String],
492    operation: Option<&RepositoryOperationStatus>,
493    remote_tracking: Option<&GitRemoteTrackingStatus>,
494    import_hint: Option<&GitOverlayImportHint>,
495    local_action: &str,
496) -> Option<String> {
497    let action = if blockers.is_empty() {
498        primary_next_action(operation, remote_tracking, import_hint, Some(local_action))
499    } else if let Some(operation) = operation {
500        operation.next_action.clone()
501    } else {
502        local_action.to_string()
503    };
504    (!action.trim().is_empty()).then_some(action)
505}
506
507fn thread_resolve_rebase_followup_operator(
508    source_repo: &Repository,
509    rebase_state_path: &Path,
510    thread_id: &str,
511) -> Result<OperatorCommandOutput> {
512    let rebase_state = super::rebase::load_persisted_rebase_state(rebase_state_path)?;
513    let current_state = source_repo
514        .current_state()?
515        .ok_or_else(|| anyhow!("Thread '{}' has no current state", thread_id))?;
516    let next_action = "heddle continue".to_string();
517    let mut blockers = Vec::new();
518    if rebase_state
519        .pre_conflict_head
520        .is_none_or(|head| head == current_state.change_id)
521    {
522        blockers.push(
523            "refresh has a rebase in progress; capture a manual resolution in the thread checkout, then run `heddle rebase --continue`".to_string(),
524        );
525    }
526
527    Ok(OperatorCommandOutput {
528        status: if blockers.is_empty() {
529            "completed".to_string()
530        } else {
531            "blocked".to_string()
532        },
533        action: OperatorAction::ThreadResolve,
534        message: if blockers.is_empty() {
535            "Thread manual resolution recorded; continue the rebase".to_string()
536        } else {
537            "Thread still requires a manual rebase resolution".to_string()
538        },
539        blockers,
540        warnings: Vec::new(),
541        next_action: Some(next_action.clone()),
542        recommended_action: Some(next_action),
543    })
544}
545
546fn thread_resolve_conflict_recovery_operator(
547    source_repo: &Repository,
548    thread_id: &str,
549) -> Result<Option<OperatorCommandOutput>> {
550    if !source_repo.merge_state_manager().is_merge_in_progress() {
551        return Ok(None);
552    }
553    let unresolved = source_repo.merge_state_manager().unresolved()?;
554    let repo_arg = shell_quote(&source_repo.root().display().to_string());
555    let conflict_list_command = format!("heddle --repo {repo_arg} resolve --list");
556    let recommended_action = unresolved
557        .first()
558        .map(|path| format!("heddle --repo {repo_arg} resolve {}", shell_quote(path)))
559        .unwrap_or_else(|| format!("heddle --repo {repo_arg} continue"));
560    let blockers = if unresolved.is_empty() {
561        Vec::new()
562    } else {
563        unresolved
564            .iter()
565            .map(|path| format!("Resolve conflict marker path: {path}"))
566            .collect()
567    };
568    Ok(Some(OperatorCommandOutput {
569        status: "blocked".to_string(),
570        action: OperatorAction::ThreadResolve,
571        message: format!(
572            "Thread '{thread_id}' has conflict markers in its checkout; resolve them there, then continue"
573        ),
574        blockers,
575        warnings: Vec::new(),
576        next_action: Some(conflict_list_command),
577        recommended_action: Some(recommended_action),
578    }))
579}
580
581fn thread_resolve_refresh_operator(
582    thread_id: &str,
583    trust: &RepositoryVerificationState,
584) -> OperatorCommandOutput {
585    let land_command = land_command_with_push_target(thread_id, trust.default_remote.is_some());
586    if trust.verified {
587        return OperatorCommandOutput {
588            status: "synced".to_string(),
589            action: OperatorAction::ThreadResolve,
590            message: "Thread refreshed cleanly".to_string(),
591            blockers: Vec::new(),
592            warnings: Vec::new(),
593            next_action: Some(land_command.clone()),
594            recommended_action: Some(land_command),
595        };
596    }
597
598    OperatorCommandOutput::blocked_by_repository_verification(
599        OperatorAction::ThreadResolve,
600        format!(
601            "Thread refreshed cleanly, but repository verification is blocked: {}",
602            trust.summary
603        ),
604        trust,
605    )
606}
607
608fn no_paths_matched_advice(
609    action: &'static str,
610    error: &'static str,
611    unsafe_condition: &'static str,
612    would_change: &'static str,
613    primary_command: &'static str,
614) -> RecoveryAdvice {
615    RecoveryAdvice::safety_refusal(
616        "no_paths_matched",
617        error,
618        format!(
619            "Inspect available paths with `{primary_command}`, then retry `{action}` with a matching prefix."
620        ),
621        unsafe_condition,
622        would_change,
623        "repository state was left unchanged",
624        primary_command,
625        vec![primary_command.to_string()],
626    )
627}
628
629fn resolve_required_state(
630    repo: &Repository,
631    spec: Option<&str>,
632    message: &str,
633) -> Result<ChangeId> {
634    let spec = spec.ok_or_else(|| anyhow!(message.to_string()))?;
635    repo.resolve_state(spec)?
636        .ok_or_else(|| anyhow!(message.to_string()))
637}
638
639fn collect_worktree_split_paths(repo: &Repository, prefixes: &[String]) -> Result<Vec<String>> {
640    let baseline = match repo.current_state()? {
641        Some(state) => repo.require_tree(&state.tree)?,
642        None => objects::object::Tree::new(),
643    };
644    let status = repo.compare_worktree_cached_with_options(
645        &baseline,
646        &worktree_status_options(Some(repo.config())),
647    )?;
648    let mut paths = status
649        .modified
650        .iter()
651        .chain(status.added.iter())
652        .chain(status.deleted.iter())
653        .map(|path| path.to_string_lossy().to_string())
654        .filter(|path| matches_prefix(path, prefixes))
655        .collect::<Vec<_>>();
656    paths.sort();
657    paths.dedup();
658    Ok(paths)
659}
660
661fn collect_state_move_paths(
662    repo: &Repository,
663    base: &ChangeId,
664    current: &ChangeId,
665    prefixes: &[String],
666) -> Result<Vec<String>> {
667    let base_tree = repo
668        .store()
669        .get_state(base)?
670        .ok_or_else(|| anyhow!("Base state not found"))?
671        .tree;
672    let current_tree = repo
673        .store()
674        .get_state(current)?
675        .ok_or_else(|| anyhow!("Current state not found"))?
676        .tree;
677    let mut paths = repo
678        .diff_trees(&base_tree, &current_tree)?
679        .into_iter()
680        .map(|change| change.path)
681        .filter(|path| matches_prefix(path, prefixes))
682        .collect::<Vec<_>>();
683    paths.sort();
684    paths.dedup();
685    Ok(paths)
686}
687
688fn apply_selected_worktree_paths(
689    source_repo: &Repository,
690    target_repo: &Repository,
691    paths: &[String],
692) -> Result<()> {
693    for path in paths {
694        let source_path = source_repo.root().join(path);
695        let target_path = target_repo.root().join(path);
696        if source_path.exists() {
697            copy_path(&source_path, &target_path)?;
698        } else if target_path.exists() {
699            remove_path_recursively(&target_path)?;
700        }
701    }
702    Ok(())
703}
704
705fn apply_selected_state_paths(
706    source_repo: &Repository,
707    state_id: &ChangeId,
708    target_repo: &Repository,
709    paths: &[String],
710) -> Result<()> {
711    let state = source_repo
712        .store()
713        .get_state(state_id)?
714        .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
715    let tree = source_repo.require_tree(&state.tree)?;
716    for path in paths {
717        restore_one_path(target_repo, Some(&tree), path)?;
718    }
719    Ok(())
720}
721
722fn restore_paths_from_state(
723    repo: &Repository,
724    baseline: Option<ChangeId>,
725    paths: &[String],
726) -> Result<()> {
727    let tree = if let Some(state_id) = baseline {
728        let state = repo
729            .store()
730            .get_state(&state_id)?
731            .ok_or_else(|| anyhow!("Baseline state '{}' not found", state_id.short()))?;
732        Some(repo.require_tree(&state.tree)?)
733    } else {
734        None
735    };
736    for path in paths {
737        restore_one_path(repo, tree.as_ref(), path)?;
738    }
739    Ok(())
740}
741
742fn restore_one_path(
743    repo: &Repository,
744    baseline_tree: Option<&objects::object::Tree>,
745    path: &str,
746) -> Result<()> {
747    let target_path = repo.root().join(path);
748    if let Some(tree) = baseline_tree
749        && let Some(entry) = tree.get(path)
750    {
751        let blob = repo.require_blob(&entry.hash)?;
752        if let Some(parent) = target_path.parent() {
753            fs::create_dir_all(parent)?;
754        }
755        fs::write(&target_path, blob.content())?;
756        return Ok(());
757    }
758
759    if target_path.exists() {
760        remove_path_recursively(&target_path)?;
761    }
762    Ok(())
763}
764
765fn copy_path(from: &Path, to: &Path) -> Result<()> {
766    if from.is_dir() {
767        fs::create_dir_all(to)?;
768        for entry in fs::read_dir(from)? {
769            let entry = entry?;
770            copy_path(&entry.path(), &to.join(entry.file_name()))?;
771        }
772        return Ok(());
773    }
774
775    if let Some(parent) = to.parent() {
776        fs::create_dir_all(parent)?;
777    }
778    fs::copy(from, to)?;
779    Ok(())
780}
781
782fn matches_prefix(path: &str, prefixes: &[String]) -> bool {
783    prefixes.iter().any(|prefix| {
784        let prefix = prefix.trim_matches('/');
785        path == prefix || path.starts_with(&format!("{prefix}/"))
786    })
787}
788
789fn emit<T: Serialize>(cli: &Cli, output: &T) -> Result<()> {
790    if should_output_json(cli, None) {
791        println!("{}", serde_json::to_string(output)?);
792    } else {
793        println!("{}", serde_json::to_string_pretty(output)?);
794    }
795    Ok(())
796}
797
798fn emit_thread_resolve(cli: &Cli, repo: &Repository, output: &ThreadResolveOutput) -> Result<()> {
799    if should_output_json(cli, None) {
800        write_command_json(
801            output,
802            output_is_compact(cli),
803            NextActionValidationContext::new(&["thread", "resolve"], repo.capability()),
804        )?;
805    } else {
806        println!("{}", output.operator.message);
807        println!("Thread: {}", style::bold(&output.thread));
808        if !output.operator.blockers.is_empty() {
809            println!("{}", style::warn("Blockers:"));
810            for blocker in &output.operator.blockers {
811                println!("  - {}", style::warn(blocker));
812            }
813        }
814        if !output.operator.warnings.is_empty() {
815            println!("{}", style::warn("Reviewed:"));
816            for warning in &output.operator.warnings {
817                println!("  - {}", style::warn(warning));
818            }
819        }
820        if let Some(next) = output
821            .operator
822            .recommended_action
823            .as_ref()
824            .or(output.operator.next_action.as_ref())
825        {
826            print_next(next);
827        }
828    }
829    Ok(())
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use crate::cli::commands::git_overlay_health::VerificationCheck;
836
837    fn trust_state(verified: bool) -> RepositoryVerificationState {
838        let check = VerificationCheck {
839            name: "Mapping".to_string(),
840            status: if verified { "clean" } else { "needs_import" }.to_string(),
841            clean: verified,
842            summary: if verified {
843                "Git/Heddle mapping is clean"
844            } else {
845                "active Git branch has not been imported"
846            }
847            .to_string(),
848            recommended_action: (!verified).then(|| "heddle adopt --ref main".to_string()),
849            recommended_action_template: None,
850            recovery_commands: if verified {
851                Vec::new()
852            } else {
853                vec!["heddle adopt --ref main".to_string()]
854            },
855            recovery_action_templates: Vec::new(),
856            details: std::collections::BTreeMap::new(),
857        };
858        let machine_contract_coverage =
859            crate::cli::commands::git_overlay_health::machine_contract_coverage();
860        RepositoryVerificationState {
861            verified,
862            status: if verified { "clean" } else { "needs_import" }.to_string(),
863            repository_mode: "git-overlay".to_string(),
864            heddle_initialized: true,
865            git_branch: Some("main".to_string()),
866            heddle_thread: Some("main".to_string()),
867            worktree_dirty: false,
868            worktree_state: "clean".to_string(),
869            import_state: check.status.clone(),
870            mapping_state: check.status.clone(),
871            remote_drift: "clean".to_string(),
872            active_operation: None,
873            default_remote: None,
874            clone_verification: "not_applicable".to_string(),
875            machine_contract: crate::cli::commands::git_overlay_health::machine_contract_status(
876                &machine_contract_coverage,
877            )
878            .to_string(),
879            machine_contract_coverage,
880            summary: check.summary.clone(),
881            workflow_status: "clean".to_string(),
882            workflow_summary: "no workflow attention needed".to_string(),
883            recommended_action: check.recommended_action.clone().unwrap_or_default(),
884            recommended_action_template: check.recommended_action_template.clone(),
885            recovery_commands: check.recovery_commands.clone(),
886            recovery_action_templates: check.recovery_action_templates.clone(),
887            checks: vec![check],
888        }
889    }
890
891    #[test]
892    fn thread_resolve_reports_synced_only_when_repository_verification_is_clean() {
893        let clean = thread_resolve_refresh_operator("feature/clean", &trust_state(true));
894        assert_eq!(clean.status, "synced");
895        assert_eq!(
896            clean.recommended_action.as_deref(),
897            Some("heddle land --thread feature/clean --no-push")
898        );
899
900        let blocked = thread_resolve_refresh_operator("feature/blocked", &trust_state(false));
901        assert_eq!(blocked.status, "blocked");
902        assert!(
903            blocked
904                .message
905                .contains("repository verification is blocked"),
906            "blocked message should name verification, got: {}",
907            blocked.message
908        );
909        assert_eq!(
910            blocked.recommended_action.as_deref(),
911            Some("heddle adopt --ref main")
912        );
913        assert!(
914            blocked
915                .blockers
916                .iter()
917                .any(|blocker| blocker.contains("active Git branch has not been imported")),
918            "verification blocker should be surfaced: {:?}",
919            blocked.blockers
920        );
921    }
922
923    #[test]
924    fn thread_resolve_blockers_keep_local_recovery_ahead_of_remote_push() {
925        let blockers = vec!["Thread still has merge conflicts".to_string()];
926        let remote = GitRemoteTrackingStatus {
927            branch: "main".to_string(),
928            upstream: "origin/main".to_string(),
929            ahead: 1,
930            behind: 0,
931            local_oid: None,
932            upstream_oid: None,
933            upstream_is_undone_checkpoint: false,
934            message: "branch is ahead".to_string(),
935            next_action: "heddle push".to_string(),
936        };
937
938        let action = thread_resolve_next_action(
939            &blockers,
940            None,
941            Some(&remote),
942            None,
943            "heddle resolve --list",
944        );
945
946        assert_eq!(action.as_deref(), Some("heddle resolve --list"));
947    }
948
949    #[test]
950    fn thread_resolve_clean_state_can_surface_remote_push() {
951        let remote = GitRemoteTrackingStatus {
952            branch: "main".to_string(),
953            upstream: "origin/main".to_string(),
954            ahead: 1,
955            behind: 0,
956            local_oid: None,
957            upstream_oid: None,
958            upstream_is_undone_checkpoint: false,
959            message: "branch is ahead".to_string(),
960            next_action: "heddle push".to_string(),
961        };
962
963        let action =
964            thread_resolve_next_action(&[], None, Some(&remote), None, "heddle land --thread x");
965
966        assert_eq!(action.as_deref(), Some("heddle push"));
967    }
968
969    #[test]
970    fn empty_path_movement_refusals_use_typed_advice() {
971        let split = no_paths_matched_advice(
972            "capture split",
973            "No dirty paths matched the requested split prefixes",
974            "the worktree has no dirty paths under the requested prefixes",
975            "capture --split would not move any work into the target thread",
976            "heddle status",
977        );
978        assert_eq!(split.kind, "no_paths_matched");
979        assert_eq!(split.primary_command, "heddle status");
980        assert!(
981            split
982                .to_string()
983                .contains("Preserved: repository state was left unchanged"),
984            "display should keep the uniform advice surface: {split}"
985        );
986
987        let move_paths = no_paths_matched_advice(
988            "thread move",
989            "No captured paths matched the requested prefixes",
990            "the source thread has no captured paths under the requested prefixes",
991            "thread move would not move any captured files into the target thread",
992            "heddle thread show",
993        );
994        assert_eq!(move_paths.kind, "no_paths_matched");
995        assert_eq!(move_paths.primary_command, "heddle thread show");
996        assert!(
997            move_paths.primary_hint().contains("heddle thread show"),
998            "hint should name the inspection command: {}",
999            move_paths.primary_hint()
1000        );
1001    }
1002}