1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! `.scratch/` — the project-local **ephemeral** state root (#844), and its
//! configurable relocation.
//!
//! Three Cs:
//! - **Convention:** `.newt/` holds *tracked* per-repo newt config
//! (`config.toml`, `agent-identity.toml`, `plan.md`, `language-packs/`);
//! generated/ephemeral state (crew worktrees, the crew cargo target,
//! per-session plans) defaults to a sibling **`.scratch/`**.
//! - **Configuration:** that location is NOT hard-coded. The scratch dir is
//! resolved `NEWT_SCRATCH_DIR` (env) → `[scratch] dir` (config file) → the
//! `.scratch` default, and may be **absolute** — so an environment where the
//! checkout is read-only can point scratch at `/tmp`/`/var/tmp`, and a k8s
//! deploy can point it at a PVC mount, exactly as git worktrees conventionally
//! live outside the repo tree.
//! - **Composition:** every scratch consumer goes through [`scratch_root`] /
//! [`ensure_scratch`], so the location is decided in one place.
//!
//! **The ignore is baked into the harness, not the model.** [`ensure_scratch`]
//! writes `<root>/.gitignore` containing `*` (like cargo's `target/.gitignore`),
//! so scratch is ignored independent of the repo's root `.gitignore`. (Moot when
//! scratch is relocated outside the repo, but harmless + correct in-tree.)
use std::io;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
mod build;
pub(crate) use build::{
acquire_run as acquire_build_run, managed_partition as managed_build_partition,
validate_grant as validate_build_grant, ManagedRun,
};
/// The operator override, or the stable private managed base proposed to the
/// Build permission gate. Resolving it creates no filesystem entries.
pub(crate) fn build_scratch_base() -> PathBuf {
build_scratch_override().unwrap_or_else(build::default_base)
}
/// The default ephemeral dir, relative to the repo root. A convention, not a
/// lock-in — override it via the `[scratch] dir` config key or `NEWT_SCRATCH_DIR`.
pub const DEFAULT_SCRATCH_DIR: &str = ".scratch";
/// The `[scratch] dir` config value, published once when config resolves
/// ([`set_scratch_dir`]). The env var still wins over it at read time.
static CONFIGURED_SCRATCH_DIR: OnceLock<String> = OnceLock::new();
/// Publish the config-file scratch dir (`[scratch] dir`), once. First non-empty
/// value wins; a no-op for `None`/empty. Called from
/// `Config::apply_runtime_settings` before runtime work begins.
/// `NEWT_SCRATCH_DIR` still overrides.
pub fn set_scratch_dir(dir: impl Into<String>) {
let dir = dir.into();
if !dir.trim().is_empty() {
let _ = CONFIGURED_SCRATCH_DIR.set(dir);
}
}
/// The `[scratch] build_dir` config value (#2604), published like
/// [`CONFIGURED_SCRATCH_DIR`]. `NEWT_BUILD_SCRATCH_DIR` wins over it.
static CONFIGURED_BUILD_SCRATCH_DIR: OnceLock<String> = OnceLock::new();
/// Publish the config-file build scratch base (`[scratch] build_dir`), once,
/// with [`set_scratch_dir`]'s rules.
pub fn set_build_scratch_dir(dir: impl Into<String>) {
let dir = dir.into();
if !dir.trim().is_empty() {
let _ = CONFIGURED_BUILD_SCRATCH_DIR.set(dir);
}
}
/// A non-empty `env` value, else the published config value.
fn env_then_config(env: &str, configured: &OnceLock<String>) -> Option<String> {
std::env::var(env)
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| configured.get().cloned())
}
/// The configured scratch dir, by precedence:
/// `NEWT_SCRATCH_DIR` env → `[scratch] dir` config → `.scratch` default. The
/// result may be relative (joined under the repo) or **absolute** (used as-is),
/// so a read-only checkout or a k8s deploy can point scratch at `/tmp`, a PVC
/// mount, or anywhere writable.
#[must_use]
pub fn scratch_dir() -> String {
env_then_config("NEWT_SCRATCH_DIR", &CONFIGURED_SCRATCH_DIR)
.unwrap_or_else(|| DEFAULT_SCRATCH_DIR.to_string())
}
/// The operator's escape hatch for the build lane's scratch base (#2604):
/// `NEWT_BUILD_SCRATCH_DIR` env → `[scratch] build_dir` config. `None` uses
/// a private managed temporary base outside Git worktrees.
/// When set, a run's `TMPDIR` is `<base>/<workspace id>/<run>` (see
/// `confined_exec::build_scratch_dir`): outside the repo, so a test fixture
/// that must not sit in a git repo works, but outside the session's write grant
/// too, so the Build permission covers it. Point it somewhere no other confined
/// lane can write: a directory another lane can pre-create or symlink could
/// redirect the build lane's writes.
#[must_use]
pub fn build_scratch_override() -> Option<PathBuf> {
absolute_dir(env_then_config(
"NEWT_BUILD_SCRATCH_DIR",
&CONFIGURED_BUILD_SCRATCH_DIR,
))
}
/// Only an **absolute** setting counts: the base must sit outside any
/// workspace, and a relative one would resolve under the build's cwd.
fn absolute_dir(dir: Option<String>) -> Option<PathBuf> {
dir.map(PathBuf::from).filter(|p| p.is_absolute())
}
/// Resolve a scratch dir setting against a repo `base`: an **absolute** setting
/// is used verbatim (relocated outside the repo — `/tmp`, a PVC, …); a
/// **relative** one is joined under `base`. Pure.
#[must_use]
pub fn resolve_scratch_root(base: &Path, dir: &str) -> PathBuf {
let p = Path::new(dir);
if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
}
}
/// The ephemeral scratch root for repo `base`, honoring the configured location.
#[must_use]
pub fn scratch_root(base: &Path) -> PathBuf {
resolve_scratch_root(base, &scratch_dir())
}
/// The workspace-RELATIVE scratch subdir for state that must stay inside the
/// file-write fence (per-session plans). Uses the configured dir when it is
/// relative; falls back to the default when scratch is relocated to an absolute
/// path (a fenced write can't escape the workspace — moving session plans out to
/// an absolute scratch is a follow-up that needs the fs fence to admit it).
#[must_use]
pub fn scratch_workspace_subdir() -> String {
let dir = scratch_dir();
if Path::new(&dir).is_absolute() {
DEFAULT_SCRATCH_DIR.to_string()
} else {
dir
}
}
/// Contents of the self-ignoring `<root>/.gitignore`. `*` ignores every file
/// under the scratch root regardless of the repo's root `.gitignore`, so the
/// harness owns the ignore and the model cannot forget it.
pub const SCRATCH_GITIGNORE: &str =
"# Auto-generated by newt: ephemeral scratch (#844). Never commit this dir.\n*\n";
/// Create the scratch root for `base` and ensure its self-ignoring `.gitignore`
/// exists. Idempotent — call from every scratch-write path so scratch is *never*
/// committable, even if the root `.gitignore` is missing the entry. Returns the
/// resolved root.
///
/// # Errors
/// Propagates a directory-create or `.gitignore` write failure.
pub fn ensure_scratch(base: &Path) -> io::Result<PathBuf> {
let root = scratch_root(base);
std::fs::create_dir_all(&root)?;
let ignore = root.join(".gitignore");
if !ignore.exists() {
std::fs::write(&ignore, SCRATCH_GITIGNORE)?;
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_relative_setting_is_joined_under_base() {
assert_eq!(
resolve_scratch_root(Path::new("/repo"), ".scratch"),
PathBuf::from("/repo/.scratch")
);
assert_eq!(
resolve_scratch_root(Path::new("/repo"), ".myscratch"),
PathBuf::from("/repo/.myscratch")
);
}
#[test]
fn resolve_absolute_setting_relocates_outside_the_repo() {
// The read-only-checkout / k8s-PVC case: an absolute setting is used
// verbatim, NOT joined under the repo.
assert_eq!(
resolve_scratch_root(Path::new("/repo"), "/tmp/newt-scratch"),
PathBuf::from("/tmp/newt-scratch")
);
}
#[test]
fn workspace_subdir_falls_back_to_default_when_scratch_is_absolute() {
// A fenced write can't escape the workspace, so an absolute scratch dir
// keeps session plans on the relative default.
// (scratch_dir() with no env/config set → the default, which is relative.)
assert_eq!(scratch_workspace_subdir(), DEFAULT_SCRATCH_DIR);
}
#[test]
fn build_scratch_override_takes_only_an_absolute_dir() {
let abs = std::env::temp_dir().join("operator-build-scratch");
let abs_str = abs.to_string_lossy().into_owned();
assert_eq!(absolute_dir(Some(abs_str)), Some(abs));
assert_eq!(absolute_dir(Some("relative/scratch".into())), None);
assert_eq!(absolute_dir(None), None);
}
#[test]
fn gitignore_body_ignores_everything() {
assert!(SCRATCH_GITIGNORE.lines().any(|l| l.trim() == "*"));
}
#[test]
fn ensure_scratch_creates_dir_and_self_ignore() {
let tmp = tempfile::tempdir().unwrap();
let root = ensure_scratch(tmp.path()).unwrap();
assert!(root.is_dir());
let ignore = root.join(".gitignore");
assert!(ignore.is_file());
assert_eq!(std::fs::read_to_string(&ignore).unwrap(), SCRATCH_GITIGNORE);
}
#[test]
fn ensure_scratch_is_idempotent_and_preserves_a_customized_ignore() {
let tmp = tempfile::tempdir().unwrap();
ensure_scratch(tmp.path()).unwrap();
let ignore = scratch_root(tmp.path()).join(".gitignore");
std::fs::write(&ignore, "*\n!keep.txt\n").unwrap();
ensure_scratch(tmp.path()).unwrap();
assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n!keep.txt\n");
}
}