@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.
Files changed (3) hide show
  1. package/README.md +49 -12
  2. package/dist/index.js +128 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://badge.fury.io/js/@reachdevel%2Fopencode-git-commit.svg)](https://badge.fury.io/js/@reachdevel%2Fopencode-git-commit)
4
4
 
5
- An OpenCode plugin that automatically commits changes when your coding session becomes idle.
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 goes idle (no active operations)
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
- The commit message is intelligently extracted from the last assistant message in your conversation, making commits contextual and meaningful.
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 changes whenever your session becomes idle
22
- - **Smart Commit Messages** - Extracts context from your last conversation to create meaningful commit messages
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
- - **Conventional Commits** - Uses `feat:` or `fix:` prefixes based on message content
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. When a `session.idle` event is triggered, the plugin checks for git changes
63
- 2. If changes exist, it fetches the last assistant message from your session
64
- 3. Extracts the first line, sanitizes it, and uses it as the commit message
65
- 4. Stages all changes with `git add .`
66
- 5. Commits with `--no-verify` to bypass hooks
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
- function commitMessageFrom(messages) {
35
- const assistant = [...messages].reverse().find((m) => m.type === "assistant");
36
- const text = assistant?.content?.find((part) => part.type === "text")?.text;
37
- if (!text)
38
- return undefined;
39
- const sanitized = text
40
- .split("\n")[0]
41
- .replace(/[^a-zA-Z0-9 ]/g, "")
42
- .substring(0, 50)
43
- .trim();
44
- return sanitized || undefined;
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 buildCommitMessage(text) {
47
- const type = text.toLowerCase().includes("fix") ? "fix" : "feat";
48
- return `${type}: ${text}`;
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, sessionID) => {
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
- const messages = await ctx.session.context({ sessionID });
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
- await run(ctx.location.directory, event.data.sessionID);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reachdevel/opencode-git-commit",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Zero-config background git auto-commit plugin for OpenCode CLI",
5
5
  "author": "Levent Kurt <reachdevel@gmail.com>",
6
6
  "license": "MIT",