diff --git a/.gitignore b/.gitignore index 5292c329..38a3d8d8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ Thumbs.db data/ workspaces/ *.pid +.worktrees/ diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 5e2571e5..b688d716 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -662,7 +662,8 @@ export async function getGitLog( }) ); if (!response.ok) { - throw new Error(`Failed to get git log: ${response.statusText}`); + const errorBody = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`); } return response.json(); } diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 15f55444..4efa1d10 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2515,6 +2515,34 @@ export interface GitLogEntry { deletions: number; } +/** + * Resolve a log base ref using local-first semantics (mirrors web service.js). + * + * - Returns undefined when `from` is falsy/whitespace. + * - Returns `from` unchanged when the local ref resolves. + * - Returns `origin/` when local is absent but the remote-tracking ref exists. + * - Returns `from` unchanged when neither resolves (lets git surface the error). + */ +async function resolveBaseRefForLog( + from: string | undefined, + directory: string +): Promise { + const normalized = typeof from === 'string' ? from.trim() : undefined; + if (!normalized) return undefined; + + const checkRef = async (ref: string): Promise => { + const result = await execGit(['rev-parse', '--verify', ref], directory); + return result.exitCode === 0 && Boolean(result.stdout.trim()); + }; + + if (await checkRef(normalized)) return normalized; + + const originRef = `refs/remotes/origin/${normalized}`; + if (await checkRef(originRef)) return `origin/${normalized}`; + + return normalized; +} + /** * Get git log */ @@ -2523,6 +2551,11 @@ export async function getGitLog( options?: { maxCount?: number; from?: string; to?: string; file?: string } ): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> { const maxCount = options?.maxCount || 50; + + // Prefer the local ref; fall back to origin/ only when the local ref + // cannot be resolved (e.g. user has never checked out the base branch). + const resolvedFrom = await resolveBaseRefForLog(options?.from, directory); + const args = [ 'log', `--max-count=${maxCount}`, @@ -2530,8 +2563,12 @@ export async function getGitLog( '--shortstat', ]; - if (options?.from && options?.to) { - args.push(`${options.from}..${options.to}`); + if (resolvedFrom && options?.to) { + args.push(`${resolvedFrom}..${options.to}`); + } else if (resolvedFrom) { + args.push(`${resolvedFrom}..HEAD`); + } else if (options?.to) { + args.push(options.to); } if (options?.file) { @@ -2541,7 +2578,7 @@ export async function getGitLog( const result = await execGit(args, directory); if (result.exitCode !== 0) { - return { all: [], latest: null, total: 0 }; + throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log'); } const entries: GitLogEntry[] = []; diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 5583f4a7..686d7240 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2683,14 +2683,52 @@ export async function deleteBranch(directory, branch, options = {}) { } } +/** + * Resolve a log base ref using local-first semantics. + * + * - If `from` is falsy / whitespace → return undefined. + * - If the local ref resolves → return it unchanged (caller's intent preserved). + * - If the local ref is absent but `origin/` exists → return `origin/` + * (common when the user has never checked out the base branch locally). + * - If neither resolves → return `from` unchanged so git surfaces a meaningful error. + * + * @param {string | undefined} from - The raw `from` option value. + * @param {(ref: string) => Promise} checkRef - Returns true when the ref resolves. + * @returns {Promise} + */ +export async function resolveBaseRefForLog(from, checkRef) { + const normalized = typeof from === 'string' ? from.trim() : undefined; + if (!normalized) return undefined; + + if (await checkRef(normalized)) return normalized; + + const originRef = `refs/remotes/origin/${normalized}`; + if (await checkRef(originRef)) return `origin/${normalized}`; + + return normalized; +} + export async function getLog(directory, options = {}) { const git = await createGit(directory); try { const maxCount = options.maxCount || 50; + + // Prefer the local ref; fall back to origin/ only when the local ref + // cannot be resolved (e.g. user has never checked out the base branch). + const checkRef = async (ref) => { + try { + const out = await git.raw(['rev-parse', '--verify', ref]); + return Boolean(out && out.trim()); + } catch { + return false; + } + }; + const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef); + const baseLog = await git.log({ maxCount, - from: options.from, + from: resolvedFrom, to: options.to, file: options.file }); @@ -2703,10 +2741,10 @@ export async function getLog(directory, options = {}) { '--shortstat' ]; - if (options.from && options.to) { - logArgs.push(`${options.from}..${options.to}`); - } else if (options.from) { - logArgs.push(`${options.from}..HEAD`); + if (resolvedFrom && options.to) { + logArgs.push(`${resolvedFrom}..${options.to}`); + } else if (resolvedFrom) { + logArgs.push(`${resolvedFrom}..HEAD`); } else if (options.to) { logArgs.push(options.to); } diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js new file mode 100644 index 00000000..44c87348 --- /dev/null +++ b/packages/web/server/lib/git/service.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveBaseRefForLog } from './service.js'; + +describe('resolveBaseRefForLog', () => { + it('returns the local ref unchanged when it exists, even if origin also exists', async () => { + // Both local 'main' and 'refs/remotes/origin/main' are present. + // The local ref takes precedence — callers that ask for 'main' get 'main'. + const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main'; + expect(await resolveBaseRefForLog('main', checkRef)).toBe('main'); + }); + + it('falls back to origin/ when local ref cannot be resolved but origin can', async () => { + // Local 'main' is absent (e.g. user never checked it out), but origin/main exists. + const checkRef = async (ref) => ref === 'refs/remotes/origin/main'; + expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main'); + }); + + it('returns the original ref when neither local nor origin ref can be resolved', async () => { + // Neither ref exists; return as-is so git surfaces a meaningful error. + const checkRef = async () => false; + expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch'); + }); + + it('returns undefined when from is undefined', async () => { + const checkRef = async () => true; + expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined(); + }); + + it('returns undefined when from is an empty string', async () => { + const checkRef = async () => true; + expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined(); + }); + + it('returns undefined when from is a whitespace-only string', async () => { + const checkRef = async () => true; + expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined(); + }); +});