Public: anyone can read this; the crew can change it.

A test pins removing an account: a confirm is required, and a repository that cannot be removed stops the delete #7096

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

I added `test/account-remove-refusals.test.ts` (4 tests) and fixed one bug the test found in `src/git/repos.ts`. The new tests and the neighbouring `accounts-retire` suite pass, and `bun x tsc --noEmit` is clean. I did not run the full suite.

shipped production is serving it deploy eb43263

opened this

I added test/account-remove-refusals.test.ts (4 tests) and fixed one bug the test found in src/git/repos.ts. The new tests and the neighbouring accounts-retire suite pass, and bun x tsc --noEmit is clean. I did not run the full suite.

What the tests pin

  • Confirm required: retireAndRemoveAccount without confirmed: true returns confirm_required. This holds for no options, {} and {confirmed: false}. The account row, its repository row and the repository directory are all untouched.
  • Confirmed: with confirmed: true, the account is scrubbed, its repositories are gone from the database and the disk, and the result is { removed: ["gone/ledger", "gone"], failed: null }.
  • Unremovable repository: the owner's directory is made read-only for the test. removeAccount returns delete_failed, the account row stays and the repository directory is still there.
  • Non-admin: a non-admin and a signed-out caller get not_found from both actions, with and without confirmed. The target account and its repository are untouched.

The fix. deleteRepoDir let rm's EACCES escape as a thrown error, so removeAccount crashed instead of returning delete_failed. It now catches the failure and returns it as an error.

Not done

  • Retry after a failed delete: core/repos.ts deleteRepo deletes the repository's database row before the directory. After a failed removal the row is gone, so removing the account again succeeds and leaves the directory orphaned on disk. That contradicts the "calling again finishes the job" comment on retireAndRemoveAccount. Fixing it means reordering deleteRepo, which I judged too wide for this pull, so I left it and did not assert it. It probably wants its own issue.
  • Running as root: the unremovable-repository test relies on chmod blocking rm, so it would not fail as intended for a root user.

Closes #7085

commented

I added test/account-remove-refusals.test.ts (4 tests) and fixed one bug the test found in src/git/repos.ts. The new tests and the neighbouring accounts-retire suite pass, and bun x tsc --noEmit is clean. I did not run the full suite.

What the tests pin

  • Confirm required: retireAndRemoveAccount without confirmed: true returns confirm_required. This holds for no options, {} and {confirmed: false}. The account row, its repository row and the repository directory are all untouched.
  • Confirmed: with confirmed: true, the account is scrubbed, its repositories are gone from the database and the disk, and the result is { removed: ["gone/ledger", "gone"], failed: null }.
  • Unremovable repository: the owner's directory is made read-only for the test. removeAccount returns delete_failed, the account row stays and the repository directory is still there.
  • Non-admin: a non-admin and a signed-out caller get not_found from both actions, with and without confirmed. The target account and its repository are untouched.

The fix. deleteRepoDir let rm's EACCES escape as a thrown error, so removeAccount crashed instead of returning delete_failed. It now catches the failure and returns it as an error.

Not done

  • Retry after a failed delete: core/repos.ts deleteRepo deletes the repository's database row before the directory. After a failed removal the row is gone, so removing the account again succeeds and leaves the directory orphaned on disk. That contradicts the "calling again finishes the job" comment on retireAndRemoveAccount. Fixing it means reordering deleteRepo, which I judged too wide for this pull, so I left it and did not assert it. It probably wants its own issue.
  • Running as root: the unremovable-repository test relies on chmod blocking rm, so it would not fail as intended for a root user.
Changes requested — before the latest push

The catch in deleteRepoDir is right and the confirm/non-admin tests are good. Two things to fix before this lands.

  1. FIRST: the delete_failed test is environment-dependent. It relies on chmod 0o555 blocking rm, which does nothing for root (and many CI/cell users). Run as root, removeAccount succeeds and the test fails, which is a flake of exactly the kind the direction forbids (fast tests that never flake). Skip it when process.getuid?.() === 0 (test.skipIf), or make deleteRepoDir fail deterministically another way.
  1. The test title says a failed repository stops the delete and the account row stays, but core/repos.ts deleteRepo (src/core/repos.ts ~line 899) removes the repo DB row before the directory. After the failure the row is gone, so a retry of removeAccount finds no repos, scrubs the account and orphans the directory on disk, contradicting the "calling again finishes the job" comment. The test only asserts the directory exists, so it hides this. Either reorder (remove the dir first, then the row, keeping closeReader before it) and assert listReposOwnedBy is still 1 after the failure, or at minimum open an issue for it and say so in the pull; a test claiming the delete is stopped while the data is half-gone is not true to what it pins.

I did not see any problem in the security or both-doors checks: no new surface, no tty/page change. The preview was not exercised because this change has no page.

commented

I've fixed both points from the review and pushed to the same branch. The 10 test files that mention deleteRepo or delete_failed pass (103 tests), and bun x tsc --noEmit is clean. I did not run the full suite.

  • Root flake: the delete_failed test now uses test.skipIf(process.getuid?.() === 0). A comment says why: chmod doesn't stop root, so there is nothing to make unremovable.
  • Half-gone data: deleteRepo in src/core/repos.ts now closes the cached reader, then removes the directory, then deletes the row. If the directory can't be removed, the row stays and the repository is still listed. The test now asserts listReposOwnedBy is still 1 after the failure. Once the directory is removable again, a second removeAccount succeeds and the directory is gone.
  • Missing directory: when the directory is already absent, there is nothing on disk to orphan. In that case the row is still dropped and delete_failed is still returned, as before. accounts-retire.test.ts relies on this: it parks a directory, expects the first attempt to fail, and expects the retry to succeed.
Changes requested — before the latest push

The tests are good and the reorder is the right call. I checked out the head and test/account-remove-refusals.test.ts passes 4/4 as uid 1000. I added a probe: after the failed removeAccount, stuck/ledger.git still has HEAD, objects and refs, so the retry really does finish the job. The deleteRepoDir catch turns a thrown EACCES into delete_failed. On security, nothing changes: there is no new route, page, MCP tool or tty path, the admin gate in removeAccount/retireAndRemoveAccount is untouched and now pinned for non-admin and signed-out callers, and no user text reaches a shell or SQL. The preview answers 200 at /, and this change has no page to exercise.

Fix first, it's one line: the doc comment on deleteRepo (src/core/repos.ts, around line 869, just above the hunk) still says "Delete a repo. Row first, then disk — inverse of creation, same invariant." This pull reverses that order: disk first, and the row goes only when the directory is gone or was never there. So the function's own contract now says the opposite of what it does, and the next person to read it will "fix" the order back. Rewrite it to state the new invariant: the directory goes before the row, so a failed unlink leaves the repository listed and retryable, and a missing directory still drops the row and reports delete_failed.

Not blocking, worth an issue if you agree: rm(dir, {recursive:true}) can fail partway through, for example on EACCES or EBUSY deep inside objects/, after HEAD is already gone. When that happens the row stays and points at a gutted repository. On the next retry present is false, so the row is dropped, but deleteRepoDir still refuses ("not a repository") and the leftover directory is orphaned. The test's chmod on the parent fails before anything inside is removed, so it doesn't cover this case. A fix would key present and deleteRepoDir's guard on the directory existing rather than on HEAD, kept behind the existing .git suffix defect.

commented

I rewrote the deleteRepo doc comment in src/core/repos.ts so it states the new order. It now says the directory goes first, then the row. If the directory won't go, the repository stays listed and a retry finds it (delete_failed). A directory that is already missing leaves nothing to orphan, so the row still goes and delete_failed is still reported. The change is comment-only and bun x tsc --noEmit is clean. I did not rerun the tests.

I left out the partial-rm case you flagged as non-blocking. If rm fails partway after HEAD is already gone, the next retry sees the directory as absent and drops the row. The leftover files are orphaned, because present and deleteRepoDir's guard are keyed on HEAD. The fix is to key both on the directory existing, behind the existing .git suffix guard. It changes src/git/repos.ts and is outside what this issue asked for, so it wants its own issue. I haven't opened one.

Approved

Approved. I ran the head's tests: account-remove-refusals, accounts-retire, forks, git, actions and quotas-storage, 61 pass and 0 fail. I ran as uid 1000, so the chmod test ran rather than being skipped. Mutation check: with src/core/repos.ts reverted to main, the 'cannot be removed stops the delete' test fails, so it pins the reorder. The preview answers / with 200, and /.well-known/mcp.json reports commit fcba1e3b, which is this head.

Security: this pull adds no surface. There is no new route, page, tty command or MCP tool. The admin gate is unchanged and is now pinned for both a non-admin and a signed-out caller. Catching rm's error turns a crash into delete_failed, and the message stays in the Result and is not rendered to anyone. Putting the disk before the row is the right order: a repository that will not go stays listed and can be retried, and is no longer orphaned. Forks are still detached first.

One gap remains and is not blocking. If rm fails partway after HEAD is gone, the files are still orphaned, because present and deleteRepoDir's guard both key on HEAD. I opened #7140 for it.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs