@andre-barbosa/opencode-commit 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/generate.d.ts +18 -0
- package/dist/generate.js +58 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +98 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +147 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +118 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +67 -0
- package/package.json +12 -7
- package/commands/commit.md +0 -5
- package/src/generate.ts +0 -89
- package/src/git.ts +0 -116
- package/src/index.ts +0 -149
- package/src/prompt.ts +0 -72
- package/src/types.ts +0 -53
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @andre-barbosa/opencode-commit
|
|
2
2
|
|
|
3
|
-
OpenCode plugin that generates **Conventional Commits** messages from uncommitted git changes using a model you choose.
|
|
3
|
+
OpenCode plugin that generates **Conventional Commits** messages or split commit plans from uncommitted git changes using a model you choose.
|
|
4
4
|
|
|
5
5
|
Run `/commit` in the OpenCode TUI to get a suggested message like `feat(auth): add oauth login flow` — copy it and commit manually.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- `/commit` slash command (display only, no auto-commit)
|
|
10
|
+
- Split commit planning with `/commit split` and `/commit split <folder...>`
|
|
10
11
|
- Dedicated model for commit message generation
|
|
11
12
|
- Conventional Commits format enforced (`feat(scope): ...`, `fix(scope): ...`, etc.)
|
|
12
13
|
- Optional hint: `/commit emphasize breaking API change`
|
|
@@ -23,12 +24,18 @@ Run `/commit` in the OpenCode TUI to get a suggested message like `feat(auth): a
|
|
|
23
24
|
|
|
24
25
|
**Local development** (this repo):
|
|
25
26
|
|
|
27
|
+
Build the plugin first:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run build
|
|
31
|
+
```
|
|
32
|
+
|
|
26
33
|
```json
|
|
27
34
|
{
|
|
28
35
|
"$schema": "https://opencode.ai/config.json",
|
|
29
36
|
"plugin": [
|
|
30
37
|
[
|
|
31
|
-
"file:///C:/absolute/path/to/opencode-commit/
|
|
38
|
+
"file:///C:/absolute/path/to/opencode-commit/dist/index.js",
|
|
32
39
|
{
|
|
33
40
|
"model": "opencode-go/deepseek-v4-flash",
|
|
34
41
|
"maxDiffChars": 12000
|
|
@@ -53,6 +60,7 @@ See [opencode.example.json](opencode.example.json) for a full example.
|
|
|
53
60
|
|
|
54
61
|
Run `opencode models` to list available models.
|
|
55
62
|
Restart OpenCode after editing config.
|
|
63
|
+
When testing local changes, run `npm run build` again before restarting OpenCode.
|
|
56
64
|
|
|
57
65
|
## Usage
|
|
58
66
|
|
|
@@ -67,6 +75,22 @@ Optional extra instruction:
|
|
|
67
75
|
/commit focus on test coverage improvements
|
|
68
76
|
```
|
|
69
77
|
|
|
78
|
+
Split commit planning:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
/commit split
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This suggests one commit message per changed top-level folder. Files in the repo root are grouped under `root`.
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
/commit split apps/web packages/api
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This suggests one commit message for each requested folder with changes under that path.
|
|
91
|
+
|
|
92
|
+
Split mode only suggests a plan. It does not stage files and does not create commits.
|
|
93
|
+
|
|
70
94
|
## Plugin options
|
|
71
95
|
|
|
72
96
|
When loading the plugin as a tuple `[name, options]`:
|
|
@@ -82,6 +106,7 @@ When loading the plugin as a tuple `[name, options]`:
|
|
|
82
106
|
|
|
83
107
|
- Staged tracked changes from `git diff --staged`
|
|
84
108
|
- Unstaged tracked changes from `git diff`
|
|
109
|
+
- Changed-file metadata from `git diff --name-status`
|
|
85
110
|
- Non-ignored untracked filenames from `git ls-files --others --exclude-standard`
|
|
86
111
|
|
|
87
112
|
It does not read untracked file contents and does not ask Git for ignored files.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { CommitGroup, CommitRequest, ModelRef } from "./types.js";
|
|
3
|
+
import type { GitContext } from "./types.js";
|
|
4
|
+
type Client = ReturnType<typeof createOpencodeClient>;
|
|
5
|
+
export declare function formatModelRef(model?: ModelRef): string;
|
|
6
|
+
export declare function generateCommitMessage(input: {
|
|
7
|
+
client: Client;
|
|
8
|
+
parentSessionID: string;
|
|
9
|
+
model: ModelRef;
|
|
10
|
+
context: GitContext;
|
|
11
|
+
request: CommitRequest;
|
|
12
|
+
groups?: CommitGroup[];
|
|
13
|
+
}): Promise<{
|
|
14
|
+
content: string;
|
|
15
|
+
childSessionID: string;
|
|
16
|
+
modelLabel: string;
|
|
17
|
+
}>;
|
|
18
|
+
export {};
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { buildCommitPrompt, COMMIT_SYSTEM_PROMPT, stripCodeFences, } from "./prompt.js";
|
|
2
|
+
const COMMIT_AGENT = "plan";
|
|
3
|
+
function extractText(parts) {
|
|
4
|
+
return parts
|
|
5
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
6
|
+
.map((part) => part.text)
|
|
7
|
+
.join("")
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
export function formatModelRef(model) {
|
|
11
|
+
if (!model)
|
|
12
|
+
return "default";
|
|
13
|
+
return `${model.providerID}/${model.modelID}`;
|
|
14
|
+
}
|
|
15
|
+
async function createChildSession(client, parentSessionID) {
|
|
16
|
+
const { data: session, error } = await client.session.create({
|
|
17
|
+
body: {
|
|
18
|
+
title: "Commit message",
|
|
19
|
+
parentID: parentSessionID,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (error || !session?.id) {
|
|
23
|
+
throw new Error(`Failed to create child session: ${String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
return session.id;
|
|
26
|
+
}
|
|
27
|
+
export async function generateCommitMessage(input) {
|
|
28
|
+
const childSessionID = await createChildSession(input.client, input.parentSessionID);
|
|
29
|
+
const prompt = buildCommitPrompt({
|
|
30
|
+
context: input.context,
|
|
31
|
+
request: input.request,
|
|
32
|
+
groups: input.groups,
|
|
33
|
+
});
|
|
34
|
+
const body = {
|
|
35
|
+
agent: COMMIT_AGENT,
|
|
36
|
+
model: input.model,
|
|
37
|
+
system: COMMIT_SYSTEM_PROMPT,
|
|
38
|
+
parts: [{ type: "text", text: prompt }],
|
|
39
|
+
};
|
|
40
|
+
const { data: result, error } = await input.client.session.prompt({
|
|
41
|
+
path: { id: childSessionID },
|
|
42
|
+
body,
|
|
43
|
+
});
|
|
44
|
+
if (error) {
|
|
45
|
+
throw new Error(`Commit prompt failed: ${String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
const parts = result
|
|
48
|
+
?.parts ?? [];
|
|
49
|
+
const rawText = extractText(parts);
|
|
50
|
+
if (!rawText) {
|
|
51
|
+
throw new Error(`Commit model returned no text. Child session: ${childSessionID}`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
content: stripCodeFences(rawText),
|
|
55
|
+
childSessionID,
|
|
56
|
+
modelLabel: formatModelRef(input.model),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import type { GitContext } from "./types.js";
|
|
3
|
+
type Shell = PluginInput["$"];
|
|
4
|
+
export declare function isGitRepo($: Shell, worktree: string): Promise<boolean>;
|
|
5
|
+
export declare function gatherGitContext($: Shell, worktree: string, maxDiffChars: number): Promise<GitContext>;
|
|
6
|
+
export {};
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
async function readGitOutput($, worktree, run) {
|
|
2
|
+
const result = await run($);
|
|
3
|
+
if (result.exitCode !== 0) {
|
|
4
|
+
return "";
|
|
5
|
+
}
|
|
6
|
+
return result.stdout?.toString().trim() ?? "";
|
|
7
|
+
}
|
|
8
|
+
export async function isGitRepo($, worktree) {
|
|
9
|
+
const result = await $ `git rev-parse --git-dir`.cwd(worktree).quiet().nothrow();
|
|
10
|
+
return result.exitCode === 0;
|
|
11
|
+
}
|
|
12
|
+
function truncateDiff(diff, maxChars, label) {
|
|
13
|
+
if (diff.length <= maxChars)
|
|
14
|
+
return diff;
|
|
15
|
+
return `${diff.slice(0, maxChars)}\n\n[${label} diff truncated at ${maxChars} characters]`;
|
|
16
|
+
}
|
|
17
|
+
function splitDiffs(input) {
|
|
18
|
+
if (!input.staged || !input.unstaged) {
|
|
19
|
+
return {
|
|
20
|
+
staged: truncateDiff(input.staged, input.maxDiffChars, "staged"),
|
|
21
|
+
unstaged: truncateDiff(input.unstaged, input.maxDiffChars, "unstaged"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const half = Math.floor(input.maxDiffChars / 2);
|
|
25
|
+
let stagedLimit = Math.min(input.staged.length, half);
|
|
26
|
+
let unstagedLimit = Math.min(input.unstaged.length, input.maxDiffChars - stagedLimit);
|
|
27
|
+
stagedLimit = Math.min(input.staged.length, input.maxDiffChars - unstagedLimit);
|
|
28
|
+
return {
|
|
29
|
+
staged: truncateDiff(input.staged, stagedLimit, "staged"),
|
|
30
|
+
unstaged: truncateDiff(input.unstaged, unstagedLimit, "unstaged"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function parseNameStatus(output, source) {
|
|
34
|
+
if (!output)
|
|
35
|
+
return [];
|
|
36
|
+
return output
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((line) => {
|
|
41
|
+
const [status, ...paths] = line.split("\t");
|
|
42
|
+
return {
|
|
43
|
+
path: paths[paths.length - 1] ?? "",
|
|
44
|
+
status: status ?? "?",
|
|
45
|
+
source,
|
|
46
|
+
};
|
|
47
|
+
})
|
|
48
|
+
.filter((file) => file.path.length > 0);
|
|
49
|
+
}
|
|
50
|
+
export async function gatherGitContext($, worktree, maxDiffChars) {
|
|
51
|
+
const branch = (await readGitOutput($, worktree, ($) => $ `git symbolic-ref --quiet --short HEAD`.cwd(worktree).quiet().nothrow())) ||
|
|
52
|
+
(await readGitOutput($, worktree, ($) => $ `git rev-parse --short HEAD`.cwd(worktree).quiet().nothrow())) ||
|
|
53
|
+
"unknown";
|
|
54
|
+
const stagedStat = await readGitOutput($, worktree, ($) => $ `git diff --staged --stat`.cwd(worktree).quiet().nothrow());
|
|
55
|
+
const rawStagedDiff = await readGitOutput($, worktree, ($) => $ `git diff --staged`.cwd(worktree).quiet().nothrow());
|
|
56
|
+
const stagedNameStatus = await readGitOutput($, worktree, ($) => $ `git diff --staged --name-status`.cwd(worktree).quiet().nothrow());
|
|
57
|
+
const unstagedStat = await readGitOutput($, worktree, ($) => $ `git diff --stat`.cwd(worktree).quiet().nothrow());
|
|
58
|
+
const rawUnstagedDiff = await readGitOutput($, worktree, ($) => $ `git diff`.cwd(worktree).quiet().nothrow());
|
|
59
|
+
const unstagedNameStatus = await readGitOutput($, worktree, ($) => $ `git diff --name-status`.cwd(worktree).quiet().nothrow());
|
|
60
|
+
const untrackedOutput = await readGitOutput($, worktree, ($) => $ `git ls-files --others --exclude-standard`.cwd(worktree).quiet().nothrow());
|
|
61
|
+
const untrackedFiles = untrackedOutput
|
|
62
|
+
? untrackedOutput.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
63
|
+
: [];
|
|
64
|
+
const diffs = splitDiffs({
|
|
65
|
+
staged: rawStagedDiff,
|
|
66
|
+
unstaged: rawUnstagedDiff,
|
|
67
|
+
maxDiffChars,
|
|
68
|
+
});
|
|
69
|
+
const changedFiles = [
|
|
70
|
+
...parseNameStatus(stagedNameStatus, "staged"),
|
|
71
|
+
...parseNameStatus(unstagedNameStatus, "unstaged"),
|
|
72
|
+
...untrackedFiles.map((path) => ({
|
|
73
|
+
path,
|
|
74
|
+
status: "A",
|
|
75
|
+
source: "untracked",
|
|
76
|
+
})),
|
|
77
|
+
];
|
|
78
|
+
const logOutput = await readGitOutput($, worktree, ($) => $ `git log -5 --pretty=format:%s`.cwd(worktree).quiet().nothrow());
|
|
79
|
+
const recentCommits = logOutput
|
|
80
|
+
? logOutput.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
81
|
+
: [];
|
|
82
|
+
const hasUncommittedChanges = stagedStat.length > 0 ||
|
|
83
|
+
rawStagedDiff.length > 0 ||
|
|
84
|
+
unstagedStat.length > 0 ||
|
|
85
|
+
rawUnstagedDiff.length > 0 ||
|
|
86
|
+
untrackedFiles.length > 0;
|
|
87
|
+
return {
|
|
88
|
+
branch,
|
|
89
|
+
stagedStat,
|
|
90
|
+
stagedDiff: diffs.staged,
|
|
91
|
+
unstagedStat,
|
|
92
|
+
unstagedDiff: diffs.unstaged,
|
|
93
|
+
changedFiles,
|
|
94
|
+
untrackedFiles,
|
|
95
|
+
recentCommits,
|
|
96
|
+
hasUncommittedChanges,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { gatherGitContext, isGitRepo } from "./git.js";
|
|
2
|
+
import { generateCommitMessage } from "./generate.js";
|
|
3
|
+
import { buildCommitGroups, parseCommitRequest, parseModelRef, resolveConfig, } from "./types.js";
|
|
4
|
+
const COMMAND_NAME = "commit";
|
|
5
|
+
const COMMAND_DESCRIPTION = "Generate a commit message or split commit plan from uncommitted changes";
|
|
6
|
+
const COMMAND_TEMPLATE = "Handled by the opencode-commit plugin.";
|
|
7
|
+
function textPart(text) {
|
|
8
|
+
return { type: "text", text };
|
|
9
|
+
}
|
|
10
|
+
function formatOutput(input) {
|
|
11
|
+
if (input.request.mode === "split") {
|
|
12
|
+
const missingFolders = input.missingFolders ?? [];
|
|
13
|
+
const missingBlock = missingFolders.length > 0
|
|
14
|
+
? [
|
|
15
|
+
`No changes found for requested folder(s): ${formatFolderList(missingFolders)}.`,
|
|
16
|
+
"",
|
|
17
|
+
]
|
|
18
|
+
: [];
|
|
19
|
+
return [
|
|
20
|
+
"## Suggested commit plan",
|
|
21
|
+
"",
|
|
22
|
+
...missingBlock,
|
|
23
|
+
input.content,
|
|
24
|
+
"",
|
|
25
|
+
`Model: \`${input.modelLabel}\` · Child session: \`${input.childSessionID}\``,
|
|
26
|
+
"",
|
|
27
|
+
"Stage the listed files for each commit, then run `git commit` with the suggested message.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
"## Suggested commit message",
|
|
32
|
+
"",
|
|
33
|
+
"```",
|
|
34
|
+
input.content,
|
|
35
|
+
"```",
|
|
36
|
+
"",
|
|
37
|
+
`Model: \`${input.modelLabel}\` · Child session: \`${input.childSessionID}\``,
|
|
38
|
+
"",
|
|
39
|
+
"Copy the message above and run `git commit` when ready.",
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
function formatFolderList(folders) {
|
|
43
|
+
return folders.map((folder) => `\`${folder}\``).join(", ");
|
|
44
|
+
}
|
|
45
|
+
export const OpenCodeCommitPlugin = async ({ client, $, worktree }, options) => {
|
|
46
|
+
const config = resolveConfig(options);
|
|
47
|
+
const log = async (level, message, extra) => {
|
|
48
|
+
await client.app
|
|
49
|
+
.log({
|
|
50
|
+
body: {
|
|
51
|
+
service: "opencode-commit",
|
|
52
|
+
level,
|
|
53
|
+
message,
|
|
54
|
+
extra,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
.catch(() => { });
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
config: async (cfg) => {
|
|
61
|
+
cfg.command ??= {};
|
|
62
|
+
cfg.command[COMMAND_NAME] = {
|
|
63
|
+
description: COMMAND_DESCRIPTION,
|
|
64
|
+
template: COMMAND_TEMPLATE,
|
|
65
|
+
};
|
|
66
|
+
if (config.model) {
|
|
67
|
+
cfg.command[COMMAND_NAME].model = config.model;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"command.execute.before": async (input, output) => {
|
|
71
|
+
if (input.command !== COMMAND_NAME)
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
if (!config.model) {
|
|
75
|
+
output.parts = [
|
|
76
|
+
textPart([
|
|
77
|
+
"No commit model configured.",
|
|
78
|
+
"",
|
|
79
|
+
"Add a `model` option to the opencode-commit plugin config, for example:",
|
|
80
|
+
"`[\"@andre-barbosa/opencode-commit\", { \"model\": \"opencode-go/deepseek-v4-flash\" }]`",
|
|
81
|
+
].join("\n")),
|
|
82
|
+
];
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const model = parseModelRef(config.model);
|
|
86
|
+
const request = parseCommitRequest(input.arguments);
|
|
87
|
+
if (!(await isGitRepo($, worktree))) {
|
|
88
|
+
output.parts = [
|
|
89
|
+
textPart("Not a git repository. Run `/commit` from inside a git worktree."),
|
|
90
|
+
];
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const context = await gatherGitContext($, worktree, config.maxDiffChars);
|
|
94
|
+
if (!context.hasUncommittedChanges) {
|
|
95
|
+
output.parts = [
|
|
96
|
+
textPart("No uncommitted changes found. Make changes and run `/commit` again."),
|
|
97
|
+
];
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let groups;
|
|
101
|
+
let missingFolders = [];
|
|
102
|
+
if (request.mode === "split") {
|
|
103
|
+
const grouped = buildCommitGroups(context.changedFiles, request.folders);
|
|
104
|
+
groups = grouped.groups;
|
|
105
|
+
missingFolders = grouped.missingFolders;
|
|
106
|
+
if (groups.length === 0) {
|
|
107
|
+
const target = request.folders.length > 0
|
|
108
|
+
? `requested folder(s): ${formatFolderList(request.folders)}`
|
|
109
|
+
: "changed folders";
|
|
110
|
+
output.parts = [textPart(`No changes found for ${target}.`)];
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await log("info", "Generating commit message", {
|
|
115
|
+
model: config.model,
|
|
116
|
+
mode: request.mode,
|
|
117
|
+
folders: request.mode === "split" ? request.folders : undefined,
|
|
118
|
+
branch: context.branch,
|
|
119
|
+
sessionID: input.sessionID,
|
|
120
|
+
});
|
|
121
|
+
const result = await generateCommitMessage({
|
|
122
|
+
client,
|
|
123
|
+
parentSessionID: input.sessionID,
|
|
124
|
+
model,
|
|
125
|
+
context,
|
|
126
|
+
request,
|
|
127
|
+
groups,
|
|
128
|
+
});
|
|
129
|
+
output.parts = [
|
|
130
|
+
textPart(formatOutput({ ...result, request, missingFolders })),
|
|
131
|
+
];
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
await log("error", "Commit message generation failed", {
|
|
136
|
+
error: String(error),
|
|
137
|
+
sessionID: input.sessionID,
|
|
138
|
+
});
|
|
139
|
+
output.parts = [
|
|
140
|
+
textPart(`Failed to generate commit message: ${String(error)}`),
|
|
141
|
+
];
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
export default OpenCodeCommitPlugin;
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CommitGroup, CommitRequest, GitContext } from "./types.js";
|
|
2
|
+
export declare const COMMIT_SYSTEM_PROMPT: string;
|
|
3
|
+
export declare function buildCommitPrompt(input: {
|
|
4
|
+
context: GitContext;
|
|
5
|
+
request: CommitRequest;
|
|
6
|
+
groups?: CommitGroup[];
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function stripCodeFences(text: string): string;
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const COMMIT_SYSTEM_PROMPT = [
|
|
2
|
+
"You are a commit message writer.",
|
|
3
|
+
"Given git changes and context, produce the requested commit output.",
|
|
4
|
+
"Output only the requested commit message or commit plan.",
|
|
5
|
+
].join("\n");
|
|
6
|
+
function formatChangedFiles(files) {
|
|
7
|
+
if (files.length === 0)
|
|
8
|
+
return "(none)";
|
|
9
|
+
const filesByPath = new Map();
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
filesByPath.set(file.path, [
|
|
12
|
+
...(filesByPath.get(file.path) ?? []),
|
|
13
|
+
`${file.source}:${file.status}`,
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
return Array.from(filesByPath, ([path, details]) => `- ${path} (${details.join(", ")})`).join("\n");
|
|
17
|
+
}
|
|
18
|
+
function formatCommitGroups(groups) {
|
|
19
|
+
if (groups.length === 0)
|
|
20
|
+
return "(none)";
|
|
21
|
+
return groups
|
|
22
|
+
.map((group) => `### ${group.name}\n${formatChangedFiles(group.files)}`)
|
|
23
|
+
.join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
export function buildCommitPrompt(input) {
|
|
26
|
+
const { context, request } = input;
|
|
27
|
+
const recentCommitsBlock = context.recentCommits.length > 0
|
|
28
|
+
? context.recentCommits.map((subject) => `- ${subject}`).join("\n")
|
|
29
|
+
: "(none)";
|
|
30
|
+
const untrackedFilesBlock = context.untrackedFiles.length > 0
|
|
31
|
+
? context.untrackedFiles.map((file) => `- ${file}`).join("\n")
|
|
32
|
+
: "(none)";
|
|
33
|
+
const changedFilesBlock = formatChangedFiles(context.changedFiles);
|
|
34
|
+
const hintBlock = request.mode === "single" && request.userHint
|
|
35
|
+
? `\nAdditional instruction from the user:\n${request.userHint}\n`
|
|
36
|
+
: "";
|
|
37
|
+
const contextBlock = `Branch: ${context.branch}
|
|
38
|
+
|
|
39
|
+
Recent commit subjects (align scope naming when sensible):
|
|
40
|
+
${recentCommitsBlock}
|
|
41
|
+
|
|
42
|
+
Changed files:
|
|
43
|
+
${changedFilesBlock}
|
|
44
|
+
|
|
45
|
+
Staged tracked changes (stat):
|
|
46
|
+
${context.stagedStat || "(empty)"}
|
|
47
|
+
|
|
48
|
+
Staged tracked diff:
|
|
49
|
+
${context.stagedDiff || "(empty)"}
|
|
50
|
+
|
|
51
|
+
Unstaged tracked changes (stat):
|
|
52
|
+
${context.unstagedStat || "(empty)"}
|
|
53
|
+
|
|
54
|
+
Unstaged tracked diff:
|
|
55
|
+
${context.unstagedDiff || "(empty)"}
|
|
56
|
+
|
|
57
|
+
Untracked files (filenames only, contents not read):
|
|
58
|
+
${untrackedFilesBlock}`;
|
|
59
|
+
if (request.mode === "split") {
|
|
60
|
+
return `Write a split commit plan for the uncommitted changes below.
|
|
61
|
+
|
|
62
|
+
Format rules (Conventional Commits - required):
|
|
63
|
+
- Produce one commit plan item for each split group listed below
|
|
64
|
+
- Do NOT include groups that are not listed below
|
|
65
|
+
- Each message subject MUST use type(scope): description or type: description
|
|
66
|
+
- Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
67
|
+
- Scope is optional but preferred when changes are localized (e.g. feat(auth):, fix(api):)
|
|
68
|
+
- Description: imperative mood, lowercase, no trailing period, max 72 chars for the full subject
|
|
69
|
+
- Optional body: blank line after subject, then bullet points; keep lines <= 72 chars
|
|
70
|
+
- Breaking changes: use feat!: or fix!: prefix, or add a BREAKING CHANGE: footer
|
|
71
|
+
- Use staged and unstaged tracked diffs as the source of truth
|
|
72
|
+
- Untracked files are filenames only; do not infer contents that are not shown
|
|
73
|
+
- Output ONLY the commit plan markdown - no top-level title, no commentary, no tool calls
|
|
74
|
+
|
|
75
|
+
Output format for each group:
|
|
76
|
+
### group-name
|
|
77
|
+
Files:
|
|
78
|
+
- path/to/file
|
|
79
|
+
|
|
80
|
+
Message:
|
|
81
|
+
fenced text block containing only that group's commit message
|
|
82
|
+
|
|
83
|
+
Split groups:
|
|
84
|
+
${formatCommitGroups(input.groups ?? [])}
|
|
85
|
+
|
|
86
|
+
${contextBlock}
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
return `Write a git commit message for the uncommitted changes below.
|
|
90
|
+
|
|
91
|
+
Format rules (Conventional Commits - required):
|
|
92
|
+
- Subject line MUST use type(scope): description or type: description
|
|
93
|
+
- Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
94
|
+
- Scope is optional but preferred when changes are localized (e.g. feat(auth):, fix(api):)
|
|
95
|
+
- Description: imperative mood, lowercase, no trailing period, max 72 chars for the full subject
|
|
96
|
+
- Optional body: blank line after subject, then bullet points; keep lines <= 72 chars
|
|
97
|
+
- Breaking changes: use feat!: or fix!: prefix, or add a BREAKING CHANGE: footer
|
|
98
|
+
- Do NOT use free-form subjects like "Update files", "WIP", or "Misc changes"
|
|
99
|
+
- Use staged and unstaged tracked diffs as the source of truth
|
|
100
|
+
- Untracked files are filenames only; do not infer contents that are not shown
|
|
101
|
+
- Output ONLY the commit message text - no code fences, no commentary, no tool calls
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
feat(auth): add oauth login flow
|
|
105
|
+
|
|
106
|
+
- add google and github providers
|
|
107
|
+
- store refresh tokens in secure storage
|
|
108
|
+
|
|
109
|
+
fix(api): handle null response from user endpoint
|
|
110
|
+
${hintBlock}
|
|
111
|
+
${contextBlock}
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
export function stripCodeFences(text) {
|
|
115
|
+
const trimmed = text.trim();
|
|
116
|
+
const fenced = trimmed.match(/^```(?:\w*\n)?([\s\S]*?)```$/);
|
|
117
|
+
return (fenced?.[1] ?? trimmed).trim();
|
|
118
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type ModelRef = {
|
|
2
|
+
providerID: string;
|
|
3
|
+
modelID: string;
|
|
4
|
+
};
|
|
5
|
+
export type CommitRequest = {
|
|
6
|
+
mode: "single";
|
|
7
|
+
userHint?: string;
|
|
8
|
+
} | {
|
|
9
|
+
mode: "split";
|
|
10
|
+
folders: string[];
|
|
11
|
+
};
|
|
12
|
+
export type ChangedFileSource = "staged" | "unstaged" | "untracked";
|
|
13
|
+
export type ChangedFile = {
|
|
14
|
+
path: string;
|
|
15
|
+
status: string;
|
|
16
|
+
source: ChangedFileSource;
|
|
17
|
+
};
|
|
18
|
+
export type CommitGroup = {
|
|
19
|
+
name: string;
|
|
20
|
+
files: ChangedFile[];
|
|
21
|
+
};
|
|
22
|
+
export type PluginOptions = {
|
|
23
|
+
model?: string;
|
|
24
|
+
maxDiffChars?: number;
|
|
25
|
+
};
|
|
26
|
+
export type ResolvedPluginConfig = {
|
|
27
|
+
model?: string;
|
|
28
|
+
maxDiffChars: number;
|
|
29
|
+
};
|
|
30
|
+
export declare const DEFAULT_MAX_DIFF_CHARS = 12000;
|
|
31
|
+
export declare function resolveConfig(options?: PluginOptions): ResolvedPluginConfig;
|
|
32
|
+
export declare function parseModelRef(model: string): ModelRef;
|
|
33
|
+
export declare function parseCommitRequest(args?: string): CommitRequest;
|
|
34
|
+
export declare function buildCommitGroups(files: ChangedFile[], folders: string[]): {
|
|
35
|
+
groups: CommitGroup[];
|
|
36
|
+
missingFolders: string[];
|
|
37
|
+
};
|
|
38
|
+
export type GitContext = {
|
|
39
|
+
branch: string;
|
|
40
|
+
stagedStat: string;
|
|
41
|
+
stagedDiff: string;
|
|
42
|
+
unstagedStat: string;
|
|
43
|
+
unstagedDiff: string;
|
|
44
|
+
changedFiles: ChangedFile[];
|
|
45
|
+
untrackedFiles: string[];
|
|
46
|
+
recentCommits: string[];
|
|
47
|
+
hasUncommittedChanges: boolean;
|
|
48
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const DEFAULT_MAX_DIFF_CHARS = 12_000;
|
|
2
|
+
export function resolveConfig(options) {
|
|
3
|
+
const model = typeof options?.model === "string" ? options.model.trim() : "";
|
|
4
|
+
return {
|
|
5
|
+
model: model || undefined,
|
|
6
|
+
maxDiffChars: typeof options?.maxDiffChars === "number" && options.maxDiffChars > 0
|
|
7
|
+
? Math.floor(options.maxDiffChars)
|
|
8
|
+
: DEFAULT_MAX_DIFF_CHARS,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function parseModelRef(model) {
|
|
12
|
+
const separator = model.indexOf("/");
|
|
13
|
+
if (separator <= 0 || separator === model.length - 1) {
|
|
14
|
+
throw new Error(`Invalid commit model \`${model}\`. Use the format \`provider/model-id\`.`);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
providerID: model.slice(0, separator),
|
|
18
|
+
modelID: model.slice(separator + 1),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function normalizeFolder(folder) {
|
|
22
|
+
return folder
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/^\.\/+/, "")
|
|
25
|
+
.replace(/^\/+/, "")
|
|
26
|
+
.replace(/\/+$/, "");
|
|
27
|
+
}
|
|
28
|
+
export function parseCommitRequest(args) {
|
|
29
|
+
const trimmed = args?.trim() ?? "";
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
return { mode: "single" };
|
|
32
|
+
const tokens = trimmed.split(/\s+/);
|
|
33
|
+
if (tokens[0]?.toLowerCase() !== "split") {
|
|
34
|
+
return { mode: "single", userHint: trimmed };
|
|
35
|
+
}
|
|
36
|
+
const folders = Array.from(new Set(tokens.slice(1).map(normalizeFolder).filter(Boolean)));
|
|
37
|
+
return { mode: "split", folders };
|
|
38
|
+
}
|
|
39
|
+
function groupNameForPath(path) {
|
|
40
|
+
const slashIndex = path.indexOf("/");
|
|
41
|
+
return slashIndex === -1 ? "root" : path.slice(0, slashIndex);
|
|
42
|
+
}
|
|
43
|
+
function isInsideFolder(path, folder) {
|
|
44
|
+
return path === folder || path.startsWith(`${folder}/`);
|
|
45
|
+
}
|
|
46
|
+
export function buildCommitGroups(files, folders) {
|
|
47
|
+
if (folders.length > 0) {
|
|
48
|
+
const groups = folders
|
|
49
|
+
.map((folder) => ({
|
|
50
|
+
name: folder,
|
|
51
|
+
files: files.filter((file) => isInsideFolder(file.path, folder)),
|
|
52
|
+
}))
|
|
53
|
+
.filter((group) => group.files.length > 0);
|
|
54
|
+
const missingFolders = folders.filter((folder) => !groups.some((group) => group.name === folder));
|
|
55
|
+
return { groups, missingFolders };
|
|
56
|
+
}
|
|
57
|
+
const groupsByName = new Map();
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const name = groupNameForPath(file.path);
|
|
60
|
+
groupsByName.set(name, [...(groupsByName.get(name) ?? []), file]);
|
|
61
|
+
}
|
|
62
|
+
const groups = Array.from(groupsByName, ([name, groupFiles]) => ({
|
|
63
|
+
name,
|
|
64
|
+
files: groupFiles,
|
|
65
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
return { groups, missingFolders: [] };
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andre-barbosa/opencode-commit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "OpenCode plugin that generates Conventional Commit messages via /commit using a configured model",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "OpenCode plugin that generates Conventional Commit messages and split commit plans via /commit using a configured model",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"files": [
|
|
11
|
-
"
|
|
12
|
-
"commands",
|
|
15
|
+
"dist",
|
|
13
16
|
"opencode.example.json",
|
|
14
17
|
"README.md",
|
|
15
18
|
"LICENSE"
|
|
16
19
|
],
|
|
17
20
|
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
22
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
20
25
|
},
|
|
21
26
|
"keywords": [
|
|
22
27
|
"opencode",
|
package/commands/commit.md
DELETED
package/src/generate.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
-
import type { ModelRef } from "./types.js";
|
|
3
|
-
import {
|
|
4
|
-
buildCommitPrompt,
|
|
5
|
-
COMMIT_SYSTEM_PROMPT,
|
|
6
|
-
stripCodeFences,
|
|
7
|
-
} from "./prompt.js";
|
|
8
|
-
import type { GitContext } from "./types.js";
|
|
9
|
-
|
|
10
|
-
type Client = ReturnType<typeof createOpencodeClient>;
|
|
11
|
-
const COMMIT_AGENT = "plan";
|
|
12
|
-
|
|
13
|
-
function extractText(parts: Array<{ type?: string; text?: string }>): string {
|
|
14
|
-
return parts
|
|
15
|
-
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
16
|
-
.map((part) => part.text as string)
|
|
17
|
-
.join("")
|
|
18
|
-
.trim();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function formatModelRef(model?: ModelRef): string {
|
|
22
|
-
if (!model) return "default";
|
|
23
|
-
return `${model.providerID}/${model.modelID}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function createChildSession(
|
|
27
|
-
client: Client,
|
|
28
|
-
parentSessionID: string,
|
|
29
|
-
): Promise<string> {
|
|
30
|
-
const { data: session, error } = await client.session.create({
|
|
31
|
-
body: {
|
|
32
|
-
title: "Commit message",
|
|
33
|
-
parentID: parentSessionID,
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
if (error || !session?.id) {
|
|
38
|
-
throw new Error(`Failed to create child session: ${String(error)}`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return session.id;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function generateCommitMessage(input: {
|
|
45
|
-
client: Client;
|
|
46
|
-
parentSessionID: string;
|
|
47
|
-
model: ModelRef;
|
|
48
|
-
context: GitContext;
|
|
49
|
-
userHint?: string;
|
|
50
|
-
}): Promise<{ message: string; childSessionID: string; modelLabel: string }> {
|
|
51
|
-
const childSessionID = await createChildSession(
|
|
52
|
-
input.client,
|
|
53
|
-
input.parentSessionID,
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const prompt = buildCommitPrompt(input.context, input.userHint);
|
|
57
|
-
const body = {
|
|
58
|
-
agent: COMMIT_AGENT,
|
|
59
|
-
model: input.model,
|
|
60
|
-
system: COMMIT_SYSTEM_PROMPT,
|
|
61
|
-
parts: [{ type: "text" as const, text: prompt }],
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const { data: result, error } = await input.client.session.prompt({
|
|
65
|
-
path: { id: childSessionID },
|
|
66
|
-
body,
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
if (error) {
|
|
70
|
-
throw new Error(`Commit prompt failed: ${String(error)}`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const parts =
|
|
74
|
-
(result as { parts?: Array<{ type?: string; text?: string }> } | undefined)
|
|
75
|
-
?.parts ?? [];
|
|
76
|
-
const rawText = extractText(parts);
|
|
77
|
-
|
|
78
|
-
if (!rawText) {
|
|
79
|
-
throw new Error(
|
|
80
|
-
`Commit model returned no text. Child session: ${childSessionID}`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
message: stripCodeFences(rawText),
|
|
86
|
-
childSessionID,
|
|
87
|
-
modelLabel: formatModelRef(input.model),
|
|
88
|
-
};
|
|
89
|
-
}
|
package/src/git.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
-
import type { GitContext } from "./types.js";
|
|
3
|
-
|
|
4
|
-
type Shell = PluginInput["$"];
|
|
5
|
-
|
|
6
|
-
async function readGitOutput(
|
|
7
|
-
$: Shell,
|
|
8
|
-
worktree: string,
|
|
9
|
-
run: ($: Shell) => Promise<{ exitCode: number; stdout?: { toString(): string } }>,
|
|
10
|
-
): Promise<string> {
|
|
11
|
-
const result = await run($);
|
|
12
|
-
if (result.exitCode !== 0) {
|
|
13
|
-
return "";
|
|
14
|
-
}
|
|
15
|
-
return result.stdout?.toString().trim() ?? "";
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function isGitRepo($: Shell, worktree: string): Promise<boolean> {
|
|
19
|
-
const result = await $`git rev-parse --git-dir`.cwd(worktree).quiet().nothrow();
|
|
20
|
-
return result.exitCode === 0;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function truncateDiff(diff: string, maxChars: number, label: string): string {
|
|
24
|
-
if (diff.length <= maxChars) return diff;
|
|
25
|
-
return `${diff.slice(0, maxChars)}\n\n[${label} diff truncated at ${maxChars} characters]`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function splitDiffs(input: {
|
|
29
|
-
staged: string;
|
|
30
|
-
unstaged: string;
|
|
31
|
-
maxDiffChars: number;
|
|
32
|
-
}): { staged: string; unstaged: string } {
|
|
33
|
-
if (!input.staged || !input.unstaged) {
|
|
34
|
-
return {
|
|
35
|
-
staged: truncateDiff(input.staged, input.maxDiffChars, "staged"),
|
|
36
|
-
unstaged: truncateDiff(input.unstaged, input.maxDiffChars, "unstaged"),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const half = Math.floor(input.maxDiffChars / 2);
|
|
41
|
-
let stagedLimit = Math.min(input.staged.length, half);
|
|
42
|
-
let unstagedLimit = Math.min(
|
|
43
|
-
input.unstaged.length,
|
|
44
|
-
input.maxDiffChars - stagedLimit,
|
|
45
|
-
);
|
|
46
|
-
stagedLimit = Math.min(input.staged.length, input.maxDiffChars - unstagedLimit);
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
staged: truncateDiff(input.staged, stagedLimit, "staged"),
|
|
50
|
-
unstaged: truncateDiff(input.unstaged, unstagedLimit, "unstaged"),
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function gatherGitContext(
|
|
55
|
-
$: Shell,
|
|
56
|
-
worktree: string,
|
|
57
|
-
maxDiffChars: number,
|
|
58
|
-
): Promise<GitContext> {
|
|
59
|
-
const branch =
|
|
60
|
-
(await readGitOutput($, worktree, ($) =>
|
|
61
|
-
$`git symbolic-ref --quiet --short HEAD`.cwd(worktree).quiet().nothrow(),
|
|
62
|
-
)) ||
|
|
63
|
-
(await readGitOutput($, worktree, ($) =>
|
|
64
|
-
$`git rev-parse --short HEAD`.cwd(worktree).quiet().nothrow(),
|
|
65
|
-
)) ||
|
|
66
|
-
"unknown";
|
|
67
|
-
|
|
68
|
-
const stagedStat = await readGitOutput($, worktree, ($) =>
|
|
69
|
-
$`git diff --staged --stat`.cwd(worktree).quiet().nothrow(),
|
|
70
|
-
);
|
|
71
|
-
const rawStagedDiff = await readGitOutput($, worktree, ($) =>
|
|
72
|
-
$`git diff --staged`.cwd(worktree).quiet().nothrow(),
|
|
73
|
-
);
|
|
74
|
-
const unstagedStat = await readGitOutput($, worktree, ($) =>
|
|
75
|
-
$`git diff --stat`.cwd(worktree).quiet().nothrow(),
|
|
76
|
-
);
|
|
77
|
-
const rawUnstagedDiff = await readGitOutput($, worktree, ($) =>
|
|
78
|
-
$`git diff`.cwd(worktree).quiet().nothrow(),
|
|
79
|
-
);
|
|
80
|
-
const untrackedOutput = await readGitOutput($, worktree, ($) =>
|
|
81
|
-
$`git ls-files --others --exclude-standard`.cwd(worktree).quiet().nothrow(),
|
|
82
|
-
);
|
|
83
|
-
const untrackedFiles = untrackedOutput
|
|
84
|
-
? untrackedOutput.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
85
|
-
: [];
|
|
86
|
-
const diffs = splitDiffs({
|
|
87
|
-
staged: rawStagedDiff,
|
|
88
|
-
unstaged: rawUnstagedDiff,
|
|
89
|
-
maxDiffChars,
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const logOutput = await readGitOutput($, worktree, ($) =>
|
|
93
|
-
$`git log -5 --pretty=format:%s`.cwd(worktree).quiet().nothrow(),
|
|
94
|
-
);
|
|
95
|
-
const recentCommits = logOutput
|
|
96
|
-
? logOutput.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
97
|
-
: [];
|
|
98
|
-
|
|
99
|
-
const hasUncommittedChanges =
|
|
100
|
-
stagedStat.length > 0 ||
|
|
101
|
-
rawStagedDiff.length > 0 ||
|
|
102
|
-
unstagedStat.length > 0 ||
|
|
103
|
-
rawUnstagedDiff.length > 0 ||
|
|
104
|
-
untrackedFiles.length > 0;
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
branch,
|
|
108
|
-
stagedStat,
|
|
109
|
-
stagedDiff: diffs.staged,
|
|
110
|
-
unstagedStat,
|
|
111
|
-
unstagedDiff: diffs.unstaged,
|
|
112
|
-
untrackedFiles,
|
|
113
|
-
recentCommits,
|
|
114
|
-
hasUncommittedChanges,
|
|
115
|
-
};
|
|
116
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
-
import type { Part } from "@opencode-ai/sdk";
|
|
3
|
-
import { gatherGitContext, isGitRepo } from "./git.js";
|
|
4
|
-
import { generateCommitMessage } from "./generate.js";
|
|
5
|
-
import { parseModelRef, resolveConfig, type PluginOptions } from "./types.js";
|
|
6
|
-
|
|
7
|
-
const COMMAND_NAME = "commit";
|
|
8
|
-
const COMMAND_DESCRIPTION = "Generate a commit message from uncommitted changes";
|
|
9
|
-
const COMMAND_TEMPLATE = "Handled by the opencode-commit plugin.";
|
|
10
|
-
const SKIP_ERROR = "skip";
|
|
11
|
-
|
|
12
|
-
function textPart(text: string): Part {
|
|
13
|
-
return { type: "text", text } as Part;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function formatOutput(input: {
|
|
17
|
-
message: string;
|
|
18
|
-
modelLabel: string;
|
|
19
|
-
childSessionID: string;
|
|
20
|
-
}): string {
|
|
21
|
-
return [
|
|
22
|
-
"## Suggested commit message",
|
|
23
|
-
"",
|
|
24
|
-
"```",
|
|
25
|
-
input.message,
|
|
26
|
-
"```",
|
|
27
|
-
"",
|
|
28
|
-
`Model: \`${input.modelLabel}\` · Child session: \`${input.childSessionID}\``,
|
|
29
|
-
"",
|
|
30
|
-
"Copy the message above and run `git commit` when ready.",
|
|
31
|
-
].join("\n");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const skipCommand = (): never => {
|
|
35
|
-
throw new Error(SKIP_ERROR);
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const OpenCodeCommitPlugin: Plugin = async (
|
|
39
|
-
{ client, $, worktree },
|
|
40
|
-
options?: PluginOptions,
|
|
41
|
-
) => {
|
|
42
|
-
const config = resolveConfig(options);
|
|
43
|
-
|
|
44
|
-
const log = async (
|
|
45
|
-
level: "info" | "warn" | "error",
|
|
46
|
-
message: string,
|
|
47
|
-
extra?: Record<string, unknown>,
|
|
48
|
-
) => {
|
|
49
|
-
await client.app
|
|
50
|
-
.log({
|
|
51
|
-
body: {
|
|
52
|
-
service: "opencode-commit",
|
|
53
|
-
level,
|
|
54
|
-
message,
|
|
55
|
-
extra,
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
.catch(() => {});
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
config: async (cfg) => {
|
|
63
|
-
cfg.command ??= {};
|
|
64
|
-
cfg.command[COMMAND_NAME] ??= {
|
|
65
|
-
description: COMMAND_DESCRIPTION,
|
|
66
|
-
template: COMMAND_TEMPLATE,
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
if (config.model && !cfg.command[COMMAND_NAME].model) {
|
|
70
|
-
cfg.command[COMMAND_NAME].model = config.model;
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
"command.execute.before": async (input, output) => {
|
|
74
|
-
if (input.command !== COMMAND_NAME) return;
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
if (!config.model) {
|
|
78
|
-
output.parts = [
|
|
79
|
-
textPart(
|
|
80
|
-
[
|
|
81
|
-
"No commit model configured.",
|
|
82
|
-
"",
|
|
83
|
-
"Add a `model` option to the opencode-commit plugin config, for example:",
|
|
84
|
-
"`[\"@andre-barbosa/opencode-commit\", { \"model\": \"opencode-go/deepseek-v4-flash\" }]`",
|
|
85
|
-
].join("\n"),
|
|
86
|
-
),
|
|
87
|
-
];
|
|
88
|
-
return skipCommand();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const model = parseModelRef(config.model);
|
|
92
|
-
|
|
93
|
-
if (!(await isGitRepo($, worktree))) {
|
|
94
|
-
output.parts = [
|
|
95
|
-
textPart(
|
|
96
|
-
"Not a git repository. Run `/commit` from inside a git worktree.",
|
|
97
|
-
),
|
|
98
|
-
];
|
|
99
|
-
return skipCommand();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const context = await gatherGitContext($, worktree, config.maxDiffChars);
|
|
103
|
-
|
|
104
|
-
if (!context.hasUncommittedChanges) {
|
|
105
|
-
output.parts = [
|
|
106
|
-
textPart(
|
|
107
|
-
"No uncommitted changes found. Make changes and run `/commit` again.",
|
|
108
|
-
),
|
|
109
|
-
];
|
|
110
|
-
return skipCommand();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
await log("info", "Generating commit message", {
|
|
114
|
-
model: config.model,
|
|
115
|
-
branch: context.branch,
|
|
116
|
-
sessionID: input.sessionID,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const userHint = input.arguments?.trim() || undefined;
|
|
120
|
-
const result = await generateCommitMessage({
|
|
121
|
-
client,
|
|
122
|
-
parentSessionID: input.sessionID,
|
|
123
|
-
model,
|
|
124
|
-
context,
|
|
125
|
-
userHint,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
output.parts = [textPart(formatOutput(result))];
|
|
129
|
-
return skipCommand();
|
|
130
|
-
} catch (error) {
|
|
131
|
-
if (error instanceof Error && error.message === SKIP_ERROR) {
|
|
132
|
-
throw error;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
await log("error", "Commit message generation failed", {
|
|
136
|
-
error: String(error),
|
|
137
|
-
sessionID: input.sessionID,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
output.parts = [
|
|
141
|
-
textPart(`Failed to generate commit message: ${String(error)}`),
|
|
142
|
-
];
|
|
143
|
-
return skipCommand();
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
export default OpenCodeCommitPlugin;
|
package/src/prompt.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import type { GitContext } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export const COMMIT_SYSTEM_PROMPT = [
|
|
4
|
-
"You are a commit message writer.",
|
|
5
|
-
"Given git changes and context, produce a single commit message.",
|
|
6
|
-
"Output only the commit message text.",
|
|
7
|
-
].join("\n");
|
|
8
|
-
|
|
9
|
-
export function buildCommitPrompt(context: GitContext, userHint?: string): string {
|
|
10
|
-
const recentCommitsBlock =
|
|
11
|
-
context.recentCommits.length > 0
|
|
12
|
-
? context.recentCommits.map((subject) => `- ${subject}`).join("\n")
|
|
13
|
-
: "(none)";
|
|
14
|
-
const untrackedFilesBlock =
|
|
15
|
-
context.untrackedFiles.length > 0
|
|
16
|
-
? context.untrackedFiles.map((file) => `- ${file}`).join("\n")
|
|
17
|
-
: "(none)";
|
|
18
|
-
|
|
19
|
-
const hintBlock =
|
|
20
|
-
userHint && userHint.trim().length > 0
|
|
21
|
-
? `\nAdditional instruction from the user:\n${userHint.trim()}\n`
|
|
22
|
-
: "";
|
|
23
|
-
|
|
24
|
-
return `Write a git commit message for the uncommitted changes below.
|
|
25
|
-
|
|
26
|
-
Format rules (Conventional Commits - required):
|
|
27
|
-
- Subject line MUST use type(scope): description or type: description
|
|
28
|
-
- Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
29
|
-
- Scope is optional but preferred when changes are localized (e.g. feat(auth):, fix(api):)
|
|
30
|
-
- Description: imperative mood, lowercase, no trailing period, max 72 chars for the full subject
|
|
31
|
-
- Optional body: blank line after subject, then bullet points; keep lines <= 72 chars
|
|
32
|
-
- Breaking changes: use feat!: or fix!: prefix, or add a BREAKING CHANGE: footer
|
|
33
|
-
- Do NOT use free-form subjects like "Update files", "WIP", or "Misc changes"
|
|
34
|
-
- Use staged and unstaged tracked diffs as the source of truth
|
|
35
|
-
- Untracked files are filenames only; do not infer contents that are not shown
|
|
36
|
-
- Output ONLY the commit message text - no code fences, no commentary, no tool calls
|
|
37
|
-
|
|
38
|
-
Examples:
|
|
39
|
-
feat(auth): add oauth login flow
|
|
40
|
-
|
|
41
|
-
- add google and github providers
|
|
42
|
-
- store refresh tokens in secure storage
|
|
43
|
-
|
|
44
|
-
fix(api): handle null response from user endpoint
|
|
45
|
-
${hintBlock}
|
|
46
|
-
Branch: ${context.branch}
|
|
47
|
-
|
|
48
|
-
Recent commit subjects (align scope naming when sensible):
|
|
49
|
-
${recentCommitsBlock}
|
|
50
|
-
|
|
51
|
-
Staged tracked changes (stat):
|
|
52
|
-
${context.stagedStat || "(empty)"}
|
|
53
|
-
|
|
54
|
-
Staged tracked diff:
|
|
55
|
-
${context.stagedDiff || "(empty)"}
|
|
56
|
-
|
|
57
|
-
Unstaged tracked changes (stat):
|
|
58
|
-
${context.unstagedStat || "(empty)"}
|
|
59
|
-
|
|
60
|
-
Unstaged tracked diff:
|
|
61
|
-
${context.unstagedDiff || "(empty)"}
|
|
62
|
-
|
|
63
|
-
Untracked files (filenames only, contents not read):
|
|
64
|
-
${untrackedFilesBlock}
|
|
65
|
-
`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function stripCodeFences(text: string): string {
|
|
69
|
-
const trimmed = text.trim();
|
|
70
|
-
const fenced = trimmed.match(/^```(?:\w*\n)?([\s\S]*?)```$/);
|
|
71
|
-
return (fenced?.[1] ?? trimmed).trim();
|
|
72
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
export type ModelRef = {
|
|
2
|
-
providerID: string;
|
|
3
|
-
modelID: string;
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
export type PluginOptions = {
|
|
7
|
-
model?: string;
|
|
8
|
-
maxDiffChars?: number;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type ResolvedPluginConfig = {
|
|
12
|
-
model?: string;
|
|
13
|
-
maxDiffChars: number;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export const DEFAULT_MAX_DIFF_CHARS = 12_000;
|
|
17
|
-
|
|
18
|
-
export function resolveConfig(options?: PluginOptions): ResolvedPluginConfig {
|
|
19
|
-
const model = typeof options?.model === "string" ? options.model.trim() : "";
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
model: model || undefined,
|
|
23
|
-
maxDiffChars:
|
|
24
|
-
typeof options?.maxDiffChars === "number" && options.maxDiffChars > 0
|
|
25
|
-
? Math.floor(options.maxDiffChars)
|
|
26
|
-
: DEFAULT_MAX_DIFF_CHARS,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function parseModelRef(model: string): ModelRef {
|
|
31
|
-
const separator = model.indexOf("/");
|
|
32
|
-
if (separator <= 0 || separator === model.length - 1) {
|
|
33
|
-
throw new Error(
|
|
34
|
-
`Invalid commit model \`${model}\`. Use the format \`provider/model-id\`.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
providerID: model.slice(0, separator),
|
|
40
|
-
modelID: model.slice(separator + 1),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type GitContext = {
|
|
45
|
-
branch: string;
|
|
46
|
-
stagedStat: string;
|
|
47
|
-
stagedDiff: string;
|
|
48
|
-
unstagedStat: string;
|
|
49
|
-
unstagedDiff: string;
|
|
50
|
-
untrackedFiles: string[];
|
|
51
|
-
recentCommits: string[];
|
|
52
|
-
hasUncommittedChanges: boolean;
|
|
53
|
-
};
|