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::{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, ¤t_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}