Skip to main content

heddle_core/
thread_shaping.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Thread capture-split and thread-move repository operations.
3
4use std::{error::Error, fmt, fs, path::Path};
5
6use anyhow::{Result, anyhow};
7use chrono::Utc;
8use objects::{
9    fs_ops::remove_path_recursively,
10    object::{StateId, ThreadName},
11    store::ObjectStore,
12};
13use refs::Head;
14use repo::{
15    Repository, Thread, ThreadFreshness, ThreadManager, ThreadMode, ThreadState,
16    WorktreeStatusOptions,
17};
18use serde::Serialize;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct ThreadMoveOutput {
22    pub from_thread: String,
23    pub to_thread: String,
24    pub moved_paths: Vec<String>,
25    pub source_state_id: Option<String>,
26    pub target_state_id: String,
27    pub message: String,
28}
29
30#[derive(Debug, Clone)]
31pub struct CaptureSplitOptions {
32    pub into: String,
33    pub prefixes: Vec<String>,
34    pub intent: Option<String>,
35    pub worktree_status_options: WorktreeStatusOptions,
36}
37
38#[derive(Debug, Clone)]
39pub struct ThreadMoveOptions {
40    pub from: String,
41    pub to: String,
42    pub prefixes: Vec<String>,
43    pub message: Option<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct NoPathsMatchedDetails {
48    pub action: &'static str,
49    pub error: &'static str,
50    pub unsafe_condition: &'static str,
51    pub would_change: &'static str,
52    pub primary_command: &'static str,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ThreadShapingError {
57    NoCurrentThread,
58    NoPathsMatched(NoPathsMatchedDetails),
59    ThreadNotFound {
60        thread_id: String,
61        action: &'static str,
62    },
63    ImportedGitRefNotManaged {
64        thread_id: String,
65    },
66}
67
68impl fmt::Display for ThreadShapingError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::NoCurrentThread => write!(f, "No current thread"),
72            Self::NoPathsMatched(details) => write!(f, "{}", details.error),
73            Self::ThreadNotFound { thread_id, .. } => {
74                write!(f, "Thread '{thread_id}' not found")
75            }
76            Self::ImportedGitRefNotManaged { thread_id } => write!(
77                f,
78                "'{thread_id}' is an imported Git ref, not a managed Heddle thread"
79            ),
80        }
81    }
82}
83
84impl Error for ThreadShapingError {}
85
86pub fn capture_split(
87    repo: &Repository,
88    opts: CaptureSplitOptions,
89    snapshot: impl Fn(&Repository, Option<String>) -> Result<String>,
90) -> Result<ThreadMoveOutput> {
91    let current = current_thread(repo)?.ok_or(ThreadShapingError::NoCurrentThread)?;
92    let target = load_thread(repo, &opts.into, "load thread")?;
93    let moved_paths =
94        collect_worktree_split_paths(repo, &opts.prefixes, &opts.worktree_status_options)?;
95    if moved_paths.is_empty() {
96        return Err(ThreadShapingError::NoPathsMatched(no_paths_matched_details(
97            "capture split",
98            "No dirty paths matched the requested split prefixes",
99            "the worktree has no dirty paths under the requested prefixes",
100            "capture --split would not move any work into the target thread",
101            "heddle status",
102        ))
103        .into());
104    }
105
106    let target_repo = Repository::open(&target.execution_path)?;
107    apply_selected_worktree_paths(repo, &target_repo, &moved_paths)?;
108    let target_snapshot = snapshot(
109        &target_repo,
110        Some(
111            opts.intent
112                .unwrap_or_else(|| format!("Split paths from {}", current.id)),
113        ),
114    )?;
115
116    restore_paths_from_state(repo, repo.head()?, &moved_paths)?;
117
118    Ok(ThreadMoveOutput {
119        from_thread: current.id,
120        to_thread: target.id,
121        moved_paths,
122        source_state_id: None,
123        target_state_id: target_snapshot,
124        message: "Split selected paths into target thread".to_string(),
125    })
126}
127
128pub fn thread_move(
129    repo: &Repository,
130    opts: ThreadMoveOptions,
131    snapshot: impl Fn(&Repository, Option<String>) -> Result<String>,
132) -> Result<ThreadMoveOutput> {
133    let source = load_thread(repo, &opts.from, "load thread")?;
134    let target = load_thread(repo, &opts.to, "load thread")?;
135    let source_repo = Repository::open(&source.execution_path)?;
136    let target_repo = Repository::open(&target.execution_path)?;
137
138    let source_current = resolve_required_state(
139        &source_repo,
140        source.current_state.as_deref(),
141        "source thread has no current state",
142    )?;
143    let source_base = resolve_required_state(
144        &source_repo,
145        Some(&source.base_state),
146        "source thread has no base state",
147    )?;
148    let moved_paths =
149        collect_state_move_paths(&source_repo, &source_base, &source_current, &opts.prefixes)?;
150    if moved_paths.is_empty() {
151        return Err(ThreadShapingError::NoPathsMatched(no_paths_matched_details(
152            "thread move",
153            "No captured paths matched the requested prefixes",
154            "the source thread has no captured paths under the requested prefixes",
155            "thread move would not move any captured files into the target thread",
156            "heddle thread show",
157        ))
158        .into());
159    }
160
161    apply_selected_state_paths(&source_repo, &source_current, &target_repo, &moved_paths)?;
162    let target_snapshot = snapshot(
163        &target_repo,
164        Some(
165            opts.message
166                .clone()
167                .unwrap_or_else(|| format!("Move paths from {}", source.id)),
168        ),
169    )?;
170
171    restore_paths_from_state(&source_repo, Some(source_base), &moved_paths)?;
172    let source_snapshot = snapshot(
173        &source_repo,
174        Some(
175            opts.message
176                .unwrap_or_else(|| format!("Move paths to {}", target.id)),
177        ),
178    )?;
179
180    Ok(ThreadMoveOutput {
181        from_thread: source.id,
182        to_thread: target.id,
183        moved_paths,
184        source_state_id: Some(source_snapshot),
185        target_state_id: target_snapshot,
186        message: "Moved selected paths between threads".to_string(),
187    })
188}
189
190fn thread_manager(repo: &Repository) -> ThreadManager {
191    ThreadManager::new(repo.heddle_dir())
192}
193
194fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
195    if let Some(thread) = thread_manager(repo).find_by_execution_root(repo.root())? {
196        return Ok(Some(thread));
197    }
198
199    let Head::Attached { thread } = repo.head_ref()? else {
200        return Ok(None);
201    };
202    let current_state = repo.refs().get_thread(&thread)?.map(|id| id.short());
203    let base_root = current_state
204        .as_deref()
205        .and_then(|state| repo.resolve_state(state).ok().flatten())
206        .and_then(|id| repo.store().get_state(&id).ok().flatten())
207        .map(|state| state.tree.short())
208        .unwrap_or_default();
209
210    let thread_str = thread.to_string();
211    Ok(Some(Thread {
212        id: thread_str.clone(),
213        thread: thread_str,
214        target_thread: None,
215        parent_thread: None,
216        mode: ThreadMode::Materialized,
217        state: ThreadState::Active,
218        base_state: current_state.clone().unwrap_or_default(),
219        base_root,
220        current_state,
221        merged_state: None,
222        task: None,
223        execution_path: repo.root().to_path_buf(),
224        materialized_path: None,
225        changed_paths: Vec::new(),
226        impact_categories: Vec::new(),
227        heavy_impact_paths: Vec::new(),
228        promotion_suggested: false,
229        freshness: ThreadFreshness::Unknown,
230        verification_summary: Default::default(),
231        confidence_summary: Default::default(),
232        integration_policy_result: Default::default(),
233        created_at: Utc::now(),
234        updated_at: Utc::now(),
235        ephemeral: None,
236        auto: false,
237        shared_target_dir: None,
238    }))
239}
240
241fn load_thread(repo: &Repository, thread_id: &str, action: &'static str) -> Result<Thread> {
242    match thread_manager(repo).load(thread_id)? {
243        Some(thread) => Ok(thread),
244        None if repo
245            .refs()
246            .get_thread(&ThreadName::new(thread_id))?
247            .is_some() =>
248        {
249            Err(ThreadShapingError::ImportedGitRefNotManaged {
250                thread_id: thread_id.to_string(),
251            }
252            .into())
253        }
254        None => Err(ThreadShapingError::ThreadNotFound {
255            thread_id: thread_id.to_string(),
256            action,
257        }
258        .into()),
259    }
260}
261
262fn no_paths_matched_details(
263    action: &'static str,
264    error: &'static str,
265    unsafe_condition: &'static str,
266    would_change: &'static str,
267    primary_command: &'static str,
268) -> NoPathsMatchedDetails {
269    NoPathsMatchedDetails {
270        action,
271        error,
272        unsafe_condition,
273        would_change,
274        primary_command,
275    }
276}
277
278fn resolve_required_state(repo: &Repository, spec: Option<&str>, message: &str) -> Result<StateId> {
279    let spec = spec.ok_or_else(|| anyhow!(message.to_string()))?;
280    repo.resolve_state(spec)?
281        .ok_or_else(|| anyhow!(message.to_string()))
282}
283
284fn collect_worktree_split_paths(
285    repo: &Repository,
286    prefixes: &[String],
287    worktree_status_options: &WorktreeStatusOptions,
288) -> Result<Vec<String>> {
289    let baseline = match repo.current_state()? {
290        Some(state) => repo.require_tree(&state.tree)?,
291        None => objects::object::Tree::new(),
292    };
293    let status = repo.compare_worktree_cached_with_options(&baseline, worktree_status_options)?;
294    let mut paths = status
295        .modified
296        .iter()
297        .chain(status.added.iter())
298        .chain(status.deleted.iter())
299        .map(|path| path.to_string_lossy().to_string())
300        .filter(|path| matches_prefix(path, prefixes))
301        .collect::<Vec<_>>();
302    paths.sort();
303    paths.dedup();
304    Ok(paths)
305}
306
307fn collect_state_move_paths(
308    repo: &Repository,
309    base: &StateId,
310    current: &StateId,
311    prefixes: &[String],
312) -> Result<Vec<String>> {
313    let base_tree = repo
314        .store()
315        .get_state(base)?
316        .ok_or_else(|| anyhow!("Base state not found"))?
317        .tree;
318    let current_tree = repo
319        .store()
320        .get_state(current)?
321        .ok_or_else(|| anyhow!("Current state not found"))?
322        .tree;
323    let mut paths = repo
324        .diff_trees(&base_tree, &current_tree)?
325        .into_iter()
326        .map(|change| change.path)
327        .filter(|path| matches_prefix(path, prefixes))
328        .collect::<Vec<_>>();
329    paths.sort();
330    paths.dedup();
331    Ok(paths)
332}
333
334fn apply_selected_worktree_paths(
335    source_repo: &Repository,
336    target_repo: &Repository,
337    paths: &[String],
338) -> Result<()> {
339    for path in paths {
340        let source_path = source_repo.root().join(path);
341        let target_path = target_repo.root().join(path);
342        if source_path.exists() {
343            copy_path(&source_path, &target_path)?;
344        } else if target_path.exists() {
345            remove_path_recursively(&target_path)?;
346        }
347    }
348    Ok(())
349}
350
351fn apply_selected_state_paths(
352    source_repo: &Repository,
353    state_id: &StateId,
354    target_repo: &Repository,
355    paths: &[String],
356) -> Result<()> {
357    let state = source_repo
358        .store()
359        .get_state(state_id)?
360        .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
361    let tree = source_repo.require_tree(&state.tree)?;
362    for path in paths {
363        restore_one_path(target_repo, Some(&tree), path)?;
364    }
365    Ok(())
366}
367
368fn restore_paths_from_state(
369    repo: &Repository,
370    baseline: Option<StateId>,
371    paths: &[String],
372) -> Result<()> {
373    let tree = if let Some(state_id) = baseline {
374        let state = repo
375            .store()
376            .get_state(&state_id)?
377            .ok_or_else(|| anyhow!("Baseline state '{}' not found", state_id.short()))?;
378        Some(repo.require_tree(&state.tree)?)
379    } else {
380        None
381    };
382    for path in paths {
383        restore_one_path(repo, tree.as_ref(), path)?;
384    }
385    Ok(())
386}
387
388fn restore_one_path(
389    repo: &Repository,
390    baseline_tree: Option<&objects::object::Tree>,
391    path: &str,
392) -> Result<()> {
393    let target_path = repo.root().join(path);
394    if let Some(tree) = baseline_tree
395        && let Some(entry) = tree.get(path)
396    {
397        let Some(hash) = entry.leaf_content_hash() else {
398            return Ok(());
399        };
400        let blob = repo.require_blob(&hash)?;
401        if let Some(parent) = target_path.parent() {
402            fs::create_dir_all(parent)?;
403        }
404        fs::write(&target_path, blob.content())?;
405        return Ok(());
406    }
407
408    if target_path.exists() {
409        remove_path_recursively(&target_path)?;
410    }
411    Ok(())
412}
413
414fn copy_path(from: &Path, to: &Path) -> Result<()> {
415    if from.is_dir() {
416        fs::create_dir_all(to)?;
417        for entry in fs::read_dir(from)? {
418            let entry = entry?;
419            copy_path(&entry.path(), &to.join(entry.file_name()))?;
420        }
421        return Ok(());
422    }
423
424    if let Some(parent) = to.parent() {
425        fs::create_dir_all(parent)?;
426    }
427    fs::copy(from, to)?;
428    Ok(())
429}
430
431fn matches_prefix(path: &str, prefixes: &[String]) -> bool {
432    prefixes.iter().any(|prefix| {
433        let prefix = prefix.trim_matches('/');
434        path == prefix || path.starts_with(&format!("{prefix}/"))
435    })
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn empty_path_movement_refusals_use_typed_error_details() {
444        let split = no_paths_matched_details(
445            "capture split",
446            "No dirty paths matched the requested split prefixes",
447            "the worktree has no dirty paths under the requested prefixes",
448            "capture --split would not move any work into the target thread",
449            "heddle status",
450        );
451        assert_eq!(split.action, "capture split");
452        assert_eq!(split.primary_command, "heddle status");
453        assert_eq!(
454            split.error,
455            "No dirty paths matched the requested split prefixes"
456        );
457
458        let move_paths = no_paths_matched_details(
459            "thread move",
460            "No captured paths matched the requested prefixes",
461            "the source thread has no captured paths under the requested prefixes",
462            "thread move would not move any captured files into the target thread",
463            "heddle thread show",
464        );
465        assert_eq!(move_paths.action, "thread move");
466        assert_eq!(move_paths.primary_command, "heddle thread show");
467        assert_eq!(
468            move_paths.error,
469            "No captured paths matched the requested prefixes"
470        );
471    }
472
473    #[test]
474    fn matches_prefix_respects_directory_boundaries() {
475        let prefixes = vec!["auth".to_string()];
476        assert!(matches_prefix("auth", &prefixes));
477        assert!(matches_prefix("auth/login.rs", &prefixes));
478        assert!(!matches_prefix("authz.rs", &prefixes));
479    }
480}