thoughts_tool/workspace/
mod.rs1mod readiness;
2
3pub use readiness::ensure_thoughts_environment_ready;
4
5use anyhow::Context;
6use anyhow::Result;
7use atomicwrites::AtomicFile;
8use atomicwrites::OverwriteBehavior;
9use serde_json::json;
10use std::fs;
11use std::io::Write;
12use std::path::Path;
13use std::path::PathBuf;
14use tracing::debug;
15
16use crate::config::Mount;
17use crate::config::RepoConfigManager;
18use crate::git::utils::HeadState;
19use crate::git::utils::find_repo_root;
20use crate::git::utils::get_control_repo_root;
21use crate::git::utils::get_current_branch;
22use crate::git::utils::get_head_state;
23use crate::git::utils::get_remote_url;
24use crate::mount::MountResolver;
25
26fn is_main_like(branch: &str) -> bool {
28 matches!(branch, "main" | "master")
29}
30
31fn main_branch_lockout_error(branch: &str) -> anyhow::Error {
33 anyhow::anyhow!(
34 "Branch protection: operations that create or access branch-specific work are blocked on '{branch}'.\n\
35 Create a feature branch first, then re-run:\n git checkout -b my/feature\n\n\
36 Note: branch-agnostic commands like 'thoughts work list' and 'thoughts references list' are allowed on main."
37 )
38}
39
40fn is_weekly_dir_name(name: &str) -> bool {
42 if let Some((year, rest)) = name.split_once("-W")
44 && year.len() == 4
45 && year.chars().all(|c| c.is_ascii_digit())
46 && rest.len() == 2
47 && rest.chars().all(|c| c.is_ascii_digit())
48 && let Ok(w) = rest.parse::<u32>()
49 {
50 return (1..=53).contains(&w);
51 }
52 if let Some((year, rest)) = name.split_once("_week_")
54 && year.len() == 4
55 && year.chars().all(|c| c.is_ascii_digit())
56 && rest.len() == 2
57 && rest.chars().all(|c| c.is_ascii_digit())
58 && let Ok(w) = rest.parse::<u32>()
59 {
60 return (1..=53).contains(&w);
61 }
62 false
63}
64
65fn next_archive_name(completed_dir: &Path, base_name: &str) -> PathBuf {
67 let candidate = completed_dir.join(base_name);
68 if !candidate.exists() {
69 return candidate;
70 }
71 let mut i = 1usize;
72 loop {
73 let with_suffix = if i == 1 {
74 format!("{base_name}-migrated")
75 } else {
76 format!("{base_name}-migrated-{i}")
77 };
78 let p = completed_dir.join(with_suffix);
79 if !p.exists() {
80 return p;
81 }
82 i += 1;
83 }
84}
85
86fn auto_archive_weekly_dirs(thoughts_root: &Path) -> Result<()> {
88 let completed = thoughts_root.join("completed");
89 let _ = std::fs::create_dir_all(&completed);
90 for entry in std::fs::read_dir(thoughts_root)? {
91 let entry = entry?;
92 let p = entry.path();
93 if !p.is_dir() {
94 continue;
95 }
96 let name = entry.file_name();
97 let name = name.to_string_lossy();
98 if name == "completed" || name == "active" {
99 continue;
100 }
101 if is_weekly_dir_name(&name) {
102 let dest = next_archive_name(&completed, &name);
103 debug!("Archiving weekly dir {} -> {}", p.display(), dest.display());
104 std::fs::rename(&p, &dest).with_context(|| {
105 format!(
106 "Failed to archive weekly dir {} -> {}",
107 p.display(),
108 dest.display()
109 )
110 })?;
111 }
112 }
113 Ok(())
114}
115
116fn migrate_active_layer(thoughts_root: &Path) -> Result<()> {
121 let active = thoughts_root.join("active");
122
123 if active.exists() && active.is_dir() && !active.is_symlink() {
125 debug!("Migrating active/ layer at {}", thoughts_root.display());
126
127 for entry in std::fs::read_dir(&active)? {
129 let entry = entry?;
130 let p = entry.path();
131 if p.is_dir() {
132 let name = entry.file_name();
133 let newp = thoughts_root.join(&name);
134 if !newp.exists() {
135 std::fs::rename(&p, &newp).with_context(|| {
136 format!("Failed to move {} to {}", p.display(), newp.display())
137 })?;
138 debug!("Migrated {} -> {}", p.display(), newp.display());
139 }
140 }
141 }
142
143 #[cfg(unix)]
145 {
146 use std::os::unix::fs as unixfs;
147 if std::fs::read_dir(&active)?.next().is_none() {
149 let _ = std::fs::remove_dir(&active);
150 if unixfs::symlink(".", &active).is_ok() {
151 debug!("Created compatibility symlink: active -> .");
152 }
153 }
154 }
155 }
156 Ok(())
157}
158
159#[derive(Debug, Clone)]
161pub struct ActiveWork {
162 pub dir_name: String,
163 pub base: PathBuf,
164 pub research: PathBuf,
165 pub plans: PathBuf,
166 pub artifacts: PathBuf,
167 pub logs: PathBuf,
168 pub remote_url: Option<String>,
170 pub repo_subpath: Option<String>,
172 pub thoughts_git_ref: Option<String>,
174}
175
176struct ResolvedThoughtsRoot {
178 path: PathBuf,
179 remote_url: Option<String>,
180 repo_subpath: Option<String>,
181 thoughts_git_ref: Option<String>,
182}
183
184fn resolve_thoughts_root() -> Result<ResolvedThoughtsRoot> {
186 let control_root = get_control_repo_root(&std::env::current_dir()?)?;
187 let mgr = RepoConfigManager::new(control_root);
188 let ds = mgr.load_desired_state()?.ok_or_else(|| {
189 anyhow::anyhow!("No repository configuration found. Run 'thoughts init'.")
190 })?;
191
192 let tm = ds.thoughts_mount.as_ref().ok_or_else(|| {
193 anyhow::anyhow!(
194 "No thoughts_mount configured in repository configuration.\n\
195 Add thoughts_mount to .thoughts/config.json and run 'thoughts mount update'."
196 )
197 })?;
198
199 let resolver = MountResolver::new()?;
200 let mount = Mount::Git {
201 url: tm.remote.clone(),
202 subpath: tm.subpath.clone(),
203 sync: tm.sync,
204 };
205
206 let path = resolver.resolve_mount(&mount).context(
207 "Thoughts mount not cloned. Run 'thoughts sync' or 'thoughts mount update' first.",
208 )?;
209
210 let thoughts_git_ref = find_repo_root(&path).ok().and_then(|repo_root| {
211 get_head_state(&repo_root)
212 .ok()
213 .and_then(|state| match state {
214 HeadState::Attached(name) => Some(name),
215 _ => None,
216 })
217 });
218
219 Ok(ResolvedThoughtsRoot {
220 path,
221 remote_url: Some(tm.remote.clone()),
222 repo_subpath: tm.subpath.clone(),
223 thoughts_git_ref,
224 })
225}
226
227pub fn check_branch_allowed() -> Result<()> {
230 let resolved = resolve_thoughts_root()?;
231 migrate_active_layer(&resolved.path)?;
233 auto_archive_weekly_dirs(&resolved.path)?;
234 let code_root = find_repo_root(&std::env::current_dir()?)?;
235 let branch = get_current_branch(&code_root)?;
236 if is_main_like(&branch) {
237 return Err(main_branch_lockout_error(&branch));
238 }
239 Ok(())
240}
241
242pub fn ensure_active_work() -> Result<ActiveWork> {
245 let resolved = resolve_thoughts_root()?;
246
247 migrate_active_layer(&resolved.path)?;
249 auto_archive_weekly_dirs(&resolved.path)?;
250
251 let code_root = find_repo_root(&std::env::current_dir()?)?;
253 let branch = get_current_branch(&code_root)?;
254 if is_main_like(&branch) {
255 return Err(main_branch_lockout_error(&branch));
256 }
257
258 let dir_name = branch;
260 let base = resolved.path.join(&dir_name);
261
262 if base.exists() {
264 for sub in ["research", "plans", "artifacts", "logs"] {
266 let subdir = base.join(sub);
267 if !subdir.exists() {
268 fs::create_dir_all(&subdir)
269 .with_context(|| format!("Failed to ensure {sub} directory"))?;
270 }
271 }
272 let manifest_path = base.join("manifest.json");
274 if !manifest_path.exists() {
275 let source_repo = get_remote_url(&code_root).unwrap_or_else(|_| "unknown".to_string());
276 let manifest = json!({
277 "source_repo": source_repo,
278 "branch_or_week": dir_name,
279 "started_at": chrono::Utc::now().to_rfc3339(),
280 });
281 AtomicFile::new(&manifest_path, OverwriteBehavior::AllowOverwrite)
282 .write(|f| f.write_all(serde_json::to_string_pretty(&manifest)?.as_bytes()))
283 .with_context(|| {
284 format!("Failed to write manifest at {}", manifest_path.display())
285 })?;
286 }
287 } else {
288 fs::create_dir_all(base.join("research")).context("Failed to create research directory")?;
289 fs::create_dir_all(base.join("plans")).context("Failed to create plans directory")?;
290 fs::create_dir_all(base.join("artifacts"))
291 .context("Failed to create artifacts directory")?;
292 fs::create_dir_all(base.join("logs")).context("Failed to create logs directory")?;
293
294 let source_repo = get_remote_url(&code_root).unwrap_or_else(|_| "unknown".to_string());
296 let manifest = json!({
297 "source_repo": source_repo,
298 "branch_or_week": dir_name,
299 "started_at": chrono::Utc::now().to_rfc3339(),
300 });
301
302 let manifest_path = base.join("manifest.json");
303 AtomicFile::new(&manifest_path, OverwriteBehavior::AllowOverwrite)
304 .write(|f| f.write_all(serde_json::to_string_pretty(&manifest)?.as_bytes()))
305 .with_context(|| format!("Failed to write manifest at {}", manifest_path.display()))?;
306 }
307
308 Ok(ActiveWork {
309 dir_name,
310 base: base.clone(),
311 research: base.join("research"),
312 plans: base.join("plans"),
313 artifacts: base.join("artifacts"),
314 logs: base.join("logs"),
315 remote_url: resolved.remote_url,
316 repo_subpath: resolved.repo_subpath,
317 thoughts_git_ref: resolved.thoughts_git_ref,
318 })
319}
320
321#[cfg(test)]
322mod branch_lock_tests {
323 use super::*;
324 use std::fs;
325 use tempfile::TempDir;
326
327 #[test]
328 fn is_main_like_detection() {
329 assert!(is_main_like("main"));
330 assert!(is_main_like("master"));
331 assert!(!is_main_like("feature/login"));
332 assert!(!is_main_like("main-feature"));
333 assert!(!is_main_like("my-master"));
334 }
335
336 #[test]
337 fn weekly_name_detection() {
338 assert!(is_weekly_dir_name("2025-W01"));
340 assert!(is_weekly_dir_name("2024-W53"));
341 assert!(is_weekly_dir_name("2020-W10"));
342
343 assert!(is_weekly_dir_name("2024_week_52"));
345 assert!(is_weekly_dir_name("2025_week_01"));
346
347 assert!(!is_weekly_dir_name("feat/login-page"));
349 assert!(!is_weekly_dir_name("main"));
350 assert!(!is_weekly_dir_name("master"));
351 assert!(!is_weekly_dir_name("feature-2025-W01"));
352
353 assert!(!is_weekly_dir_name("2025-W00"));
355 assert!(!is_weekly_dir_name("2025-W54"));
356 assert!(!is_weekly_dir_name("2025_week_00"));
357 assert!(!is_weekly_dir_name("2025_week_54"));
358
359 assert!(!is_weekly_dir_name("2025-W1")); assert!(!is_weekly_dir_name("202-W01")); assert!(!is_weekly_dir_name("2025_week_1")); }
364
365 #[test]
366 fn auto_archive_moves_weekly_dirs() {
367 let temp = TempDir::new().unwrap();
368 let root = temp.path();
369
370 fs::create_dir_all(root.join("2025-W01")).unwrap();
372 fs::create_dir_all(root.join("2024_week_52")).unwrap();
373 fs::create_dir_all(root.join("feature-branch")).unwrap();
375
376 auto_archive_weekly_dirs(root).unwrap();
377
378 assert!(!root.join("2025-W01").exists());
380 assert!(!root.join("2024_week_52").exists());
381 assert!(root.join("completed/2025-W01").exists());
382 assert!(root.join("completed/2024_week_52").exists());
383
384 assert!(root.join("feature-branch").exists());
386 }
387
388 #[test]
389 fn auto_archive_handles_collision() {
390 let temp = TempDir::new().unwrap();
391 let root = temp.path();
392
393 fs::create_dir_all(root.join("completed/2025-W01")).unwrap();
395 fs::create_dir_all(root.join("2025-W01")).unwrap();
397
398 auto_archive_weekly_dirs(root).unwrap();
399
400 assert!(!root.join("2025-W01").exists());
402 assert!(root.join("completed/2025-W01").exists());
403 assert!(root.join("completed/2025-W01-migrated").exists());
404 }
405
406 #[test]
407 fn auto_archive_multiple_collision() {
408 let temp = TempDir::new().unwrap();
409 let root = temp.path();
410
411 fs::create_dir_all(root.join("completed/2025-W01")).unwrap();
413 fs::create_dir_all(root.join("completed/2025-W01-migrated")).unwrap();
414
415 fs::create_dir_all(root.join("2025-W01")).unwrap();
417
418 auto_archive_weekly_dirs(root).unwrap();
419
420 assert!(!root.join("2025-W01").exists());
422 assert!(root.join("completed/2025-W01").exists());
424 assert!(root.join("completed/2025-W01-migrated").exists());
425 assert!(root.join("completed/2025-W01-migrated-2").exists());
427 }
428
429 #[test]
430 fn auto_archive_idempotent() {
431 let temp = TempDir::new().unwrap();
432 let root = temp.path();
433
434 fs::create_dir_all(root.join("feature-branch")).unwrap();
436 fs::create_dir_all(root.join("completed")).unwrap();
437
438 auto_archive_weekly_dirs(root).unwrap();
440 auto_archive_weekly_dirs(root).unwrap();
441
442 assert!(root.join("feature-branch").exists());
443 }
444
445 #[test]
446 fn lockout_error_message_format() {
447 let err = main_branch_lockout_error("main");
448 let msg = err.to_string();
449 assert!(msg.contains("Branch protection"));
451 assert!(msg.contains("'main'"));
452 assert!(msg.contains("git checkout -b"));
453 assert!(msg.contains("work list"));
454 }
455}