docs/design/git-pull-dirty-worktree.md
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.
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.
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 same git pull as 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 is 409 pull_failed carrying git's message — also
when there was nothing to stash (edits hidden by skip-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 is 409 pull_failed as 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 with
stashRestoreConflict: true and stashSha; git keeps the entry, and
output names it.force: fetch (--prune), check the upstream, refuse unless the update
is a fast-forward, then git reset --hard + git clean -fd (ignored
files are kept) and git merge --ff-only <validated sha>. Destructive,
so everything is validated before anything is discarded: a branch
deleted on the remote is refused as 409 pull_failed with nothing
discarded, a diverged branch as 409 diverged while 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. force is refused (409 force_unsupported) when the workspace cwd is below the repository root,
because git reset --hard acts 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.
These are the boundaries of the feature; each is a decision, not an omission:
git pull the terminal runs, so pull.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, as pull_failed), with git's own hint.git pull and the plain
pull on main already 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.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.GitPullFailure; the pre-existing
regex classification in the route is unchanged and still covers the
plain pull.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: true.force: true.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.
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.
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.