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::{ChangeId, 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_change_id: Option<String>,
26    pub target_change_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_change_id: None,
123        target_change_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_change_id: Some(source_snapshot),
185        target_change_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(
279    repo: &Repository,
280    spec: Option<&str>,
281    message: &str,
282) -> Result<ChangeId> {
283    let spec = spec.ok_or_else(|| anyhow!(message.to_string()))?;
284    repo.resolve_state(spec)?
285        .ok_or_else(|| anyhow!(message.to_string()))
286}
287
288fn collect_worktree_split_paths(
289    repo: &Repository,
290    prefixes: &[String],
291    worktree_status_options: &WorktreeStatusOptions,
292) -> Result<Vec<String>> {
293    let baseline = match repo.current_state()? {
294        Some(state) => repo.require_tree(&state.tree)?,
295        None => objects::object::Tree::new(),
296    };
297    let status = repo.compare_worktree_cached_with_options(&baseline, worktree_status_options)?;
298    let mut paths = status
299        .modified
300        .iter()
301        .chain(status.added.iter())
302        .chain(status.deleted.iter())
303        .map(|path| path.to_string_lossy().to_string())
304        .filter(|path| matches_prefix(path, prefixes))
305        .collect::<Vec<_>>();
306    paths.sort();
307    paths.dedup();
308    Ok(paths)
309}
310
311fn collect_state_move_paths(
312    repo: &Repository,
313    base: &ChangeId,
314    current: &ChangeId,
315    prefixes: &[String],
316) -> Result<Vec<String>> {
317    let base_tree = repo
318        .store()
319        .get_state(base)?
320        .ok_or_else(|| anyhow!("Base state not found"))?
321        .tree;
322    let current_tree = repo
323        .store()
324        .get_state(current)?
325        .ok_or_else(|| anyhow!("Current state not found"))?
326        .tree;
327    let mut paths = repo
328        .diff_trees(&base_tree, &current_tree)?
329        .into_iter()
330        .map(|change| change.path)
331        .filter(|path| matches_prefix(path, prefixes))
332        .collect::<Vec<_>>();
333    paths.sort();
334    paths.dedup();
335    Ok(paths)
336}
337
338fn apply_selected_worktree_paths(
339    source_repo: &Repository,
340    target_repo: &Repository,
341    paths: &[String],
342) -> Result<()> {
343    for path in paths {
344        let source_path = source_repo.root().join(path);
345        let target_path = target_repo.root().join(path);
346        if source_path.exists() {
347            copy_path(&source_path, &target_path)?;
348        } else if target_path.exists() {
349            remove_path_recursively(&target_path)?;
350        }
351    }
352    Ok(())
353}
354
355fn apply_selected_state_paths(
356    source_repo: &Repository,
357    state_id: &ChangeId,
358    target_repo: &Repository,
359    paths: &[String],
360) -> Result<()> {
361    let state = source_repo
362        .store()
363        .get_state(state_id)?
364        .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
365    let tree = source_repo.require_tree(&state.tree)?;
366    for path in paths {
367        restore_one_path(target_repo, Some(&tree), path)?;
368    }
369    Ok(())
370}
371
372fn restore_paths_from_state(
373    repo: &Repository,
374    baseline: Option<ChangeId>,
375    paths: &[String],
376) -> Result<()> {
377    let tree = if let Some(state_id) = baseline {
378        let state = repo
379            .store()
380            .get_state(&state_id)?
381            .ok_or_else(|| anyhow!("Baseline state '{}' not found", state_id.short()))?;
382        Some(repo.require_tree(&state.tree)?)
383    } else {
384        None
385    };
386    for path in paths {
387        restore_one_path(repo, tree.as_ref(), path)?;
388    }
389    Ok(())
390}
391
392fn restore_one_path(
393    repo: &Repository,
394    baseline_tree: Option<&objects::object::Tree>,
395    path: &str,
396) -> Result<()> {
397    let target_path = repo.root().join(path);
398    if let Some(tree) = baseline_tree
399        && let Some(entry) = tree.get(path)
400    {
401        let Some(hash) = entry.leaf_content_hash() else {
402            return Ok(());
403        };
404        let blob = repo.require_blob(&hash)?;
405        if let Some(parent) = target_path.parent() {
406            fs::create_dir_all(parent)?;
407        }
408        fs::write(&target_path, blob.content())?;
409        return Ok(());
410    }
411
412    if target_path.exists() {
413        remove_path_recursively(&target_path)?;
414    }
415    Ok(())
416}
417
418fn copy_path(from: &Path, to: &Path) -> Result<()> {
419    if from.is_dir() {
420        fs::create_dir_all(to)?;
421        for entry in fs::read_dir(from)? {
422            let entry = entry?;
423            copy_path(&entry.path(), &to.join(entry.file_name()))?;
424        }
425        return Ok(());
426    }
427
428    if let Some(parent) = to.parent() {
429        fs::create_dir_all(parent)?;
430    }
431    fs::copy(from, to)?;
432    Ok(())
433}
434
435fn matches_prefix(path: &str, prefixes: &[String]) -> bool {
436    prefixes.iter().any(|prefix| {
437        let prefix = prefix.trim_matches('/');
438        path == prefix || path.starts_with(&format!("{prefix}/"))
439    })
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn empty_path_movement_refusals_use_typed_error_details() {
448        let split = no_paths_matched_details(
449            "capture split",
450            "No dirty paths matched the requested split prefixes",
451            "the worktree has no dirty paths under the requested prefixes",
452            "capture --split would not move any work into the target thread",
453            "heddle status",
454        );
455        assert_eq!(split.action, "capture split");
456        assert_eq!(split.primary_command, "heddle status");
457        assert_eq!(
458            split.error,
459            "No dirty paths matched the requested split prefixes"
460        );
461
462        let move_paths = no_paths_matched_details(
463            "thread move",
464            "No captured paths matched the requested prefixes",
465            "the source thread has no captured paths under the requested prefixes",
466            "thread move would not move any captured files into the target thread",
467            "heddle thread show",
468        );
469        assert_eq!(move_paths.action, "thread move");
470        assert_eq!(move_paths.primary_command, "heddle thread show");
471        assert_eq!(
472            move_paths.error,
473            "No captured paths matched the requested prefixes"
474        );
475    }
476
477    #[test]
478    fn matches_prefix_respects_directory_boundaries() {
479        let prefixes = vec!["auth".to_string()];
480        assert!(matches_prefix("auth", &prefixes));
481        assert!(matches_prefix("auth/login.rs", &prefixes));
482        assert!(!matches_prefix("authz.rs", &prefixes));
483    }
484}