1use 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 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 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, ¤t_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}