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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! Keep tm-regenerated harness scaffolding out of a project's git history
//! (issue #3427).
//!
//! Why: `tm` writes composed agents (`.claude/agents/`), skills
//! (`.claude/skills/`), and output styles (`.claude/output-styles/`) directly
//! into a project's working tree — a managed workspace deploys all three
//! project-locally, and the `#2125` project-tier output-style deploy runs
//! unconditionally for every session regardless of managed/standalone. If a
//! project ever commits one of those paths (accidentally, in a PR, or from a
//! pre-#3427 install with no `.gitignore` guard), the NEXT session's
//! regeneration writes the same path back as local content — and a later
//! `git merge --ff-only origin/main` aborts with "your local changes would be
//! overwritten", exactly the `duettoresearch/duetto-eve-agents` reproduction
//! (28 commits stranded, resolved only by `git rm -r --cached` + a manual
//! `.gitignore`, duetto-eve-agents#111). This is Option 1 (prevent) of #3427;
//! `crate::daemon::doctor_scaffold_tracking` is Option 2 (detect + exact
//! remediation for a project that already committed these paths — a
//! `.gitignore` entry added AFTER the fact does not untrack anything already
//! in the index).
//! What: [`ensure_scaffold_gitignored`] appends a small, clearly-delimited
//! managed block to `<project_dir>/.gitignore` naming exactly the three
//! harness-owned subdirectories tm writes into — never the broader
//! `.claude/` (which also holds `settings.json` / project `.mcp.json`,
//! legitimately shared config the issue explicitly calls out as NOT in
//! scope). Gated on `project_dir` actually being a git working tree (a
//! `.git` entry present) — mirroring the existing
//! `daemon::managed_routes::inproject::ensure_worktrees_gitignored`
//! precedent — so a non-git project (or a bare library/test call) never
//! grows a stray `.gitignore`. Idempotent: a repeat call is a no-op once the
//! managed block's begin marker is present, and an existing `.gitignore`
//! with unrelated content is preserved byte-for-byte apart from the
//! appended block. This only edits the WORKING TREE file — it never `git
//! add`s or commits it, so the change goes through the project's normal
//! review/diff flow like any other tm-authored edit.
//! Test: `writes_block_to_fresh_gitignore`, `idempotent_on_repeat_call`,
//! `preserves_unrelated_existing_content`, `noop_when_not_a_git_repo`,
//! `appends_newline_when_existing_file_lacks_trailing_newline`.
use HashSet;
use Write as _;
use Path;
use crate;
/// First line of the managed block — its presence anywhere in the file is what
/// routes a repeat call into [`refresh_block`] instead of appending a second
/// block.
pub const SCAFFOLD_GITIGNORE_BEGIN: &str =
"# >>> trusty-mpm harness scaffolding (auto-managed by tm, issue #3427) >>>";
/// Last line of the managed block.
pub const SCAFFOLD_GITIGNORE_END: &str = "# <<< trusty-mpm harness scaffolding <<<";
/// The exact harness-owned paths tm writes into, and nothing else.
///
/// Why: surgical by design — `.claude/settings.json` and the project
/// `.mcp.json` are legitimately shared, tracked config (issue #3427's
/// explicit "Important Note") and must never be swept up by this list.
///
/// #4832 added the `.trusty-mpm/` runtime-output paths. `tm` writes a
/// per-session compiled prompt and a `last-instructions.md` inspection stash
/// into the project's harness directory on every launch, and neither was
/// covered here — a target project that ran `tm` grew untracked noise (or, if
/// committed once, the same "your local changes would be overwritten" merge
/// abort this module exists to prevent). Note what is deliberately NOT listed:
/// `.trusty-mpm/framework/` holds the operator-authored `manifest.toml`
/// override, and `.trusty-mpm/config.toml` is written by `tm project init` —
/// both are project config an operator MAY want tracked, so ignoring them would
/// repeat the over-broad `.claude/` mistake called out above.
///
/// #7762 added the two `settings*.json.lock` sidecars. Every managed launch now
/// runs its settings write under an `flock(2)` sidecar
/// ([`crate::core::settings_lock`]), and that sidecar is created beside a file
/// the project legitimately tracks — so without these two lines the very fix
/// that stopped the launch dropping a `.bak` starts dropping a `.lock` into
/// `git status` instead. The settings files themselves stay absent from this
/// list, exactly as the "Important Note" above requires: only the harness's own
/// lock artifact is ignored, never the config it guards.
/// #7932: `.claude/skills/*` is the glob form on purpose. A trailing-slash
/// DIRECTORY pattern stops git descending into the directory at all, so a
/// project that tracks one skill and re-includes it below the block
/// (`!/.claude/skills/cargo-commands/`, PR #7916) has a dead negation — the
/// tracked file goes ignored the moment the block is regenerated. The glob
/// ignores each child instead, which leaves the negation reachable. It applies
/// only to `skills`: `agents` and `output-styles` have no tracked children in
/// this repo (`git ls-files .claude`), so they stay in the cheaper directory
/// form until one does.
/// What: trailing-slash directory patterns, the one skills glob, the one stash
/// FILE, and the two lock sidecars — so only the harness-owned subtrees and
/// artifacts are ignored, not sibling config.
/// Test: `writes_block_to_fresh_gitignore`,
/// `block_covers_session_output_but_not_project_config`,
/// `lock_entries_match_the_settings_lock_sidecar`,
/// `a_tracked_skill_survives_a_refresh_of_the_block`.
pub const SCAFFOLD_IGNORED_PATHS: & = &;
/// Ensure `<project_dir>/.gitignore` carries the tm-scaffolding managed
/// block, when `project_dir` is a git working tree.
///
/// Why/What: see the module doc. Returns `Ok(true)` when the block was written
/// or refreshed, `Ok(false)` when it was already up to date or `project_dir` is
/// not a git repo (both are legitimate no-ops, not failures). Propagates genuine
/// I/O errors (permissions, disk full) so the non-fatal caller can log them
/// rather than silently swallowing a real problem.
///
/// #7762: the begin marker alone used to be the whole idempotency check, so a
/// project scaffolded before a path was added to [`SCAFFOLD_IGNORED_PATHS`]
/// never gained it — the new entry reached fresh projects only. A block whose
/// body is missing a managed path is now re-rendered in place, which is what
/// carries the two `settings*.json.lock` entries to the projects that already
/// have a block.
///
/// #7875: the rendered block omits any managed path the project already spells
/// outside the block, so a launch against a project that hand-added one of them
/// converges instead of appending a duplicate every time.
/// Test: `writes_block_to_fresh_gitignore`, `idempotent_on_repeat_call`,
/// `preserves_unrelated_existing_content`, `noop_when_not_a_git_repo`,
/// `an_existing_block_gains_newly_managed_paths`,
/// `this_repos_committed_block_matches_the_generator`,
/// `an_unreadable_gitignore_is_reported_not_treated_as_empty`.
/// Inclusive line-index range of the managed block within `lines`, if present.
///
/// What: the begin marker's first occurrence, and the first end marker at or
/// after it. `None` when either marker is missing.
/// Test: `an_existing_block_gains_newly_managed_paths`,
/// `a_block_with_no_end_marker_is_left_alone`.
/// The rule a `.gitignore` line expresses, normalized for comparison (#7875).
///
/// Why: the block must not re-add an entry the project already spells
/// elsewhere in the file. `/.claude/settings.json.lock` (added by hand in PR
/// #7860) and the block's `.claude/settings.json.lock` are the same rule —
/// only the anchoring slash differs — and without this the block appended a
/// duplicate on every launch.
/// What: trims, then rejects blanks, comments, and negations, and strips one
/// leading `/`. A negation is the OPPOSITE of a rule, so it can never count as
/// coverage — that is what keeps `!/.claude/skills/cargo-commands/` from
/// suppressing a managed path.
/// Test: `an_entry_spelled_outside_the_block_is_not_duplicated`,
/// `a_negation_outside_the_block_is_not_treated_as_coverage`.
/// The managed block as it must read inside `existing`: begin, every managed
/// path the rest of the file does not already carry, end.
///
/// Why (#7875): rendering the block is file-dependent, not constant — an entry
/// the project already spells outside the block is omitted rather than
/// duplicated. Rendering and the idempotency check therefore share this one
/// function: what a refresh would write IS what it compares against.
/// What: newline-terminated throughout, including the end marker, so a caller
/// can append it or splice it between two slices of an existing file. Only
/// lines OUTSIDE the current block count as coverage; the block's own entries
/// would otherwise erase themselves.
/// Test: `writes_block_to_fresh_gitignore`,
/// `an_entry_spelled_outside_the_block_is_not_duplicated`.
/// Re-render an already-installed block that is missing a managed path (#7762).
///
/// Why: this is the upgrade path. Every entry added to
/// [`SCAFFOLD_IGNORED_PATHS`] after a project was first scaffolded would
/// otherwise reach only projects that never ran tm before.
/// What: locates the begin/end markers, and rewrites the whole file with a
/// freshly [`render_block`]ed body between them when any managed path is absent
/// from the current one. Content OUTSIDE the markers is carried through
/// verbatim, including operator lines added between them and the block — the
/// entire file is rewritten, so the trailing-newline shape of the original is
/// preserved deliberately rather than incidentally. A block whose END marker was
/// hand-deleted is left alone: rewriting it would have to guess where the
/// operator's own lines resume.
///
/// #7875: the check is now "does the block already read exactly as
/// [`render_block_for`] would write it", not "does it contain every managed
/// path". The old form could not see an entry the project spells outside the
/// block, so it re-added a duplicate on every launch and never converged.
///
/// #7440-class hazard: the rewrite publishes through the shared
/// [`atomic_write`] (stage to a sibling temp file, then rename), so a failed
/// write leaves the operator's `.gitignore` byte-for-byte intact and returns
/// `Err` — never a truncated file, and never `Ok`.
/// Test: `an_existing_block_gains_newly_managed_paths`,
/// `idempotent_on_repeat_call`, `a_block_with_no_end_marker_is_left_alone`,
/// `an_entry_spelled_outside_the_block_is_not_duplicated`,
/// `a_failed_write_leaves_the_gitignore_intact_and_reports_the_error`.