From 682f5768ee2704612ac7f7c73788242c97b6b486 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sat, 21 Feb 2026 14:05:42 -0800 Subject: [PATCH 1/4] feat(ai): stream reasoning preview in ai ask --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 8945979..7629124 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1244,4 +1244,9 @@ function truncateHistoryLine(line: string): string { return `${line.slice(0, MAX_HISTORY_OUTPUT_LINE_LENGTH)}...`; } +function normalizeHistoryLine(line: string): string { + const visible = line.split('\r').pop() ?? ''; + return visible.trim().length === 0 ? '' : visible; +} + main(); From e1eac254ddc2000577ecc415ba6b87417e8dff1b Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sat, 21 Feb 2026 14:28:29 -0800 Subject: [PATCH 2/4] feat: add AI branch and commit generation to create command --- QUICKSTART.md | 6 + README.md | 15 +- src/commands/create.test.ts | 68 ++++++++- src/commands/create.ts | 279 ++++++++++++++++++++++++++++++++---- src/index.ts | 25 +++- src/lib/git.test.ts | 11 ++ src/lib/git.ts | 16 +++ 7 files changed, 384 insertions(+), 36 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index 91286e7..af596c8 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -62,6 +62,12 @@ dub create feat/new-layer -um "feat: ..." # pick hunks dub create feat/new-layer -pm "feat: ..." + +# AI-generate branch + commit from staged changes +dub create --ai + +# stage all, then AI-generate branch + commit (supports -ai shorthand) +dub create -ai ``` ## 3) Inspect and Navigate diff --git a/README.md b/README.md index f3a0a67..d6752b4 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Notes: - `dub create` auto-initializes state if needed. - Running `dub init` manually is still useful for explicit setup. -### `dub create ` +### `dub create [branch]` Create a branch stacked on top of the current branch. @@ -139,13 +139,20 @@ dub create feat/my-change -um "feat: ..." # interactive hunk staging + create + commit dub create feat/my-change -pm "feat: ..." + +# AI-generate branch + conventional commit from staged changes +dub create --ai + +# stage all, then AI-generate branch + commit (supports -ai shorthand) +dub create -ai ``` Flags: - `-m, --message `: commit message -- `-a, --all`: stage all changes before commit (requires `-m`) -- `-u, --update`: stage tracked-file updates before commit (requires `-m`) -- `-p, --patch`: select hunks interactively before commit (requires `-m`) +- `-a, --all`: stage all changes before commit (requires `-m` or `--ai`) +- `-u, --update`: stage tracked-file updates before commit (requires `-m` or `--ai`) +- `-p, --patch`: select hunks interactively before commit (requires `-m` or `--ai`) +- `-i, --ai`: AI-generate branch + conventional commit from staged changes ### `dub modify` / `dub m` diff --git a/src/commands/create.test.ts b/src/commands/create.test.ts index dc2c2ef..d3285f5 100644 --- a/src/commands/create.test.ts +++ b/src/commands/create.test.ts @@ -1,7 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTestRepo, gitInRepo } from '../../test/helpers'; +import { writeConfig } from '../lib/config'; import { getCurrentBranch } from '../lib/git'; import { readState } from '../lib/state'; import { readUndoEntry } from '../lib/undo-log'; @@ -10,17 +11,20 @@ import { init } from './init'; let dir: string; let cleanup: () => Promise; +let envSnapshot: NodeJS.ProcessEnv; beforeEach(async () => { const repo = await createTestRepo(); dir = repo.dir; cleanup = repo.cleanup; + envSnapshot = { ...process.env }; await init(dir); await gitInRepo(dir, ['add', '.']); await gitInRepo(dir, ['commit', '-m', 'init dubstack']); }); afterEach(async () => { + process.env = envSnapshot; await cleanup(); }); @@ -177,3 +181,65 @@ describe('create with -u -m', () => { ).rejects.toThrow("require '-m'"); }); }); + +describe('create with --ai', () => { + it('creates branch and commit from AI output using staged changes', async () => { + await writeConfig({ aiAssistantEnabled: true }, dir); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + fs.writeFileSync(path.join(dir, 'ai-feature.ts'), 'export const ai = 1;\n'); + await gitInRepo(dir, ['add', 'ai-feature.ts']); + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/ai-created-branch","message":"feat: add ai create mode"}', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await create( + undefined as unknown as string, + dir, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result.branch).toBe('feat/ai-created-branch'); + expect(result.committed).toBe('feat: add ai create mode'); + + const { stdout } = await gitInRepo(dir, ['log', '-1', '--format=%s']); + expect(stdout.trim()).toBe('feat: add ai create mode'); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'google-model', + }), + ); + }); + + it('requires ai assistant to be enabled in config', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + fs.writeFileSync(path.join(dir, 'ai-off.ts'), 'export const off = true;\n'); + await gitInRepo(dir, ['add', 'ai-off.ts']); + + const generateText = vi.fn(); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + await expect( + create( + undefined as unknown as string, + dir, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ), + ).rejects.toThrow("Enable it with 'dub config ai-assistant on'."); + }); +}); diff --git a/src/commands/create.ts b/src/commands/create.ts index 35fc57c..e338ff1 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,11 +1,17 @@ +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { LanguageModel } from 'ai'; +import { createGateway, generateText } from 'ai'; +import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; import { branchExists, commitStaged, createBranch, getCurrentBranch, + getDiff, hasStagedChanges, interactiveStage, + isValidBranchName, stageAll, stageUpdate, } from '../lib/git'; @@ -13,6 +19,7 @@ import { addBranchToStack, ensureState, writeState } from '../lib/state'; import { saveUndoEntry } from '../lib/undo-log'; interface CreateOptions { + ai?: boolean; message?: string; all?: boolean; update?: boolean; @@ -25,53 +32,126 @@ interface CreateResult { committed?: string; } +interface CreateDependencies { + generateText: typeof generateText; + createGoogleGenerativeAI: typeof createGoogleGenerativeAI; + createGateway: typeof createGateway; +} + +const DEFAULT_DEPS: CreateDependencies = { + generateText, + createGoogleGenerativeAI, + createGateway, +}; + +const CONVENTIONAL_COMMIT_RE = + /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/; + /** * Creates a new branch stacked on top of the current branch. * * When `-m` is provided, also commits staged changes on the new branch. - * When `-a` is provided, stages all changes first (requires `-m`). + * When `-a/-u/-p` are provided, stages changes first (requires `-m` or `--ai`). + * When `--ai` is provided, branch + commit message are generated from staged changes. * * @param name - Name of the new branch to create * @param cwd - Working directory (auto-initializes if needed) - * @param options - Optional message and all flags + * @param options - Optional create flags * @returns The created branch name, its parent, and committed message if applicable - * @throws {DubError} If branch exists, HEAD is detached, -a without -m, or nothing to commit + * @throws {DubError} If branch exists, HEAD is detached, invalid option combos, or nothing to commit */ export async function create( - name: string, + name: string | undefined, cwd: string, options?: CreateOptions, + deps: CreateDependencies = DEFAULT_DEPS, ): Promise { - if ((options?.all || options?.update || options?.patch) && !options.message) { + const normalizedOptions = options ?? {}; + const useAi = normalizedOptions.ai ?? false; + + if ( + (normalizedOptions.all || + normalizedOptions.update || + normalizedOptions.patch) && + !normalizedOptions.message && + !useAi + ) { throw new DubError( - "'--all', '--update', and '--patch' require '-m'. Pass a commit message.", + "'--all', '--update', and '--patch' require '-m' or '--ai'. Pass a commit message or let AI generate one.", ); } - const state = await ensureState(cwd); - const parent = await getCurrentBranch(cwd); + if (useAi && normalizedOptions.message) { + throw new DubError("'--ai' cannot be combined with '-m'."); + } - if (await branchExists(name, cwd)) { - throw new DubError(`Branch '${name}' already exists.`); + if (!useAi && !name?.trim()) { + throw new DubError( + "Branch name is required. Pass '' or use '--ai'.", + ); } - if (options?.message) { - if (options.patch) { + if (useAi && name?.trim()) { + throw new DubError( + "Do not pass with '--ai'. It generates branch and commit names from staged changes.", + ); + } + + const state = await ensureState(cwd); + const parent = await getCurrentBranch(cwd); + let branchName = name?.trim(); + let commitMessage = normalizedOptions.message?.trim(); + + if (commitMessage || useAi) { + if (normalizedOptions.patch) { await interactiveStage(cwd); - } else if (options.all) { + } else if (normalizedOptions.all) { await stageAll(cwd); - } else if (options.update) { + } else if (normalizedOptions.update) { await stageUpdate(cwd); } if (!(await hasStagedChanges(cwd))) { - const hint = options.all - ? 'No changes to commit.' - : "No staged changes. Stage files with 'git add' or use '-a' to stage all."; + const hint = + normalizedOptions.all || + normalizedOptions.update || + normalizedOptions.patch + ? 'No changes to commit.' + : useAi + ? "No staged changes. Stage files with 'git add' or use '-a' with '--ai'." + : "No staged changes. Stage files with 'git add' or use '-a' to stage all."; throw new DubError(hint); } } + if (useAi) { + const config = await readConfig(cwd); + if (!config.aiAssistantEnabled) { + throw new DubError( + "AI assistant is disabled for this repo. Enable it with 'dub config ai-assistant on'.", + ); + } + + const stagedDiff = await getDiff(cwd, true); + const generated = await generateBranchAndCommitFromAi(stagedDiff, deps); + branchName = generated.branch; + commitMessage = generated.message; + } + + if (!branchName) { + throw new DubError( + "Branch name is required. Pass '' or use '--ai'.", + ); + } + + if (!(await isValidBranchName(branchName, cwd))) { + throw new DubError(`Branch name '${branchName}' is invalid.`); + } + + if (await branchExists(branchName, cwd)) { + throw new DubError(`Branch '${branchName}' already exists.`); + } + await saveUndoEntry( { operation: 'create', @@ -79,26 +159,175 @@ export async function create( previousBranch: parent, previousState: structuredClone(state), branchTips: {}, - createdBranches: [name], + createdBranches: [branchName], }, cwd, ); - await createBranch(name, cwd); - addBranchToStack(state, name, parent); + await createBranch(branchName, cwd); + addBranchToStack(state, branchName, parent); await writeState(state, cwd); - if (options?.message) { + if (commitMessage) { try { - await commitStaged(options.message, cwd); + await commitStaged(commitMessage, cwd); } catch (error) { const reason = error instanceof DubError ? error.message : String(error); throw new DubError( - `Branch '${name}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`, + `Branch '${branchName}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`, ); } - return { branch: name, parent, committed: options.message }; + return { branch: branchName, parent, committed: commitMessage }; + } + + return { branch: branchName, parent }; +} + +async function generateBranchAndCommitFromAi( + stagedDiff: string, + deps: CreateDependencies, +): Promise<{ branch: string; message: string }> { + const resolved = resolveModel(deps); + const diffForPrompt = truncate(stagedDiff.trim(), 12_000); + const prompt = [ + 'Generate a git branch name and conventional commit message from the staged diff.', + 'Return JSON only, exactly like: {"branch":"feat/your-branch","message":"feat: summary"}', + 'Rules:', + '- branch must be lowercase, slash-delimited, and kebab-case.', + '- message must be a Conventional Commit subject line.', + '- keep message under 72 characters when possible.', + '- do not include markdown fences.', + '', + 'STAGED_DIFF_START', + diffForPrompt.length > 0 ? diffForPrompt : '[No textual diff available]', + 'STAGED_DIFF_END', + ].join('\n'); + + const result = await deps.generateText({ + model: resolved.model, + system: + 'You produce concise git metadata. Output strict JSON only and never add extra commentary.', + prompt, + }); + + return parseAiCreateResponse(result.text); +} + +function resolveModel(deps: CreateDependencies): { + provider: 'google' | 'gateway'; + model: LanguageModel; + modelId: string; +} { + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (geminiApiKey) { + const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); + return { + provider: 'google', + model: google('gemini-3-flash'), + modelId: 'gemini-3-flash', + }; + } + + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (gatewayApiKey) { + const gateway = deps.createGateway({ apiKey: gatewayApiKey }); + return { + provider: 'gateway', + model: gateway('google/gemini-3-flash'), + modelId: 'google/gemini-3-flash', + }; + } + + throw new DubError( + "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", + ); +} + +function parseAiCreateResponse(text: string): { + branch: string; + message: string; +} { + const candidate = extractJsonObject(text); + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + + const rawBranch = getStringValue(parsed, 'branch'); + const rawMessage = getStringValue(parsed, 'message'); + const branch = normalizeBranchName(rawBranch); + const message = normalizeCommitMessage(rawMessage); + + if (branch.length === 0) { + throw new DubError('AI assistant generated an empty branch name.'); } - return { branch: name, parent }; + if (!CONVENTIONAL_COMMIT_RE.test(message)) { + throw new DubError( + "AI assistant generated a non-conventional commit message. Re-run '--ai' or pass '-m' manually.", + ); + } + + return { branch, message }; +} + +function getStringValue(source: object, key: string): string { + const value = (source as Record)[key]; + if (typeof value !== 'string') { + throw new DubError(`AI assistant metadata is missing '${key}'.`); + } + return value; +} + +function normalizeBranchName(value: string): string { + return value + .trim() + .replace(/^`+|`+$/g, '') + .replace(/^refs\/heads\//, '') + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9./_-]+/g, '-') + .replace(/\/+/g, '/') + .replace(/-+/g, '-') + .replace(/^\/+|\/+$/g, '') + .replace(/^\.+/, '') + .replace(/\.+$/, ''); +} + +function normalizeCommitMessage(value: string): string { + return value + .trim() + .replace(/^`+|`+$/g, '') + .replace(/\s+/g, ' '); +} + +function extractJsonObject(text: string): string { + const trimmed = text.trim(); + const withoutFences = + trimmed.startsWith('```') && trimmed.endsWith('```') + ? trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '') + : trimmed; + const start = withoutFences.indexOf('{'); + const end = withoutFences.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + return withoutFences.slice(start, end + 1); +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}\n...[truncated]`; } diff --git a/src/index.ts b/src/index.ts index 7629124..8ac5866 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,31 +91,43 @@ Examples: program .command('create') - .argument('', 'Name of the new branch to create') + .argument('[branch-name]', 'Name of the new branch to create') .description('Create a new branch stacked on top of the current branch') .option('-m, --message ', 'Commit staged changes with this message') - .option('-a, --all', 'Stage all changes before committing (requires -m)') + .option( + '-a, --all', + 'Stage all changes before committing (requires -m or --ai)', + ) .option( '-u, --update', - 'Stage tracked file updates before committing (requires -m)', + 'Stage tracked file updates before committing (requires -m or --ai)', + ) + .option( + '-p, --patch', + 'Pick hunks to stage before committing (requires -m or --ai)', + ) + .option( + '-i, --ai', + 'AI-generate branch + conventional commit from staged changes', ) - .option('-p, --patch', 'Pick hunks to stage before committing (requires -m)') .addHelpText( 'after', ` Examples: $ dub create feat/api Create branch only $ dub create feat/api -m "feat: add API" Create branch + commit staged - $ dub create feat/api -am "feat: add API" Stage all + create + commit`, + $ dub create feat/api -am "feat: add API" Stage all + create + commit + $ dub create --ai AI-generate branch + commit from staged`, ) .action( async ( - branchName: string, + branchName: string | undefined, options: { message?: string; all?: boolean; update?: boolean; patch?: boolean; + ai?: boolean; }, ) => { const result = await create(branchName, process.cwd(), { @@ -123,6 +135,7 @@ Examples: all: options.all, update: options.update, patch: options.patch, + ai: options.ai, }); if (result.committed) { console.log( diff --git a/src/lib/git.test.ts b/src/lib/git.test.ts index c487698..8c5d9ef 100644 --- a/src/lib/git.test.ts +++ b/src/lib/git.test.ts @@ -14,6 +14,7 @@ import { getMergeBase, hasStagedChanges, isGitRepo, + isValidBranchName, isWorkingTreeClean, rebaseOnto, stageAll, @@ -69,6 +70,16 @@ describe('branchExists', () => { }); }); +describe('isValidBranchName', () => { + it('returns true for a valid branch name', async () => { + expect(await isValidBranchName('feat/valid-name', dir)).toBe(true); + }); + + it('returns false for an invalid branch name', async () => { + expect(await isValidBranchName('feat with spaces', dir)).toBe(false); + }); +}); + describe('createBranch', () => { it('creates a new branch and switches to it', async () => { await createBranch('feat/test', dir); diff --git a/src/lib/git.ts b/src/lib/git.ts index a83ccb4..60b4b55 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -79,6 +79,22 @@ export async function branchExists( } } +/** + * Checks whether a branch name is valid according to git ref rules. + * @returns `true` when valid, `false` when invalid. Never throws. + */ +export async function isValidBranchName( + name: string, + cwd: string, +): Promise { + try { + await execa('git', ['check-ref-format', '--branch', name], { cwd }); + return true; + } catch { + return false; + } +} + /** * Creates a new branch and switches to it. * @throws {DubError} If a branch with that name already exists. From 33fc49f08d0b73e36b9bc606206409b2d7dffb44 Mon Sep 17 00:00:00 2001 From: dubscode <1205902+dubscode@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:45:48 +0000 Subject: [PATCH 3/4] chore(biome): apply automated formatting and lint fixes --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 8ac5866..c9d9459 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1257,7 +1257,7 @@ function truncateHistoryLine(line: string): string { return `${line.slice(0, MAX_HISTORY_OUTPUT_LINE_LENGTH)}...`; } -function normalizeHistoryLine(line: string): string { +function _normalizeHistoryLine(line: string): string { const visible = line.split('\r').pop() ?? ''; return visible.trim().length === 0 ? '' : visible; } From 3ca59ab60786cb33d817e85c130a7852fdcb2aba Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sat, 21 Feb 2026 14:47:52 -0800 Subject: [PATCH 4/4] fix(create): redact staged diff before ai prompt --- src/commands/create.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/commands/create.ts | 4 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/commands/create.test.ts b/src/commands/create.test.ts index d3285f5..af02aa7 100644 --- a/src/commands/create.test.ts +++ b/src/commands/create.test.ts @@ -242,4 +242,40 @@ describe('create with --ai', () => { ), ).rejects.toThrow("Enable it with 'dub config ai-assistant on'."); }); + + it('redacts sensitive staged diff content before sending prompt to AI', async () => { + await writeConfig({ aiAssistantEnabled: true }, dir); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + fs.writeFileSync( + path.join(dir, 'secrets.ts'), + 'export const token = "sk-supersecret123456";\nexport const key = "AIzaSecretToken1234567890";\n', + ); + await gitInRepo(dir, ['add', 'secrets.ts']); + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/redacted-prompt","message":"feat: redact ai prompt diff"}', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + await create( + undefined as unknown as string, + dir, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + const call = vi.mocked(generateText).mock.calls[0]?.[0]; + expect(call).toBeDefined(); + const prompt = String(call?.prompt ?? ''); + expect(prompt).toContain('[REDACTED]'); + expect(prompt).not.toContain('sk-supersecret123456'); + expect(prompt).not.toContain('AIzaSecretToken1234567890'); + }); }); diff --git a/src/commands/create.ts b/src/commands/create.ts index e338ff1..15c8122 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -15,6 +15,7 @@ import { stageAll, stageUpdate, } from '../lib/git'; +import { redactSensitiveText } from '../lib/history'; import { addBranchToStack, ensureState, writeState } from '../lib/state'; import { saveUndoEntry } from '../lib/undo-log'; @@ -188,7 +189,8 @@ async function generateBranchAndCommitFromAi( deps: CreateDependencies, ): Promise<{ branch: string; message: string }> { const resolved = resolveModel(deps); - const diffForPrompt = truncate(stagedDiff.trim(), 12_000); + const redactedDiff = redactSensitiveText(stagedDiff).trim(); + const diffForPrompt = truncate(redactedDiff, 12_000); const prompt = [ 'Generate a git branch name and conventional commit message from the staged diff.', 'Return JSON only, exactly like: {"branch":"feat/your-branch","message":"feat: summary"}',