opencode-gitwt 0.2.1 → 0.3.0

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 +26 -3
  2. package/package.json +3 -4
  3. package/src/index.js +121 -60
package/README.md CHANGED
@@ -3,19 +3,22 @@
3
3
  OpenCode adapter for `gitwt`. It exposes the shared worktree core as agent tools:
4
4
  https://github.com/voischev/gitwt
5
5
 
6
- Requires Node.js 22.14+ and Git. Supports OpenCode 1.19.3–<2.
6
+ Requires Node.js 22.14+ and Git. Supports OpenCode V1 (1.19.3+) and V2 (2.2.5+).
7
7
 
8
8
  ## Use in OpenCode
9
9
 
10
- Add the plugin to your project's `opencode.json`, then restart OpenCode:
10
+ For **OpenCode V2**, add to your project's `opencode.json`:
11
11
 
12
12
  ```json
13
13
  {
14
14
  "$schema": "https://opencode.ai/config.json",
15
- "plugin": ["opencode-gitwt@latest"]
15
+ "plugins": ["opencode-gitwt@latest"]
16
16
  }
17
17
  ```
18
18
 
19
+ For **V1**, use `"plugin": ["opencode-gitwt@latest"]` instead. Restart OpenCode
20
+ after installation. Both versions expose the same four gitwt tools.
21
+
19
22
  Ask the agent in chat, for example:
20
23
 
21
24
  > Create a worktree on branch feature/login from main, fix the login issue there,
@@ -43,3 +46,23 @@ and `~/.config/gitwt/config.json` globally. Options include `remote`, `base`,
43
46
 
44
47
  For the standalone `gitwt` command, run `npm install -g @voischev/gitwt`.
45
48
  Move existing settings to these paths and remove comments and trailing commas.
49
+
50
+ ## Why use it with OpenCode V2?
51
+
52
+ V2 already has native worktree tools, setup commands, and session moves.
53
+ Use gitwt when you want:
54
+
55
+ - **One workflow across agents:** the same standalone CLI, Node API, and JSON
56
+ contract in OpenCode, Codex, Claude Code, and scripts.
57
+ - **Named branches from a fresh remote base:** gitwt fetches the selected base
58
+ and verifies path, branch, and commit before setup. V2's built-in Git strategy
59
+ creates a detached checkout from a local ref or HEAD, without fetching.
60
+ - **Per-worktree lifecycle hooks:** saved `postCreate` and `preRemove` commands;
61
+ teardown failure blocks removal through gitwt, even with `--force`.
62
+
63
+ Prefer native V2 features for integrated UI navigation and actual session moves.
64
+ Native removal does not run gitwt's teardown hooks: remove gitwt-managed copies
65
+ through `gitwt_remove` or the CLI when those hooks matter.
66
+
67
+ Comparison verified against OpenCode V2.2.5's built-in Git strategy:
68
+ https://github.com/anomalyco/opencode/blob/v2.2.5/packages/core/src/worktree.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitwt",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Git worktree tools for OpenCode, powered by gitwt",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "engines": {
17
17
  "node": ">=22.14.0",
18
- "opencode": ">=1.19.3 <2"
18
+ "opencode": ">=1.19.3 <2 || >=2.2.5 <3"
19
19
  },
20
20
  "scripts": {
21
21
  "check": "biome check .",
@@ -23,8 +23,7 @@
23
23
  "release": "node scripts/release.js"
24
24
  },
25
25
  "dependencies": {
26
- "@voischev/gitwt": "^0.1.4",
27
- "zod": "4.1.8"
26
+ "@voischev/gitwt": "^0.1.4"
28
27
  },
29
28
  "devDependencies": {
30
29
  "@biomejs/biome": "2.6.3"
package/src/index.js CHANGED
@@ -1,69 +1,130 @@
1
- import { createWorktree, listWorktrees, removeWorktree, resolveWorktree } from "@voischev/gitwt";
2
- import { z } from "zod";
1
+ import {
2
+ createWorktree,
3
+ GitwtError,
4
+ listWorktrees,
5
+ removeWorktree,
6
+ resolveWorktree,
7
+ } from "@voischev/gitwt";
3
8
 
4
9
  export async function GitwtPlugin({ directory }, config = {}) {
5
- return {
6
- tool: {
7
- gitwt_create: {
8
- description:
9
- "Create and verify a new worktree. Use worktreePath for commands and files in the current session. Keep the worktree when pausing or handing off a task.",
10
- args: {
11
- branch: z.string().describe("New branch name"),
12
- base: z.string().optional().describe("Remote base branch"),
13
- from: z
14
- .string()
15
- .optional()
16
- .describe("Local ref or verified commit instead of a remote base"),
17
- path: z.string().optional().describe("Destination path"),
18
- noHooks: z.boolean().optional().describe("Disable hooks for this worktree"),
10
+ const tool = {
11
+ gitwt_create: {
12
+ description:
13
+ "Create and verify a new worktree. Use worktreePath for commands and files in the current session. Keep the worktree when pausing or handing off a task.",
14
+ args: {
15
+ branch: { type: "string", description: "New branch name" },
16
+ base: { type: "string", description: "Remote base branch" },
17
+ from: {
18
+ type: "string",
19
+ description: "Local ref or verified commit instead of a remote base",
19
20
  },
20
- execute: async (args, ctx) =>
21
- JSON.stringify(
22
- await createWorktree(ctx.directory ?? directory, {
23
- ...args,
24
- signal: ctx.abort,
25
- config: { ...config, base: args.base ?? config.base },
26
- }),
27
- ),
28
- },
29
- gitwt_list: {
30
- description:
31
- "List Git worktrees. With status, check dirty and available ahead/behind counts; null means unknown.",
32
- args: { status: z.boolean().optional().describe("Check live worktree status") },
33
- execute: async (args, ctx) =>
34
- JSON.stringify(await listWorktrees(ctx.directory ?? directory, args)),
35
- },
36
- gitwt_cd: {
37
- description:
38
- "Resolve a branch or full path to a worktree with HEAD and status. Use worktreePath explicitly; this does not change the session directory.",
39
- args: { target: z.string().describe("Exact branch name or full worktree path") },
40
- execute: async ({ target }, ctx) =>
41
- JSON.stringify(
42
- await resolveWorktree(ctx.directory ?? directory, target, { status: true }),
43
- ),
21
+ path: { type: "string", description: "Destination path" },
22
+ noHooks: { type: "boolean", description: "Disable hooks for this worktree" },
44
23
  },
45
- gitwt_remove: {
46
- description:
47
- "Remove a worktree only when explicitly requested and no longer in use. Force discards local changes but does not bypass a failing preRemove hook.",
48
- args: {
49
- target: z.string().describe("Exact branch name or full worktree path"),
50
- force: z.boolean().optional().describe("Allow discarding local changes"),
51
- keepBranch: z
52
- .boolean()
53
- .optional()
54
- .describe("Keep the branch after removing the worktree"),
55
- },
56
- execute: async ({ target, force, keepBranch }, ctx) =>
57
- JSON.stringify(
58
- await removeWorktree(ctx.directory ?? directory, target, {
59
- force,
60
- deleteBranch: !keepBranch,
61
- signal: ctx.abort,
62
- }),
63
- ),
24
+ execute: async (args, ctx) =>
25
+ JSON.stringify(
26
+ await createWorktree(ctx.directory ?? directory, {
27
+ ...args,
28
+ signal: ctx.abort,
29
+ config: { ...config, base: args.base ?? config.base },
30
+ }),
31
+ ),
32
+ },
33
+ gitwt_list: {
34
+ description:
35
+ "List Git worktrees. With status, check dirty and available ahead/behind counts; null means unknown.",
36
+ args: { status: { type: "boolean", description: "Check live worktree status" } },
37
+ execute: async (args, ctx) =>
38
+ JSON.stringify(await listWorktrees(ctx.directory ?? directory, args)),
39
+ },
40
+ gitwt_cd: {
41
+ description:
42
+ "Resolve a branch or full path to a worktree with HEAD and status. Use worktreePath explicitly; this does not change the session directory.",
43
+ args: { target: { type: "string", description: "Exact branch name or full worktree path" } },
44
+ execute: async ({ target }, ctx) =>
45
+ JSON.stringify(await resolveWorktree(ctx.directory ?? directory, target, { status: true })),
46
+ },
47
+ gitwt_remove: {
48
+ description:
49
+ "Remove a worktree only when explicitly requested and no longer in use. Force discards local changes but does not bypass a failing preRemove hook.",
50
+ args: {
51
+ target: { type: "string", description: "Exact branch name or full worktree path" },
52
+ force: { type: "boolean", description: "Allow discarding local changes" },
53
+ keepBranch: { type: "boolean", description: "Keep the branch after removing the worktree" },
64
54
  },
55
+ execute: async ({ target, force, keepBranch }, ctx) =>
56
+ JSON.stringify(
57
+ await removeWorktree(ctx.directory ?? directory, target, {
58
+ force,
59
+ deleteBranch: !keepBranch,
60
+ signal: ctx.abort,
61
+ }),
62
+ ),
63
+ },
64
+ };
65
+ const required = {
66
+ gitwt_create: ["branch"],
67
+ gitwt_list: [],
68
+ gitwt_cd: ["target"],
69
+ gitwt_remove: ["target"],
70
+ };
71
+ for (const [id, definition] of Object.entries(tool)) {
72
+ const execute = definition.execute;
73
+ definition.execute = async (args, ctx) => {
74
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
75
+ throw new GitwtError("invalid_arguments", `${id}: expected an argument object`);
76
+ }
77
+ for (const key of Object.keys(args)) {
78
+ if (!Object.hasOwn(definition.args, key)) {
79
+ throw new GitwtError("invalid_arguments", `${id}: unknown argument ${key}`);
80
+ }
81
+ }
82
+ for (const [key, schema] of Object.entries(definition.args)) {
83
+ if (args[key] === undefined && !required[id].includes(key)) continue;
84
+ if (typeof args[key] !== schema.type) {
85
+ throw new GitwtError("invalid_arguments", `${id}: ${key} must be ${schema.type}`);
86
+ }
87
+ }
88
+ return execute(args, ctx);
89
+ };
90
+ }
91
+ return {
92
+ tool,
93
+ "tool.definition": async ({ toolID }, output) => {
94
+ if (!Object.hasOwn(tool, toolID)) return;
95
+ output.jsonSchema = {
96
+ type: "object",
97
+ properties: tool[toolID].args,
98
+ required: required[toolID],
99
+ additionalProperties: false,
100
+ };
65
101
  },
66
102
  };
67
103
  }
68
104
 
69
- export default { id: "opencode-gitwt", server: GitwtPlugin };
105
+ export default {
106
+ id: "opencode-gitwt",
107
+ server: GitwtPlugin,
108
+ async setup(ctx) {
109
+ const directory = ctx.location.directory;
110
+ const hooks = await GitwtPlugin({ directory }, ctx.options);
111
+ const definitions = [];
112
+ for (const [name, definition] of Object.entries(hooks.tool)) {
113
+ const output = {};
114
+ await hooks["tool.definition"]({ toolID: name }, output);
115
+ definitions.push({
116
+ name,
117
+ description: definition.description,
118
+ input: output.jsonSchema,
119
+ options: { codemode: false },
120
+ async execute(args, call) {
121
+ call.signal.throwIfAborted();
122
+ return { content: await definition.execute(args, { directory, abort: call.signal }) };
123
+ },
124
+ });
125
+ }
126
+ await ctx.tool.transform((editor) => {
127
+ for (const definition of definitions) editor.add(definition);
128
+ });
129
+ },
130
+ };