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