Repository navigation
fix(project): treat a vanished info/exclude as writable like .hack/.gitignore - #460
Merged
Merged
Conversation
…itignore
ensureHackGitInfoExclude now applies the same probe-error policy as
ensureHackDirGitignore: an ENOENT from the write probe counts as writable
(the rename recreates the file); any other probe error is rethrown and
the best-effort wrapper skips the update with a debug log.
Also restores the exact toEqual({ follow: true }) assertion for doctor
and up, stops asserting Bun's null resolve value of fs.promises.access,
and corrects docs/cli.md: under clack the refusal is two lines (the
guide line and the marker line), not one.
Refs HACK-1412
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 24, 2026 05:49
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…urate gitignore entry, no-TTY Tailscale test) (#479) The six small CLI follow-ups in HACK-1426, from the verifiers of #466, #441, #453, #460 and #462. ## Root cause 1. **Tailscale guard had no test.** The `canPrompt()` guard in front of "Turn on Tailscale?" in `setupTailscale` (`src/commands/ssh.ts`) had no case in `tests/no-tty-prompt-sites-cli.test.ts`. If you changed it to `if (false)`, every ssh case still passed. 2. **Cold cache in the first case.** Each run of that file uses a fresh private HOME. Bun keeps its runtime transpiler cache under HOME (`~/Library/Caches/bun/@t@` on macOS), so the first spawn started cold inside its 10 s kill bound. On a loaded host that first case took 6.3 s and was SIGKILLed. 3. **`hack up` dropped the error code.** For a missing project, `runUpCommand` caught `MissingProjectContextError` and, without `--json`, logged only its message. `down`, `restart`, `ps`, `logs` and `open` let the error reach the CLI's closed-error renderer, which prints `E_PROJECT_NOT_FOUND: ...`. 4. **Indented `.gitignore` entry counted as present.** `ensureGitignoreEntry` (`src/lib/fs.ts`) compared trimmed lines. Git keeps leading whitespace as part of the pattern, so ` .hack.secret.key` does not ignore the key. Hack counted that line as the entry, wrote nothing, then found the key not ignored. It then refused with the "a later rule re-includes the key" message, which names the wrong cause. 5. **ACL test could leave mode 0444.** In `tests/git-info-exclude.test.ts`, the macOS ACL test ran `chmod 0444`, the ACL grant and `access(W_OK)` before the `try` whose `finally` restores mode 0644. If any of those steps failed, the file kept mode 0444. 6. **Paired fixture lost a case.** #462 gave the paired `unsearchable` fixture a `.hack/.internal` directory. After that, nothing covered a `.hack` at mode 000 with no `.hack/.internal`, which is the most common real layout. ## Fix 1. New paired no-TTY case: `ssh --tailscale` runs against a logging `tailscale` stand-in placed first on PATH. The stand-in reports `{"BackendState":"Stopped"}`. The case expects exit 1, the `Run \`tailscale up\`, then re-run \`hack ssh\`.` hint, no prompt, and no `tailscale up` call. 2. `beforeAll` now runs one warm-up child (`index.ts --help`) under the file's private HOME. It has its own named budget: `WARM_UP_KILL_AFTER_MS` = 90 s, and the hook bound is that plus 10 s. A kill fails the file with a clear message. The cases keep the 10 s bound. 3. `runUpCommand` now handles `MissingProjectContextError` itself only under `--json`. Without `--json` it rethrows, so `hack up` prints `ERROR: E_PROJECT_NOT_FOUND: No .hack/ (or legacy .dev/) found. Run: hack init` (console logger), the same as the other lifecycle commands. `docs/cli.md` already promised this. 4. `ensureGitignoreEntry` now reads each line the way git does: it drops a trailing CR and trailing spaces, and keeps leading whitespace. An indented line no longer counts as the entry, so Hack appends the exact entry, git ignores the key, and the secret write goes ahead. The "re- included" refusal now appears only when the exact entry is present and git still does not ignore the key, which is what its message says. Tabs are not trimmed, which matches git's `trim_trailing_spaces`. 5. The ACL test's mode change, ACL grant and `access` probe now run inside the `try`. 6. New `unsearchable-no-internal` fixture (never adopted, no `.hack/.internal`). Its `.hack` at mode 000 is the first case in the paired CLI/MCP loop. The proof-matrix row in `docs/reference/ax-target-result-contract.md` now names this case. ## Tests and mutation evidence For each mutation I applied it, ran the focused tests, and restored the file. - **`if (!canPrompt())` changed to `if (false)` in `setupTailscale`:** the new case fails. The child was SIGKILLed at the 10 s bound with the prompt open. The other 3 ssh cases pass. The stand-in recorded no `up` call. - **`up`'s non-JSON branch reverted:** 2 tests fail, `up -p <empty dir> without --json …` and `up from a working directory without a project …`. The same new loop over `down`, `restart`, `ps`, `logs` and `open` still passes. - **`line.trim() === entryLine` restored in `ensureGitignoreEntry`:** 2 tests fail, the CLI case "an indented entry … is completed by an exact entry instead of being refused as re-included" and the unit case "ensureGitignoreEntry reads each line as git does …". - **Exact comparison, `line === entryLine`:** the unit case fails on the trailing-space and CRLF inputs. - **EACCES/EPERM no longer mapped to `permission` in the adoption marker's `classifyStateRootFailure`:** the paired loop fails on its first case, `unsearchable-no- internal` with `.hack` at 000. It gets `adoption_state_unsafe` / `E_CONFIG_INVALID` where `input_permission_denied` / `E_PERMISSION` is expected. - **Warm-up removed (timing, idle host):** the first case took 1.10 s without the warm-up and 0.65 s with it. The later cases are unchanged. ## Validation - Focused tests: 6 files (`no-tty-prompt-sites-cli`, `missing-project-error-code`, `project-env- key-ignore`, `project-target-inspect`, `git-info-exclude`, `doctor-generated-files`), 121 pass and 0 fail. `project-up-command` also passes. Runs used a private HOME, HACK_HOME and DOCKER_CONFIG, and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, as a non-root user on macOS. - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: `privacy-check: ok`. `@hack/cli:check` shows the 34 warnings that were already there. Biome finds no issues in the changed files. - `bun run docs:cli-reference`: no diff. Only an error line changed, not the CLI surface. No consumer agent guidance changed. - gitleaks 8.30.1 (`gitleaks dir` on the exported `git log -p origin/next..HEAD`): no leaks found. - Full suite: the hosted CI `test` job (all shards) at this PR head. ## Gaps - I did not reproduce item 2 under load. The evidence is that the warm-up moves the cold start out of the first case: on an idle host, 1.10 s became 0.65 s. The reported failure was 6.3 s at load about 60. - Item 5 is a structural move with unchanged assertions. No mutation tells the old and new placement apart without failing the ACL setup itself. - As in #462, no mutation tells the no-`.internal` case apart from the `.hack` 000 case with `.internal`. Both get EACCES from the same state-root `lstat`. The new case is live (it fails first under the classifier mutation) but not independent. - Locally, `tests/native-compose-adoption-search-depth.test.ts` "a pending adoption more than 64 directories above …" fails in its setup with `E_LEGACY_ADOPTION_DEADLINE`. It fails the same way with `origin/next`'s `src/`, so this change did not cause it. Hosted CI decides. - Not run as root. The mode-dependent cases are skipped as root, as before. Refs HACK-1426 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups from the #439 (HACK-1378) verifier. Refs HACK-1412.
Root cause
ensureHackDirGitignore(.hack/.gitignore) treats an ENOENT from itsaccess(W_OK)probe as writable and rethrows unexpected codes.ensureHackGitInfoExcludelet every probe error, ENOENT included, end in a skipped update. The docs said both follow "the same policy". This made no practical difference, because the update is best-effort.tests/cli-per-command-options.test.tshad changedtoEqual({ follow: true })totoMatchObjectfordoctorandup. The parsed values are exactly{ follow: true }.tests/git-info-exclude.test.tsassertedresolves.toBeNull()onfs.promises.access. That value isnullunder Bun andundefinedunder Node.docs/cli.mdcalled theE_PROJECT_NOT_FOUNDrefusal "one closed error line". Under clack it is two lines: the│guide line, then the■line.Fix
src/lib/git-info-exclude.ts: a newprobeExcludeWriteAccesstreats an ENOENT from the probe as writable, so the rename recreates the file. Any other probe error, read-only codes included, is rethrown, and the existing best-effort wrapper turns it into a skipped update with a debug log. This matchesisHackDirGitignoreWritable. The function's invariants doc now says so.toEqual({ follow: true }).access(...). It still fails ifaccessrejects.docs/cli.mdnow describes the output per logger: console and gum print one line each on stderr; clack prints two lines on stdout.Tests and evidence
a probe ENOENT counts as writable and another probe error skips: a probe that rejects withEIOleaves the file unchanged. A probe that removes the file and rejects with ENOENT leads to the file being recreated with the managed block and mode 0644.tests/git-info-exclude.test.ts,tests/cli-per-command-options.test.ts): 24 pass, 0 fail.--jsonvalue withtoEqual: bothdoctorandupcases fail.toMatchObjectwould have passed here.accessrejects: the macOS ACL test fails.bun run check(includesprivacy:check) exits 0;bun run --cwd packages/cli typecheckhas 0 errors; Biome passes on the changed files; gitleaks 8.30.1 found no leaks overorigin/next..HEAD.docs:cli-referenceandgenerate:agent-pluginsdo not apply.testjob at this head.Gaps
access(2)is not reproduced.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.