Update button refuses on untracked files, calling them "uncommitted changes"
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 0
- Avg merge
- 1h 21m
- Merged PRs (30d)
- 41
Description
Found by running #146 end-to-end against a scratch clone, after it had already been reviewed and its tests were green. Filed as a follow-up on the developer's call to merge #146 now.
## What is wrong
`runSelfUpdate` (`src/daemon/self-update-run.ts`) gates on the working tree being completely clean:
```ts
const statusOut = (await git(["status", "--porcelain"], root)).stdout;
if (statusOut.trim() !== "") {
return { ok: false, error: "the checkout has uncommitted changes" };
}
```
`git status --porcelain` reports **untracked** files as well as modified ones. So a single stray untracked file blocks every update, under a message that names something else.
## Evidence
Running a real update against a clone of the branch, the very first attempt refused:
```
1. status before anything: {"action":{"kind":"blocked","reason":"dirty"},"fetchError":null}
2. POST /api/update -> 400 {"error":"the checkout has uncommitted changes"}
$ git status --porcelain
?? node_modules
```
Nothing was uncommitted. The only entry was one untracked symlink. The same watcher then reported the checkout as `blocked/dirty` in the cockpit, so the button would have told the developer to go and commit work that does not exist.
This is not hypothetical on the machine Bench runs on: at 15:00 on 2026-09-17, `/var/www/bench` was carrying an untracked `src/daemon/devin-skills.ts`. With #146 live, the Update button would have refused for as long as that file sat there.
## Why tracked-only is the honest test
`git merge --ff-only` refuses on its own if an untracked file would be overwritten by the merge, so nothing is lost by allowing them. The gate exists to protect **the developer's uncommitted work**, and an untracked file is not at risk from a fast-forward that does not touch it.
## Acceptance criteria
- [ ] An untracked file that the incoming commits do not touch does not block an update.
- [ ] Modified or staged tracked files still block it, with the message they have now.
- [ ] If the fast-forward itself fails because an untracked file is in the way, that failure is reported in its own words rather than as "uncommitted changes".
- [ ] The watcher's `blocked` reason agrees with whatever the route would do — the cockpit must not say "dirty" for a checkout the route would happily update.
- [ ] A test covers the untracked case specifically: an untracked file present, update proceeds.
## Out of scope
- Stashing, or any attempt to preserve or move the developer's work. The refusal path stays a refusal.
- Changing what happens on a genuinely diverged branch.
- The build/rollback path (#146 already covers it).
## Verification
```
pnpm typecheck
pnpm test
pnpm build
```
Manual: drop an untracked file in the checkout, confirm the button still offers the update and that the update completes.
## Related
- #146 — the feature this came out of, merged as #147.
Contributor guide
Research direction
Start in src/daemon/self-update-run.ts at runSelfUpdate and trace the watcher that reports the checkout as blocked/dirty. Run pnpm test, then add coverage for an unrelated untracked file while preserving blocking for modified or staged tracked files; verify the route and watcher agree and run pnpm typecheck, pnpm test, and pnpm build.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, typescript
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100