1use 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, ¤t_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}