@reachdevel/opencode-git-commit 2.0.1 → 2.0.3
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 +49 -12
- package/dist/index.js +128 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/js/@reachdevel%2Fopencode-git-commit)
|
|
4
4
|
|
|
5
|
-
An OpenCode plugin that automatically commits changes when your coding session
|
|
5
|
+
An OpenCode plugin that automatically commits changes when your coding session ends.
|
|
6
6
|
|
|
7
7
|
Requires OpenCode v2 (uses the `@opencode/plugin` v2 plugin API).
|
|
8
8
|
|
|
@@ -10,19 +10,23 @@ Requires OpenCode v2 (uses the `@opencode/plugin` v2 plugin API).
|
|
|
10
10
|
|
|
11
11
|
This plugin monitors your OpenCode session and automatically creates git commits when:
|
|
12
12
|
|
|
13
|
-
1. The session
|
|
13
|
+
1. The session finishes a run (succeeded, failed, or interrupted)
|
|
14
14
|
2. There are uncommitted changes in your working directory
|
|
15
15
|
3. A git repository exists (or can be initialized)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Commit messages are derived from the staged diff itself, so they are deterministic and cost nothing to produce.
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
21
|
-
- **Automatic Commits** - Commits
|
|
22
|
-
- **
|
|
21
|
+
- **Automatic Commits** - Commits as soon as a session run reaches a terminal state
|
|
22
|
+
- **Diff-Derived Messages** - Build Conventional Commit subjects from the staged diff, with no model call and no guessing from conversation prose
|
|
23
|
+
- **Type Inference** - Picks `feat`, `fix`, `test`, `docs`, or `chore` from the change shape and the paths involved
|
|
24
|
+
- **Representative File** - Leads with the most meaningful path, ranking source over tests over docs over build output
|
|
23
25
|
- **Auto-initialize** - Automatically initializes a git repository if one doesn't exist
|
|
24
26
|
- **Respects .gitignore** - Git handles ignored files automatically
|
|
25
|
-
- **
|
|
27
|
+
- **Identity Fallback** - Commits with a bot identity only when the machine has no `user.email` configured
|
|
28
|
+
- **Bypasses Hooks** - Uses `--no-verify` so auto-commits never block on hooks
|
|
29
|
+
- **Serializes Per Directory** - Queues commits per working directory so concurrent sessions can't collide
|
|
26
30
|
|
|
27
31
|
## Installation
|
|
28
32
|
|
|
@@ -59,11 +63,40 @@ Then add to `opencode.json`:
|
|
|
59
63
|
|
|
60
64
|
## How It Works
|
|
61
65
|
|
|
62
|
-
1.
|
|
63
|
-
2.
|
|
64
|
-
3.
|
|
65
|
-
4.
|
|
66
|
-
5.
|
|
66
|
+
1. A terminal session event (`session.execution.succeeded`, `.failed`, or `.interrupted`) arrives
|
|
67
|
+
2. `git status --porcelain` checks for uncommitted changes; if the directory isn't a repository yet, one is initialized
|
|
68
|
+
3. Everything is staged with `git add .`, so new files are included in the description
|
|
69
|
+
4. `git diff --cached --name-status` and `--numstat` describe the staged change set
|
|
70
|
+
5. Paths and line deltas determine the Conventional Commit type and the file list
|
|
71
|
+
6. The commit is created with `--no-verify`
|
|
72
|
+
|
|
73
|
+
### Message Format
|
|
74
|
+
|
|
75
|
+
Subjects follow Conventional Commits and are capped at 72 characters:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
<type>: <verb> <files>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Situation | Example |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| Only files added | `feat: add src/brand-new.ts` |
|
|
84
|
+
| Mixed edits | `fix: update src/a.ts and README.md` |
|
|
85
|
+
| Only tests touched | `test: update src/a.test.ts` |
|
|
86
|
+
| Only docs touched | `docs: update README.md` |
|
|
87
|
+
| Only deletions | `chore: remove src/gone.ts` |
|
|
88
|
+
| Lockfiles / `dist/` / CI config only | `chore: update dist/index.js` |
|
|
89
|
+
| More than two files | `fix: update src/one.ts and 5 more files` |
|
|
90
|
+
|
|
91
|
+
Type inference is purely structural, which keeps it predictable:
|
|
92
|
+
|
|
93
|
+
- `test` - every changed path looks like a test (`*.test.*`, `__tests__/`, `spec/`)
|
|
94
|
+
- `docs` - every changed path looks like documentation (`*.md`, `docs/`)
|
|
95
|
+
- `chore` - build output, lockfiles, CI config, or deletions only
|
|
96
|
+
- `feat` - every changed path is newly added
|
|
97
|
+
- `fix` - anything else, meaning existing files were modified
|
|
98
|
+
|
|
99
|
+
Because messages come from the diff rather than the model's last sentence, they never inherit stray markup, backticks, or file paths mangled by sanitizing. If diff parsing fails for any reason, the plugin falls back to `chore: automated update` and logs the reason at debug level instead of failing the commit.
|
|
67
100
|
|
|
68
101
|
## Configuration
|
|
69
102
|
|
|
@@ -77,9 +110,13 @@ To disable it for a project, remove its id from the plugin list:
|
|
|
77
110
|
}
|
|
78
111
|
```
|
|
79
112
|
|
|
113
|
+
## Notes
|
|
114
|
+
|
|
115
|
+
Messages describe *what changed*, not *why*. They are structural by design: deterministic, free, and unable to fail. If you want prose messages, layer a model call on top of this rather than replacing it.
|
|
116
|
+
|
|
80
117
|
## Requirements
|
|
81
118
|
|
|
82
|
-
- OpenCode with plugin support
|
|
119
|
+
- OpenCode v2 or later with plugin support
|
|
83
120
|
- Node.js (for running the plugin)
|
|
84
121
|
- Git installed on your system
|
|
85
122
|
|
package/dist/index.js
CHANGED
|
@@ -31,21 +31,105 @@ async function isRepository(cwd) {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
const TEST_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
35
|
+
const DOC_RE = /(^|\/)(docs?)\/|\.(md|mdx|rst)$|^readme/i;
|
|
36
|
+
const BUILD_RE = /(^|\/)(dist|build|out|coverage|\.next|target|vendor)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|.*\.lock|\.DS_Store|tsconfig.*\.json|.*\.config\.[cm]?[jt]s)$|^\.github\//;
|
|
37
|
+
const SOURCE_RE = /\.(cm?js|[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cc|cpp|hpp|cs|php|svelte|vue|sh|bash|sql|html|css|scss|astro)$/;
|
|
38
|
+
/** Ranking used to pick the most representative file when several changed. */
|
|
39
|
+
function rank(file) {
|
|
40
|
+
if (BUILD_RE.test(file))
|
|
41
|
+
return 3;
|
|
42
|
+
if (DOC_RE.test(file))
|
|
43
|
+
return 2;
|
|
44
|
+
if (TEST_RE.test(file))
|
|
45
|
+
return 1;
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
function isAll(changes, pattern) {
|
|
49
|
+
return changes.every((c) => pattern.test(c.file));
|
|
50
|
+
}
|
|
51
|
+
/** Parse `git diff --cached --name-status`, e.g. "M\tsrc/a.ts" or "R100\told\tnew". */
|
|
52
|
+
function parseNameStatus(output) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (const line of output.split("\n")) {
|
|
55
|
+
if (!line.trim())
|
|
56
|
+
continue;
|
|
57
|
+
const fields = line.split("\t");
|
|
58
|
+
entries.push({ status: fields[0] ?? "", file: fields[fields.length - 1] ?? "" });
|
|
59
|
+
}
|
|
60
|
+
return entries;
|
|
61
|
+
}
|
|
62
|
+
/** Parse `git diff --cached --numstat`, e.g. "6\t1\tsrc/a.ts". */
|
|
63
|
+
function parseNumstat(output) {
|
|
64
|
+
const stats = new Map();
|
|
65
|
+
for (const line of output.split("\n")) {
|
|
66
|
+
if (!line.trim())
|
|
67
|
+
continue;
|
|
68
|
+
const [added, deleted, ...rest] = line.split("\t");
|
|
69
|
+
const file = rest[rest.length - 1];
|
|
70
|
+
if (!file)
|
|
71
|
+
continue;
|
|
72
|
+
stats.set(file, {
|
|
73
|
+
added: Number.isFinite(+added) ? +added : 0,
|
|
74
|
+
deleted: Number.isFinite(+deleted) ? +deleted : 0,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return stats;
|
|
78
|
+
}
|
|
79
|
+
async function stagedChanges(cwd) {
|
|
80
|
+
const [nameStatus, numstat] = await Promise.all([
|
|
81
|
+
git(cwd, ["diff", "--cached", "--name-status"]),
|
|
82
|
+
git(cwd, ["diff", "--cached", "--numstat"]),
|
|
83
|
+
]);
|
|
84
|
+
const stats = parseNumstat(numstat);
|
|
85
|
+
return parseNameStatus(nameStatus).map(({ status, file }) => ({
|
|
86
|
+
status,
|
|
87
|
+
file,
|
|
88
|
+
added: stats.get(file)?.added ?? 0,
|
|
89
|
+
deleted: stats.get(file)?.deleted ?? 0,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/** Map the change set onto a Conventional Commits type. */
|
|
93
|
+
function classify(changes) {
|
|
94
|
+
const allAdded = changes.every((c) => c.status.startsWith("A"));
|
|
95
|
+
const allDeleted = changes.every((c) => c.status.startsWith("D"));
|
|
96
|
+
if (isAll(changes, TEST_RE))
|
|
97
|
+
return "test";
|
|
98
|
+
if (isAll(changes, DOC_RE))
|
|
99
|
+
return "docs";
|
|
100
|
+
if (isAll(changes, BUILD_RE))
|
|
101
|
+
return "chore";
|
|
102
|
+
if (allDeleted)
|
|
103
|
+
return "chore";
|
|
104
|
+
if (allAdded)
|
|
105
|
+
return "feat";
|
|
106
|
+
return "fix";
|
|
45
107
|
}
|
|
46
|
-
function
|
|
47
|
-
const
|
|
48
|
-
|
|
108
|
+
function describeFiles(files) {
|
|
109
|
+
const [first, second] = files;
|
|
110
|
+
if (files.length === 1)
|
|
111
|
+
return first;
|
|
112
|
+
if (files.length === 2)
|
|
113
|
+
return `${first} and ${second}`;
|
|
114
|
+
// "and N more files" counts everything beyond the first, not beyond what we kept.
|
|
115
|
+
return `${first} and ${files.length - 1} more files`;
|
|
116
|
+
}
|
|
117
|
+
/** Build a commit subject from the staged diff alone - no model call, no prose guessing. */
|
|
118
|
+
async function commitMessageFromDiff(cwd) {
|
|
119
|
+
const changes = await stagedChanges(cwd);
|
|
120
|
+
if (!changes.length)
|
|
121
|
+
return undefined;
|
|
122
|
+
const type = classify(changes);
|
|
123
|
+
const allDeleted = changes.every((c) => c.status.startsWith("D"));
|
|
124
|
+
// Lead with the most representative path and keep the subject readable.
|
|
125
|
+
const files = changes
|
|
126
|
+
.slice()
|
|
127
|
+
.sort((a, b) => rank(a.file) - rank(b.file) || a.file.length - b.file.length)
|
|
128
|
+
.map((c) => c.file);
|
|
129
|
+
const verb = allDeleted ? "remove" : type === "feat" ? "add" : "update";
|
|
130
|
+
const subject = `${type}: ${verb} ${describeFiles(files)}`;
|
|
131
|
+
const message = subject.length > 72 ? `${subject.slice(0, 69).trimEnd()}...` : subject;
|
|
132
|
+
return message.replace(/[\r\n]/g, " ");
|
|
49
133
|
}
|
|
50
134
|
const GitAutocommitPlugin = Plugin.define({
|
|
51
135
|
id: "git-autocommit",
|
|
@@ -53,7 +137,7 @@ const GitAutocommitPlugin = Plugin.define({
|
|
|
53
137
|
log("info", `Plugin initialized for ${ctx.location.directory}`);
|
|
54
138
|
const controller = new AbortController();
|
|
55
139
|
const pending = new Map();
|
|
56
|
-
const run = (directory
|
|
140
|
+
const run = (directory) => {
|
|
57
141
|
const previous = pending.get(directory) ?? Promise.resolve();
|
|
58
142
|
const next = previous.then(async () => {
|
|
59
143
|
const cwd = directory;
|
|
@@ -67,11 +151,18 @@ const GitAutocommitPlugin = Plugin.define({
|
|
|
67
151
|
log("debug", "No changes detected");
|
|
68
152
|
return;
|
|
69
153
|
}
|
|
70
|
-
|
|
71
|
-
const text = commitMessageFrom(messages) ?? "automated update";
|
|
72
|
-
const message = buildCommitMessage(text);
|
|
73
|
-
// Stage and commit (git respects .gitignore by default)
|
|
154
|
+
// Stage first so the diff describes everything, including brand new files
|
|
74
155
|
await git(cwd, ["add", "."]);
|
|
156
|
+
// Derive the message from the staged diff itself: deterministic, local, never garbled
|
|
157
|
+
let message;
|
|
158
|
+
try {
|
|
159
|
+
message = await commitMessageFromDiff(cwd);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
log("debug", `Falling back to generic message: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
}
|
|
164
|
+
if (!message)
|
|
165
|
+
message = "chore: automated update";
|
|
75
166
|
// Fall back to a bot identity only when the machine has no git identity configured
|
|
76
167
|
const identity = process.env.GIT_AUTHOR_EMAIL || (await git(cwd, ["config", "--get", "user.email"]).catch(() => ""))
|
|
77
168
|
? []
|
|
@@ -86,6 +177,18 @@ const GitAutocommitPlugin = Plugin.define({
|
|
|
86
177
|
pending.set(directory, settled);
|
|
87
178
|
return settled;
|
|
88
179
|
};
|
|
180
|
+
// Only commit on root session endings - skip subagent sessions so their
|
|
181
|
+
// work isn't committed while the parent task is still in flight.
|
|
182
|
+
async function isRootSession(sessionID) {
|
|
183
|
+
try {
|
|
184
|
+
const info = await ctx.session.get({ sessionID });
|
|
185
|
+
return !info?.parentID;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// If we can't determine parentage, run to avoid losing commits
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
89
192
|
void (async () => {
|
|
90
193
|
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
91
194
|
// OpenCode v2 does not publish session.idle to plugins. The terminal
|
|
@@ -93,7 +196,12 @@ const GitAutocommitPlugin = Plugin.define({
|
|
|
93
196
|
if (!isTerminal(event))
|
|
94
197
|
continue;
|
|
95
198
|
log("debug", `Session finished (${event.type})`);
|
|
96
|
-
|
|
199
|
+
// Skip subagent sessions: only root session endings trigger commits
|
|
200
|
+
if (!(await isRootSession(event.data.sessionID))) {
|
|
201
|
+
log("debug", `Skipping subagent session ${event.data.sessionID}`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
await run(ctx.location.directory);
|
|
97
205
|
}
|
|
98
206
|
})();
|
|
99
207
|
return () => controller.abort();
|
package/package.json
CHANGED