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