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