composer/composer · error · RuntimeException

Source directory {path} has unpushed changes on the current

Error message

Source directory {path} has unpushed changes on the current branch:
{unpushed}

What it means

Thrown by GitDownloader::cleanChanges() when getUnpushedChanges() returns a non-null result (commits exist on the local branch that are not on any remote) and either the IO is interactive or the 'discard-changes' config is not true. Composer aborts to avoid destroying unpushed work.

Source

Thrown at src/Composer/Downloader/GitDownloader.php:335

            if (!$unpushedChanges) {
                break;
            }
        }

        return $unpushedChanges;
    }

    /**
     * @inheritDoc
     */
    protected function cleanChanges(PackageInterface $package, string $path, bool $update): PromiseInterface
    {
        GitUtil::cleanEnv($this->process);
        $path = $this->normalizePath($path);

        $unpushed = $this->getUnpushedChanges($package, $path);
        if ($unpushed && ($this->io->isInteractive() || $this->config->get('discard-changes') !== true)) {
            throw new \RuntimeException('Source directory ' . $path . ' has unpushed changes on the current branch: '."\n".$unpushed);
        }

        if (null === ($changes = $this->getLocalChanges($package, $path))) {
            return \React\Promise\resolve(null);
        }

        if (!$this->io->isInteractive()) {
            $discardChanges = $this->config->get('discard-changes');
            if (true === $discardChanges) {
                return $this->discardChanges($path);
            }
            if ('stash' === $discardChanges) {
                if (!$update) {
                    return parent::cleanChanges($package, $path, $update);
                }

                return $this->stashChanges($path);
            }

View on GitHub (pinned to 6ffc117740)

Solutions

  1. Push the local commits to a remote so getUnpushedChanges() returns clean, then re-run the update.
  2. In non-interactive mode, add "config": {"discard-changes": true} to composer.json to force-discarding (destroys unpushed commits).
  3. In interactive mode, choose 's' to stash changes (reapplied after) or 'y' to discard.
  4. Move your changes out of vendor/ into a proper fork/patch before updating.

Example fix

// before: unpushed commits block update
composer update my/pkg

# push them first
cd vendor/my/pkg && git push origin my-branch && cd -
composer update my/pkg

# or opt-in to discard in composer.json:
{
  "config": { "discard-changes": true }
}
Defensive patterns

Strategy: validation

Validate before calling

// Before update, check for unpushed commits yourself and act deliberately
if (is_dir($path . '/.git')) {
    $proc->execute(['git', 'show-ref', '--head', '-d'], $refs, $path);
    $proc->execute(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $branch, $path);
    $branch = trim($branch);
    $proc->execute(['git', 'rev-parse', $branch], $local, $path);
    $proc->execute(['git', 'rev-parse', 'origin/' . $branch], $remote, $path);
    if (trim($local) !== trim($remote) && trim($remote) !== '') {
        // push or discard before invoking the downloader
    }
}

Prevention

When it happens

Trigger: A developer made commits inside a source-installed package (vendor/<pkg>) on a branch, then ran `composer update` on that package. Composer detects the commits aren't pushed anywhere and refuses to overwrite them.

Common situations: Developing patches inside vendored git packages, hotfixes committed directly to a --prefer-source dependency, or forgetting to push local work before updating.

Related errors


AI-assisted analysis of composer/composer@6ffc117740 (2026-08-07). Data as JSON: /api/errors/5c454f01f3b3d62a. Report an issue: GitHub.