1use std::{fs, path::Path};
5
6use anyhow::{Result, anyhow};
7use objects::{fs_ops::remove_path_recursively, object::ChangeId};
8use repo::Repository;
9use serde::Serialize;
10
11use super::{
12 merge::merge_thread_into_current,
13 operator_core::OperatorCommandOutput,
14 operator_loop::primary_next_action,
15 ready_cmd::worktree_dirty,
16 snapshot::{SnapshotAgentOverrides, create_snapshot},
17 thread_cmd::{load_thread, refresh_thread, refresh_thread_freshness},
18};
19use crate::{
20 cli::{Cli, should_output_json, style, worktree_status_options},
21 config::UserConfig,
22};
23
24#[derive(Debug, Serialize)]
25pub struct ThreadMoveOutput {
26 pub from_thread: String,
27 pub to_thread: String,
28 pub moved_paths: Vec<String>,
29 pub source_change_id: Option<String>,
30 pub target_change_id: String,
31 pub message: String,
32}
33
34#[derive(Debug, Serialize)]
35pub struct ThreadResolveOutput {
36 #[serde(flatten)]
37 pub operator: OperatorCommandOutput,
38 pub thread: String,
39}
40
41#[derive(Debug, Serialize)]
42pub struct ThreadAbsorbOutput {
43 pub thread: String,
44 pub into: String,
45 pub preview_only: bool,
46 pub conflicts: Vec<String>,
47 pub merge_state: Option<String>,
48 pub message: String,
49}
50
51pub fn cmd_capture_split(
52 cli: &Cli,
53 into: String,
54 prefixes: Vec<String>,
55 intent: Option<String>,
56) -> Result<()> {
57 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
58 let current = super::thread_cmd::current_thread(&repo)?.ok_or_else(|| {
59 anyhow!("No current thread; `heddle capture --split` requires an active thread checkout")
60 })?;
61 let target = load_thread(&repo, &into)?;
62 let moved_paths = collect_worktree_split_paths(&repo, &prefixes)?;
63 if moved_paths.is_empty() {
64 return Err(anyhow!(
65 "No dirty paths matched the requested split prefixes"
66 ));
67 }
68
69 let target_repo = Repository::open(&target.execution_path)?;
70 apply_selected_worktree_paths(&repo, &target_repo, &moved_paths)?;
71 let user_config = UserConfig::load_default().unwrap_or_default();
72 let target_snapshot = create_snapshot(
73 &target_repo,
74 &user_config,
75 Some(intent.unwrap_or_else(|| format!("Split paths from {}", current.id))),
76 None,
77 SnapshotAgentOverrides {
78 provider: None,
79 model: None,
80 session: None,
81 segment: None,
82 policy: None,
83 no_policy: false,
84 no_agent: false,
85 },
86 )?;
87
88 restore_paths_from_state(&repo, repo.head()?, &moved_paths)?;
89
90 let output = ThreadMoveOutput {
91 from_thread: current.id,
92 to_thread: target.id,
93 moved_paths,
94 source_change_id: None,
95 target_change_id: target_snapshot.change_id,
96 message: "Split selected paths into target thread".to_string(),
97 };
98 emit(cli, &output)
99}
100
101pub fn cmd_thread_move(
102 cli: &Cli,
103 from: String,
104 to: String,
105 prefixes: Vec<String>,
106 message: Option<String>,
107) -> Result<()> {
108 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
109 let source = load_thread(&repo, &from)?;
110 let target = load_thread(&repo, &to)?;
111 let source_repo = Repository::open(&source.execution_path)?;
112 let target_repo = Repository::open(&target.execution_path)?;
113
114 let source_current = resolve_required_state(
115 &source_repo,
116 source.current_state.as_deref(),
117 "source thread has no current state",
118 )?;
119 let source_base = resolve_required_state(
120 &source_repo,
121 Some(&source.base_state),
122 "source thread has no base state",
123 )?;
124 let moved_paths =
125 collect_state_move_paths(&source_repo, &source_base, &source_current, &prefixes)?;
126 if moved_paths.is_empty() {
127 return Err(anyhow!("No captured paths matched the requested prefixes"));
128 }
129
130 apply_selected_state_paths(&source_repo, &source_current, &target_repo, &moved_paths)?;
131 let user_config = UserConfig::load_default().unwrap_or_default();
132 let target_snapshot = create_snapshot(
133 &target_repo,
134 &user_config,
135 Some(
136 message
137 .clone()
138 .unwrap_or_else(|| format!("Move paths from {}", source.id)),
139 ),
140 None,
141 SnapshotAgentOverrides {
142 provider: None,
143 model: None,
144 session: None,
145 segment: None,
146 policy: None,
147 no_policy: false,
148 no_agent: false,
149 },
150 )?;
151
152 restore_paths_from_state(&source_repo, Some(source_base), &moved_paths)?;
153 let source_snapshot = create_snapshot(
154 &source_repo,
155 &user_config,
156 Some(message.unwrap_or_else(|| format!("Move paths to {}", target.id))),
157 None,
158 SnapshotAgentOverrides {
159 provider: None,
160 model: None,
161 session: None,
162 segment: None,
163 policy: None,
164 no_policy: false,
165 no_agent: false,
166 },
167 )?;
168
169 let output = ThreadMoveOutput {
170 from_thread: source.id,
171 to_thread: target.id,
172 moved_paths,
173 source_change_id: Some(source_snapshot.change_id),
174 target_change_id: target_snapshot.change_id,
175 message: "Moved selected paths between threads".to_string(),
176 };
177 emit(cli, &output)
178}
179
180pub fn cmd_thread_absorb(
181 cli: &Cli,
182 thread: String,
183 into: Option<String>,
184 message: Option<String>,
185 preview: bool,
186) -> Result<()> {
187 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
188 let child = load_thread(&repo, &thread)?;
189 let parent_id = into
190 .or(child.parent_thread.clone())
191 .ok_or_else(|| anyhow!("Thread '{}' has no recorded parent; pass --into", child.id))?;
192 let parent = load_thread(&repo, &parent_id)?;
193 let parent_repo = Repository::open(&parent.execution_path)?;
194 let user_config = UserConfig::load_default().unwrap_or_default();
195 let status_options = worktree_status_options(Some(parent_repo.config()));
196 if worktree_dirty(&parent_repo, &status_options)? {
197 create_snapshot(
198 &parent_repo,
199 &user_config,
200 Some(format!("Prepare absorb of {}", child.id)),
201 None,
202 SnapshotAgentOverrides {
203 provider: None,
204 model: None,
205 session: None,
206 segment: None,
207 policy: None,
208 no_policy: false,
209 no_agent: false,
210 },
211 )?;
212 }
213 let output = merge_thread_into_current(
214 &parent_repo,
215 &child.thread,
216 message,
217 false,
218 preview,
219 false,
220 false,
221 false,
222 )?;
223 emit(
224 cli,
225 &ThreadAbsorbOutput {
226 thread: child.id,
227 into: parent_id,
228 preview_only: output.preview_only,
229 conflicts: output.conflicts,
230 merge_state: output.merge_state,
231 message: output.operator.message,
232 },
233 )
234}
235
236pub fn cmd_thread_resolve(cli: &Cli, thread_id: String) -> Result<()> {
237 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
238 let mut thread = load_thread(&repo, &thread_id)?;
239 refresh_thread_freshness(&repo, &mut thread)?;
240 let source_root = if thread.execution_path.as_os_str().is_empty() {
241 repo.root().to_path_buf()
242 } else {
243 thread.execution_path.clone()
244 };
245 let source_repo = Repository::open(&source_root)?;
246
247 if thread.freshness == repo::ThreadFreshness::Stale
248 && refresh_thread(&repo, &thread_id, cli).is_ok()
249 {
250 let manager = super::thread_cmd::thread_manager(&repo);
251 let mut refreshed_thread = manager
252 .load(&thread_id)?
253 .ok_or_else(|| anyhow!("Thread '{}' not found after refresh", thread_id))?;
254 let resolved_state = repo
255 .refs()
256 .get_thread(&refreshed_thread.thread)?
257 .map(|id| id.short());
258 refreshed_thread.integration_policy_result.status = Some("manual_resolved".to_string());
259 refreshed_thread.integration_policy_result.reason =
260 Some("manual integration resolution captured".to_string());
261 refreshed_thread
262 .integration_policy_result
263 .manual_resolution_state = resolved_state;
264 manager.save(&refreshed_thread)?;
265 return emit_thread_resolve(
266 cli,
267 &ThreadResolveOutput {
268 operator: OperatorCommandOutput {
269 status: "synced".to_string(),
270 action: "resolve".to_string(),
271 message: "Thread refreshed cleanly".to_string(),
272 blockers: Vec::new(),
273 warnings: Vec::new(),
274 next_action: Some(format!("heddle ship --thread {}", thread.id)),
275 recommended_action: Some(format!("heddle ship --thread {}", thread.id)),
276 },
277 thread: thread_id,
278 },
279 );
280 }
281
282 let summary = super::thread::find_thread_summary(&repo, &thread.id)?
283 .ok_or_else(|| anyhow!("Thread '{}' not found", thread.id))?;
284 let rebase_state_path = source_repo.heddle_dir().join("REBASE_STATE");
285 let mut blockers = if rebase_state_path.exists() {
286 Vec::new()
287 } else {
288 summary.blockers.clone()
289 };
290 let mut recommended_action = summary.recommended_action.clone();
291 if blockers.is_empty() && rebase_state_path.exists() {
292 let rebase_state = super::rebase::load_persisted_rebase_state(&rebase_state_path)?;
293 let current_state = source_repo
294 .current_state()?
295 .ok_or_else(|| anyhow!("Thread '{}' has no current state", thread.id))?;
296 if rebase_state
297 .pre_conflict_head
298 .is_some_and(|head| head != current_state.change_id)
299 {
300 recommended_action = "heddle rebase --continue".to_string();
301 } else {
302 blockers.push(
303 "refresh has a rebase in progress; capture a manual resolution in the thread checkout, then run `heddle rebase --continue`".to_string(),
304 );
305 }
306 }
307 if blockers.is_empty()
308 && !rebase_state_path.exists()
309 && thread
310 .integration_policy_result
311 .manual_resolution_state
312 .is_none()
313 {
314 let preview =
315 merge_thread_into_current(&repo, &thread.id, None, false, true, false, false, false)?;
316 if preview.conflict_count > 0 {
317 blockers.push(format!(
318 "Thread '{}' still has merge conflicts: {}",
319 thread.id,
320 preview.conflicts.join(", ")
321 ));
322 recommended_action = format!("heddle merge {}", thread.id);
323 }
324 }
325 if blockers.is_empty() {
326 let manager = super::thread_cmd::thread_manager(&repo);
327 thread.integration_policy_result.status = Some("manual_resolved".to_string());
328 thread.integration_policy_result.reason =
329 Some("manual integration resolution captured".to_string());
330 thread.integration_policy_result.manual_resolution_state =
331 repo.refs().get_thread(&thread.thread)?.map(|id| id.short());
332 manager.save(&thread)?;
333 }
334 let recommended_action = if blockers.is_empty() {
335 if rebase_state_path.exists() {
336 recommended_action
337 } else {
338 format!("heddle ship --thread {}", summary.name)
339 }
340 } else {
341 recommended_action
342 };
343 let operation = repo.operation_status()?;
344 let remote_tracking = repo.git_remote_tracking_status()?;
345 let import_hint = repo.git_overlay_import_hint()?;
346 let recommended_action = primary_next_action(
347 operation.as_ref(),
348 remote_tracking.as_ref(),
349 import_hint.as_ref(),
350 Some(&recommended_action),
351 );
352 emit_thread_resolve(
353 cli,
354 &ThreadResolveOutput {
355 operator: OperatorCommandOutput {
356 status: if blockers.is_empty() {
357 "completed".to_string()
358 } else {
359 "blocked".to_string()
360 },
361 action: "resolve".to_string(),
362 message: "Thread requires a manual follow-up".to_string(),
363 blockers: blockers.clone(),
364 warnings: Vec::new(),
365 next_action: Some(recommended_action.clone()),
366 recommended_action: Some(recommended_action),
367 },
368 thread: summary.name.clone(),
369 },
370 )
371}
372
373fn resolve_required_state(
374 repo: &Repository,
375 spec: Option<&str>,
376 message: &str,
377) -> Result<ChangeId> {
378 let spec = spec.ok_or_else(|| anyhow!(message.to_string()))?;
379 repo.resolve_state(spec)?
380 .ok_or_else(|| anyhow!(message.to_string()))
381}
382
383fn collect_worktree_split_paths(repo: &Repository, prefixes: &[String]) -> Result<Vec<String>> {
384 let baseline = repo
385 .current_state()?
386 .and_then(|state| repo.store().get_tree(&state.tree).transpose())
387 .transpose()?
388 .unwrap_or_default();
389 let status = repo.compare_worktree_cached_with_options(
390 &baseline,
391 &worktree_status_options(Some(repo.config())),
392 )?;
393 let mut paths = status
394 .modified
395 .iter()
396 .chain(status.added.iter())
397 .chain(status.deleted.iter())
398 .map(|path| path.to_string_lossy().to_string())
399 .filter(|path| matches_prefix(path, prefixes))
400 .collect::<Vec<_>>();
401 paths.sort();
402 paths.dedup();
403 Ok(paths)
404}
405
406fn collect_state_move_paths(
407 repo: &Repository,
408 base: &ChangeId,
409 current: &ChangeId,
410 prefixes: &[String],
411) -> Result<Vec<String>> {
412 let base_tree = repo
413 .store()
414 .get_state(base)?
415 .ok_or_else(|| anyhow!("Base state not found"))?
416 .tree;
417 let current_tree = repo
418 .store()
419 .get_state(current)?
420 .ok_or_else(|| anyhow!("Current state not found"))?
421 .tree;
422 let mut paths = repo
423 .diff_trees(&base_tree, ¤t_tree)?
424 .into_iter()
425 .map(|change| change.path)
426 .filter(|path| matches_prefix(path, prefixes))
427 .collect::<Vec<_>>();
428 paths.sort();
429 paths.dedup();
430 Ok(paths)
431}
432
433fn apply_selected_worktree_paths(
434 source_repo: &Repository,
435 target_repo: &Repository,
436 paths: &[String],
437) -> Result<()> {
438 for path in paths {
439 let source_path = source_repo.root().join(path);
440 let target_path = target_repo.root().join(path);
441 if source_path.exists() {
442 copy_path(&source_path, &target_path)?;
443 } else if target_path.exists() {
444 remove_path_recursively(&target_path)?;
445 }
446 }
447 Ok(())
448}
449
450fn apply_selected_state_paths(
451 source_repo: &Repository,
452 state_id: &ChangeId,
453 target_repo: &Repository,
454 paths: &[String],
455) -> Result<()> {
456 let state = source_repo
457 .store()
458 .get_state(state_id)?
459 .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
460 let tree = source_repo
461 .store()
462 .get_tree(&state.tree)?
463 .unwrap_or_default();
464 for path in paths {
465 restore_one_path(target_repo, Some(&tree), path)?;
466 }
467 Ok(())
468}
469
470fn restore_paths_from_state(
471 repo: &Repository,
472 baseline: Option<ChangeId>,
473 paths: &[String],
474) -> Result<()> {
475 let tree = if let Some(state_id) = baseline {
476 let state = repo
477 .store()
478 .get_state(&state_id)?
479 .ok_or_else(|| anyhow!("Baseline state '{}' not found", state_id.short()))?;
480 Some(repo.store().get_tree(&state.tree)?.unwrap_or_default())
481 } else {
482 None
483 };
484 for path in paths {
485 restore_one_path(repo, tree.as_ref(), path)?;
486 }
487 Ok(())
488}
489
490fn restore_one_path(
491 repo: &Repository,
492 baseline_tree: Option<&objects::object::Tree>,
493 path: &str,
494) -> Result<()> {
495 let target_path = repo.root().join(path);
496 if let Some(tree) = baseline_tree
497 && let Some(entry) = tree.get(path)
498 {
499 let blob = repo.require_blob(&entry.hash)?;
500 if let Some(parent) = target_path.parent() {
501 fs::create_dir_all(parent)?;
502 }
503 fs::write(&target_path, blob.content())?;
504 return Ok(());
505 }
506
507 if target_path.exists() {
508 remove_path_recursively(&target_path)?;
509 }
510 Ok(())
511}
512
513fn copy_path(from: &Path, to: &Path) -> Result<()> {
514 if from.is_dir() {
515 fs::create_dir_all(to)?;
516 for entry in fs::read_dir(from)? {
517 let entry = entry?;
518 copy_path(&entry.path(), &to.join(entry.file_name()))?;
519 }
520 return Ok(());
521 }
522
523 if let Some(parent) = to.parent() {
524 fs::create_dir_all(parent)?;
525 }
526 fs::copy(from, to)?;
527 Ok(())
528}
529
530fn matches_prefix(path: &str, prefixes: &[String]) -> bool {
531 prefixes.iter().any(|prefix| {
532 let prefix = prefix.trim_matches('/');
533 path == prefix || path.starts_with(&format!("{prefix}/"))
534 })
535}
536
537fn emit<T: Serialize>(cli: &Cli, output: &T) -> Result<()> {
538 if should_output_json(cli, None) {
539 println!("{}", serde_json::to_string(output)?);
540 } else {
541 println!("{}", serde_json::to_string_pretty(output)?);
542 }
543 Ok(())
544}
545
546fn emit_thread_resolve(cli: &Cli, output: &ThreadResolveOutput) -> Result<()> {
547 if should_output_json(cli, None) {
548 println!("{}", serde_json::to_string(output)?);
549 } else {
550 println!("{}", output.operator.message);
551 println!("Thread: {}", style::bold(&output.thread));
552 if !output.operator.blockers.is_empty() {
553 println!("{}", style::warn("Blockers:"));
554 for blocker in &output.operator.blockers {
555 println!(" - {}", style::warn(blocker));
556 }
557 }
558 if let Some(next) = output
559 .operator
560 .recommended_action
561 .as_ref()
562 .or(output.operator.next_action.as_ref())
563 {
564 println!("Next: {}", style::bold(next));
565 }
566 }
567 Ok(())
568}