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
38 changes: 0 additions & 38 deletions CLAUDE.md

This file was deleted.

2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default tseslint.config(
languageOptions: { parser: tseslint.parser },
plugins: { sonarjs },
rules: {
"sonarjs/cognitive-complexity": ["error", 15]
"sonarjs/cognitive-complexity": ["error", 10]
}
}
);
23 changes: 22 additions & 1 deletion src/cli/commit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { Commit };

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

import { Future } from "@/libs/future";
Expand All @@ -11,6 +12,7 @@ import { resolveProvider } from "@/domain/llm/auth-resolver";
import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router";
import { Nothing, type Maybe, Just } from "@/libs/maybe";
import { loading } from "@/infra/ui/spinner";
import { renderPushNote } from "@/infra/ui/push-note";

import color from "picocolors";

Expand Down Expand Up @@ -87,7 +89,26 @@ class Commit {
: publish ? "Published successfully!"
: "Pushed successfully!";

return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).map(() => {});
return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).chain((result) =>
Future.concurrently<
Error,
{
commit: repo.CommitMetadata;
localBranch: string;
upstream: Maybe<string>;
remoteUrl: string;
pr: pr.PrLookup;
}
>({
commit: repo.getCommitMetadata(),
localBranch: repo.getCurrentBranch(),
upstream: repo.getUpstream(),
remoteUrl: repo.getTrackingRemoteUrl(),
pr: pr.getOpenPullRequest()
})
.map((parts) => renderPushNote({ ...parts, range: result.range }))
.chainRej<Error>(() => Future.resolve(undefined))
);
}

interact(diff: string, message: string): Future<Error, void> {
Expand Down
44 changes: 39 additions & 5 deletions src/domain/commit/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function promptConventional(gitDiff: string): string {
- You MAY use inline code formatting with single backticks, e.g. \`function_name\`, \`git diff\`.
- Do NOT use multiline code fences (no \`\`\` blocks).
- Keep language concise and concrete. Prefer what the change DOES over HOW it is implemented.
- End each bullet with a period for consistency with the project's existing history.
</rules>

<examples>
Expand All @@ -66,11 +67,39 @@ function promptConventional(gitDiff: string): string {
+ console.log('[INFO]', new Date().toISOString(), message);
}
</git_diff>
<classification>SMALL</classification>
<commit_message>
feat(logger): add timestamp to info logs
</commit_message>
</example>

<example>
<git_diff>
// Multiple files, new function and wiring
diff --git a/tools/prompting.py b/tools/prompting.py
index 1111111..2222222 100644
--- a/tools/prompting.py
+++ b/tools/prompting.py
@@ -1,0 +1,40 @@
+def prompt_commit_message(git_diff: string) -> string:
+ \"\"\"Generate a commit message prompt from a git diff.\"\"\"
+ ...

diff --git a/tests/test_prompting.py b/tests/test_prompting.py
index 3333333..4444444 100644
--- a/tests/test_prompting.py
+++ b/tests/test_prompting.py
@@ -1,0 +1,25 @@
+def test_prompt_commit_message():
+ ...
</git_diff>
<commit_message>
feat(prompting): add prompt_commit_message for git diff analysis

- Add helper to generate commit messages from git diffs following the project guidelines.
- Include initial implementation of \`prompt_commit_message\` with unit tests covering basic usage.
- Wire the helper into the commit flow so diff inputs produce structured prompts.
</commit_message>
</example>
</examples>

<input>
Expand All @@ -84,7 +113,7 @@ function promptConventional(gitDiff: string): string {
2. Do NOT output the classification (SMALL/MEDIUM/LARGE) in your response.
3. Then output ONLY the final commit message text, with no explanation.
4. Do NOT wrap the commit message in quotes or code fences.
5. Always start with a Conventional Commits type prefix and capitalize the first letter after the prefix.
5. Always start with a Conventional Commits type prefix. Use lowercase for the first word after the prefix (except for proper nouns and acronyms), matching the style of the examples.
6. Respect the required format based on size:
- SMALL: single line only.
- MEDIUM/LARGE:
Expand Down Expand Up @@ -127,6 +156,7 @@ function promptImperative(gitDiff: string): string {
- You MAY use inline code formatting with single backticks, e.g. \`function_name\`, \`git diff\`.
- Do NOT use multiline code fences (no \`\`\` blocks).
- Keep language concise and concrete. Prefer what the change DOES over HOW it is implemented.
- End each bullet with a period for consistency with the project's existing history.
</rules>

<examples>
Expand All @@ -144,7 +174,6 @@ function promptImperative(gitDiff: string): string {
}
</git_diff>

<classification>SMALL</classification>
<commit_message>
Update info logger to include timestamp
</commit_message>
Expand All @@ -171,7 +200,6 @@ function promptImperative(gitDiff: string): string {
+ ...
</git_diff>

<classification>MEDIUM</classification>
<commit_message>
Add prompt_commit_message function for git diff analysis

Expand Down Expand Up @@ -229,6 +257,10 @@ function promptCustom(gitDiff: string, template: Maybe<string>): string {
${processedTemplate}
</user_template>

<git_diff>
${gitDiff}
</git_diff>

<output_instructions>
1. Follow the user's template style and format.
2. Analyze the content and create a commit message that matches the template pattern.
Expand All @@ -254,6 +286,8 @@ function getRefinePrompt(params: { diff: string; currentMessage: string; adjustm
`<adjustment>\n${params.adjustment}\n</adjustment>`,
systemInstruction:
"You revise commit messages. Use the diff and the user's adjustment to produce a polished commit message. " +
"Preserve required formatting rules: SMALL=single line; MEDIUM/LARGE=title, blank line, bullets prefixed with '- '."
"Preserve required formatting rules: SMALL=single line; MEDIUM/LARGE=title, blank line, bullets prefixed with '- '. " +
"Preserve the original convention: if the current message starts with a Conventional Commits prefix (feat, fix, refactor, chore, docs, style, test, perf, ci, build), keep it; otherwise keep the imperative style. " +
"Output ONLY the revised commit message. No preamble, no explanation, no code fences, no surrounding quotes."
};
}
178 changes: 112 additions & 66 deletions src/infra/git/repo.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,131 @@
export { checkIsGitRepo, getStagedDiff, performCommit, performPush, getCurrentBranch, hasUpstream };
export {
checkIsGitRepo,
getStagedDiff,
performCommit,
performPush,
getCurrentBranch,
hasUpstream,
getUpstream,
getCommitMetadata,
getRemoteUrl,
getTrackingRemoteUrl,
type CommitMetadata,
type PushResult,
type PushRange
};

import { Future } from "@/libs/future";
import { spawn } from "node:child_process";
import { Just, Nothing, type Maybe } from "@/libs/maybe";
import { execBin } from "@/infra/shell";
import { unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const execGit = (args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> =>
new Promise((resolve, reject) => {
const proc = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
proc.on("error", reject);
proc.on("close", (exitCode) => resolve({ stdout, stderr, exitCode: exitCode ?? 1 }));
});
type CommitMetadata = {
hash: string;
short: string;
subject: string;
authorName: string;
authorEmail: string;
date: Date;
};

type PushRange = { before: string; after: string };

type PushResult = {
output: string;
range: Maybe<PushRange>;
};

const execGitChecked = (args: string[], fallbackMsg: string): Future<Error, string> =>
execBin("git", args).chain(({ stdout, stderr, exitCode }) =>
exitCode !== 0 ?
Future.reject<Error, string>(new Error(stderr.trim() || stdout.trim() || fallbackMsg))
: Future.resolve<Error, string>(stdout)
);

const parsePushRange = (output: string): Maybe<PushRange> => {
const m = output.match(/([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/);
if (!m) return Nothing();
const [, before, after] = m;
return before && after ? Just({ before, after }) : Nothing();
};

const checkIsGitRepo = (): Future<Error, void> =>
Future.attemptP(async () => {
const { exitCode } = await execGit(["rev-parse", "--is-inside-work-tree"]);
if (exitCode !== 0) throw new Error("Not a git repository");
});
execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {});

const getStagedDiff = (): Future<Error, string> =>
Future.attemptP(async () => {
const { stdout, stderr, exitCode } = await execGit(["diff", "--staged"]);

if (exitCode !== 0) {
throw new Error(stderr.trim() || "Failed to get staged changes");
}

if (!stdout.trim()) throw new Error("No staged changes found");
return stdout;
});
execGitChecked(["diff", "--staged"], "Failed to get staged changes").chain((stdout) =>
stdout.trim() ?
Future.resolve<Error, string>(stdout)
: Future.reject<Error, string>(new Error("No staged changes found"))
);

const performCommit = (message: string): Future<Error, string> => {
const tmpPath = join(tmpdir(), `commit-msg-${Date.now()}.txt`);
return Future.bracket(
Future.attemptP(() => writeFile(tmpPath, message, "utf-8")),
() => Future.attemptP(() => unlink(tmpPath).catch(() => {})),
() => execBin("git", ["commit", "-F", tmpPath])
).chain(({ stdout, stderr, exitCode }) =>
exitCode !== 0 ?
Future.reject<Error, string>(new Error(stderr.trim() || stdout.trim() || "Commit failed"))
: Future.resolve<Error, string>(
"\n" +
stdout
.split("\n")
.filter((line) => !line.startsWith("["))
.join("\n")
.trim() +
"\n"
)
);
};

const performPush = (branch?: string, publish = false, forceWithLease = false): Future<Error, PushResult> => {
const args = publish && branch ? ["push", "--set-upstream", "origin", branch] : ["push"];
if (forceWithLease) args.push("--force-with-lease");
return execBin("git", args).chain(({ stdout, stderr, exitCode }) =>
exitCode !== 0 ?
Future.reject<Error, PushResult>(new Error(stderr.trim() || stdout.trim() || "Push failed"))
: Future.resolve<Error, PushResult>({
output: stdout + stderr,
range: parsePushRange(stdout + "\n" + stderr)
})
);
};

const performCommit = (message: string): Future<Error, string> =>
Future.attemptP(async () => {
const tmpPath = join(tmpdir(), `commit-msg-${Date.now()}.txt`);
await writeFile(tmpPath, message, "utf-8");
const getCurrentBranch = (): Future<Error, string> =>
execGitChecked(["rev-parse", "--abbrev-ref", "HEAD"], "Failed to get current branch").map((s) => s.trim());

const { stdout, stderr, exitCode } = await execGit(["commit", "-F", tmpPath]);
const hasUpstream = (): Future<Error, boolean> =>
execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map(({ exitCode }) => exitCode === 0);

await unlink(tmpPath).catch(() => {});
const getUpstream = (): Future<Error, Maybe<string>> =>
execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map(({ stdout, exitCode }) =>
exitCode !== 0 ? Nothing<string>() : Just(stdout.trim())
);

if (exitCode !== 0) {
throw new Error(stderr.trim() || stdout.trim() || "Commit failed");
}
const getRemoteUrl = (remote: string = "origin"): Future<Error, string> =>
execGitChecked(["remote", "get-url", remote], `Failed to read remote '${remote}' url`).map((s) => s.trim());

const stats = stdout
.split("\n")
.filter((line) => !line.startsWith("["))
.join("\n");
const parseRemoteFromUpstream = (upstream: string): Maybe<string> => {
const idx = upstream.indexOf("/");
return idx > 0 ? Just(upstream.slice(0, idx)) : Nothing();
};

return "\n" + stats.trim() + "\n";
const getTrackingRemoteUrl = (): Future<Error, string> =>
getUpstream().chain((maybeRef) => {
const remote = maybeRef instanceof Just ? parseRemoteFromUpstream(maybeRef.value) : Nothing<string>();
return getRemoteUrl(remote instanceof Just ? remote.value : "origin");
});

const performPush = (branch?: string, publish = false, forceWithLease = false): Future<Error, string> =>
Future.attemptP(async () => {
const args = publish && branch ? ["push", "--set-upstream", "origin", branch] : ["push"];

if (forceWithLease) args.push("--force-with-lease");

const { stdout, stderr, exitCode } = await execGit(args);

if (exitCode !== 0) {
throw new Error(stderr.trim() || stdout.trim() || "Push failed");
const getCommitMetadata = (ref: string = "HEAD"): Future<Error, CommitMetadata> =>
execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain(
(stdout) => {
const [hash, short, subject, authorName, authorEmail, iso] = stdout.split("\n");
return hash && short && subject !== undefined && authorName !== undefined && authorEmail !== undefined && iso ?
Future.resolve<Error, CommitMetadata>({ hash, short, subject, authorName, authorEmail, date: new Date(iso) })
: Future.reject<Error, CommitMetadata>(new Error("Malformed git log output"));
}

return stdout || stderr;
});

const getCurrentBranch = (): Future<Error, string> =>
Future.attemptP(async () => {
const { stdout, exitCode } = await execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
if (exitCode !== 0) throw new Error("Failed to get current branch");
return stdout.trim();
});

const hasUpstream = (): Future<Error, boolean> =>
Future.attemptP(async () => {
const { exitCode } = await execGit(["rev-parse", "--abbrev-ref", "@{u}"]);
return exitCode === 0;
});
);
Loading