Skip to content
Merged
5 changes: 4 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Commit } from "@/cli/commit";
import { Branch } from "@/cli/branch";
import { Setup } from "@/cli/setup";
import { Doctor } from "@/cli/doctor";
import { ModelCommand } from "@/cli/model";
Expand All @@ -11,7 +12,7 @@ import { checkUpdate } from "@/cli/update";

import color from "picocolors";

const NOTIFIER_COMMANDS = new Set<CliCommand["type"]>(["generate", "setup", "doctor", "model", "effort"]);
const NOTIFIER_COMMANDS = new Set<CliCommand["type"]>(["generate", "setup", "doctor", "model", "effort", "branch"]);

const main = () => {
const args = process.argv.slice(2);
Expand All @@ -35,6 +36,8 @@ const main = () => {
return ModelCommand.create().chain((m) => m.run());
case "effort":
return EffortCommand.create().chain((e) => e.run());
case "branch":
return Branch.create().chain((b) => b.run());
case "update":
return Update.create().run();
case "version":
Expand Down
113 changes: 113 additions & 0 deletions src/cli/branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export { Branch };

import * as p from "@clack/prompts";
import * as repo from "@/infra/git/repo";

import { Future } from "@/libs/future";
import { loadConfig } from "@/infra/storage/config";
import { Setup } from "@/cli/setup";
import { type Config, type ProviderConfig } from "@/domain/config/config";
import { resolveProvider } from "@/domain/llm/auth-resolver";
import { generateBranchNameSuggestions, type BranchSuggestion } from "@/domain/llm/router";
import { renderBranchNote } from "@/infra/ui/push-note";
import { loading } from "@/infra/ui/spinner";
import { Just, Nothing, type Maybe } from "@/libs/maybe";

import color from "picocolors";

class Branch {
private constructor(private readonly providerConfig: ProviderConfig) {}

static create(): Future<Error, Branch> {
return loadConfig()
.chainRej((): Future<Error, Config> => {
p.log.warn(color.yellow("No configuration found. Let's set you up first."));
return Setup.create()
.chain((s) => s.run())
.chain(() => loadConfig());
})
.chain((config) => resolveProvider(config).map((ai) => new Branch(ai)));
}

run(): Future<Error, void> {
return repo
.checkIsGitRepo()
.chain(() => repo.getLocalChangeContext())
.bichain(
(e): Future<Error, void> => {
if (repo.isNoLocalChangesError(e)) {
p.log.warn(color.yellow(repo.NO_LOCAL_CHANGES_MESSAGE));
p.outro("No local changes — nothing to suggest a branch for.");
return Future.resolve(undefined);
}
return Future.reject(e);
},
(ctx): Future<Error, void> =>
loading("Suggesting branch names...", "Suggestions ready!", generateBranchNameSuggestions(this.providerConfig, ctx))
.chain((s) =>
this.promptPick(s.names).chain(
(maybePicked): Future<Error, { picked: string; metadata: typeof s.metadata } | undefined> =>
maybePicked.maybe<Future<Error, { picked: string; metadata: typeof s.metadata } | undefined>>(Future.resolve(undefined), (picked) =>
this.confirmForkFromBase().chain((proceed) => {
if (!proceed) {
p.outro("Operation cancelled.");
return Future.resolve(undefined);
}
return repo.createAndSwitchBranch(picked).map(() => ({ picked, metadata: s.metadata }));
})
)
)
)
.chain((result) => {
if (!result) return Future.resolve(undefined);
return repo.findBaseBranch().map((baseBranch) => {
renderBranchNote({
branch: result.picked,
baseBranch,
request: Just(result.metadata)
});
p.outro(color.green("Switched to new branch."));
});
})
)
.mapRej((e) => {
p.log.error(color.red(e.message));
return e;
});
}

private confirmForkFromBase(): Future<Error, boolean> {
return Future.concurrently<Error, { current: Maybe<string>; base: Maybe<string> }>({
current: repo.findCurrentBranch(),
base: repo.findBaseBranch()
}).chain(({ current, base }) =>
current.maybe(Future.resolve(true), (curr) =>
base.maybe(Future.resolve(true), (b) =>
curr === b ?
Future.resolve(true)
: Future.attemptP(async () => {
p.log.warn(color.yellow(`You're on '${curr}', not the base branch '${b}'. The new branch will fork from '${curr}'.`));
const ok = await p.confirm({ message: `Create branch off '${curr}' anyway?` });
return !(p.isCancel(ok) || !ok);
})
)
)
);
}

private promptPick(suggestions: readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]): Future<Error, Maybe<string>> {
return Future.attemptP(async () => {
const choice = await p.select({
message: "Create branch",
options: suggestions.map((s) => ({ value: s.name, label: `${s.name} — ${s.rationale}` }))
});

if (p.isCancel(choice)) {
p.outro("Operation cancelled.");
return Nothing<string>();
}

return Just(choice);
});
}
}
6 changes: 6 additions & 0 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type CliCommand =
| { type: "doctor" }
| { type: "model" }
| { type: "effort" }
| { type: "branch" }
| { type: "update" }
| { type: "version" }
| { type: "help" };
Expand All @@ -30,6 +31,9 @@ const cliCommandDecoder: D.Decoder<CliCommand> = D.array(D.string).chain((args)
return D.succeed({ type: "model" });
case "effort":
return D.succeed({ type: "effort" });
case "branch":
case "new-branch":
return D.succeed({ type: "branch" });
case "update":
return D.succeed({ type: "update" });
case "--version":
Expand All @@ -51,6 +55,8 @@ Usage: commit-tools [command]

Commands:
generate (default) Generate a commit message
branch Suggest branch names from local changes and create one
new-branch Alias for branch
setup Configure authentication and conventions
login Alias for setup (re-authenticate)
doctor Check installation and environment
Expand Down
120 changes: 120 additions & 0 deletions src/domain/branch/suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
export { stripOptionalJsonFence, parseBranchSuggestions, validateGitBranchName, parseAndValidateBranchSuggestions, type BranchSuggestion };

import * as D from "@/libs/json/decoder";
import { Failure, Success, type Result } from "@/libs/result";

const MAX_BRANCH_NAME_LENGTH = 64;
const MAX_RATIONALE_LENGTH = 120;

const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const TRUNK_NAMES = new Set(["main", "master", "develop", "head"]);

const FORBIDDEN_FIRST_SEGMENTS = new Set([
"feat",
"fix",
"chore",
"docs",
"refactor",
"test",
"perf",
"build",
"ci",
"style",
"revert",
"feature",
"bugfix",
"hotfix",
"release",
"add",
"update",
"change",
"improve",
"tweak",
"misc",
"wip",
"tmp"
]);

type BranchSuggestion = { readonly name: string; readonly rationale: string };

const nonEmptyString = (label: string): D.Decoder<string> =>
D.string.chain((s) => {
const t = s.trim();
return t.length === 0 ? D.fail(`${label} must be non-empty`) : D.succeed(t);
});

const boundedNonEmptyString = (label: string, max: number): D.Decoder<string> =>
D.string.chain((s) => {
const t = s.trim();
if (t.length === 0) return D.fail(`${label} must be non-empty`);
if (t.length > max) return D.fail(`${label} exceeds ${max} chars`);
return D.succeed(t);
});

const branchSuggestionDecoder: D.Decoder<BranchSuggestion> = D.object({
name: nonEmptyString("name"),
rationale: boundedNonEmptyString("rationale", MAX_RATIONALE_LENGTH)
});

const threeSuggestions: D.Decoder<readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]> = D.array(branchSuggestionDecoder).chain((xs) => {
if (xs.length !== 3) return D.fail("expected exactly 3 suggestions");
const [a, b, c] = xs;
if (a === undefined || b === undefined || c === undefined) return D.fail("expected 3 suggestions");
return D.succeed([a, b, c] as const);
});

const suggestionsPayloadDecoder = D.object({
suggestions: threeSuggestions
});

const stripOptionalJsonFence = (s: string): string => {
const t = s.trim();
if (!t.startsWith("```")) {
return t;
}
const firstNl = t.indexOf("\n");
const body = firstNl === -1 ? "" : t.slice(firstNl + 1);
Comment thread
rafaeelricco marked this conversation as resolved.
Comment on lines +76 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse single-line fenced JSON suggestions

The fence stripper assumes a newline after the opening backticks; when the model returns a single-line fenced payload like json {"suggestions":[...]} , firstNl is -1 and the function returns an empty body, causing valid JSON output to be rejected and branch suggestion generation to fail intermittently.

Useful? React with 👍 / 👎.

const close = body.indexOf("```");
if (close === -1) {
return body.trim();
}
return body.slice(0, close).trim();
};

const parseBranchSuggestions = (raw: string): Result<Error, readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]> => {
const trimmed = stripOptionalJsonFence(raw.trim());
let json: unknown;
try {
json = JSON.parse(trimmed);
} catch {
return Failure(new Error("Branch suggestions: invalid JSON"));
}
return D.decode(json, suggestionsPayloadDecoder)
.mapFailure((msg) => new Error(`Branch suggestions: ${msg}`))
.map((row) => row.suggestions);
};

const validateGitBranchName = (name: string): Result<Error, string> => {
if (name.length > MAX_BRANCH_NAME_LENGTH) {
return Failure(new Error(`Invalid branch name (max ${MAX_BRANCH_NAME_LENGTH} characters): ${name}`));
}
if (!SLUG_PATTERN.test(name)) {
return Failure(new Error(`Invalid branch name (use lowercase kebab-case, no slashes): ${name}`));
}
if (TRUNK_NAMES.has(name.toLowerCase())) {
return Failure(new Error(`Reserved branch name: ${name}`));
}
const firstSegment = name.split("-")[0];
if (firstSegment !== undefined && FORBIDDEN_FIRST_SEGMENTS.has(firstSegment)) {
return Failure(new Error(`Branch name must not start with type prefix token: ${name}`));
}
return Success(name);
};

const validateSuggestion = (s: BranchSuggestion): Result<Error, BranchSuggestion> => validateGitBranchName(s.name).map(() => s);

const parseAndValidateBranchSuggestions = (raw: string): Result<Error, readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]> =>
parseBranchSuggestions(raw).chain(([a, b, c]) =>
validateSuggestion(a).chain((va) => validateSuggestion(b).chain((vb) => validateSuggestion(c).map((vc) => [va, vb, vc] as const)))
);
96 changes: 95 additions & 1 deletion src/domain/commit/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { getPrompt, getRefinePrompt };
export { getPrompt, getRefinePrompt, getBranchNamePrompt };

import { CommitConvention } from "@/domain/config/config";
import { Just, Nothing, type Maybe } from "@/libs/maybe";
Expand Down Expand Up @@ -275,6 +275,100 @@ function promptCustom(gitDiff: string, template: Maybe<string>): string {
}
}

function getBranchNamePrompt(context: string): string {
return `
<work_snapshot>
${context}
</work_snapshot>

<role>
You are a senior engineer reading the work snapshot above.
Propose three distinct branch names for this work.
</role>

<output_shape>
Return ONE JSON object, no markdown, no prose.
First character "{", last character "}". Schema:
{"suggestions":[
{"name":"<slug>","rationale":"<one short clause>"},
{"name":"<slug>","rationale":"<one short clause>"},
{"name":"<slug>","rationale":"<one short clause>"}
]}
</output_shape>

<slug_rules>
- Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ (lowercase, single hyphens, no slashes)
- Length: roughly 15-50 characters, never over 60.
- Grounded in tokens from file paths, symbols, or domain nouns in the snapshot.
- Forbidden as the FIRST token: change-type labels (feat, fix, chore, docs,
refactor, test, perf, build, ci, feature, bugfix, hotfix, release) AND vague
verbs (add, update, change, improve, tweak, misc, wip, tmp).
- ALLOWED as the LAST token: a change-kind word (refactor, cleanup, rewrite,
hardening, migration, feature) when it sharpens the framing.
Example: "frontend-list-ui-refactor" is valid because "refactor" is the suffix.
- Forbidden anywhere: tooling/instruction words — suggestion(s), prompt,
llm, model, cli, tool(s), command(s), workflow, meta, kebab-case, snapshot,
context, branch-name, name-picker.
- Never trunk names: main, master, develop, head.
- Area prefix: if every changed file shares one top-level area visible in
the paths (a monorepo package, a top-level src/<area> subtree, or a
clearly named layer like "frontend"/"backend"/"api"/"web"), at least one
slug SHOULD start with that area as its leading token (e.g. "frontend-...",
"api-...", "web-..."). Do not invent areas that aren't in the file paths.
- Preferred shape for the broader-theme suggestion: <area>-<theme>-<kind>
where <kind> is a change-kind suffix from the allowed list (e.g.
"frontend-list-ui-refactor", "api-auth-hardening"). Use this shape when
the diff spans multiple files under one area; skip it for narrow diffs.
</slug_rules>

<rationale_rules>
- One short clause, no more than 80 characters, lowercase start, no trailing period.
- Explains WHY this framing — what facet of the change it emphasizes.
- Do not repeat the slug verbatim. Do not just restate file names.
</rationale_rules>

<diversity_axes>
The three suggestions MUST cover three different axes. Pick three from:
- component/module focus (names the specific code being extracted or built)
- broader feature/theme framing (names the overall shape of the work)
- user-visible change framing (names what a product user would notice)
- refactor/architecture framing (names the structural shift)
- shared/reusable focus (names what becomes reusable across pages)
</diversity_axes>

<synthesis_protocol>
Before answering, internally (you do NOT output these steps):
1. List every file in the snapshot and the one-phrase intent of each hunk.
2. Group the hunks into 1-3 themes that span multiple files.
3. Pick the three diversity axes that best describe this diff.
4. Draft a slug for each axis, then verify each slug:
(a) matches the pattern, (b) is grounded in snapshot tokens,
(c) is not just a subset of another slug,
(d) frames a different axis than the other two.
5. If two slugs frame the same axis, replace one before emitting.
</synthesis_protocol>

<examples>
<example>
<work_snapshot_summary>Diff refactors ops/campaigns + org/campaigns + ops/events + ops/products under app/frontend/ to use shared EmptyState, FilterPill, CampaignCard; adds pagination counts ("Showing X-Y of N") with restyled Pagination component.</work_snapshot_summary>
<output>{"suggestions":[{"name":"frontend-list-ui-refactor","rationale":"broader framing of the cross-page list restructure"},{"name":"frontend-shared-list-components","rationale":"emphasizes the extracted EmptyState, FilterPill, and CampaignCard"},{"name":"frontend-pagination-with-counts","rationale":"leads with the most user-visible change"}]}</output>
</example>
<example>
<work_snapshot_summary>Adds null-guard to src/parser/parser.ts and a regression test in src/parser/parser.test.ts.</work_snapshot_summary>
<output>{"suggestions":[{"name":"parser-null-guard","rationale":"names the specific code path being hardened"},{"name":"parser-hardening","rationale":"broader framing across guard and regression test"},{"name":"crash-on-empty-input","rationale":"user-visible bug being prevented"}]}</output>
</example>
<example>
<work_snapshot_summary>Adds /api/users/:id/sessions endpoint with handler in api/handlers/sessions.ts, DB query in api/db/sessions.ts, OpenAPI schema in api/openapi.yaml.</work_snapshot_summary>
<output>{"suggestions":[{"name":"api-user-sessions-endpoint","rationale":"component focus on the new sessions handler"},{"name":"api-sessions-feature","rationale":"broader framing across handler, query, and schema"},{"name":"list-active-sessions","rationale":"user-visible capability the endpoint exposes"}]}</output>
</example>
</examples>

<output_instructions>
Emit ONLY the JSON object. No prose, no markdown fences, no commentary.
</output_instructions>
`;
}

function getRefinePrompt(params: { diff: string; currentMessage: string; adjustment: string }): {
prompt: string;
systemInstruction: string;
Expand Down
Loading