From 399fcf03de2d69f6555c5937119e13932a881853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 18:09:22 +0200 Subject: [PATCH] Fix bare pull aborting on a divergent branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar surfaces Pull the instant a branch is behind, so an ahead+behind (diverged) branch runs a plain `git pull`. Since git 2.27 that aborts with "fatal: Need to specify how to reconcile divergent branches" unless a strategy is pinned, stranding the user: Pull keeps failing and Push is blocked for being behind. Default a non-rebase pull to `--ff` (fast-forward when possible, merge when diverged) unless the user has set pull.ff themselves — enough to satisfy git's reconciliation check while respecting an explicit config. Adds an integration test covering both the divergent merge and the pull.ff=only opt-out. --- src/main/git/sync.test.ts | 113 ++++++++++++++++++++++++++++++++++++++ src/main/git/sync.ts | 25 ++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/main/git/sync.test.ts diff --git a/src/main/git/sync.test.ts b/src/main/git/sync.test.ts new file mode 100644 index 0000000..307498d --- /dev/null +++ b/src/main/git/sync.test.ts @@ -0,0 +1,113 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pull } from './sync' + +// Hermetic git: point global + system config at an empty file so the host's +// config (notably a machine-wide pull.rebase / pull.ff) can't mask the very +// case under test — a bare pull with no reconciliation strategy configured. +let configHome: string + +beforeAll(() => { + configHome = mkdtempSync(join(tmpdir(), 'gitgrove-sync-config-')) + const emptyConfig = join(configHome, 'gitconfig') + writeFileSync(emptyConfig, '') + process.env.GIT_CONFIG_GLOBAL = emptyConfig + process.env.GIT_CONFIG_SYSTEM = emptyConfig +}) + +afterAll(() => { + rmSync(configHome, { recursive: true, force: true }) + delete process.env.GIT_CONFIG_GLOBAL + delete process.env.GIT_CONFIG_SYSTEM +}) + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function tmp(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + dirs.push(dir) + return dir +} + +function configureIdentity(dir: string): void { + git(dir, 'config', 'user.name', 'Test User') + git(dir, 'config', 'user.email', 'test@example.com') + git(dir, 'config', 'commit.gpgsign', 'false') +} + +function put(dir: string, name: string, content: string): void { + writeFileSync(join(dir, name), content) +} + +function commit(dir: string, name: string, content: string, message: string): void { + put(dir, name, content) + git(dir, 'add', '-A') + git(dir, 'commit', '-q', '-m', message) +} + +/** + * A clone whose local `main` has diverged from its upstream: one commit only + * the remote has, one commit only the clone has. This is the exact state that + * makes a bare `git pull` (no reconciliation strategy) abort since git 2.27. + */ +function divergedClone(): string { + const bare = tmp('gitgrove-sync-remote-') + git(bare, 'init', '-q', '--bare', '-b', 'main') + + // Seed the remote through a throwaway working clone. + const seed = tmp('gitgrove-sync-seed-') + git(seed, 'clone', '-q', bare, seed) + configureIdentity(seed) + commit(seed, 'README.md', 'seed\n', 'initial') + git(seed, 'push', '-q', 'origin', 'main') + + // The clone under test, forked from the seed commit. + const clone = tmp('gitgrove-sync-clone-') + git(clone, 'clone', '-q', bare, clone) + configureIdentity(clone) + + // Advance the remote by one commit (the clone is now "behind"). + commit(seed, 'remote.txt', 'from remote\n', 'remote change') + git(seed, 'push', '-q', 'origin', 'main') + + // Advance the clone by a different commit (now "ahead" as well → diverged). + commit(clone, 'local.txt', 'from local\n', 'local change') + + return clone +} + +describe('pull', () => { + test('reconciles a divergent branch with a merge instead of aborting', async () => { + const clone = divergedClone() + + // The regression guard: a bare pull here used to fail with + // "fatal: Need to specify how to reconcile divergent branches". + await pull(clone) + + // Both histories survive: a merge brought the remote commit in while the + // local commit stayed, and the merge itself is now HEAD. + const log = git(clone, 'log', '--pretty=%s') + expect(log).toContain('remote change') + expect(log).toContain('local change') + expect(git(clone, 'rev-list', '--count', '--merges', 'HEAD~1..HEAD').trim()).toBe('1') + }) + + test('respects an explicit pull.ff=only, leaving the divergence for the user', async () => { + const clone = divergedClone() + git(clone, 'config', 'pull.ff', 'only') + + // With their own strategy pinned we add nothing, so git's own ff-only rule + // applies and refuses the non-fast-forward — their choice, not our default. + await expect(pull(clone)).rejects.toThrow() + }) +}) diff --git a/src/main/git/sync.ts b/src/main/git/sync.ts index 55a3dce..6e19fa9 100644 --- a/src/main/git/sync.ts +++ b/src/main/git/sync.ts @@ -13,7 +13,7 @@ import { spawn } from 'node:child_process' import { askpassEnv } from './askpass' import { friendlyAuthError } from './askpass-prompt' import { locateGit, locateGitLfs } from './bin' -import { type ProgressHandler, parseProgressText, run, runOnce } from './exec' +import { type ProgressHandler, parseProgressText, run, runOnce, runRead } from './exec' import { getLfsHealth } from './lfs' import { openLfsProgressChannel } from './lfs-progress' @@ -60,12 +60,35 @@ export async function pull( ): Promise { const args = ['-c', 'core.editor=true', 'pull', '--progress'] if (opts.rebase) args.push('--rebase') + else args.push(...(await divergentPullArgs(repoPath))) const env = await askpassEnv() await withLfsProgress(onProgress, (lfsEnv) => run(repoPath, args, { onProgress, env: { ...env, ...lfsEnv } }) ).catch(rethrowFriendly(env)) } +/** + * Extra args that tell a plain (non-rebase) pull how to reconcile a *divergent* + * branch. Since git 2.27 a bare `git pull` that can't fast-forward aborts with + * "fatal: Need to specify how to reconcile divergent branches" unless the + * strategy is pinned — via pull.rebase, pull.ff, or a flag. Our toolbar surfaces + * Pull the instant the branch is behind, so an ahead+behind (diverged) branch + * hits that fatal and strands the user: Pull keeps failing and Push is blocked + * for being behind, with no obvious way out. + * + * So when the user hasn't stated a preference (pull.ff unset) we default to + * `--ff`: fast-forward when possible, merge when diverged — the intuitive + * behaviour, and enough to satisfy git's reconciliation check. If pull.ff is + * set we pass nothing and let their config win; pull.rebase, if set, git honours + * over `--ff` regardless (rebase path taken). + */ +async function divergentPullArgs(repoPath: string): Promise { + const pullFf = await runRead(repoPath, ['config', '--get', 'pull.ff'], { + tolerateExitCodes: [1] + }).catch(() => '') + return pullFf.trim() ? [] : ['--ff'] +} + export async function push( repoPath: string, opts: { setUpstream?: { remote: string; branch: string }; forceWithLease?: boolean } = {},