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