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
//! Should tm run `git init` before seeding a `CLAUDE.md`? (#7673 round 3)
//!
//! Why: the seed-site guard ([`crate::core::claude_md_seed::refuse_seed_at`])
//! closed the `$HOME` case, but left a gap the owner ruled on 2026-09-13: a
//! marker-less directory below `$HOME` that is not in any git repository —
//! `~/projects`, say — is still seedable, and the seeded file then becomes an
//! ancestor `CLAUDE.md` for every project beneath it. Two scans close it:
//! UPWARD (is `dir` already inside a repository — possibly one
//! [`harness_root::harness_root_for`][crate::core::harness_root::harness_root_for]
//! missed because `git` itself is unavailable?) and DOWNWARD (is `dir` itself a
//! WORKSPACE PARENT that would inject the seed into every child repository
//! beneath it?).
//!
//! What: [`offer_git_init`] is the one function the seed call site runs right
//! after [`crate::core::claude_md_seed::refuse_seed_at`] returns `None`. It
//! never offers `git init` inside an existing repository — that would create a
//! nested repository (an accidental submodule) — and it refuses outright when
//! `dir` is a workspace parent. Otherwise it asks the caller's `should_init`
//! seam, defaulting to declined (no `git init`, seed anyway) for every
//! non-interactive caller: `None` means "no prompt capability", and a real
//! `git init` only ever runs through [`trusty_common::git::command`] — the
//! workspace's one `git` subprocess entry point — never a second
//! `Command::new("git")`. An accepted offer whose `git init` fails refuses the
//! seed rather than reading as a decline (#7774 review). [`recheck_before_seed`]
//! is the same guard taken a second time, immediately before the bytes land,
//! because the caller does further work between the two (#7764).
//! Test: `claude_md_seed_git_tests.rs`.
use std::path::Path;
use crate::core::child_repo_scan::{ChildRepoScan, scan_for_child_repo};
use crate::core::claude_md_seed::{SeedRefusal, refuse_seed_at};
/// Does any strict ancestor of `dir` contain a `.git` entry?
///
/// Why: [`harness_root::harness_root_for`][crate::core::harness_root::harness_root_for]
/// answers the same question through a `git` subprocess, which is right for
/// the ordinary case but blind when `git` itself is unavailable while a
/// `.git` directory genuinely sits on disk. This is the filesystem-only
/// fallback the owner's ruling asks for; it never replaces the git-backed
/// check, only backs it up.
/// Test: `an_ancestor_git_directory_is_found_without_the_git_binary`.
fn has_git_ancestor(dir: &Path) -> bool {
dir.ancestors().skip(1).any(|a| a.join(".git").exists())
}
/// May tm seed a `CLAUDE.md` at `dir`, and should it run `git init` first?
///
/// Why: see the module header.
/// What: `Err(refusal)` when [`refuse_seed_at`] already refuses `dir` (Home /
/// AboveHome — unchanged), or when `dir` is a workspace parent
/// ([`SeedRefusal::WorkspaceParent`], naming the child repository found), or
/// when the downward scan could not finish ([`SeedRefusal::ScanIncomplete`]).
/// `Ok(false)` with `should_init` never called when `dir` is already inside a
/// repository — `harness_root_for` is checked before the downward scan, and
/// [`has_git_ancestor`] only after it, so an ancestor `.git` git itself rejects
/// cannot skip the scan — because running `git init` there would create a
/// nested repository. Otherwise `Ok(true)` only when `should_init` is `Some`
/// and returns `true`, in which case [`run_git_init`] has run and git reports
/// `dir` as the top of a work tree; `Ok(false)` for a `None` seam (no prompt
/// capability — every non-interactive caller) or a declined one. An accepted
/// offer after which git does not report that is `Err` carrying
/// [`SeedRefusal::GitInitFailed`], never `Ok(false)`. Either `Ok` variant means
/// "go ahead and seed"; only `Err` means "do not write the file".
/// Test: `a_workspace_parent_is_refused_naming_the_child`,
/// `a_wide_node_modules_sibling_never_lets_a_workspace_parent_seed`,
/// `an_exhausted_scan_refuses_to_seed`,
/// `an_unreadable_child_directory_refuses_to_seed`,
/// `git_init_is_never_offered_inside_an_existing_repository`,
/// `a_non_interactive_caller_declines_git_init_and_still_may_seed`,
/// `an_accepted_offer_runs_git_init`,
/// `an_accepted_offer_on_a_first_touch_directory_runs_git_init`,
/// `a_failed_git_init_refuses_to_seed`,
/// `a_stale_ancestor_git_file_cannot_bypass_the_workspace_parent_scan`.
pub fn offer_git_init(
dir: &Path,
home: Option<&Path>,
should_init: Option<&mut dyn FnMut() -> bool>,
) -> Result<bool, SeedRefusal> {
if let Some(refusal) = refuse_seed_at(dir, home) {
return Err(refusal);
}
if crate::core::harness_root::harness_root_for(dir).is_some() {
// Already tracked — never offer `git init` here, because that would
// nest a second repository inside the one that already owns `dir`.
return Ok(false);
}
// #7673: only a scan that checked everything may reach the offer or the seed.
// #7774 review: the scan runs BEFORE the filesystem-only ancestor check, so
// a stale or broken ancestor `.git` cannot skip the workspace-parent refusal.
match scan_for_child_repo(dir) {
ChildRepoScan::Clear => {}
ChildRepoScan::Found(child) => return Err(SeedRefusal::WorkspaceParent(child)),
ChildRepoScan::Incomplete(stop) => return Err(SeedRefusal::ScanIncomplete(stop)),
}
if has_git_ancestor(dir) {
// The same nested-repository rule, for an ancestor git cannot see.
return Ok(false);
}
let wants_init = should_init.is_some_and(|f| f());
if !wants_init {
return Ok(false);
}
run_git_init(dir)?;
Ok(true)
}
/// Take the workspace-parent guard again, immediately before the seed write
/// (#7764).
///
/// Why: [`offer_git_init`] answers "may tm seed here", and the seed call site
/// then runs several git subprocesses and creates directories before writing.
/// A child repository created inside that window was invisible to the earlier
/// scan, and the file landed above it — the injection #7673 exists to refuse.
/// The window cannot be eliminated (no filesystem lock spans it), so the check
/// that DECIDES the write is the one taken last.
/// What: [`offer_git_init`] with no prompt seam, so it can never run a second
/// `git init`, and its `Ok` payload — "the operator accepted an offer" — is
/// discarded as already spent. A directory the first call DID initialise is a
/// repository by now, so the harness-root arm answers before the scan and an
/// accepted offer still seeds.
/// Test: `a_child_repository_created_between_the_check_and_the_write_refuses_the_seed`,
/// `an_accepted_git_init_offer_still_seeds_through_the_recheck`.
pub fn recheck_before_seed(dir: &Path, home: Option<&Path>) -> Result<(), SeedRefusal> {
offer_git_init(dir, home, None).map(|_| ())
}
/// Run `git init` for `dir` and prove it left a git work tree there.
///
/// Why: a failed `git init` is not a decline — the operator asked for a
/// repository — and git's exit status alone does not prove one exists in
/// `dir`: an inherited `GIT_DIR` puts it elsewhere, a `git` on `PATH` may do
/// nothing, a `-`-prefixed `dir` parses as an option, and a pre-existing or
/// templated `HEAD` git does not rewrite leaves no usable repository (#7774
/// review).
/// What: `git init -q -- <dir>` through [`trusty_common::git::command`] with
/// every [`GIT_REDIRECT_ENV`] variable removed. `dir` is positional, so git
/// creates a first-touch directory the pipeline has not created yet, and `--`
/// keeps it a path. `Ok(())` only when git exits 0 AND [`is_work_tree_top`]
/// holds; otherwise [`SeedRefusal::GitInitFailed`].
/// Test: `a_failed_git_init_refuses_to_seed`,
/// `an_inherited_git_dir_cannot_redirect_git_init`,
/// `a_git_that_exits_zero_without_initialising_refuses_to_seed`,
/// `a_dash_prefixed_directory_is_initialised_as_a_path`,
/// `an_existing_empty_head_refuses_to_seed`,
/// `a_template_with_a_garbage_head_refuses_to_seed`.
fn run_git_init(dir: &Path) -> Result<(), SeedRefusal> {
let out = without_git_redirects(trusty_common::git::command().args(["init", "-q", "--"]))
.arg(dir)
.output()
.map_err(|e| SeedRefusal::GitInitFailed(format!("could not run git: {e}")))?;
if !out.status.success() {
return Err(SeedRefusal::GitInitFailed(init_failure_reason(&out)));
}
if !is_work_tree_top(dir) {
return Err(SeedRefusal::GitInitFailed(format!(
"git init exited 0 but git does not report {} as the top of a work tree",
dir.display()
)));
}
Ok(())
}
/// Environment variables that point git at a repository, object store, index
/// or config other than the one discovered from its working directory.
///
/// What: `git rev-parse --local-env-vars` (the set git itself clears when it
/// changes repository), plus the two discovery controls. `GIT_TEMPLATE_DIR` is
/// deliberately absent: a template is operator content, and a bad one is
/// caught by [`is_work_tree_top`].
const GIT_REDIRECT_ENV: &[&str] = &[
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_OBJECT_DIRECTORY",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_GRAFT_FILE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_PREFIX",
"GIT_SHALLOW_FILE",
"GIT_COMMON_DIR",
"GIT_CEILING_DIRECTORIES",
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
];
/// `cmd` with every [`GIT_REDIRECT_ENV`] variable removed from its environment.
fn without_git_redirects(cmd: &mut std::process::Command) -> &mut std::process::Command {
for var in GIT_REDIRECT_ENV {
cmd.env_remove(var);
}
cmd
}
/// Does git itself report `dir` as the top of a work tree?
///
/// What: `git -C <dir> rev-parse --show-toplevel` with [`GIT_REDIRECT_ENV`]
/// removed; `true` only when it exits 0 and names the same canonical directory
/// as `dir`. Not `harness_root_for`, which inherits the caller's git
/// environment and maps a linked worktree or base clone to its owner.
fn is_work_tree_top(dir: &Path) -> bool {
let Ok(out) = without_git_redirects(
trusty_common::git::command_in(dir).args(["rev-parse", "--show-toplevel"]),
)
.output() else {
return false;
};
let reported = String::from_utf8_lossy(&out.stdout);
let reported = reported.trim();
if !out.status.success() || reported.is_empty() {
return false;
}
match (std::fs::canonicalize(reported), std::fs::canonicalize(dir)) {
(Ok(top), Ok(target)) => top == target,
_ => false,
}
}
/// Why `git init` failed, for [`SeedRefusal::GitInitFailed`]: git's own
/// trimmed stderr, or the exit status when git wrote nothing.
fn init_failure_reason(out: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
match stderr.trim() {
"" => format!("git init exited with {}", out.status),
text => text.to_string(),
}
}
#[cfg(test)]
#[path = "claude_md_seed_git_tests.rs"]
mod tests;