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