-
Notifications
You must be signed in to change notification settings - Fork 0
Add branch command for AI-suggested branch names from local changes #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c43182f
06d1752
f39f87e
c49ec83
b411384
43b35e4
4d7386f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| } | ||
| } |
| 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 on lines
+76
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fence stripper assumes a newline after the opening backticks; when the model returns a single-line fenced payload like 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))) | ||
| ); | ||
Uh oh!
There was an error while loading. Please reload this page.