Post-merge review follow-ups for the dirty-worktree pull (#10390): - fetchOnly combined with stash or force was silently winning: the flow performed a bare fetch and dropped the resolution the caller asked for. Both combinations are refused now — a 400 invalid_fetch_only_combination at the route and a mirrored guard in gitPull — matching the existing stash+force exclusivity. - A stash or force pull on a detached HEAD surfaced git's raw "HEAD does not point to a branch" through an unclassified 500; it is a typed pull_failed refusal now, before anything is touched. - validatedUpstream resolves the branch once and reads the configured upstream inline, so the no-upstream tail is explicit about the one probe race that can reach it instead of looking unreachable. - The resolution panel no longer offers Discard after the daemon refused it with force_unsupported: the refusal is permanent for a subdirectory workspace, so re-offering it could only loop. - Design doc: note that a stored-back stash entry lands on top of the stack, and state the plain pull's invariant precisely (same git invocation; output now path-redacted like every other response).
8.9 KiB
Git pull with a dirty working tree
Goal
Let the Web Shell "Update Project" action succeed when the workspace has
uncommitted changes, instead of dead-ending at an opaque
dirty_working_tree error that forces users back to a terminal.
Problem
The workspace git pull path (branch picker popover → SDK
workspaceGitPull → POST /workspace(s)/git/pull → core gitPull) runs a
plain git pull. With local modifications to files the incoming merge
touches, git refuses to merge; the route already classified that failure
as 409 dirty_working_tree with a path-redacted git message, but the
popover only rendered the SDK's raw error string, so nothing actionable
surfaced.
Behavior
POST /workspace/git/pull and POST /workspaces/:workspace/git/pull
accept two new boolean options (both default false, mutually exclusive —
sending both is a 400). They are the two things a user would do in a
terminal, and nothing more:
stash: fetch (--prune, so a tracking ref pruned earlier is back if the remote branch exists again), check the upstream,git stash push --include-untracked, run the samegit pullas before, then restore the entry. If the pull fails, the merge or rebase it started is aborted and the entry is restored, so the workspace is back in its pre-pull state; the response is409 pull_failedcarrying git's message — also when there was nothing to stash (edits hidden byskip-worktree, a diverged clean tree), so the client shows git's reason instead of re-offering the same resolution. If git refuses the stash itself (an intent-to-add entry, a wedged index lock), nothing has been touched and the refusal is409 pull_failedas well. If the pull succeeds but the restore does not (a conflict, or an incoming file at a path the stash holds untracked), the response is still a success withstashRestoreConflict: trueandstashSha; git keeps the entry, andoutputnames it.force: fetch (--prune), check the upstream, refuse unless the update is a fast-forward, thengit reset --hard+git clean -fd(ignored files are kept) andgit merge --ff-only <validated sha>. Destructive, so everything is validated before anything is discarded: a branch deleted on the remote is refused as409 pull_failedwith nothing discarded, a diverged branch as409 divergedwhile the local changes are still intact. The merge integrates the exact commit that was validated, by SHA: neither a push landing between the check and the merge nor a concurrent fetch moving@{upstream}can turn the validated fast-forward into a refusal after the discard.forceis refused (409 force_unsupported) when the workspace cwd is below the repository root, becausegit reset --hardacts on the whole repository and would erase changes outside the workspace.
Both options are refused (409 operation_in_progress) while a merge,
cherry-pick, revert, rebase or am is parked in the worktree: git stash push and git reset --hard both clear that state — a resolved but
uncommitted merge lives only in MERGE_HEAD. The probe runs immediately
before each of those two commands, so the window in which a terminal can
park an operation unseen is the command itself. The states are read
through git rev-parse --git-path, so linked worktrees resolve correctly
and a branch named MERGE_HEAD cannot shadow them.
The failure recovery aborts only the merge or rebase the pull itself
created, and its provenance comes from git: when such a state already
exists, git pull exits 128 before touching the tree, so a merge or
rebase present after an exit of 1 (git attempted the integration and
stopped on conflicts) is the pull's own. It must also point at the
upstream tip the pull integrated. Anything else — a merge a terminal
parked meanwhile, whatever its tip — is left in place, and the failure
names the stash entry that still holds the user's changes. The recovery
helpers are best-effort end to end: a probe or listing failure never
turns a recovered state into an unclassified error.
The stash entry is identified by provenance and SHA, never by position:
the listing is compared before and after the push and the entry chosen is
the new one carrying the auto-stash message (a terminal push landing in
between sits above it and is left alone; if that re-listing fails, the
refusal points at the entry by its message); the restore is git stash apply <sha>; the drop names the slot resolved right before it and then
checks the SHA git reports as dropped — git has no identity-addressed
drop — and if the slots shifted under it, the other entry is git stash stored back and ours is reported as kept; should even that store fail,
the displaced entry's SHA and the command that brings it back are in the
output. Every notice about a kept entry carries its SHA. A stored-back
entry lands on top of the stack rather than in its old slot — position is
not part of any identity here, and the output says what happened — so a
terminal relying on git stash pop order should read git stash list
first.
A plain pull — no option — runs the exact git invocation it always has;
the only response-level change is that its output is now path-redacted
like every other response. The three options are mutually exclusive:
stash+force and fetchOnly combined with either are refused as 400s
rather than silently dropping one of them.
Non-goals (deliberate)
These are the boundaries of the feature; each is a decision, not an omission:
- Ambient git configuration is honored, not overridden. The pull is
the same
git pullthe terminal runs, sopull.rebase,pull.ff,merge.ff, autostash and signing settings apply exactly as they do there. A host whose policy makes a diverged plain pull fatal gets the same fatal here (restored, aspull_failed), with git's own hint. - Ignored files are expendable, as in git. Git checks incoming files
out over ignored paths silently; a terminal
git pulland the plain pull onmainalready do this. The resolution flows do not add a collision preflight: one that is correct for every path shape (renames, symlinks, case folding, criss-cross merge bases, nested repositories) is a re-implementation of git's checkout rules, not a feature of this UI. - No cross-request serialization. Concurrent pulls on one repository
fail loudly on git's own
index.lock; the provenance-based capture, identity-based restore and checked drop fail closed (entry kept,stashRestoreConflict, or the other entry stored back) rather than applying or dropping someone else's entry. Nothing is lost in any interleaving. - Failures are classified by what the core did, not by matching git's
text. The new codes come from
GitPullFailure; the pre-existing regex classification in the route is unchanged and still covers the plain pull.
UI
When the plain pull fails with 409 dirty_working_tree, the branch picker
footer switches from the status line to a resolution panel offering:
- Stash Changes and Update — pulls with
stash: true. - Discard Changes and Update… — destructive, so it takes a second
click: the panel swaps to a warning plus a confirm button that pulls with
force: true. - Cancel — dismisses the panel.
The panel stays mounted (with a spinner on the clicked button) while its
own pull is in flight, resets whenever the popover is reopened, and is
dismissed by any competing action that actually runs (checkout, push, a
valid new-branch submit). A success with stashRestoreConflict renders
as a warning naming the stash entry (stashSha) that holds the changes;
that warning survives the reopen reset, since the pull may settle while
the popover is closed and it is the only signal the user gets. A
force_unsupported refusal keeps the panel up with the daemon's
explanation in place of the blocked line, because the tree is still
dirty and stashing is still available. Every other refusal —
pull_failed, diverged, operation_in_progress — renders the daemon's
message, which carries git's own notice or the core's explanation,
instead of the SDK's route label.
The SDK's workspaceGitPull takes an optional per-call timeout so the
popover can outsize the client's default fetch budget: the stash flow's
failure path chains up to 16 git commands, each with its own 30s limit,
so the popover allows 600s.
Ownership
Both routes keep their existing scoping: the legacy route is
legacy-primary-workspace scoped; the qualified route resolves the trusted
runtime and contained cwd exactly as before. The new options only change
which git commands run inside the resolved workspace and add no new trust
surface.
Alternatives considered
Automatic stash on every dirty pull was rejected: silently moving the
user's changes through a stash is surprising, and the discard option is
destructive, so both need an explicit choice. git pull --autostash
performs that same stash-then-restore implicitly, so it was not adopted.