Skip to main content

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