@andre-barbosa/opencode-commit 0.1.1 → 0.1.4
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/LICENSE +21 -21
- package/README.md +137 -127
- 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 +155 -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/opencode.example.json +12 -17
- package/package.json +55 -51
- package/agents/commit-writer.md +0 -27
- package/commands/commit.md +0 -5
- package/src/generate.ts +0 -112
- package/src/git.ts +0 -64
- package/src/index.ts +0 -136
- package/src/prompt.ts +0 -51
- package/src/types.ts +0 -40
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 André Barbosa
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 André Barbosa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,127 +1,137 @@
|
|
|
1
|
-
# @andre-barbosa/opencode-commit
|
|
2
|
-
|
|
3
|
-
OpenCode plugin that generates **Conventional Commits** messages from
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
## Features
|
|
8
|
-
|
|
9
|
-
- `/commit` slash command (display only, no auto-commit)
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
## Plugin options
|
|
95
|
-
|
|
96
|
-
When loading the plugin as a tuple `[name, options]`:
|
|
97
|
-
|
|
98
|
-
| Option | Default | Description |
|
|
99
|
-
| --- | --- | --- |
|
|
100
|
-
| `
|
|
101
|
-
| `maxDiffChars` | `12000` | Max
|
|
102
|
-
|
|
103
|
-
##
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
# @andre-barbosa/opencode-commit
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that generates **Conventional Commits** messages or split commit plans from uncommitted git changes using a model you choose.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- `/commit` slash command (display only, no auto-commit)
|
|
10
|
+
- Split commit planning with `/commit split` and `/commit split <folder...>`
|
|
11
|
+
- Dedicated model for commit message generation
|
|
12
|
+
- Conventional Commits format enforced (`feat(scope): ...`, `fix(scope): ...`, etc.)
|
|
13
|
+
- Optional hint: `/commit emphasize breaking API change`
|
|
14
|
+
- Uses staged diffs, unstaged diffs, non-ignored untracked filenames, branch name, and recent commit subjects for context
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- [OpenCode](https://opencode.ai/) with plugin support
|
|
19
|
+
- A git repository with uncommitted changes
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### Configure the plugin
|
|
24
|
+
|
|
25
|
+
**Local development** (this repo):
|
|
26
|
+
|
|
27
|
+
Build the plugin first:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run build
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"$schema": "https://opencode.ai/config.json",
|
|
36
|
+
"plugin": [
|
|
37
|
+
[
|
|
38
|
+
"file:///C:/absolute/path/to/opencode-commit/dist/index.js",
|
|
39
|
+
{
|
|
40
|
+
"model": "opencode-go/deepseek-v4-flash",
|
|
41
|
+
"maxDiffChars": 12000
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**From npm:**
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"$schema": "https://opencode.ai/config.json",
|
|
53
|
+
"plugin": [
|
|
54
|
+
["@andre-barbosa/opencode-commit", { "model": "opencode-go/deepseek-v4-flash" }]
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See [opencode.example.json](opencode.example.json) for a full example.
|
|
60
|
+
|
|
61
|
+
Run `opencode models` to list available models.
|
|
62
|
+
Restart OpenCode after editing config.
|
|
63
|
+
When testing local changes, run `npm run build` again before restarting OpenCode.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
1. Make changes in a git repository. Staging is optional.
|
|
68
|
+
2. Open OpenCode in the project.
|
|
69
|
+
3. Run `/commit`.
|
|
70
|
+
4. Copy the suggested message and commit: `git commit -m "feat(scope): ..."`.
|
|
71
|
+
|
|
72
|
+
Optional extra instruction:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
/commit focus on test coverage improvements
|
|
76
|
+
```
|
|
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
|
+
|
|
94
|
+
## Plugin options
|
|
95
|
+
|
|
96
|
+
When loading the plugin as a tuple `[name, options]`:
|
|
97
|
+
|
|
98
|
+
| Option | Default | Description |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `model` | required | Model to use in `provider/model-id` format |
|
|
101
|
+
| `maxDiffChars` | `12000` | Max tracked diff characters sent across staged and unstaged diffs |
|
|
102
|
+
|
|
103
|
+
## Change scope
|
|
104
|
+
|
|
105
|
+
`/commit` analyzes:
|
|
106
|
+
|
|
107
|
+
- Staged tracked changes from `git diff --staged`
|
|
108
|
+
- Unstaged tracked changes from `git diff`
|
|
109
|
+
- Changed-file metadata from `git diff --name-status`
|
|
110
|
+
- Non-ignored untracked filenames from `git ls-files --others --exclude-standard`
|
|
111
|
+
|
|
112
|
+
It does not read untracked file contents and does not ask Git for ignored files.
|
|
113
|
+
|
|
114
|
+
## Commit message format
|
|
115
|
+
|
|
116
|
+
Generated messages follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
type(scope): short imperative description
|
|
120
|
+
|
|
121
|
+
- optional bullet body
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`.
|
|
125
|
+
|
|
126
|
+
## Project layout
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
opencode-commit/
|
|
130
|
+
├── src/ # plugin source
|
|
131
|
+
├── commands/ # optional /commit command stub
|
|
132
|
+
└── opencode.example.json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -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,155 @@
|
|
|
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
|
+
const SKIP_ERROR = "skip";
|
|
8
|
+
function textPart(text) {
|
|
9
|
+
return { type: "text", text };
|
|
10
|
+
}
|
|
11
|
+
function formatOutput(input) {
|
|
12
|
+
if (input.request.mode === "split") {
|
|
13
|
+
const missingFolders = input.missingFolders ?? [];
|
|
14
|
+
const missingBlock = missingFolders.length > 0
|
|
15
|
+
? [
|
|
16
|
+
`No changes found for requested folder(s): ${formatFolderList(missingFolders)}.`,
|
|
17
|
+
"",
|
|
18
|
+
]
|
|
19
|
+
: [];
|
|
20
|
+
return [
|
|
21
|
+
"## Suggested commit plan",
|
|
22
|
+
"",
|
|
23
|
+
...missingBlock,
|
|
24
|
+
input.content,
|
|
25
|
+
"",
|
|
26
|
+
`Model: \`${input.modelLabel}\` · Child session: \`${input.childSessionID}\``,
|
|
27
|
+
"",
|
|
28
|
+
"Stage the listed files for each commit, then run `git commit` with the suggested message.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
"## Suggested commit message",
|
|
33
|
+
"",
|
|
34
|
+
"```",
|
|
35
|
+
input.content,
|
|
36
|
+
"```",
|
|
37
|
+
"",
|
|
38
|
+
`Model: \`${input.modelLabel}\` · Child session: \`${input.childSessionID}\``,
|
|
39
|
+
"",
|
|
40
|
+
"Copy the message above and run `git commit` when ready.",
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
function formatFolderList(folders) {
|
|
44
|
+
return folders.map((folder) => `\`${folder}\``).join(", ");
|
|
45
|
+
}
|
|
46
|
+
const skipCommand = () => {
|
|
47
|
+
throw new Error(SKIP_ERROR);
|
|
48
|
+
};
|
|
49
|
+
export const OpenCodeCommitPlugin = async ({ client, $, worktree }, options) => {
|
|
50
|
+
const config = resolveConfig(options);
|
|
51
|
+
const log = async (level, message, extra) => {
|
|
52
|
+
await client.app
|
|
53
|
+
.log({
|
|
54
|
+
body: {
|
|
55
|
+
service: "opencode-commit",
|
|
56
|
+
level,
|
|
57
|
+
message,
|
|
58
|
+
extra,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
.catch(() => { });
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
config: async (cfg) => {
|
|
65
|
+
cfg.command ??= {};
|
|
66
|
+
cfg.command[COMMAND_NAME] = {
|
|
67
|
+
description: COMMAND_DESCRIPTION,
|
|
68
|
+
template: COMMAND_TEMPLATE,
|
|
69
|
+
subtask: true,
|
|
70
|
+
};
|
|
71
|
+
if (config.model) {
|
|
72
|
+
cfg.command[COMMAND_NAME].model = config.model;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"command.execute.before": async (input, output) => {
|
|
76
|
+
if (input.command !== COMMAND_NAME)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
if (!config.model) {
|
|
80
|
+
output.parts = [
|
|
81
|
+
textPart([
|
|
82
|
+
"No commit model configured.",
|
|
83
|
+
"",
|
|
84
|
+
"Add a `model` option to the opencode-commit plugin config, for example:",
|
|
85
|
+
"`[\"@andre-barbosa/opencode-commit\", { \"model\": \"opencode-go/deepseek-v4-flash\" }]`",
|
|
86
|
+
].join("\n")),
|
|
87
|
+
];
|
|
88
|
+
return skipCommand();
|
|
89
|
+
}
|
|
90
|
+
const model = parseModelRef(config.model);
|
|
91
|
+
const request = parseCommitRequest(input.arguments);
|
|
92
|
+
if (!(await isGitRepo($, worktree))) {
|
|
93
|
+
output.parts = [
|
|
94
|
+
textPart("Not a git repository. Run `/commit` from inside a git worktree."),
|
|
95
|
+
];
|
|
96
|
+
return skipCommand();
|
|
97
|
+
}
|
|
98
|
+
const context = await gatherGitContext($, worktree, config.maxDiffChars);
|
|
99
|
+
if (!context.hasUncommittedChanges) {
|
|
100
|
+
output.parts = [
|
|
101
|
+
textPart("No uncommitted changes found. Make changes and run `/commit` again."),
|
|
102
|
+
];
|
|
103
|
+
return skipCommand();
|
|
104
|
+
}
|
|
105
|
+
let groups;
|
|
106
|
+
let missingFolders = [];
|
|
107
|
+
if (request.mode === "split") {
|
|
108
|
+
const grouped = buildCommitGroups(context.changedFiles, request.folders);
|
|
109
|
+
groups = grouped.groups;
|
|
110
|
+
missingFolders = grouped.missingFolders;
|
|
111
|
+
if (groups.length === 0) {
|
|
112
|
+
const target = request.folders.length > 0
|
|
113
|
+
? `requested folder(s): ${formatFolderList(request.folders)}`
|
|
114
|
+
: "changed folders";
|
|
115
|
+
output.parts = [textPart(`No changes found for ${target}.`)];
|
|
116
|
+
return skipCommand();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await log("info", "Generating commit message", {
|
|
120
|
+
model: config.model,
|
|
121
|
+
mode: request.mode,
|
|
122
|
+
folders: request.mode === "split" ? request.folders : undefined,
|
|
123
|
+
branch: context.branch,
|
|
124
|
+
sessionID: input.sessionID,
|
|
125
|
+
});
|
|
126
|
+
const result = await generateCommitMessage({
|
|
127
|
+
client,
|
|
128
|
+
parentSessionID: input.sessionID,
|
|
129
|
+
model,
|
|
130
|
+
context,
|
|
131
|
+
request,
|
|
132
|
+
groups,
|
|
133
|
+
});
|
|
134
|
+
output.parts = [
|
|
135
|
+
textPart(formatOutput({ ...result, request, missingFolders })),
|
|
136
|
+
];
|
|
137
|
+
return skipCommand();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof Error && error.message === SKIP_ERROR) {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
await log("error", "Commit message generation failed", {
|
|
144
|
+
error: String(error),
|
|
145
|
+
sessionID: input.sessionID,
|
|
146
|
+
});
|
|
147
|
+
output.parts = [
|
|
148
|
+
textPart(`Failed to generate commit message: ${String(error)}`),
|
|
149
|
+
];
|
|
150
|
+
return skipCommand();
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
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;
|