Skip to content

Check out here forces the PR branch reset and can discard local commits and tracked edits #14940

Description

@saphid

Routing: New-issue candidate; data-loss fix needs maintainer triage. Bounded searches for checkout/force, checkout/local commits, and discard/data-loss titles found no exact report inspected here; results are partial. #7928 concerns explaining checkout failures from uncommitted changes, not evidence that forced local PR checkout preserves work. Recheck reports before filing.

Environment and verification limits

Research date: 2026-10-03. Fetched origin/main successfully; target: 7ff2eabf56. Local research host: macOS / Darwin 25.5.0 arm64, Node v24.12.0, gh 2.94.0. Main pins Effect 4.0.0-rc.115. This lane used read-only source comparisons and existing evidence; it did not launch a server, browser, desktop app, or live forge checkout. Historical regression results below are explicitly historical, not fresh runtime reproductions on this SHA. Source fingerprints and exact verification scope are in verification.json.

Reproduction environment

Use a disposable clone of a repository with an existing GitHub PR and working local gh authentication. Let H be its head branch and R its remote head. This reproduction intentionally exercises destructive current behavior only in that disposable clone; never use a checkout containing valuable work. The lane itself did not perform a live checkout.

Exact reproduction on current main — local commits

  1. In the disposable clone, check out H at R and create an extra local commit L on a tracked file. Do not push L. Record H's full commit ID and git log --oneline -3; H is now ahead of the PR.
  2. Keep H checked out. Register/select this clone as the project's current checkout in T3 and open the PR panel.
  3. Choose Check out here (server preparePullRequestThread with mode: "local").
  4. Inspect H/HEAD and git log --oneline -3 again. Main delegates to gh pr checkout <reference> --force; gh documents that flag as “Reset the existing local branch to the latest state of the pull request.” The local branch can move from L back to R, removing L from branch history. The commit may remain recoverable in the reflog; this report does not claim immediate object deletion.

Exact reproduction on current main — tracked edits

  1. Start a separate disposable copy with H checked out at the PR head R.
  2. Edit a tracked file without committing; record git diff and the file contents. Test an unstaged edit and a staged edit separately.
  3. Invoke the same Check out here action. The server does not check dirty tracked files before handing off forced checkout. On a forced reset of the current head branch, tracked work can be discarded. Do not generalize this to every branch-switch case: a CLI may refuse some conflicting switches.
  4. Compare file contents, staged/unstaged diff, branch, and HEAD. A successful reset must not be reported as safe merely because the branch lands on the PR.

Control: a clean disposable checkout without divergent local commits should still be able to check out the PR normally after the minimal correction.

Actual versus expected

Actual: local mode always sets force: true; the GitHub adapter emits --force. No tracked-edit refusal runs before it. Local commits can disappear from the branch and tracked work is exposed to forced reset; the successful response unconditionally reports isOnPullRequestHead: true. Expected: the ordinary checkout action preserves local work. Refuse tracked staged/unstaged edits before a mutation, avoid forced checkout, and let an unforced checkout refuse a divergent branch instead of resetting it.

User impact

A routine PR action can discard edits or remove unpublished commits from the working branch. Commit recovery may require reflog knowledge; uncommitted edits may have no recovery path. Web and desktop reach the same server action. This is a forge checkout issue, independent of Codex/Claude execution adapters or remote transport.

Current-main verification

  • The complete local-mode block is byte-for-byte unchanged from the reproduction baseline: checkoutChangeRequest receives force: true before statusDetails is read.
  • GitHubCli still adds --force when that input is true, and the existing main test prepares pull request threads in local mode by checking out the PR branch explicitly expects pr checkout 64 --force.
  • Read-only gh pr checkout --help on the research host confirms the force-reset contract. Neither that help output nor a passing happy-path test alone proves live data loss.
  • The historical regression fixture did fail on the older baseline, and the relevant main implementation is unchanged. Today's confirmation is source/CLI-contract based; no destructive real-gh reproduction or new browser evidence was run here.

Source: apps/server/src/git/GitManager.ts; apps/server/src/sourceControl/GitHubCli.ts; apps/server/src/git/GitManager.test.ts.

Minimal proposed fix scope — no implementation

On current main, remove the unconditional force option from local PR checkout and add a focused guard refusing tracked staged/unstaged edits before calling the provider. Test dirty refusal, preservation of a divergent same-named local branch, and successful clean checkout. Keep untracked-file policy unchanged unless a necessary Git conflict makes checkout refuse. This concern does not depend on P10 or P11. No rollback engine, git-config replay, new network/head verification, tracking contract fields, warning UI, or forced-reset workaround.

Evidence available

  • Historical P12-before.log: exit 1, 16 failed / 1 passed on the earlier baseline; the dirty-checkout fixture fails, and output records forced checkout. The fake gh boundary models branch/reset behavior using real temporary Git repositories, so this is not a live gh-server reproduction.
  • P12.diff contains the fixtures; independent review confirms the data-loss concern and requests the minimal P12a split. Broader branch pass counts are not proof that this minimal split already exists.
  • Fresh verification.json records identical local-mode source; the CLI flag semantics were read from gh help.
  • Still needed for implementation evidence: a disposable real-gh clone and web before/after screenshots showing the local commit/file contents, refusal, and survival of the original work. No such current-main media was captured in this lane.

🤖 Generated with Claude Code

Activity

  1. juliusmarminge commented on Oct 3, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Triage

    Thanks for the thorough write-up, @saphid! I confirmed this on main at a7b3ce8c08. It's a real data-loss bug.

    Local pull-request checkout always passes force: true, and for GitHub that becomes gh pr checkout <n> --force. In current gh, --force checks out the existing local branch and then runs git reset --hard onto the pull-request head (syncBranchCmds in cli/cli's pkg/cmd/pr/checkout/checkout.go). I ran that same sequence in a disposable repository:

    • An extra local commit on the pull-request branch was removed from the branch. It was still in the reflog.
    • An unstaged edit to a tracked file was deleted, with no way to recover it.
    • A staged edit to a tracked file was deleted the same way.
    • A new staged file on another branch survived the git checkout of the pull-request branch, but git reset --hard then deleted it and dropped the extra commit.
    • A conflicting unstaged edit on another branch stopped at git checkout and was left intact. Untracked files survived git reset --hard.

    The menu item is In this repository, with the subtitle "Switches the branch you are working in, like gh pr checkout." Plain gh pr checkout without --force fast-forwards and refuses a diverged branch, so the label promises something safer than what runs. Local mode also returns isOnPullRequestHead: true unconditionally after the provider call, so the "Checked out here" toast can't report a refused move. The worktree path already handles this: refreshCheckedOutBranch leaves a dirty tree or a non-matching checkout alone and reports onTarget: false. GitManager.test.ts currently expects pr checkout 64 --force, and nothing guards staged or unstaged tracked files before the call.

    This is separate from #7928, which is about explaining a refused git checkout when creating a thread.

    Other providers hit the same force: true call site but behave differently. GitLab ignores the flag (glab mr checkout without --force), and Azure doesn't pass a force flag. Bitbucket's force path runs git branch --force, which won't move the branch that's currently checked out but will move one that isn't, dropping its commits before switching. Forgejo's force path is git reset --keep, which moves the branch and keeps non-conflicting uncommitted edits. The working-tree wipe is specific to GitHub's --force.

    The linked verification.json and P12 logs aren't attached to this issue, but they aren't needed to establish the failure.

    A fix should stop hard-resetting this checkout and preserve unpublished commits and tracked edits. A clean fast-forward and a first checkout of a missing branch should keep working, and isOnPullRequestHead should come from the resulting HEAD the way worktree reuse already does it.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions