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
Empty file removed .beads/dolt-monitor.pid.lock
Empty file.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ coverage
# Dolt database files (added by bd init)
.dolt/
*.db
.beads/dolt-monitor.pid.lock

# Turborepo
.turbo
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/commands/sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DubError } from '../lib/errors';

vi.mock('../lib/git.js', () => ({
branchExists: vi.fn(),
Expand Down Expand Up @@ -207,6 +208,50 @@ describe('sync', () => {
expect(mockRestack).toHaveBeenCalled();
});

it('treats trunk fast-forward conflicts as resettable when --force is set', async () => {
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{ name: 'feat/a', parent: 'main' },
]),
);
mockFastForwardBranchToRef.mockImplementation(
async (branch: string) => branch !== 'main',
);
mockGetRefSha.mockResolvedValue('same-sha');

await sync('/repo', { interactive: false, force: true, restack: false });

expect(mockHardResetBranchToRef).toHaveBeenCalledWith(
'main',
'origin/main',
'/repo',
);
});

it('fails trunk sync immediately on non-conflict fast-forward errors', async () => {
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{ name: 'feat/a', parent: 'main' },
]),
);
mockFastForwardBranchToRef.mockRejectedValue(
new DubError(
"Failed to checkout branch 'main'.\nYour local changes would be overwritten by checkout.",
),
);

await expect(
sync('/repo', { interactive: false, restack: false }),
).rejects.toThrow("Failed to checkout branch 'main'.");
expect(mockHardResetBranchToRef).not.toHaveBeenCalledWith(
'main',
'origin/main',
'/repo',
);
});

it('restores missing local branch from remote', async () => {
mockReadState.mockResolvedValue(
makeState([
Expand Down Expand Up @@ -254,6 +299,30 @@ describe('sync', () => {
expect(result.branches[0].status).toBe('needs-remote-sync-safe');
});

it('surfaces branch reset root-cause details when sync reset fails', async () => {
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{ name: 'feat/a', parent: 'main' },
]),
);
mockGetRefSha
.mockResolvedValueOnce('local-a')
.mockResolvedValueOnce('remote-a');
mockIsAncestor.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
mockHardResetBranchToRef.mockRejectedValue(
new DubError(
"Failed to hard reset 'feat/a' to 'origin/feat/a'.\nfatal: cannot lock ref",
),
);

await expect(
sync('/repo', { interactive: false, restack: false }),
).rejects.toThrow(
/Failed to hard reset 'feat\/a' to 'origin\/feat\/a'\.[\s\S]*cannot lock ref/,
);
});

it('skips diverged branch in non-interactive mode without force', async () => {
mockReadState.mockResolvedValue(
makeState([
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/lib/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { createTestRepo, gitInRepo } from '../../test/helpers';
import { DubError } from './errors';
import {
branchExists,
checkoutBranch,
commitStaged,
commitStagedFromFile,
createBranch,
deleteBranch,
fastForwardBranchToRef,
forceBranchTo,
getBranchTip,
getCurrentBranch,
getDiffBetween,
getMergeBase,
hardResetBranchToRef,
hasStagedChanges,
hasUniquePatchCommits,
isGitRepo,
Expand Down Expand Up @@ -101,6 +104,29 @@ describe('createBranch', () => {
});
});

describe('checkoutBranch', () => {
it('throws a detailed error when checkout is blocked by local changes', async () => {
fs.writeFileSync(path.join(dir, 'shared.txt'), 'main');
await gitInRepo(dir, ['add', 'shared.txt']);
await gitInRepo(dir, ['commit', '-m', 'add shared file']);

await gitInRepo(dir, ['checkout', '-b', 'feat/checkout-blocked']);
fs.writeFileSync(path.join(dir, 'shared.txt'), 'feat');
await gitInRepo(dir, ['add', 'shared.txt']);
await gitInRepo(dir, ['commit', '-m', 'feat change']);

await gitInRepo(dir, ['checkout', 'main']);
fs.writeFileSync(path.join(dir, 'shared.txt'), 'dirty');

await expect(checkoutBranch('feat/checkout-blocked', dir)).rejects.toThrow(
"Failed to checkout branch 'feat/checkout-blocked'.",
);
await expect(
checkoutBranch('feat/checkout-blocked', dir),
).rejects.not.toThrow("Branch 'feat/checkout-blocked' not found.");
});
});

describe('isWorkingTreeClean', () => {
it('returns true on a clean repo', async () => {
expect(await isWorkingTreeClean(dir)).toBe(true);
Expand Down Expand Up @@ -252,6 +278,38 @@ describe('forceBranchTo', () => {
});
});

describe('fastForwardBranchToRef', () => {
it('returns false only for true fast-forward conflicts', async () => {
await gitInRepo(dir, ['checkout', '-b', 'feat/ff-target']);
fs.writeFileSync(path.join(dir, 'ff.txt'), 'target');
await gitInRepo(dir, ['add', 'ff.txt']);
await gitInRepo(dir, ['commit', '-m', 'target commit']);

await gitInRepo(dir, ['checkout', 'main']);
fs.writeFileSync(path.join(dir, 'main.txt'), 'main');
await gitInRepo(dir, ['add', 'main.txt']);
await gitInRepo(dir, ['commit', '-m', 'main commit']);

await expect(
fastForwardBranchToRef('main', 'feat/ff-target', dir),
).resolves.toBe(false);
});

it('throws details for non-conflict failures', async () => {
await expect(
fastForwardBranchToRef('main', 'does-not-exist', dir),
).rejects.toThrow("Failed to fast-forward 'main' to 'does-not-exist'.");
});
});

describe('hardResetBranchToRef', () => {
it('includes root-cause details on reset failures', async () => {
await expect(
hardResetBranchToRef('main', 'does-not-exist', dir),
).rejects.toThrow("Failed to hard reset 'main' to 'does-not-exist'.");
});
});

describe('deleteBranch', () => {
it('removes a branch', async () => {
await createBranch('to-delete', dir);
Expand Down
85 changes: 78 additions & 7 deletions packages/cli/src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,21 @@ export async function createBranch(name: string, cwd: string): Promise<void> {

/**
* Switches to an existing branch.
* @throws {DubError} If the branch does not exist.
* @throws {DubError} If the branch does not exist, or if checkout fails for
* any other git error (for example, dirty working tree or ref lock failures).
*/
export async function checkoutBranch(name: string, cwd: string): Promise<void> {
try {
await execa('git', ['checkout', name], { cwd });
} catch {
throw new DubError(`Branch '${name}' not found.`);
} catch (error) {
const details = readGitCommandOutput(error);
if (isMissingBranchCheckoutError(details, name)) {
throw new DubError(`Branch '${name}' not found.`);
}

throw new DubError(
formatGitFailure(`Failed to checkout branch '${name}'.`, details),
);
}
}

Expand Down Expand Up @@ -726,8 +734,13 @@ export async function hardResetBranchToRef(
await checkoutBranch(branch, cwd);
}
await execa('git', ['reset', '--hard', ref], { cwd });
} catch {
throw new DubError(`Failed to hard reset '${branch}' to '${ref}'.`);
} catch (error) {
throw new DubError(
formatGitFailure(
`Failed to hard reset '${branch}' to '${ref}'.`,
readGitCommandOutput(error),
),
);
}
}

Expand All @@ -747,8 +760,17 @@ export async function fastForwardBranchToRef(
}
await execa('git', ['merge', '--ff-only', ref], { cwd });
return true;
} catch {
return false;
} catch (error) {
const details = readGitCommandOutput(error);
if (isFastForwardConflictError(details)) {
return false;
}
throw new DubError(
formatGitFailure(
`Failed to fast-forward '${branch}' to '${ref}'.`,
details,
),
);
}
}

Expand Down Expand Up @@ -777,3 +799,52 @@ export async function rebaseBranchOntoRef(
return false;
}
}

function readGitCommandOutput(error: unknown): string {
const stderr =
typeof (error as { stderr?: unknown })?.stderr === 'string'
? (error as { stderr: string }).stderr
: '';
const stdout =
typeof (error as { stdout?: unknown })?.stdout === 'string'
? (error as { stdout: string }).stdout
: '';
const shortMessage =
typeof (error as { shortMessage?: unknown })?.shortMessage === 'string'
? (error as { shortMessage: string }).shortMessage
: '';
const message =
error instanceof Error && typeof error.message === 'string'
? error.message
: '';

return [stderr, stdout, shortMessage, message]
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
}

function formatGitFailure(base: string, details: string): string {
const condensed = details.trim();
if (!condensed) return base;
return `${base}\n${condensed}`;
}

function isMissingBranchCheckoutError(output: string, name: string): boolean {
const normalized = output.toLowerCase();
if (
normalized.includes(`pathspec '${name.toLowerCase()}' did not match`) ||
normalized.includes(`invalid reference: ${name.toLowerCase()}`)
) {
return true;
}
return normalized.includes('did not match any file(s) known to git');
}

function isFastForwardConflictError(output: string): boolean {
const normalized = output.toLowerCase();
return (
normalized.includes('not possible to fast-forward') ||
normalized.includes('cannot fast-forward')
);
}
Loading
Loading