Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/main/git/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
25 changes: 24 additions & 1 deletion src/main/git/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -60,12 +60,35 @@ export async function pull(
): Promise<void> {
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<string[]> {
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 } = {},
Expand Down
Loading