Skip to content

Commit 69d9088

Browse files
Merge fix/exclude-weave-state: coordination state auto-excluded from the working tree
2 parents efbdd03 + e5f7304 commit 69d9088

8 files changed

Lines changed: 257 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,10 @@ for the full flag list; the table below is what each one is for.
322322

323323
`claim`/`release`/`status`/`apply` all operate on the same `.weave/state.automerge` CRDT
324324
document as the MCP tools below: the CLI and MCP server are two front ends onto one
325-
coordination state.
325+
coordination state. That document lives in the repo's working tree but is never repo
326+
content: the first time weave writes it, it adds `.weave/` to the repo's local
327+
`.git/info/exclude` (never your own `.gitignore`), so it never shows up in `git status`
328+
or gets swept into `git add -A`.
326329

327330
## MCP Server
328331

crates/weave-core/src/git.rs

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! The `git` queries weave asks, and the four ways one can fail.
1+
//! The `git` queries weave asks, and the ways one can fail.
22
//!
33
//! Every function here shells out to `git`, and every one of them used to
44
//! answer failure with `Box<dyn Error>` built from a `format!` string — so
@@ -50,6 +50,17 @@ pub enum GitError {
5050
status: i32,
5151
stderr: String,
5252
},
53+
54+
/// `git` answered fine, but a filesystem operation this module needed on
55+
/// top of that answer (writing the local exclude file) failed. Distinct
56+
/// from [`GitError::NotRunnable`] because the process that failed here is
57+
/// our own I/O, not git's.
58+
#[error("could not update {path}: {source}")]
59+
Io {
60+
path: String,
61+
#[source]
62+
source: std::io::Error,
63+
},
5364
}
5465

5566
/// Run `git -C <dir> <args>` and hand back its output, or say which way it
@@ -237,6 +248,82 @@ pub fn diff_files(
237248
Ok(files)
238249
}
239250

251+
/// Make sure `entry` (a `.gitignore`-style pattern) is listed in this
252+
/// repository's *local* exclude file, without touching anything the
253+
/// repository itself tracks.
254+
///
255+
/// Weave writes its own coordination state (`.weave/`) into a repo's working
256+
/// tree, and that state must never show up in `git status`, get swept into
257+
/// `git add -A`, or ride along in a generated patch — it is local machine
258+
/// state, not repository content. `.git/info/exclude` is the git-native place
259+
/// for exactly that: it behaves like a `.gitignore` but lives inside `.git/`,
260+
/// so it is never committed, never pushed, and never collides with a
261+
/// `.gitignore` the repository's own maintainers write and version.
262+
///
263+
/// Resolved via `git rev-parse --git-path info/exclude` rather than assuming
264+
/// `<repo_root>/.git/info/exclude`, so this also lands in the right place for
265+
/// a linked worktree (whose private git dir is elsewhere) or a submodule
266+
/// (whose `.git` is a file, not a directory) — anywhere git itself would
267+
/// consider "the local exclude file for this working tree".
268+
///
269+
/// Idempotent: a second call with the same `entry` is a no-op, so this is
270+
/// safe to call on every save rather than only on first creation — which
271+
/// also means a repository whose `.weave/` directory predates this fix gets
272+
/// it retroactively on its very next save.
273+
///
274+
/// A best-effort courtesy, not a requirement: `repo_root` not being inside a
275+
/// git repository, or `git` not being runnable at all, comes back `Ok(())` —
276+
/// silently doing nothing is correct there, since there is no repository
277+
/// tracking to protect against. Only an I/O failure on a `.git` this call
278+
/// *did* manage to identify is reported, and even that is expected to be
279+
/// swallowed by callers for whom this is advisory (see
280+
/// `weave_crdt::EntityStateDoc::save`, which must never fail to write state
281+
/// just because the exclude file couldn't be touched).
282+
pub fn ensure_locally_excluded(repo_root: &Path, entry: &str) -> Result<(), GitError> {
283+
let args = ["rev-parse", "--git-path", "info/exclude"];
284+
let output = run(repo_root, &args)?;
285+
if !output.status.success() {
286+
// Not inside a git repository (or some other refusal) — nothing to
287+
// exclude from, and that's fine.
288+
return Ok(());
289+
}
290+
let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
291+
if printed.is_empty() {
292+
return Ok(());
293+
}
294+
let exclude_path = {
295+
let p = PathBuf::from(&printed);
296+
if p.is_absolute() {
297+
p
298+
} else {
299+
repo_root.join(p)
300+
}
301+
};
302+
303+
let io_err = |source: std::io::Error| GitError::Io {
304+
path: exclude_path.display().to_string(),
305+
source,
306+
};
307+
308+
if let Some(parent) = exclude_path.parent() {
309+
std::fs::create_dir_all(parent).map_err(io_err)?;
310+
}
311+
312+
let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default();
313+
if existing.lines().any(|line| line.trim() == entry) {
314+
return Ok(()); // already excluded
315+
}
316+
317+
let mut updated = existing;
318+
if !updated.is_empty() && !updated.ends_with('\n') {
319+
updated.push('\n');
320+
}
321+
updated.push_str(entry);
322+
updated.push('\n');
323+
std::fs::write(&exclude_path, updated).map_err(io_err)?;
324+
Ok(())
325+
}
326+
240327
#[cfg(test)]
241328
mod tests {
242329
use super::*;
@@ -286,4 +373,78 @@ mod tests {
286373
other => panic!("expected NoMergeBase, got {other:?}"),
287374
}
288375
}
376+
377+
fn init_repo(dir: &Path) {
378+
for args in [
379+
vec!["init", "-q"],
380+
vec!["config", "user.email", "test@example.com"],
381+
vec!["config", "user.name", "Test"],
382+
] {
383+
let status = Command::new("git")
384+
.arg("-C")
385+
.arg(dir)
386+
.args(&args)
387+
.status()
388+
.expect("git must be runnable for this test");
389+
assert!(status.success(), "git {args:?} failed");
390+
}
391+
}
392+
393+
#[test]
394+
fn ensure_locally_excluded_writes_the_entry_to_info_exclude_not_gitignore() {
395+
let dir = tempfile::tempdir().expect("tempdir");
396+
init_repo(dir.path());
397+
398+
ensure_locally_excluded(dir.path(), ".weave/").expect("ensure_locally_excluded");
399+
400+
let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude"))
401+
.expect("info/exclude should exist");
402+
assert!(exclude.lines().any(|l| l == ".weave/"));
403+
assert!(
404+
!dir.path().join(".gitignore").exists(),
405+
"must never create or touch the repository's own .gitignore"
406+
);
407+
}
408+
409+
#[test]
410+
fn ensure_locally_excluded_is_idempotent() {
411+
let dir = tempfile::tempdir().expect("tempdir");
412+
init_repo(dir.path());
413+
414+
ensure_locally_excluded(dir.path(), ".weave/").expect("first call");
415+
ensure_locally_excluded(dir.path(), ".weave/").expect("second call");
416+
ensure_locally_excluded(dir.path(), ".weave/").expect("third call");
417+
418+
let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude"))
419+
.expect("info/exclude should exist");
420+
let hits = exclude.lines().filter(|l| *l == ".weave/").count();
421+
assert_eq!(hits, 1, "repeated calls must not duplicate the entry");
422+
}
423+
424+
#[test]
425+
fn ensure_locally_excluded_preserves_an_existing_exclude_file() {
426+
let dir = tempfile::tempdir().expect("tempdir");
427+
init_repo(dir.path());
428+
let exclude_path = dir.path().join(".git/info/exclude");
429+
std::fs::write(&exclude_path, "*.local-scratch\n").expect("seed existing exclude");
430+
431+
ensure_locally_excluded(dir.path(), ".weave/").expect("ensure_locally_excluded");
432+
433+
let exclude = std::fs::read_to_string(&exclude_path).expect("read exclude");
434+
assert!(exclude.lines().any(|l| l == "*.local-scratch"));
435+
assert!(exclude.lines().any(|l| l == ".weave/"));
436+
}
437+
438+
#[test]
439+
fn ensure_locally_excluded_is_a_silent_no_op_outside_a_repository() {
440+
let dir = tempfile::tempdir().expect("tempdir (not a git repo)");
441+
442+
let result = ensure_locally_excluded(dir.path(), ".weave/");
443+
444+
assert!(result.is_ok(), "must not fail just because there's no repo");
445+
assert!(
446+
!dir.path().join(".git").exists(),
447+
"must not create a .git directory as a side effect"
448+
);
449+
}
289450
}

crates/weave-crdt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ test-helpers = []
2020
# them on without shipping them to real consumers.
2121
[dev-dependencies]
2222
weave-crdt = { path = ".", features = ["test-helpers"] }
23+
tempfile = "3"
2324

2425
[dependencies]
2526
automerge = "0.11"

crates/weave-crdt/src/state.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ impl EntityStateDoc {
209209
}
210210
if let Some(parent) = self.path.parent() {
211211
std::fs::create_dir_all(parent)?;
212+
// `parent` is `.weave/` — weave's own coordination state, not
213+
// repository content. It must never show up in `git status`,
214+
// get swept into `git add -A`, or ride along in a generated
215+
// patch (see `weave_core::git::ensure_locally_excluded`).
216+
// Best-effort and idempotent, on every save rather than only the
217+
// first: a repo we can't find, or a git we can't run, must never
218+
// block writing the state a caller asked for, and a `.weave/`
219+
// directory that predates this fix gets excluded retroactively
220+
// on its very next save.
221+
if let Some(repo_root) = parent.parent() {
222+
let _ = weave_core::git::ensure_locally_excluded(repo_root, ".weave/");
223+
}
212224
}
213225
let data = self.doc.save();
214226
std::fs::write(&self.path, data)?;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//! RED test for the `.weave/` working-tree leak.
2+
//!
3+
//! A 327-run transcript study found that `.weave/state.automerge` — the
4+
//! binary Automerge doc every `EntityStateDoc::save()` writes under the
5+
//! target repo's `.weave/` directory — was neither tracked nor ignored.
6+
//! `git status` reported it as `?? .weave/`, `git add -A` staged the binary
7+
//! blob, and any patch built from the staged diff carried it along,
8+
//! silently invalidating the patch.
9+
//!
10+
//! This test drives the real product path — `EntityStateDoc::open` +
11+
//! `save()` — against a fresh, real git repository (via the actual `git`
12+
//! binary, not a fake), and asserts the two user-visible symptoms are gone:
13+
//! `git status --porcelain` reports nothing under `.weave/`, and `git add -A`
14+
//! followed by `git diff --cached --name-only` stages nothing under it
15+
//! either.
16+
17+
use std::path::Path;
18+
use std::process::Command;
19+
20+
use weave_crdt::EntityStateDoc;
21+
22+
fn git(dir: &Path, args: &[&str]) -> String {
23+
let out = Command::new("git")
24+
.arg("-C")
25+
.arg(dir)
26+
.args(args)
27+
.output()
28+
.expect("git must be runnable for this test");
29+
assert!(
30+
out.status.success(),
31+
"git {args:?} failed: {}",
32+
String::from_utf8_lossy(&out.stderr)
33+
);
34+
String::from_utf8_lossy(&out.stdout).trim().to_string()
35+
}
36+
37+
#[test]
38+
fn weave_state_never_shows_up_in_git_status_or_a_staged_diff() {
39+
let dir = tempfile::tempdir().expect("tempdir");
40+
let repo = dir.path();
41+
42+
git(repo, &["init", "-q"]);
43+
git(repo, &["config", "user.email", "test@example.com"]);
44+
git(repo, &["config", "user.name", "Test"]);
45+
// A repo needs at least one commit for `git status`/`git diff --cached`
46+
// to have a baseline to compare against — an empty repo would pass this
47+
// test's assertions trivially.
48+
std::fs::write(repo.join("README.md"), "hello\n").expect("write readme");
49+
git(repo, &["add", "README.md"]);
50+
git(repo, &["commit", "-q", "-m", "initial"]);
51+
52+
// The exact door every weave-cli command and the weave-mcp server open:
53+
// `<repo_root>/.weave/state.automerge`.
54+
let state_path = repo.join(".weave").join("state.automerge");
55+
let mut state = EntityStateDoc::open(&state_path).expect("open a fresh state doc");
56+
state.save().expect("save creates .weave/ and writes the doc");
57+
58+
assert!(
59+
state_path.exists(),
60+
"the state file should actually have been written to disk"
61+
);
62+
63+
let status = git(repo, &["status", "--porcelain"]);
64+
assert!(
65+
!status.lines().any(|l| l.contains(".weave")),
66+
"`git status --porcelain` must not mention .weave/, got:\n{status}"
67+
);
68+
69+
git(repo, &["add", "-A"]);
70+
let staged = git(repo, &["diff", "--cached", "--name-only"]);
71+
assert!(
72+
!staged.lines().any(|l| l.starts_with(".weave/")),
73+
"`git add -A` must not stage anything under .weave/, got:\n{staged}"
74+
);
75+
}

docs/docs.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ <h2>Architecture</h2>
186186
<div class="arch-arrow">&darr;</div>
187187
<div class="arch-layer" style="border-color: var(--purple);">
188188
<div class="label" style="color: var(--purple);">weave-crdt</div>
189-
<div class="desc">Automerge-backed entity state. Advisory claims, agent tracking, conflict detection. Persisted at <code style="color:var(--cyan)">.weave/state.automerge</code>. Not committed to git &mdash; local coordination state.</div>
189+
<div class="desc">Automerge-backed entity state. Advisory claims, agent tracking, conflict detection. Persisted at <code style="color:var(--cyan)">.weave/state.automerge</code>. Not committed to git &mdash; local coordination state, auto-excluded via <code style="color:var(--cyan)">.git/info/exclude</code> on first write.</div>
190190
</div>
191191
<div class="arch-arrow">&darr;</div>
192192
<div class="arch-layer" style="border-color: var(--cyan);">

docs/learn.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ <h2>Automerge: a CRDT for JSON documents</h2>
228228
<li><strong>Operations:</strong> an audit log of claims, releases, and modifications</li>
229229
</ul>
230230

231-
<p>This is saved as a binary file at <code>.weave/state.automerge</code> in your repo root. It's not committed to git &mdash; it's local coordination state, like <code>.git/</code> itself.</p>
231+
<p>This is saved as a binary file at <code>.weave/state.automerge</code> in your repo root. It's not committed to git &mdash; it's local coordination state, like <code>.git/</code> itself. The first time weave writes it, it also adds <code>.weave/</code> to your repo's local <code>.git/info/exclude</code> (never your own <code>.gitignore</code>), so <code>git status</code> and <code>git add -A</code> ignore it automatically &mdash; nothing for you to configure.</p>
232232

233233
<h2>Why advisory locks, not hard locks?</h2>
234234

0 commit comments

Comments
 (0)