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::{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, &current_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}