Desktop Material

Deleted upstream pull recovery

When a pull fails because the current branch's remote-tracking branch no longer exists — someone deleted it after merging a pull request, or renamed it on the remote — Desktop Material offers to check out the repository's default branch and pull that instead, rather than leaving a raw Git error on screen. The offer is also raised per repository from Pull all and reviewed batch sync, which is where recovering by hand is most tedious.

Behavior and configuration

The detection rule

Two independent conditions must both hold before recovery is offered.

  1. Git's own structured classification. The failure must carry dugite's GitError.NoExistingRemoteBranch, dugite's classification of Git's "no such ref was fetched" diagnostic. Desktop Material never re-parses stderr itself for this.
  2. The remote's own answer. The store then runs git ls-remote --exit-code --heads -- <remote> refs/heads/<branch> against the actual remote and only offers recovery when the remote answers that the branch is not there. The probe runs as a background task so a missing or refused credential fails closed instead of raising a credential prompt on the back of a failed pull.

The structured error alone is treated as a nomination, not as proof. A remote that still advertises the branch, or a remote that cannot be reached or authenticated with, declines the offer, and the original Git error keeps its ordinary error surface.

What is deliberately not offered

Recovery is not offered, and the failure keeps its normal Git error, when:

A single batch sync raises at most ten recovery dialogs. Repositories affected beyond that cap still say so in their result row and are recovered by opening them individually; they simply do not stack another modal.

The dirty-worktree refusal

The switch is a real mutation of the user's worktree, so it is refused rather than worked around. Desktop Material re-reads the repository immediately before acting and returns a refusal — changing nothing at all — when:

Refusal Meaning
no-default-branch No default branch is configured or discoverable. Desktop Material reports this instead of guessing at main or master.
dirty-worktree Uncommitted changes are present. The switch never stashes, moves, or discards them.
conflicted-worktree Unresolved conflicts are present.
operation-in-progress Another push, pull, or fetch already holds the repository.
already-on-default-branch The default branch is already checked out.
no-current-branch Detached HEAD or an unborn branch.

The checkout itself is issued with the confirmation uncommitted-changes strategy pinned explicitly, so a profile configured to always stash cannot turn this recovery into a silent stash. If the checkout still does not land on the default branch, the recovery stops there and reports checkout-failed without deleting a branch or attempting a pull.

Failure modes and verification

The retried pull is reported as it actually ended. Git reports pull failures through the repository's GitStore rather than by throwing, so the retry is watched for the error it emits and a failure is reported with its Git message intact — the notification never claims a success that did not happen. A stale branch that could not be deleted says why it was kept.

Covered by app/test/unit/pull-branch-deleted-test.ts, app/test/unit/pull-branch-deleted-store-test.ts, and app/test/unit/pull-branch-deleted-ui-test.ts: the offer and each exclusion, the branch-name validation that keeps an option-looking ref out of the ls-remote argument list, an end-to-end ls-remote probe against a real local remote, every refusal, the honest retry reporting, the unticked delete option and its stranded-commit warning, per-repository batch offers and their cap, and both languages at every funny level.