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
6 changes: 6 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Notes:
- `dub create` auto-initializes state if needed.
- Running `dub init` manually is still useful for explicit setup.

### `dub create <branch>`
### `dub create [branch]`

Create a branch stacked on top of the current branch.

Expand All @@ -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 <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`

Expand Down
104 changes: 103 additions & 1 deletion src/commands/create.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,17 +11,20 @@ import { init } from './init';

let dir: string;
let cleanup: () => Promise<void>;
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();
});

Expand Down Expand Up @@ -177,3 +181,101 @@ 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'.");
});

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');
});
});
Loading