newt_core/scratch.rs
1//! `.scratch/` — the project-local **ephemeral** state root (#844), and its
2//! configurable relocation.
3//!
4//! Three Cs:
5//! - **Convention:** `.newt/` holds *tracked* per-repo newt config
6//! (`config.toml`, `agent-identity.toml`, `plan.md`, `language-packs/`);
7//! generated/ephemeral state (crew worktrees, the crew cargo target,
8//! per-session plans) defaults to a sibling **`.scratch/`**.
9//! - **Configuration:** that location is NOT hard-coded. The scratch dir is
10//! resolved `NEWT_SCRATCH_DIR` (env) → `[scratch] dir` (config file) → the
11//! `.scratch` default, and may be **absolute** — so an environment where the
12//! checkout is read-only can point scratch at `/tmp`/`/var/tmp`, and a k8s
13//! deploy can point it at a PVC mount, exactly as git worktrees conventionally
14//! live outside the repo tree.
15//! - **Composition:** every scratch consumer goes through [`scratch_root`] /
16//! [`ensure_scratch`], so the location is decided in one place.
17//!
18//! **The ignore is baked into the harness, not the model.** [`ensure_scratch`]
19//! writes `<root>/.gitignore` containing `*` (like cargo's `target/.gitignore`),
20//! so scratch is ignored independent of the repo's root `.gitignore`. (Moot when
21//! scratch is relocated outside the repo, but harmless + correct in-tree.)
22
23use std::io;
24use std::path::{Path, PathBuf};
25use std::sync::OnceLock;
26
27/// The default ephemeral dir, relative to the repo root. A convention, not a
28/// lock-in — override it via the `[scratch] dir` config key or `NEWT_SCRATCH_DIR`.
29pub const DEFAULT_SCRATCH_DIR: &str = ".scratch";
30
31/// The `[scratch] dir` config value, published once when config resolves
32/// ([`set_scratch_dir`]). The env var still wins over it at read time.
33static CONFIGURED_SCRATCH_DIR: OnceLock<String> = OnceLock::new();
34
35/// Publish the config-file scratch dir (`[scratch] dir`), once. First non-empty
36/// value wins; a no-op for `None`/empty. Called from `Config::resolve` so every
37/// config-loading entry point picks it up. `NEWT_SCRATCH_DIR` still overrides.
38pub fn set_scratch_dir(dir: impl Into<String>) {
39 let dir = dir.into();
40 if !dir.trim().is_empty() {
41 let _ = CONFIGURED_SCRATCH_DIR.set(dir);
42 }
43}
44
45/// The configured scratch dir, by precedence:
46/// `NEWT_SCRATCH_DIR` env → `[scratch] dir` config → `.scratch` default. The
47/// result may be relative (joined under the repo) or **absolute** (used as-is),
48/// so a read-only checkout or a k8s deploy can point scratch at `/tmp`, a PVC
49/// mount, or anywhere writable.
50#[must_use]
51pub fn scratch_dir() -> String {
52 if let Ok(v) = std::env::var("NEWT_SCRATCH_DIR") {
53 if !v.trim().is_empty() {
54 return v;
55 }
56 }
57 CONFIGURED_SCRATCH_DIR
58 .get()
59 .cloned()
60 .unwrap_or_else(|| DEFAULT_SCRATCH_DIR.to_string())
61}
62
63/// Resolve a scratch dir setting against a repo `base`: an **absolute** setting
64/// is used verbatim (relocated outside the repo — `/tmp`, a PVC, …); a
65/// **relative** one is joined under `base`. Pure.
66#[must_use]
67pub fn resolve_scratch_root(base: &Path, dir: &str) -> PathBuf {
68 let p = Path::new(dir);
69 if p.is_absolute() {
70 p.to_path_buf()
71 } else {
72 base.join(p)
73 }
74}
75
76/// The ephemeral scratch root for repo `base`, honoring the configured location.
77#[must_use]
78pub fn scratch_root(base: &Path) -> PathBuf {
79 resolve_scratch_root(base, &scratch_dir())
80}
81
82/// The workspace-RELATIVE scratch subdir for state that must stay inside the
83/// file-write fence (per-session plans). Uses the configured dir when it is
84/// relative; falls back to the default when scratch is relocated to an absolute
85/// path (a fenced write can't escape the workspace — moving session plans out to
86/// an absolute scratch is a follow-up that needs the fs fence to admit it).
87#[must_use]
88pub fn scratch_workspace_subdir() -> String {
89 let dir = scratch_dir();
90 if Path::new(&dir).is_absolute() {
91 DEFAULT_SCRATCH_DIR.to_string()
92 } else {
93 dir
94 }
95}
96
97/// Contents of the self-ignoring `<root>/.gitignore`. `*` ignores every file
98/// under the scratch root regardless of the repo's root `.gitignore`, so the
99/// harness owns the ignore and the model cannot forget it.
100pub const SCRATCH_GITIGNORE: &str =
101 "# Auto-generated by newt: ephemeral scratch (#844). Never commit this dir.\n*\n";
102
103/// Create the scratch root for `base` and ensure its self-ignoring `.gitignore`
104/// exists. Idempotent — call from every scratch-write path so scratch is *never*
105/// committable, even if the root `.gitignore` is missing the entry. Returns the
106/// resolved root.
107///
108/// # Errors
109/// Propagates a directory-create or `.gitignore` write failure.
110pub fn ensure_scratch(base: &Path) -> io::Result<PathBuf> {
111 let root = scratch_root(base);
112 std::fs::create_dir_all(&root)?;
113 let ignore = root.join(".gitignore");
114 if !ignore.exists() {
115 std::fs::write(&ignore, SCRATCH_GITIGNORE)?;
116 }
117 Ok(root)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn resolve_relative_setting_is_joined_under_base() {
126 assert_eq!(
127 resolve_scratch_root(Path::new("/repo"), ".scratch"),
128 PathBuf::from("/repo/.scratch")
129 );
130 assert_eq!(
131 resolve_scratch_root(Path::new("/repo"), ".myscratch"),
132 PathBuf::from("/repo/.myscratch")
133 );
134 }
135
136 #[test]
137 fn resolve_absolute_setting_relocates_outside_the_repo() {
138 // The read-only-checkout / k8s-PVC case: an absolute setting is used
139 // verbatim, NOT joined under the repo.
140 assert_eq!(
141 resolve_scratch_root(Path::new("/repo"), "/tmp/newt-scratch"),
142 PathBuf::from("/tmp/newt-scratch")
143 );
144 }
145
146 #[test]
147 fn workspace_subdir_falls_back_to_default_when_scratch_is_absolute() {
148 // A fenced write can't escape the workspace, so an absolute scratch dir
149 // keeps session plans on the relative default.
150 // (scratch_dir() with no env/config set → the default, which is relative.)
151 assert_eq!(scratch_workspace_subdir(), DEFAULT_SCRATCH_DIR);
152 }
153
154 #[test]
155 fn gitignore_body_ignores_everything() {
156 assert!(SCRATCH_GITIGNORE.lines().any(|l| l.trim() == "*"));
157 }
158
159 #[test]
160 fn ensure_scratch_creates_dir_and_self_ignore() {
161 let tmp = tempfile::tempdir().unwrap();
162 let root = ensure_scratch(tmp.path()).unwrap();
163 assert!(root.is_dir());
164 let ignore = root.join(".gitignore");
165 assert!(ignore.is_file());
166 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), SCRATCH_GITIGNORE);
167 }
168
169 #[test]
170 fn ensure_scratch_is_idempotent_and_preserves_a_customized_ignore() {
171 let tmp = tempfile::tempdir().unwrap();
172 ensure_scratch(tmp.path()).unwrap();
173 let ignore = scratch_root(tmp.path()).join(".gitignore");
174 std::fs::write(&ignore, "*\n!keep.txt\n").unwrap();
175 ensure_scratch(tmp.path()).unwrap();
176 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n!keep.txt\n");
177 }
178}