← 输入框|github.com|刷新
Skip to content

Windows: gh extension upgrade/remove delete the installed extension folder and leave it broken when a file in it is in use #14868

Open

Agent suggestions

Public preview

Description

@danriedl

Describe the bug

On Windows, gh extension upgrade, gh extension remove and gh extension install delete the installed extension's folder before doing anything else. If any file in it is in use, gh stops half-way and leaves an extension that gh extension upgrade can no longer repair.

The cause is in pkg/cmd/extension/manager.go. installBin, installGit, InstallLocal and Remove all call cleanExtensionUpdateDir(name) first:

func (m *Manager) cleanExtensionUpdateDir(name string) error {
	if err := os.RemoveAll(m.UpdateDir(name)); err != nil {
		return fmt.Errorf("failed to remove previous extension update state: %w", err)
	}
	return nil
}

UpdateDir(name) is config.StateDir()/extensions/<name> and the install folder is config.DataDir()/extensions/<name>. On Linux and macOS these are different folders. On Windows, go-gh/pkg/config resolves both StateDir() and DataDir() to %LOCALAPPDATA%\GitHub CLI, so the "update state" folder is the installed extension itself.

os.RemoveAll deletes what it can, including manifest.yml, and then fails on whatever is locked. Windows locks the extension's .exe while it runs, and it locks a folder that is any process's working directory. Without manifest.yml the extension is listed with no repo or version, and every later gh extension upgrade fails with unable to retrieve latest version for extension "<name>". Only remove + install recovers.

This came in with #9934, which added the update-state folder. github/gh-aw ran into the same thing and traced it to the same lines (github/gh-aw#27269). They worked around it on their side, but this repo never got an issue.

Affected version

gh version 2.102.0 (2026-09-30)

Also reproduced with 2.92.0 and 2.103.3. cleanExtensionUpdateDir is unchanged on trunk.

Steps to reproduce the behavior

On Windows, with any binary extension (gh-foo below):

  1. gh extension install owner/gh-foo
  2. Keep the extension running, e.g. a long-running gh foo <command> in one terminal. A terminal opened with %LOCALAPPDATA%\GitHub CLI\extensions\gh-foo as its working directory has the same effect.
  3. In a second terminal: gh extension remove foo. gh extension upgrade foo takes the same path whenever an update is available.
  4. gh extension list

Expected vs actual behavior

Expected: gh only touches its own update state. An upgrade while the extension runs either succeeds (a running .exe can be renamed aside) or fails before changing the install, and the installed extension stays usable either way.

Actual (upgrade prints the same error after [foo]: and then some extensions failed to upgrade):

failed to remove previous extension update state: unlinkat C:\Users\<user>\AppData\Local\GitHub CLI\extensions\gh-foo: The process cannot access the file because it is being used by another process.

gh extension list then shows gh foo with no repo and no version, manifest.yml is gone, and gh extension upgrade foo fails from then on with unable to retrieve latest version until gh extension remove foo && gh extension install owner/gh-foo.

Possible fixes, in increasing scope:

  • Delete only the state file inside UpdateDir(name) instead of the folder, or skip cleanExtensionUpdateDir when UpdateDir(name) equals the install folder.
  • Use a separate state folder on Windows, e.g. %LOCALAPPDATA%\GitHub CLI\state. Existing state would need a migration.

Logs

The error above is the only output. The command is local file handling, so GH_DEBUG=api adds nothing past the release lookup.

Activity

  1. cli-triage commented on Oct 29, 2026

    @cli-triage

    No duplicates found — searches for cleanExtensionUpdateDir, "extension update directory Windows", and "unable to retrieve latest version" turned up nothing else tracking this.

    I'm suggesting bug, priority-2, and gh-extension. The report's root-cause analysis checks out against current source: UpdateDir is built from config.StateDir(), while the install directory is built from config.DataDir() (same file, ~line 51-52). On most platforms these differ, but on Windows go-gh resolves both to %LOCALAPPDATA%\GitHub CLI, making them identical. cleanExtensionUpdateDir then calls os.RemoveAll on that shared path, which is called from installBin, installGit, InstallLocal, and Remove before any other work is done — so on Windows this removes the live, installed extension directory rather than just update-check metadata. If any file inside (e.g. a running .exe, or a directory in use as a cwd) is locked, RemoveAll partially succeeds, deleting manifest.yml while leaving the locked file behind, which matches the reported broken state. I'd suggest priority-2 since it affects Windows users of binary extensions broadly and has no fix without manual remove+reinstall, but doesn't cause true data loss or affect non-Windows platforms.

    Generated by Issue Triage (skills-driven) for #14868 · copilot · auto · 38.5 AIC · ⊞ 4.5K · ◷

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions