From a77d7a6546a8130667ed5aa32d15902b1d65f7c1 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Fri, 27 Feb 2026 21:06:02 -0800 Subject: [PATCH] fix(server,cli): improve git resource defaults and panic-safe errors --- apps/cli/src/commands/add.ts | 21 +++++++++++---- apps/cli/src/commands/ask.ts | 13 ++++++++- apps/server/src/errors.test.ts | 4 ++- apps/server/src/errors.ts | 14 +++++++++- apps/server/src/index.ts | 48 ++++++++++++++++++++++++++++++++-- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts index 196f0e4b..e992c3e5 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -286,7 +286,12 @@ async function addGitResourceWizard( const result = await Result.tryPromise(async () => { const finalUrl = await promptInput(rl, 'URL', normalizedUrl); const name = await promptInput(rl, 'Name', urlParts.repo); - const branch = await promptInput(rl, 'Branch', 'main'); + const branchInput = await promptInput( + rl, + 'Branch (optional, auto-detect default if empty)', + '' + ); + const branch = branchInput.trim(); const wantSearchPaths = await promptConfirm( rl, 'Do you want to add search paths (subdirectories to focus on)?' @@ -300,7 +305,7 @@ async function addGitResourceWizard( console.log(' Type: git'); console.log(` Name: ${name}`); console.log(` URL: ${finalUrl}`); - console.log(` Branch: ${branch}`); + console.log(` Branch: ${branch || '(auto-detect remote default branch)'}`); if (searchPaths.length > 0) console.log(` Search: ${searchPaths.join(', ')}`); if (notes) console.log(` Notes: ${notes}`); console.log(` Config: ${options.global ? 'global' : 'project'}`); @@ -325,7 +330,7 @@ async function addGitResourceWizard( type: 'git', name, url: finalUrl, - branch, + ...(branch ? { branch } : {}), ...(searchPaths.length === 1 && { searchPath: searchPaths[0] }), ...(searchPaths.length > 1 && { searchPaths }), ...(notes && { specialNotes: notes }) @@ -337,6 +342,9 @@ async function addGitResourceWizard( if (resource.type === 'git' && resource.url !== finalUrl) { console.log(` URL normalized: ${resource.url}`); } + if (resource.type === 'git' && !branch) { + console.log(` Auto-detected branch: ${resource.branch}`); + } console.log('\nYou can now use this resource:'); console.log(` btca ask -r ${name} -q "your question"`); }); @@ -498,7 +506,7 @@ export const addCommand = new Command('add') .argument('[reference]', 'Repository URL, local path, or npm package reference') .option('-g, --global', 'Add to global config instead of project config') .option('-n, --name ', 'Resource name') - .option('-b, --branch ', 'Git branch (default: main)') + .option('-b, --branch ', 'Git branch (auto-detected when omitted)') .option('-s, --search-path ', 'Search paths within repo (can specify multiple)') .option('--notes ', 'Special notes for the agent') .option('-t, --type ', 'Resource type: git, local, or npm (auto-detected if not specified)') @@ -585,7 +593,7 @@ export const addCommand = new Command('add') type: 'git', name: options.name, url: normalizedUrl, - branch: options.branch ?? 'main', + ...(options.branch ? { branch: options.branch } : {}), ...(searchPaths.length === 1 && { searchPath: searchPaths[0] }), ...(searchPaths.length > 1 && { searchPaths }), ...(options.notes && { specialNotes: options.notes }) @@ -597,6 +605,9 @@ export const addCommand = new Command('add') if (resource.type === 'git' && resource.url !== normalizedUrl) { console.log(` URL normalized: ${resource.url}`); } + if (resource.type === 'git' && !options.branch) { + console.log(` Auto-detected branch: ${resource.branch}`); + } return; } diff --git a/apps/cli/src/commands/ask.ts b/apps/cli/src/commands/ask.ts index c4940b49..5f06d38f 100644 --- a/apps/cli/src/commands/ask.ts +++ b/apps/cli/src/commands/ask.ts @@ -39,6 +39,17 @@ function getErrorDisplayDetails(error: unknown): { message: string; hint?: strin const stripped = message.slice('Unhandled exception:'.length).trim(); return stripped.length > 0 ? stripped : message; }; + const fallbackWrapperMessage = (message: string) => { + if ( + message === 'match err handler threw' || + message === 'match ok handler threw' || + message.endsWith('handler threw') || + message.endsWith('callback threw') + ) { + return 'Internal error while processing a result. Check server logs for details.'; + } + return normalizeMessage(message); + }; const chain: unknown[] = []; const visited = new Set(); @@ -69,7 +80,7 @@ function getErrorDisplayDetails(error: unknown): { message: string; hint?: strin for (const entry of chain) { const entryMessage = readStringField(entry, 'message'); if (entryMessage) { - message = normalizeMessage(entryMessage); + message = fallbackWrapperMessage(entryMessage); break; } } diff --git a/apps/server/src/errors.test.ts b/apps/server/src/errors.test.ts index 0a48f68b..54ad02a8 100644 --- a/apps/server/src/errors.test.ts +++ b/apps/server/src/errors.test.ts @@ -59,7 +59,9 @@ describe('errors', () => { cyclic.cause = cyclic; expect(getErrorTag(cyclic)).toBe('Panic'); - expect(getErrorMessage(cyclic)).toBe('match err handler threw'); + expect(getErrorMessage(cyclic)).toBe( + 'Internal error while processing a result. Check logs for details.' + ); expect(getErrorHint(cyclic)).toBeUndefined(); }); }); diff --git a/apps/server/src/errors.ts b/apps/server/src/errors.ts index 9b46fd58..fd8a480f 100644 --- a/apps/server/src/errors.ts +++ b/apps/server/src/errors.ts @@ -41,6 +41,18 @@ const normalizeMessage = (message: string) => { return stripped.length > 0 ? stripped : message; }; +const fallbackWrapperMessage = (message: string) => { + if ( + message === 'match err handler threw' || + message === 'match ok handler threw' || + /handler threw$/u.test(message) || + /callback threw$/u.test(message) + ) { + return 'Internal error while processing a result. Check logs for details.'; + } + return normalizeMessage(message); +}; + const isWrapperEntry = (entry: unknown) => { const tag = readStringField(entry, '_tag'); const message = readStringField(entry, 'message'); @@ -101,7 +113,7 @@ export const getErrorMessage = (error: unknown): string => { for (const entry of chain) { const message = readStringField(entry, 'message'); - if (message) return normalizeMessage(message); + if (message) return fallbackWrapperMessage(message); } return String(error); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8ef2827c..6e1a639e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -90,6 +90,48 @@ const normalizeQuestionResourceReference = (reference: string): string => { return reference; }; +const withGitAuthArgs = (args: string[]) => { + const token = process.env.BTCA_GIT_TOKEN?.trim(); + if (!token) return args; + return [ + '-c', + 'credential.helper=!f() { test "$1" = get && echo "username=x-access-token" && echo "password=$BTCA_GIT_TOKEN"; }; f', + ...args + ]; +}; + +const detectDefaultBranchForRepository = async (repoUrl: string): Promise => { + const proc = Bun.spawn(['git', ...withGitAuthArgs(['ls-remote', '--symref', repoUrl, 'HEAD'])], { + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0' + } + }); + + const [stdoutText, stderrText, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited + ]); + if (exitCode !== 0) { + Metrics.info('resource.git.default_branch.detect_failed', { + url: repoUrl, + error: stderrText.trim().slice(0, 300) + }); + return undefined; + } + + const line = stdoutText + .split('\n') + .find((entry) => entry.trim().startsWith('ref:') && entry.includes('\tHEAD')); + if (!line) return undefined; + + const match = line.match(/^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/); + return match?.[1]; +}; + const QuestionRequestSchema = z.object({ question: z .string() @@ -135,7 +177,7 @@ const AddGitResourceRequestSchema = z.object({ type: z.literal('git'), name: GitResourceSchema.shape.name, url: GitResourceSchema.shape.url, - branch: GitResourceSchema.shape.branch.optional().default('main'), + branch: GitResourceSchema.shape.branch.optional(), searchPath: GitResourceSchema.shape.searchPath, searchPaths: GitResourceSchema.shape.searchPaths, specialNotes: GitResourceSchema.shape.specialNotes @@ -448,11 +490,13 @@ const createApp = (deps: { if (decoded.type === 'git') { // Normalize GitHub URLs (e.g., /blob/main/file.txt → base repo URL) const normalizedUrl = normalizeGitHubUrl(decoded.url); + const branch = + decoded.branch ?? (await detectDefaultBranchForRepository(normalizedUrl)) ?? 'main'; const resource = { type: 'git' as const, name: decoded.name, url: normalizedUrl, - branch: decoded.branch ?? 'main', + branch, ...(decoded.searchPath && { searchPath: decoded.searchPath }), ...(decoded.searchPaths && { searchPaths: decoded.searchPaths }), ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })