@rmyndharis/aimhooman 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,30 @@ Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
65
65
  hook checks dispatcher integrity there without scanning unresolved references,
66
66
  then keeps the full-scan veto at `prepared`, after Git has locked them.
67
67
 
68
+ ### Frictionless agent guard (the everyday commands run)
69
+
70
+ The layer-2 PreToolUse guard reports paths and refuses unprovable protected Git
71
+ mutations. It does not stand between a developer and the commands they run to read a
72
+ repository. A read-only Git subcommand piped to a filter is a source: `git log | head`,
73
+ `git status | grep modified`, `git diff | cat`, and the `cd repo && git log | head`
74
+ prefix produce stdout for the downstream filter and cannot hide or feed a commit, so
75
+ they run with no refusal. Build and test toolchains (`npm`, `cargo`, `make`, `jest`,
76
+ `eslint`, `tsc`, `pytest`, ...) are allowed as a source for the same reason. The listing
77
+ forms of branch/tag/remote/stash/notes move no ref and run; their mutating forms
78
+ (`git branch -D`, `git tag v1`) still reach the reference-transaction guard. A commit
79
+ made of already-safe halves runs as a whole: `build && git add . && git commit` is
80
+ allowed, because an unmodelled prefix is not a bypass and pre-commit still scans the real
81
+ index at commit time.
82
+
83
+ What stays refused is what can actually get past the scan. Git as a pipe sink
84
+ (`cat patch | git apply`) reads stdin, which can drive `apply`/`am`/`hash-object`.
85
+ Pipe-to-shell, subshells, `eval`, command substitution, and readers that execute their
86
+ own input stay opaque. `git commit --no-verify`, an explicit `core.hooksPath` override,
87
+ and `git push` refuse on the tiers that own them. The boundary that decides whether a
88
+ commit is scanned is the pre-commit and reference-transaction hook, never a denial of the
89
+ shell line that reads the repo. Refusing the read taught agents to drop the `&&` gate
90
+ rather than run the command on its own, which is the friction this tier exists to avoid.
91
+
68
92
  ### Components
69
93
 
70
94
  1. **Rule catalog** — `rules/paths.json`, `rules/attribution.json`,
@@ -118,6 +142,10 @@ means local rules only add restrictions and can never override a core block.
118
142
  transparent in unsupported bare repositories.
119
143
  - **HEAD-safe unstage.** `git restore --staged` needs HEAD; on a repository's
120
144
  initial commit it falls back to `git rm --cached --ignore-unmatch`.
145
+ - **Repair never mints an empty commit.** When the pre-commit repair unstages the last
146
+ staged path (stage only a `.env`, then `git commit`), it exits 10 so Git stops, the
147
+ same outcome as committing with nothing staged. Carrying on would create a commit Git
148
+ itself would have refused and leave the developer a junk commit to `git reset --hard`.
121
149
  - **Anchored attribution rules.** The AI-noreply rule is anchored to trailer
122
150
  lines (`*-by:`) so it never strips a prose line that merely mentions the email.
123
151
  - **`compliance` keeps disclosure.** AI-attribution rules resolve to `allow`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
@@ -95,15 +95,15 @@
95
95
  },
96
96
  {
97
97
  "id": "attribution.generated-with",
98
- "version": 2,
98
+ "version": 3,
99
99
  "provider": "generic",
100
100
  "category": "ai-attribution",
101
101
  "confidence": "high",
102
102
  "kind": "message",
103
103
  "match": {
104
104
  "content": [
105
- "(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)[.!]?\\s*$",
106
- "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)\\]\\([^)]*\\)\\s*$"
105
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+(?:with|by)\\s+(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}[.!]?\\s*(?:🤖\\s*)?$",
106
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}\\]\\([^)]*\\)\\s*(?:🤖\\s*)?$"
107
107
  ]
108
108
  },
109
109
  "actions": {
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "id": "marker.corner-cut",
4
- "version": 2,
4
+ "version": 3,
5
5
  "provider": "generic",
6
6
  "category": "ai-marker",
7
7
  "confidence": "medium",
@@ -14,19 +14,32 @@
14
14
  "**/docs/**",
15
15
  "**/test/**",
16
16
  "**/tests/**",
17
+ "**/__tests__/**",
18
+ "**/testdata/**",
17
19
  "**/fixtures/**",
18
20
  "**/__fixtures__/**",
19
21
  "**/vendor/**",
22
+ "**/vendored/**",
20
23
  "**/third_party/**",
21
24
  "**/node_modules/**",
22
25
  "**/dist/**",
23
26
  "**/build/**",
27
+ "out/**",
28
+ "target/**",
29
+ "**/.next/**",
24
30
  "**/coverage/**",
25
31
  "**/generated/**",
26
32
  "**/*.generated.*",
27
33
  "**/*.min.js",
28
34
  "**/*.map",
29
- "**/*.md"
35
+ "**/*.md",
36
+ "**/*.mdx",
37
+ "**/*.markdown",
38
+ "**/*.rst",
39
+ "**/*.test.*",
40
+ "**/*.spec.*",
41
+ "**/*_test.*",
42
+ "**/*_spec.*"
30
43
  ],
31
44
  "content": [
32
45
  "(?i)\\b(ponytail|caveman|yagni-oneliner):"
@@ -44,7 +57,7 @@
44
57
  },
45
58
  {
46
59
  "id": "marker.ai-authored",
47
- "version": 1,
60
+ "version": 2,
48
61
  "provider": "generic",
49
62
  "category": "ai-marker",
50
63
  "confidence": "low",
@@ -57,22 +70,35 @@
57
70
  "**/docs/**",
58
71
  "**/test/**",
59
72
  "**/tests/**",
73
+ "**/__tests__/**",
74
+ "**/testdata/**",
60
75
  "**/fixtures/**",
61
76
  "**/__fixtures__/**",
62
77
  "**/vendor/**",
78
+ "**/vendored/**",
63
79
  "**/third_party/**",
64
80
  "**/node_modules/**",
65
81
  "**/dist/**",
66
82
  "**/build/**",
83
+ "out/**",
84
+ "target/**",
85
+ "**/.next/**",
67
86
  "**/coverage/**",
68
87
  "**/generated/**",
69
88
  "**/*.generated.*",
70
89
  "**/*.min.js",
71
90
  "**/*.map",
72
- "**/*.md"
91
+ "**/*.md",
92
+ "**/*.mdx",
93
+ "**/*.markdown",
94
+ "**/*.rst",
95
+ "**/*.test.*",
96
+ "**/*.spec.*",
97
+ "**/*_test.*",
98
+ "**/*_spec.*"
73
99
  ],
74
100
  "content": [
75
- "(?i)\\b(ai[- ]?generated|generated by (ai|copilot|claude|chatgpt|codex))\\b"
101
+ "(?i)\\b(generated by (ai|copilot|claude|chatgpt|codex))\\b"
76
102
  ]
77
103
  },
78
104
  "actions": {
package/rules/paths.json CHANGED
@@ -1,12 +1,13 @@
1
1
  [
2
2
  {
3
3
  "id": "claude.local-settings",
4
- "version": 1,
4
+ "version": 2,
5
5
  "provider": "claude-code",
6
6
  "category": "local-settings",
7
7
  "confidence": "high",
8
8
  "kind": "path",
9
9
  "match": {
10
+ "path_case": "insensitive",
10
11
  "paths": [
11
12
  ".claude/settings.local.json",
12
13
  "**/.claude/settings.local.json"
@@ -24,17 +25,16 @@
24
25
  },
25
26
  {
26
27
  "id": "claude.session-state",
27
- "version": 1,
28
+ "version": 2,
28
29
  "provider": "claude-code",
29
30
  "category": "ephemeral-state",
30
31
  "confidence": "high",
31
32
  "kind": "path",
32
33
  "match": {
34
+ "path_case": "insensitive",
33
35
  "paths": [
34
36
  ".claude.json",
35
37
  "**/.claude.json",
36
- ".claude/.credentials.json",
37
- "**/.claude/.credentials.json",
38
38
  ".claude/session*.json",
39
39
  "**/.claude/session*.json",
40
40
  ".claude/history*",
@@ -64,12 +64,13 @@
64
64
  },
65
65
  {
66
66
  "id": "codex.session-state",
67
- "version": 1,
67
+ "version": 2,
68
68
  "provider": "codex",
69
69
  "category": "ephemeral-state",
70
70
  "confidence": "high",
71
71
  "kind": "path",
72
72
  "match": {
73
+ "path_case": "insensitive",
73
74
  "paths": [
74
75
  ".codex/sessions/**",
75
76
  "**/.codex/sessions/**",
@@ -93,12 +94,13 @@
93
94
  },
94
95
  {
95
96
  "id": "copilot.session-state",
96
- "version": 2,
97
+ "version": 3,
97
98
  "provider": "copilot",
98
99
  "category": "ephemeral-state",
99
100
  "confidence": "high",
100
101
  "kind": "path",
101
102
  "match": {
103
+ "path_case": "insensitive",
102
104
  "paths": [
103
105
  ".copilot/**",
104
106
  "**/.copilot/**"
@@ -116,12 +118,13 @@
116
118
  },
117
119
  {
118
120
  "id": "cursor.session-state",
119
- "version": 1,
121
+ "version": 2,
120
122
  "provider": "cursor",
121
123
  "category": "ephemeral-state",
122
124
  "confidence": "medium",
123
125
  "kind": "path",
124
126
  "match": {
127
+ "path_case": "insensitive",
125
128
  "paths": [
126
129
  ".cursor/session*",
127
130
  "**/.cursor/session*",
@@ -143,12 +146,13 @@
143
146
  },
144
147
  {
145
148
  "id": "aider.history",
146
- "version": 2,
149
+ "version": 3,
147
150
  "provider": "aider",
148
151
  "category": "ephemeral-state",
149
152
  "confidence": "high",
150
153
  "kind": "path",
151
154
  "match": {
155
+ "path_case": "insensitive",
152
156
  "paths": [
153
157
  ".aider.*",
154
158
  "**/.aider.*"
@@ -166,12 +170,13 @@
166
170
  },
167
171
  {
168
172
  "id": "specstory.history",
169
- "version": 1,
173
+ "version": 2,
170
174
  "provider": "specstory",
171
175
  "category": "ephemeral-state",
172
176
  "confidence": "medium",
173
177
  "kind": "path",
174
178
  "match": {
179
+ "path_case": "insensitive",
175
180
  "paths": [
176
181
  ".specstory/**",
177
182
  "**/.specstory/**"
@@ -189,12 +194,13 @@
189
194
  },
190
195
  {
191
196
  "id": "continue.sessions",
192
- "version": 1,
197
+ "version": 2,
193
198
  "provider": "continue",
194
199
  "category": "ephemeral-state",
195
200
  "confidence": "medium",
196
201
  "kind": "path",
197
202
  "match": {
203
+ "path_case": "insensitive",
198
204
  "paths": [
199
205
  ".continue/sessions/**",
200
206
  "**/.continue/sessions/**"
@@ -212,7 +218,7 @@
212
218
  },
213
219
  {
214
220
  "id": "secret.dotenv",
215
- "version": 2,
221
+ "version": 3,
216
222
  "provider": "generic",
217
223
  "category": "secret",
218
224
  "confidence": "high",
@@ -226,16 +232,11 @@
226
232
  "**/.env.*"
227
233
  ],
228
234
  "except": [
229
- ".env.example",
230
- "**/.env.example",
231
- ".env.sample",
232
- "**/.env.sample",
233
- ".env.template",
234
- "**/.env.template",
235
- ".env.dist",
236
- "**/.env.dist",
237
- ".env.defaults",
238
- "**/.env.defaults"
235
+ "**/*.example",
236
+ "**/*.sample",
237
+ "**/*.template",
238
+ "**/*.dist",
239
+ "**/*.defaults"
239
240
  ]
240
241
  },
241
242
  "actions": {
@@ -303,8 +304,33 @@
303
304
  ]
304
305
  },
305
306
  {
306
- "id": "generic.agent-instructions",
307
+ "id": "secret.claude-credentials",
307
308
  "version": 1,
309
+ "provider": "claude-code",
310
+ "category": "secret",
311
+ "confidence": "high",
312
+ "kind": "path",
313
+ "match": {
314
+ "path_case": "insensitive",
315
+ "paths": [
316
+ ".claude/.credentials.json",
317
+ "**/.claude/.credentials.json"
318
+ ]
319
+ },
320
+ "actions": {
321
+ "clean": "block",
322
+ "strict": "block",
323
+ "compliance": "block"
324
+ },
325
+ "reason": "This file holds an OAuth access token and must never be committed.",
326
+ "remediation": [
327
+ "git restore --staged <path>",
328
+ "Revoke the token if it already reached a commit."
329
+ ]
330
+ },
331
+ {
332
+ "id": "generic.agent-instructions",
333
+ "version": 2,
308
334
  "provider": "generic",
309
335
  "category": "ambiguous-instructions",
310
336
  "confidence": "medium",
@@ -328,18 +354,19 @@
328
354
  },
329
355
  "reason": "Agent instruction files may be intentional team config. Review before committing.",
330
356
  "remediation": [
331
- "aimhooman allow <path> --reason \"shared team config\"",
357
+ "aimhooman review <path> --head <commit> --reason \"shared team config\"",
332
358
  "or unstage if it is personal"
333
359
  ]
334
360
  },
335
361
  {
336
362
  "id": "generic.project-policy",
337
- "version": 1,
363
+ "version": 2,
338
364
  "provider": "aimhooman",
339
365
  "category": "policy-config",
340
366
  "confidence": "high",
341
367
  "kind": "path",
342
368
  "match": {
369
+ "path_case": "insensitive",
343
370
  "paths": [
344
371
  ".aimhooman.json",
345
372
  "**/.aimhooman.json"
@@ -352,54 +379,54 @@
352
379
  },
353
380
  "reason": "Versioned enforcement policy changes require explicit human review.",
354
381
  "remediation": [
355
- "aimhooman allow .aimhooman.json --reason \"reviewed team policy change\"",
382
+ "aimhooman review .aimhooman.json --head <commit> --reason \"reviewed team policy change\"",
356
383
  "or unstage the policy change"
357
384
  ]
358
385
  },
359
386
  {
360
387
  "id": "playwright-mcp.state",
361
- "version": 1,
388
+ "version": 2,
362
389
  "provider": "playwright-mcp",
363
390
  "category": "ephemeral-state",
364
391
  "confidence": "high",
365
392
  "kind": "path",
366
- "match": { "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
393
+ "match": { "path_case": "insensitive", "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
367
394
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
368
395
  "reason": "Playwright MCP session artifacts are local, not repository content.",
369
396
  "remediation": ["git restore --staged <path>"]
370
397
  },
371
398
  {
372
399
  "id": "remember.state",
373
- "version": 1,
400
+ "version": 2,
374
401
  "provider": "remember",
375
402
  "category": "ephemeral-state",
376
403
  "confidence": "high",
377
404
  "kind": "path",
378
- "match": { "paths": [".remember/**", "**/.remember/**"] },
405
+ "match": { "path_case": "insensitive", "paths": [".remember/**", "**/.remember/**"] },
379
406
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
380
407
  "reason": "Remember second-brain data is local, not repository content.",
381
408
  "remediation": ["git restore --staged <path>"]
382
409
  },
383
410
  {
384
411
  "id": "superpowers.state",
385
- "version": 1,
412
+ "version": 2,
386
413
  "provider": "superpowers",
387
414
  "category": "ephemeral-state",
388
415
  "confidence": "high",
389
416
  "kind": "path",
390
- "match": { "paths": [".superpowers/**", "**/.superpowers/**"] },
417
+ "match": { "path_case": "insensitive", "paths": [".superpowers/**", "**/.superpowers/**"] },
391
418
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
392
419
  "reason": "Superpowers plugin state is local, not repository content.",
393
420
  "remediation": ["git restore --staged <path>"]
394
421
  },
395
422
  {
396
423
  "id": "agent.state",
397
- "version": 2,
424
+ "version": 3,
398
425
  "provider": "generic",
399
426
  "category": "ephemeral-state",
400
427
  "confidence": "high",
401
428
  "kind": "path",
402
- "match": { "paths": [".agent/**", "**/.agent/**"] },
429
+ "match": { "path_case": "insensitive", "paths": [".agent/**", "**/.agent/**"] },
403
430
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
404
431
  "reason": "Generic agent state is local, not repository content.",
405
432
  "remediation": ["git restore --staged <path>"]
@@ -45,14 +45,15 @@
45
45
  },
46
46
  {
47
47
  "id": "secret.aws-key-content",
48
- "version": 1,
48
+ "version": 2,
49
49
  "provider": "aws",
50
50
  "category": "secret",
51
51
  "confidence": "high",
52
52
  "kind": "code",
53
53
  "match": {
54
54
  "content": [
55
- "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])"
55
+ "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
56
+ "\\b(?:AKIA|ASIA)[A-Z0-9]{16}\\b"
56
57
  ]
57
58
  },
58
59
  "actions": {
@@ -74,7 +75,8 @@
74
75
  "kind": "code",
75
76
  "match": {
76
77
  "content": [
77
- "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b"
78
+ "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b",
79
+ "\\b(?:sk-ant-api[0-9]{2}-[A-Za-z0-9_-]{80,255}|sk-proj-[A-Za-z0-9_-]{40,255}|AIza[A-Za-z0-9_-]{35}|(?:sk|rk)_live_[A-Za-z0-9]{20,255}|hf_[A-Za-z0-9]{34}|SG\\.[A-Za-z0-9_-]{20,255}\\.[A-Za-z0-9_-]{40,255})\\b"
78
80
  ]
79
81
  },
80
82
  "actions": {
@@ -17,7 +17,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
17
17
  Its policy:
18
18
 
19
19
  - Never stage or commit local AI session/state files. Examples include
20
- `.claude.json`, `.claude/.credentials.json`, `.claude/session*.json`,
20
+ `.claude.json`, `.claude/session*.json`,
21
21
  `.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
22
22
  `.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
23
23
  `.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
@@ -27,8 +27,10 @@ Its policy:
27
27
  exhaustive; the packaged `rules/paths.json` is the detection source of truth.
28
28
  If one is staged, unstage it and keep it out of Git instead.
29
29
  - Never commit secrets: a real `.env` (not `.env.example`), private keys
30
- (`id_rsa` and files containing a private-key header), `.aws/credentials`, or
31
- service-account keys. Public certificates are allowed.
30
+ (`id_rsa` and files containing a private-key header), `.aws/credentials`,
31
+ `.claude/.credentials.json`, service-account keys, or a provider API key
32
+ (GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
33
+ SendGrid). Public certificates are allowed.
32
34
  - Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
33
35
  an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
34
36
  noreply emails. A commit message reads as if a human wrote it.
@@ -290,7 +290,17 @@ export function withLock(lockPath, fn, options = {}) {
290
290
  if (attempt + 1 < retries) waitForLock(retryDelayMs);
291
291
  }
292
292
  if (!held) {
293
- throw new Error(`cannot acquire state lock "${lockPath}" after ${retries} attempts`);
293
+ // Name the queue, not just the lock. This scheme writes no file at lockPath;
294
+ // only the legacy holder checked above would, so a reader of this error
295
+ // usually goes looking for a path that is not there. What blocks is a
296
+ // candidate in the queue directory, retained because its owner cannot be
297
+ // disproved, and a holder killed outright never reaches the finally below
298
+ // that would have removed its own.
299
+ throw new Error(
300
+ `cannot acquire state lock "${lockPath}" after ${retries} attempts. Contenders `
301
+ + `queue in "${queueDir}"; if no other aimhooman command is running, a candidate `
302
+ + 'there outlived the process that published it, and removing that directory clears it',
303
+ );
294
304
  }
295
305
  return fn();
296
306
  } catch (error) {
package/src/githooks.mjs CHANGED
@@ -124,7 +124,7 @@ function canonicalPath(path) {
124
124
 
125
125
  // Ask Git for the path it actually uses. In a linked worktree this resolves to
126
126
  // the common repository hooks directory, not <gitDir>/hooks.
127
- function effectiveHooksDir(repo) {
127
+ export function effectiveHooksDir(repo) {
128
128
  try {
129
129
  return execFileSync(
130
130
  'git',
package/src/gitx.mjs CHANGED
@@ -200,7 +200,7 @@ export function unmergedPaths(repo) {
200
200
  export function stagedRenameSources(repo, destinations) {
201
201
  const selected = new Set(destinations);
202
202
  if (!selected.size) return [];
203
- const entries = diffEntries(repo, ['--cached'], 1);
203
+ const entries = diffEntries(repo, ['--cached'], '1%');
204
204
  const sources = new Set(entries
205
205
  .filter((entry) => entry.status === 'R' && selected.has(entry.path) && entry.sourcePath)
206
206
  .map((entry) => entry.sourcePath));
@@ -210,7 +210,9 @@ export function stagedRenameSources(repo, destinations) {
210
210
  // there is no exact source to recover. When a blocked destination remains an
211
211
  // add even at the lowest similarity threshold, conservatively restore every
212
212
  // staged deletion. This may unstage an unrelated deletion, but it never lets
213
- // automatic repair silently commit half of a possible rename.
213
+ // automatic repair silently commit half of a possible rename. That threshold
214
+ // carries a % sign because git reads a bare 1 as the fraction 0.1, so -M1
215
+ // asks for 10% and leaves a 3%-similar rename here as an add plus a delete.
214
216
  if (entries.some((entry) => entry.status === 'A' && selected.has(entry.path))) {
215
217
  for (const entry of entries) {
216
218
  if (entry.status === 'D') sources.add(entry.path);
@@ -226,8 +228,13 @@ export function readStagedPath(repo, path) {
226
228
  const target = 'staged';
227
229
  let records;
228
230
  try {
231
+ // The --literal-pathspecs flag rather than :(top,literal) magic: magic
232
+ // parsing is disabled by GIT_LITERAL_PATHSPECS in the environment, which
233
+ // would leave the pathspec a literal filename no repository contains, so
234
+ // git matched nothing and the read reported the policy absent. cwd is
235
+ // already repo.root, which is what the top magic was for.
229
236
  records = nulStrings(gitBuf([
230
- 'ls-files', '--stage', '-z', '--', `:(top,literal)${path}`,
237
+ '--literal-pathspecs', 'ls-files', '--stage', '-z', '--', path,
231
238
  ], repo.root));
232
239
  } catch (error) {
233
240
  throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
@@ -274,7 +281,7 @@ export function readCommitPath(repo, revision, path) {
274
281
  let records;
275
282
  try {
276
283
  records = nulStrings(gitBuf([
277
- 'ls-tree', '--full-tree', '-z', oid, '--', `:(top,literal)${path}`,
284
+ '--literal-pathspecs', 'ls-tree', '--full-tree', '-z', oid, '--', path,
278
285
  ], repo.root));
279
286
  } catch (error) {
280
287
  throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
@@ -483,8 +490,13 @@ export function unstagePaths(repo, paths) {
483
490
  '--pathspec-from-file=-', '--pathspec-file-nul',
484
491
  ], opts);
485
492
  } else {
493
+ // -f waives only the check that the staged blob still matches the file
494
+ // on disk. With no HEAD that check has nothing else to pass against, so
495
+ // an artifact appended to between `git add` and `git commit` could never
496
+ // be unstaged. --cached leaves the worktree alone either way, and the
497
+ // sibling `restore --staged` branch enforces no equivalent check.
486
498
  execFileSync('git', [
487
- '--literal-pathspecs', 'rm', '--cached', '--quiet', '--ignore-unmatch',
499
+ '--literal-pathspecs', 'rm', '--cached', '-f', '--quiet', '--ignore-unmatch',
488
500
  '--pathspec-from-file=-', '--pathspec-file-nul',
489
501
  ], opts);
490
502
  }
@@ -13,6 +13,10 @@ function gitBuffer(repo, args, input) {
13
13
  encoding: 'buffer',
14
14
  maxBuffer: 128 * 1024 * 1024,
15
15
  timeout: GIT_TIMEOUT_MS,
16
+ // Same reason as gitBuf in gitx.mjs: without an explicit stdio,
17
+ // execFileSync echoes the child's stderr before it checks the exit
18
+ // status, so git's raw output reaches the terminal even on success.
19
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
16
20
  ...(input === undefined ? {} : { input }),
17
21
  });
18
22
  }