diff --git a/.beads/dolt-monitor.pid.lock b/.beads/dolt-monitor.pid.lock deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index 6b1c45e..672c9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ coverage # Dolt database files (added by bd init) .dolt/ *.db +.beads/dolt-monitor.pid.lock # Turborepo .turbo diff --git a/packages/cli/src/commands/sync.test.ts b/packages/cli/src/commands/sync.test.ts index 97f87c4..47d6242 100644 --- a/packages/cli/src/commands/sync.test.ts +++ b/packages/cli/src/commands/sync.test.ts @@ -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(), @@ -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([ @@ -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([ diff --git a/packages/cli/src/lib/git.test.ts b/packages/cli/src/lib/git.test.ts index f718460..5d2f7a3 100644 --- a/packages/cli/src/lib/git.test.ts +++ b/packages/cli/src/lib/git.test.ts @@ -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, @@ -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); @@ -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); diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index 8c521fc..6ecce11 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -114,13 +114,21 @@ export async function createBranch(name: string, cwd: string): Promise { /** * 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 { 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), + ); } } @@ -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), + ), + ); } } @@ -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, + ), + ); } } @@ -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') + ); +} diff --git a/packages/cli/src/lib/support-bundle.test.ts b/packages/cli/src/lib/support-bundle.test.ts new file mode 100644 index 0000000..d0946a6 --- /dev/null +++ b/packages/cli/src/lib/support-bundle.test.ts @@ -0,0 +1,275 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execa } from 'execa'; +import { describe, expect, it } from 'vitest'; +import { createTestRepo, gitInRepo } from '../../test/helpers'; +import { + collectSupportBundle, + formatSupportBundleSummaryMarkdown, + type SupportBundle, + type SupportBundleCollectorOverrides, +} from './support-bundle'; + +function makeBundle(overrides: Partial = {}): SupportBundle { + return { + schemaVersion: '1', + generatedAt: '2026-03-11T00:00:00.000Z', + cwd: '/repo', + collection: { + partial: false, + errors: [], + }, + sources: { + repo: { + gitRoot: '/repo', + currentBranch: 'feat/a', + remotes: ['origin\tgit@github.com:acme/repo.git (fetch)'], + }, + stack: { + tracked: true, + trunk: 'main', + currentBranch: 'feat/a', + parentBranch: 'main', + children: ['feat/b'], + pathToCurrent: ['main', 'feat/a'], + branchCount: 2, + }, + doctor: { + healthy: true, + checkedBranch: 'feat/a', + issues: [], + }, + git: { + statusShort: ['## feat/a...origin/feat/a'], + recentCommits: ['abcd123 feat: add support bundle'], + }, + history: { + recentEntries: [ + { + timestamp: '2026-03-11T00:00:00.000Z', + command: 'dub doctor', + status: 'success', + durationMs: 120, + }, + ], + }, + tooling: { + nodeVersion: 'v22.14.0', + platform: 'darwin', + arch: 'arm64', + gitVersion: 'git version 2.49.0', + ghVersion: 'gh version 2.74.0', + }, + }, + ...overrides, + }; +} + +describe('support-bundle', () => { + it('collects bundle data using source-specific collectors', async () => { + const collectors: SupportBundleCollectorOverrides = { + now: () => '2026-03-11T00:00:00.000Z', + collectRepo: async () => ({ + gitRoot: '/repo', + currentBranch: 'feat/a', + remotes: [], + }), + collectStack: async () => ({ + tracked: false, + trunk: null, + currentBranch: 'feat/a', + parentBranch: null, + children: [], + pathToCurrent: [], + branchCount: 0, + }), + collectDoctor: async () => ({ + healthy: true, + checkedBranch: 'feat/a', + issues: [], + }), + collectGit: async () => ({ + statusShort: [], + recentCommits: [], + }), + collectHistory: async () => ({ + recentEntries: [], + }), + collectTooling: async () => ({ + nodeVersion: 'v22.14.0', + platform: 'darwin', + arch: 'arm64', + gitVersion: 'git version 2.49.0', + ghVersion: null, + }), + }; + + const bundle = await collectSupportBundle('/repo', { collectors }); + + expect(bundle.schemaVersion).toBe('1'); + expect(bundle.generatedAt).toBe('2026-03-11T00:00:00.000Z'); + expect(bundle.cwd).toBe('/repo'); + expect(bundle.collection.partial).toBe(false); + expect(bundle.collection.errors).toEqual([]); + expect(bundle.sources.stack?.tracked).toBe(false); + expect(bundle.sources.tooling?.ghVersion).toBeNull(); + }); + + it('keeps collecting remaining sections when one source fails', async () => { + const bundle = await collectSupportBundle('/repo', { + collectors: { + collectRepo: async () => { + throw new Error('no git repo'); + }, + collectStack: async () => ({ + tracked: false, + trunk: null, + currentBranch: null, + parentBranch: null, + children: [], + pathToCurrent: [], + branchCount: 0, + }), + collectDoctor: async () => ({ + healthy: false, + checkedBranch: 'feat/a', + issues: [{ code: 'x', summary: 'y', fixes: ['dub doctor'] }], + }), + collectGit: async () => ({ statusShort: [], recentCommits: [] }), + collectHistory: async () => ({ recentEntries: [] }), + collectTooling: async () => ({ + nodeVersion: 'v22.14.0', + platform: 'darwin', + arch: 'arm64', + gitVersion: null, + ghVersion: null, + }), + }, + }); + + expect(bundle.collection.partial).toBe(true); + expect(bundle.collection.errors).toEqual([ + { source: 'repo', message: 'no git repo' }, + ]); + expect(bundle.sources.repo).toBeNull(); + expect(bundle.sources.doctor?.issues).toHaveLength(1); + }); + + it('formats markdown summary for operators', () => { + const markdown = formatSupportBundleSummaryMarkdown( + makeBundle({ + collection: { + partial: true, + errors: [{ source: 'repo', message: 'no git repo' }], + }, + }), + ); + + expect(markdown).toContain('# DubStack Support Report'); + expect(markdown).toContain('Collection status: partial'); + expect(markdown).toContain('- repo: unavailable'); + expect(markdown).toContain('- doctor: healthy'); + expect(markdown).toContain('Recent Dub commands'); + }); +}); + +describe('support-bundle default collectors', () => { + it('redacts credentials from git remotes in repo context', async () => { + const repo = await createTestRepo(); + + try { + await gitInRepo(repo.dir, [ + 'remote', + 'add', + 'origin', + 'https://x-access-token:ghp_supersecret@github.com/acme/repo.git', + ]); + + const bundle = await collectSupportBundle(repo.dir); + const remotes = bundle.sources.repo?.remotes ?? []; + + expect(remotes.length).toBeGreaterThan(0); + expect(remotes.join('\n')).toContain('[REDACTED]@'); + expect(remotes.join('\n')).not.toContain('ghp_supersecret'); + } finally { + await repo.cleanup(); + } + }); + + it('marks stack source as failed when stack state contains a cycle', async () => { + const repo = await createTestRepo(); + + try { + await gitInRepo(repo.dir, ['checkout', '-b', 'feat/a']); + await gitInRepo(repo.dir, ['checkout', '-b', 'feat/b']); + await gitInRepo(repo.dir, ['checkout', 'feat/a']); + + const stateDir = path.join(repo.dir, '.git', 'dubstack'); + await fs.promises.mkdir(stateDir, { recursive: true }); + await fs.promises.writeFile( + path.join(stateDir, 'state.json'), + JSON.stringify( + { + stacks: [ + { + id: 'stack-1', + branches: [ + { + name: 'main', + type: 'root', + parent: null, + pr_number: null, + pr_link: null, + }, + { + name: 'feat/a', + parent: 'feat/b', + pr_number: null, + pr_link: null, + }, + { + name: 'feat/b', + parent: 'feat/a', + pr_number: null, + pr_link: null, + }, + ], + }, + ], + }, + null, + 2, + ), + ); + + const bundle = await collectSupportBundle(repo.dir); + const stackError = bundle.collection.errors.find( + (error) => error.source === 'stack', + ); + + expect(bundle.sources.stack).toBeNull(); + expect(stackError?.message.toLowerCase()).toContain('cycle'); + } finally { + await repo.cleanup(); + } + }); + + it('still collects git status when recent commit log cannot be read', async () => { + const dir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'dubstack-support-test-'), + ); + + try { + await execa('git', ['init', '-b', 'main'], { cwd: dir }); + + const bundle = await collectSupportBundle(dir); + + expect(bundle.sources.git).not.toBeNull(); + expect(bundle.sources.git?.recentCommits).toEqual([]); + expect(bundle.sources.git?.statusShort.length ?? 0).toBeGreaterThan(0); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/lib/support-bundle.ts b/packages/cli/src/lib/support-bundle.ts new file mode 100644 index 0000000..d92b46d --- /dev/null +++ b/packages/cli/src/lib/support-bundle.ts @@ -0,0 +1,508 @@ +import process from 'node:process'; +import { execa } from 'execa'; +import { doctor } from '../commands/doctor'; +import { DubError } from './errors'; +import { getCurrentBranch, getRepoRoot } from './git'; +import { readHistory } from './history'; +import { type DubState, findStackForBranch, readState } from './state'; + +export type SupportBundleSourceName = + | 'repo' + | 'stack' + | 'doctor' + | 'git' + | 'history' + | 'tooling'; + +export interface SupportBundleCollectionError { + source: SupportBundleSourceName; + message: string; +} + +export interface SupportRepoContext { + gitRoot: string; + currentBranch: string | null; + remotes: string[]; +} + +export interface SupportStackContext { + tracked: boolean; + trunk: string | null; + currentBranch: string | null; + parentBranch: string | null; + children: string[]; + pathToCurrent: string[]; + branchCount: number; +} + +export interface SupportDoctorIssue { + code: string; + summary: string; + fixes: string[]; +} + +export interface SupportDoctorContext { + healthy: boolean; + checkedBranch: string; + issues: SupportDoctorIssue[]; +} + +export interface SupportGitContext { + statusShort: string[]; + recentCommits: string[]; +} + +export interface SupportHistoryEntry { + timestamp: string; + command: string; + status: 'success' | 'error'; + durationMs: number; +} + +export interface SupportHistoryContext { + recentEntries: SupportHistoryEntry[]; +} + +export interface SupportToolingContext { + nodeVersion: string; + platform: string; + arch: string; + gitVersion: string | null; + ghVersion: string | null; +} + +export interface SupportBundle { + schemaVersion: '1'; + generatedAt: string; + cwd: string; + collection: { + partial: boolean; + errors: SupportBundleCollectionError[]; + }; + sources: { + repo: SupportRepoContext | null; + stack: SupportStackContext | null; + doctor: SupportDoctorContext | null; + git: SupportGitContext | null; + history: SupportHistoryContext | null; + tooling: SupportToolingContext | null; + }; +} + +export interface SupportBundleCollectorOverrides { + now?: () => string; + collectRepo?: (cwd: string) => Promise; + collectStack?: (cwd: string) => Promise; + collectDoctor?: (cwd: string) => Promise; + collectGit?: (cwd: string) => Promise; + collectHistory?: (cwd: string) => Promise; + collectTooling?: (cwd: string) => Promise; +} + +export interface CollectSupportBundleOptions { + historyLimit?: number; + collectors?: SupportBundleCollectorOverrides; +} + +export async function collectSupportBundle( + cwd: string, + options: CollectSupportBundleOptions = {}, +): Promise { + const collectors = options.collectors ?? {}; + const errors: SupportBundleCollectionError[] = []; + const now = collectors.now ?? (() => new Date().toISOString()); + + const repo = await collectSource( + 'repo', + errors, + collectors.collectRepo ?? defaultCollectRepo, + cwd, + ); + const stack = await collectSource( + 'stack', + errors, + collectors.collectStack ?? defaultCollectStack, + cwd, + ); + const doctorContext = await collectSource( + 'doctor', + errors, + collectors.collectDoctor ?? defaultCollectDoctor, + cwd, + ); + const git = await collectSource( + 'git', + errors, + collectors.collectGit ?? defaultCollectGit, + cwd, + ); + const history = await collectSource( + 'history', + errors, + collectors.collectHistory ?? + ((collectorCwd: string) => + defaultCollectHistory(collectorCwd, options.historyLimit ?? 20)), + cwd, + ); + const tooling = await collectSource( + 'tooling', + errors, + collectors.collectTooling ?? defaultCollectTooling, + cwd, + ); + + return { + schemaVersion: '1', + generatedAt: now(), + cwd, + collection: { + partial: errors.length > 0, + errors, + }, + sources: { + repo, + stack, + doctor: doctorContext, + git, + history, + tooling, + }, + }; +} + +export function formatSupportBundleSummaryMarkdown( + bundle: SupportBundle, +): string { + const status = bundle.collection.partial ? 'partial' : 'complete'; + const hasSourceError = (source: SupportBundleSourceName): boolean => + bundle.collection.errors.some((error) => error.source === source); + const sectionLines = [ + `- repo: ${ + bundle.sources.repo && !hasSourceError('repo') + ? 'available' + : 'unavailable' + }`, + `- stack: ${describeStackStatus(bundle.sources.stack, hasSourceError('stack'))}`, + `- doctor: ${describeDoctorStatus( + bundle.sources.doctor, + hasSourceError('doctor'), + )}`, + `- git: ${ + bundle.sources.git && !hasSourceError('git') ? 'available' : 'unavailable' + }`, + `- history: ${ + bundle.sources.history && !hasSourceError('history') + ? 'available' + : 'unavailable' + }`, + `- tooling: ${ + bundle.sources.tooling && !hasSourceError('tooling') + ? 'available' + : 'unavailable' + }`, + ]; + + const historyLines = + bundle.sources.history?.recentEntries.length === 0 || + !bundle.sources.history + ? ['- none'] + : bundle.sources.history.recentEntries + .slice(0, 6) + .map( + (entry) => + `- ${entry.timestamp} ${entry.status === 'success' ? 'OK' : 'ERR'} ${entry.command}`, + ); + + const errorLines = + bundle.collection.errors.length === 0 + ? ['- none'] + : bundle.collection.errors.map( + (error) => `- ${error.source}: ${error.message}`, + ); + + return [ + '# DubStack Support Report', + '', + `Generated at: ${bundle.generatedAt}`, + `Collection status: ${status}`, + '', + '## Included Sources', + ...sectionLines, + '', + '## Collection Errors', + ...errorLines, + '', + '## Recent Dub commands', + ...historyLines, + ].join('\n'); +} + +async function collectSource( + source: SupportBundleSourceName, + errors: SupportBundleCollectionError[], + collector: (cwd: string) => Promise, + cwd: string, +): Promise { + try { + return await collector(cwd); + } catch (error) { + errors.push({ + source, + message: stringifyError(error), + }); + return null; + } +} + +function describeStackStatus( + stack: SupportStackContext | null, + hasError: boolean, +): string { + if (hasError) return 'unavailable'; + if (!stack) return 'unavailable'; + return stack.tracked ? 'tracked' : 'untracked'; +} + +function describeDoctorStatus( + doctorContext: SupportDoctorContext | null, + hasError: boolean, +): string { + if (hasError) return 'unavailable'; + if (!doctorContext) return 'unavailable'; + return doctorContext.healthy ? 'healthy' : 'issues'; +} + +async function defaultCollectRepo(cwd: string): Promise { + const gitRoot = await getRepoRoot(cwd); + const currentBranch = await getCurrentBranch(cwd).catch(() => null); + const remotes = await readGitRemotes(cwd); + + return { + gitRoot, + currentBranch, + remotes, + }; +} + +async function defaultCollectStack(cwd: string): Promise { + const currentBranch = await getCurrentBranch(cwd).catch(() => null); + if (!currentBranch) { + return createUntrackedStackContext(null); + } + + let state: DubState; + try { + state = await readState(cwd); + } catch (error: unknown) { + if (isStateNotInitializedError(error)) { + return createUntrackedStackContext(currentBranch); + } + throw error; + } + + const stack = findStackForBranch(state, currentBranch); + if (!stack) { + return createUntrackedStackContext(currentBranch); + } + + const current = stack.branches.find( + (branch) => branch.name === currentBranch, + ); + const root = stack.branches.find((branch) => branch.type === 'root'); + + const children = stack.branches + .filter((branch) => branch.parent === currentBranch) + .map((branch) => branch.name) + .sort(); + + const pathToCurrent: string[] = []; + const visited = new Set(); + let cursor = current; + let remainingSteps = stack.branches.length + 1; + while (cursor) { + if (visited.has(cursor.name)) { + throw new DubError( + `Detected a cycle in tracked stack state while resolving path for '${currentBranch}'.`, + ); + } + if (remainingSteps <= 0) { + throw new DubError( + `Exceeded path traversal limit while resolving stack path for '${currentBranch}'.`, + ); + } + visited.add(cursor.name); + remainingSteps -= 1; + pathToCurrent.unshift(cursor.name); + if (!cursor.parent) break; + cursor = stack.branches.find((branch) => branch.name === cursor?.parent); + } + + return { + tracked: true, + trunk: root?.name ?? null, + currentBranch, + parentBranch: current?.parent ?? null, + children, + pathToCurrent, + branchCount: stack.branches.length, + }; +} + +async function defaultCollectDoctor( + cwd: string, +): Promise { + const result = await doctor(cwd, { all: false, fetch: false }); + return { + healthy: result.healthy, + checkedBranch: result.checkedBranch, + issues: result.issues.map((issue) => ({ + code: issue.code, + summary: issue.summary, + fixes: issue.fixes, + })), + }; +} + +async function defaultCollectGit(cwd: string): Promise { + const [statusResult, commitsResult] = await Promise.allSettled([ + readGitStatusShort(cwd), + readGitRecentCommits(cwd), + ]); + + const statusShort = + statusResult.status === 'fulfilled' ? statusResult.value : []; + const recentCommits = + commitsResult.status === 'fulfilled' ? commitsResult.value : []; + + if ( + statusResult.status === 'rejected' && + commitsResult.status === 'rejected' + ) { + throw statusResult.reason; + } + + return { + statusShort, + recentCommits, + }; +} + +async function defaultCollectHistory( + cwd: string, + historyLimit: number, +): Promise { + const entries = await readHistory(cwd, { limit: historyLimit }); + return { + recentEntries: entries.map((entry) => ({ + timestamp: entry.timestamp, + command: entry.command, + status: entry.status, + durationMs: entry.durationMs, + })), + }; +} + +async function defaultCollectTooling( + _cwd: string, +): Promise { + return { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + gitVersion: await readVersionLine('git', ['--version']), + ghVersion: await readVersionLine('gh', ['--version']), + }; +} + +async function readVersionLine( + command: string, + args: string[], +): Promise { + try { + const { stdout } = await execa(command, args); + const firstLine = stdout.split('\n')[0]?.trim(); + return firstLine || null; + } catch { + return null; + } +} + +function stringifyError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return String(error); +} + +function isStateNotInitializedError(error: unknown): boolean { + return error instanceof DubError && error.message.includes('not initialized'); +} + +function createUntrackedStackContext( + currentBranch: string | null, +): SupportStackContext { + return { + tracked: false, + trunk: null, + currentBranch, + parentBranch: null, + children: [], + pathToCurrent: [], + branchCount: 0, + }; +} + +async function readGitRemotes(cwd: string): Promise { + try { + const { stdout } = await execa('git', ['remote', '-v'], { cwd }); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => sanitizeRemoteLine(line)) + .slice(0, 20); + } catch { + return []; + } +} + +async function readGitStatusShort(cwd: string): Promise { + const { stdout } = await execa('git', ['status', '--short', '--branch'], { + cwd, + }); + return stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .slice(0, 120); +} + +async function readGitRecentCommits(cwd: string): Promise { + const { stdout } = await execa('git', ['log', '--oneline', '-20'], { + cwd, + }); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 20); +} + +function sanitizeRemoteLine(line: string): string { + const parts = line.split(/\s+/); + if (parts.length < 2) return line; + + const remote = parts[0] ?? ''; + const url = parts[1] ?? ''; + const suffix = parts.length > 2 ? ` ${parts.slice(2).join(' ')}` : ''; + return `${remote} ${sanitizeRemoteUrl(url)}${suffix}`; +} + +function sanitizeRemoteUrl(url: string): string { + return url + .replace(/(https?:\/\/)([^/\s@]+)@/gi, '$1[REDACTED]@') + .replace( + /([?&](?:token|access_token|auth|key|secret)=)[^&\s]+/gi, + '$1[REDACTED]', + ); +}