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