Skip to main content

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}