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
//! Repair a persisted hook or `statusLine` command that points into a Cargo
//! build tree, by repointing it at the installed binary (issue #7262).
//!
//! Why: #7286 taught `tm doctor` to SEE this corruption and stopped there.
//! `tm doctor --fix` planned zero repairs for `hooks_build_tree_binary`, and on
//! 2026-09-10 eight projects were repaired by hand with `sed`. The removal path
//! that already exists is the wrong remedy here twice over: it takes PM
//! enforcement offline until the project's next managed launch, and it cannot
//! touch `statusLine.command` at all, because the hooks writer does not own that
//! key and a strip would leave the operator with no statusline rather than a
//! working one. Repointing has neither problem — the entry the operator already
//! has keeps its argv and gains a path that exists.
//!
//! What: [`repoint_settings_file`] rewrites every command
//! [`super::build_tree`] claims in one `.claude/settings*.json`, taking a
//! timestamped snapshot ([`super::backup::snapshot_then_prune`], the same
//! convention #7244's writer fix uses) before it writes, and writing atomically.
//! [`build_tree_commands_in`] is the read-only probe the repair driver uses to
//! decide whether a file is worth reporting when no installed binary could be
//! resolved.
//!
//! **Fail-closed, everywhere.** A settings file that cannot be read, cannot be
//! parsed, is not a JSON object, or cannot be snapshotted returns `Err` and is
//! NOT rewritten — no backup is taken for an unparseable file, because there is
//! nothing this module could write back that would preserve its contents. That
//! is the opposite of [`super::cleanup::clean_settings_file`], which answers
//! `Ok(None)` for a malformed file: `clean` is also the doctor probe's scanner
//! and must tolerate a file it does not own, whereas this is a repair the
//! operator asked for and a silent skip would read as "nothing was wrong".
//!
//! **Idempotent by construction.** A repointed command names an installed
//! binary, which [`super::build_tree::is_build_tree_hook_command`] rejects, so a
//! second pass finds nothing, returns `Ok(None)`, and writes nothing at all —
//! not even a snapshot.
//!
//! Test: `repoint_tests.rs`.
use ;
use Value;
use ;
use ;
use ;
/// What one [`repoint_settings_file`] pass found — or changed — in one file.
///
/// Why: the repair driver prints one line per file and must name the exact
/// commands, because the operator's decision ("is that really tm's entry?")
/// depends on the string and not on a count. Reporting the before/after pair
/// rather than only the new value is what lets a dry run be read as a diff.
/// What: the file, every `(old, new)` hook-command pair, the `statusLine` pair
/// when that key was affected, and — only when `force` was set — the snapshot
/// taken before the rewrite.
/// Test: `repoint_settings_file_dry_run_changes_nothing`,
/// `repoint_settings_file_applies_and_snapshots`.
/// Every build-tree command in `path`, hooks and `statusLine` alike (#7262).
///
/// Why: when no installed binary can be resolved there is nothing to repoint
/// to, and the driver must still say WHICH files carry the damage rather than
/// falling silent. Answering that from the same classifiers the repair uses
/// keeps the refusal and the repair talking about the same set.
/// What: the hook commands then the `statusLine` command, in that order. Empty
/// for a file that is missing, unreadable, unparseable, or clean — this is a
/// "should I speak up" probe, and every one of those answers is "no". The
/// repair itself reports a read or parse failure; see [`repoint_settings_file`].
/// Test: `build_tree_commands_in_lists_the_incident_commands`,
/// `build_tree_commands_in_is_empty_for_an_unparseable_file`.
/// Repoint every build-tree command in one settings file at `installed`.
///
/// Why: see the module doc — this is the `--fix` arm `hooks_build_tree_binary`
/// lacked, and the reason the 2026-09-10 repair had to be done by hand. The
/// read-modify-write below takes no cross-process lock, so a settings file
/// written concurrently by another process loses one side's edit; that limit is
/// shared with every other writer of a `.claude/settings.json` in this crate —
/// [`super::write_project_hooks`] and [`super::cleanup::clean_settings_file`].
/// What: reads `path` (missing → `Ok(None)`), parses it, and rewrites every
/// command [`repointed_hook_command`] / [`repointed_statusline_command`]
/// claims. Returns `Ok(None)` when nothing matched, which is also what a second
/// pass over an already-repaired file returns. With `force`, snapshots `path`
/// to `<name>.<YYYYMMDDTHHMMSSZ>.bak` FIRST and then writes atomically via
/// [`trusty_common::claude_config::write_json_atomic`]; in dry run the file is
/// never opened for writing and `backup_path` is `None`.
///
/// Fail-closed on every failure it can reach: a read error other than
/// `NotFound`, unparseable JSON, a non-object document, or a snapshot that
/// could not be taken each return `Err` with nothing written and no backup
/// left behind. `installed` is validated once, up front — an `installed` that
/// is itself an ephemeral build path is refused rather than written, so this
/// repair can never persist the corruption it exists to remove.
/// Test: `repoint_settings_file_applies_and_snapshots`,
/// `repoint_settings_file_dry_run_changes_nothing`,
/// `repoint_settings_file_is_idempotent`,
/// `repoint_settings_file_refuses_unparseable_json`,
/// `repoint_settings_file_refuses_a_non_object_document`,
/// `repoint_settings_file_refuses_an_ephemeral_installed_binary`,
/// `repoint_settings_file_leaves_foreign_entries_alone`.
/// The read / rewrite / snapshot / publish body of [`repoint_settings_file`].
///
/// Why (#7762): the apply arm runs this under the settings lock and the dry-run
/// arm runs it bare, so the body has to be callable both ways.
/// What: exactly what [`repoint_settings_file`] documents, minus the `installed`
/// validation its caller has already done.
/// Test: see [`repoint_settings_file`].
/// Rewrite every build-tree `hooks.<event>[*].hooks[*].command` in place.
///
/// Why: the read-side walker in [`super::cleanup`] hands out `&str`, which
/// cannot be assigned through. This is the same four-level traversal with
/// mutable access, kept beside the only mutation that needs it rather than
/// widening the shared walker to a shape one of its two callers cannot use.
/// What: replaces each claimed command with its repointed form and returns the
/// `(old, new)` pairs in document order. A level whose JSON shape is not the
/// expected object/array is skipped, exactly as the read-side walker skips it.
/// Test: `repoint_settings_file_applies_and_snapshots`,
/// `repoint_settings_file_leaves_foreign_entries_alone`.
/// Rewrite a build-tree `statusLine.command` in place (#7262, #4492).
///
/// Why: the corruption reaches this key too, and #7286 could only name it. The
/// key is a single string rather than a nested array, so it gets its own
/// two-line rewrite instead of a branch inside [`rewrite_hook_commands`].
/// What: `Some((old, new))` when [`repointed_statusline_command`] claims the
/// current value; `None` for any other shape or a missing key.
/// Test: `repoint_settings_file_applies_and_snapshots`,
/// `repoint_settings_file_ignores_an_installed_statusline`.