opencode-gitwt 0.1.0 → 0.2.1
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 +26 -125
- package/package.json +6 -10
- package/src/index.js +18 -15
- package/bin/git-wt.js +0 -100
- package/src/config.js +0 -69
- package/src/environment.js +0 -12
- package/src/process.js +0 -41
- package/src/worktree.js +0 -287
package/README.md
CHANGED
|
@@ -1,144 +1,45 @@
|
|
|
1
1
|
# opencode-gitwt
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
в текущей сессии. Git остаётся единственным реестром worktree.
|
|
3
|
+
OpenCode adapter for `gitwt`. It exposes the shared worktree core as agent tools:
|
|
4
|
+
https://github.com/voischev/gitwt
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
Requires Node.js 22.14+ and Git. Supports OpenCode 1.19.3–<2.
|
|
8
7
|
|
|
9
|
-
##
|
|
8
|
+
## Use in OpenCode
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
Add the plugin to your project's `opencode.json`, then restart OpenCode:
|
|
12
11
|
|
|
13
12
|
```json
|
|
14
13
|
{
|
|
15
14
|
"$schema": "https://opencode.ai/config.json",
|
|
16
|
-
"plugin": ["opencode-gitwt@
|
|
15
|
+
"plugin": ["opencode-gitwt@latest"]
|
|
17
16
|
}
|
|
18
17
|
```
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
Ask the agent in chat, for example:
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Замените `<commit>` нужным хешем. Сборка и install-скрипты не нужны.
|
|
27
|
-
Для локальной разработки укажите `file://`-путь к `src/index.js`.
|
|
28
|
-
После подключения перезапустите OpenCode.
|
|
29
|
-
|
|
30
|
-
## Работа агента
|
|
31
|
-
|
|
32
|
-
- `gitwt_create`: создать новую ветку и worktree, проверить корень, ветку и HEAD,
|
|
33
|
-
вернуть `worktreePath`.
|
|
34
|
-
- `gitwt_list`: показать реестр Git; `status: true` добавляет live-статус.
|
|
35
|
-
- `gitwt_cd`: найти точную ветку или полный путь, вернуть путь, HEAD и статус.
|
|
36
|
-
- `gitwt_remove`: явно удалить конкретный worktree, убедившись, что он больше
|
|
37
|
-
не используется.
|
|
38
|
-
|
|
39
|
-
```json
|
|
40
|
-
{ "branch": "feature/login", "base": "main" }
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Агент продолжает ту же сессию. Возвращённый путь нужно использовать в `workdir`
|
|
44
|
-
команд и в путях файловых инструментов. `gitwt_cd` только находит каталог:
|
|
45
|
-
он не меняет cwd процесса или контекст сессии. Завершение ответа, чистый статус
|
|
46
|
-
или merge ветки не являются командой на удаление. При паузе и передаче задачи
|
|
47
|
-
сохраните worktree и передайте его путь, ветку и оставшуюся работу.
|
|
48
|
-
|
|
49
|
-
## CLI
|
|
50
|
-
|
|
51
|
-
После публикации: `npm install -g opencode-gitwt`. Из клона: `node bin/git-wt.js`.
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
git-wt add feature/login --base main --json
|
|
55
|
-
git-wt add review-copy --from origin/feature/login --path "/tmp/review copy"
|
|
56
|
-
git-wt list --status --json
|
|
57
|
-
git-wt cd feature/login
|
|
58
|
-
git-wt hooks feature/login
|
|
59
|
-
git-wt remove feature/login
|
|
60
|
-
git-wt config
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
`add` всегда создаёт новую ветку. Для существующей работы укажите проверенный
|
|
64
|
-
ref или commit в `--from`, а новой копии дайте другое имя. По умолчанию база
|
|
65
|
-
обновляется из remote: `trunk`, затем `main`, затем `master`, затем remote HEAD.
|
|
66
|
-
Fetch повторяется один раз при ошибке. Грязный исходный checkout не мешает
|
|
67
|
-
созданию, его изменения не переносятся. Ошибка создания может оставить копию:
|
|
68
|
-
сначала осмотрите сообщённый путь, не повторяйте создание вслепую.
|
|
69
|
-
|
|
70
|
-
`--path` — полный путь копии; `--dir` — базовый каталог. Без них используется
|
|
71
|
-
`~/git-wt/<имя-репо>-<id>/<ветка>`. Имена веток сохраняются и проверяются Git.
|
|
72
|
-
`--remote` и `--prefix` переопределяют настройки.
|
|
21
|
+
> Create a worktree on branch feature/login from main, fix the login issue there,
|
|
22
|
+
> and run the tests. Keep the worktree for review.
|
|
73
23
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
24
|
+
The agent calls `gitwt_create` with `{"branch":"feature/login","base":"main"}`.
|
|
25
|
+
Use the returned `worktreePath` as the cwd for commands and as the base for
|
|
26
|
+
absolute paths in file operations. Continue in the same session; approve access
|
|
27
|
+
to that directory if OpenCode asks. `gitwt_cd` only returns the path and does not
|
|
28
|
+
change the session directory. Use `gitwt_list` to inspect copies and
|
|
29
|
+
`gitwt_remove` only on explicit request when the worktree is no longer in use.
|
|
77
30
|
|
|
78
|
-
##
|
|
31
|
+
## Tools
|
|
79
32
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
```jsonc
|
|
85
|
-
{
|
|
86
|
-
"remote": "origin",
|
|
87
|
-
"base": "auto",
|
|
88
|
-
"worktreeDir": "~/worktrees",
|
|
89
|
-
"hooks": {
|
|
90
|
-
"postCreate": [["npm", "ci"]],
|
|
91
|
-
"preRemove": [["docker", "compose", "down"]]
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Команда — argv-массив или явная shell-строка (`bash -c` / `cmd /c`).
|
|
97
|
-
В хуках доступны `BRANCH`, `WORKTREE_PATH`, `HEAD`; в postCreate дополнительно
|
|
98
|
-
`SOURCE_PATH`. cwd — целевой worktree. Подготовку окружения, например копирование
|
|
99
|
-
локального `.env`, при необходимости выполняет postCreate.
|
|
100
|
-
|
|
101
|
-
Снимок обоих хуков сохраняется в служебном каталоге конкретного Git worktree,
|
|
102
|
-
не в рабочих файлах. Текущий конфиг не подменяет preRemove существующей копии.
|
|
103
|
-
`add --no-hooks` явно сохраняет отключённые хуки; ради JSON отключать их не надо.
|
|
104
|
-
Ошибка postCreate возвращается предупреждением при сохранённой копии.
|
|
105
|
-
Ошибка preRemove блокирует удаление даже с `--force`.
|
|
106
|
-
|
|
107
|
-
`hooks` — read-only диагностика без текста команд. Только `snapshot: "present"`
|
|
108
|
-
и `preRemove: "none"` подтверждают сохранённое отключение. У вручную созданного
|
|
109
|
-
worktree снимка может не быть; это не считается доказательством отключения.
|
|
110
|
-
Повреждённый снимок вызывает ошибку, автоматически не исправляется.
|
|
111
|
-
|
|
112
|
-
## Удаление
|
|
113
|
-
|
|
114
|
-
Удаление выполняется сразу и только явно. Основной и текущий worktree защищены;
|
|
115
|
-
удаляйте из другого checkout. Dirty проверяется до и после preRemove.
|
|
116
|
-
`--force` разрешает потерю локальных изменений, но не обходит ошибку хука.
|
|
117
|
-
После удаления используется `git branch -d`: неслитая ветка сохраняется
|
|
118
|
-
с предупреждением. `--keep-branch` оставляет ветку в любом случае.
|
|
119
|
-
|
|
120
|
-
Терминалы, перенос сессий, lease, отдельная база состояний и очистка по idle
|
|
121
|
-
не используются. Нет автокоммитов, push, reset и фонового удаления.
|
|
122
|
-
|
|
123
|
-
## Проверка и npm-релиз
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
npm ci --ignore-scripts
|
|
127
|
-
npm run check
|
|
128
|
-
npm test
|
|
129
|
-
node scripts/release.js --check
|
|
130
|
-
```
|
|
33
|
+
- `gitwt_create` creates and verifies a worktree.
|
|
34
|
+
- `gitwt_list` shows registered worktrees with optional status.
|
|
35
|
+
- `gitwt_cd` resolves a branch or full path without changing cwd.
|
|
36
|
+
- `gitwt_remove` removes a worktree explicitly, optionally with `force`.
|
|
131
37
|
|
|
132
|
-
|
|
133
|
-
1.19.3 smoke-тест проверяет регистрацию инструментов в изолированном сервере
|
|
134
|
-
без LLM. Для сквозной Git-установки задайте `GITWT_TEST_PLUGIN_SPEC` равным
|
|
135
|
-
Git-spec и запустите `node --test test/plugin.test.js`.
|
|
38
|
+
## Configuration
|
|
136
39
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
публикацией. Токен доступен только кубику publish, временный `.npmrc` создаётся
|
|
141
|
-
вне репозитория. npm Trusted Publishing для SourceCraft не поддерживается.
|
|
40
|
+
The plugin uses plain JSON: `.gitwt.json` in the project
|
|
41
|
+
and `~/.config/gitwt/config.json` globally. Options include `remote`, `base`,
|
|
42
|
+
`prefix`, `worktreeDir`, and `hooks`. See the core repository for details.
|
|
142
43
|
|
|
143
|
-
|
|
144
|
-
|
|
44
|
+
For the standalone `gitwt` command, run `npm install -g @voischev/gitwt`.
|
|
45
|
+
Move existing settings to these paths and remove comments and trailing commas.
|
package/package.json
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-gitwt",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Git worktree
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Git worktree tools for OpenCode, powered by gitwt",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./server": "./src/index.js"
|
|
10
10
|
},
|
|
11
|
-
"bin": {
|
|
12
|
-
"git-wt": "./bin/git-wt.js"
|
|
13
|
-
},
|
|
14
11
|
"files": [
|
|
15
12
|
"src",
|
|
16
|
-
"bin",
|
|
17
13
|
"README.md",
|
|
18
14
|
"LICENSE"
|
|
19
15
|
],
|
|
@@ -27,7 +23,7 @@
|
|
|
27
23
|
"release": "node scripts/release.js"
|
|
28
24
|
},
|
|
29
25
|
"dependencies": {
|
|
30
|
-
"
|
|
26
|
+
"@voischev/gitwt": "^0.1.4",
|
|
31
27
|
"zod": "4.1.8"
|
|
32
28
|
},
|
|
33
29
|
"devDependencies": {
|
|
@@ -35,11 +31,11 @@
|
|
|
35
31
|
},
|
|
36
32
|
"repository": {
|
|
37
33
|
"type": "git",
|
|
38
|
-
"url": "git+https://
|
|
34
|
+
"url": "git+https://github.com/voischev/opencode-gitwt.git"
|
|
39
35
|
},
|
|
40
|
-
"homepage": "https://
|
|
36
|
+
"homepage": "https://github.com/voischev/opencode-gitwt",
|
|
41
37
|
"bugs": {
|
|
42
|
-
"url": "https://
|
|
38
|
+
"url": "https://github.com/voischev/opencode-gitwt/issues"
|
|
43
39
|
},
|
|
44
40
|
"publishConfig": {
|
|
45
41
|
"access": "public",
|
package/src/index.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
+
import { createWorktree, listWorktrees, removeWorktree, resolveWorktree } from "@voischev/gitwt";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { createWorktree, listWorktrees, removeWorktree, resolveWorktree } from "./worktree.js";
|
|
3
3
|
|
|
4
4
|
export async function GitwtPlugin({ directory }, config = {}) {
|
|
5
5
|
return {
|
|
6
6
|
tool: {
|
|
7
7
|
gitwt_create: {
|
|
8
8
|
description:
|
|
9
|
-
"
|
|
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
10
|
args: {
|
|
11
|
-
branch: z.string().describe("
|
|
12
|
-
base: z.string().optional().describe("
|
|
11
|
+
branch: z.string().describe("New branch name"),
|
|
12
|
+
base: z.string().optional().describe("Remote base branch"),
|
|
13
13
|
from: z
|
|
14
14
|
.string()
|
|
15
15
|
.optional()
|
|
16
|
-
.describe("
|
|
17
|
-
path: z.string().optional().describe("
|
|
18
|
-
noHooks: z.boolean().optional().describe("
|
|
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"),
|
|
19
19
|
},
|
|
20
20
|
execute: async (args, ctx) =>
|
|
21
21
|
JSON.stringify(
|
|
@@ -28,15 +28,15 @@ export async function GitwtPlugin({ directory }, config = {}) {
|
|
|
28
28
|
},
|
|
29
29
|
gitwt_list: {
|
|
30
30
|
description:
|
|
31
|
-
"
|
|
32
|
-
args: { status: z.boolean().optional() },
|
|
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
33
|
execute: async (args, ctx) =>
|
|
34
34
|
JSON.stringify(await listWorktrees(ctx.directory ?? directory, args)),
|
|
35
35
|
},
|
|
36
36
|
gitwt_cd: {
|
|
37
37
|
description:
|
|
38
|
-
"
|
|
39
|
-
args: { target: z.string() },
|
|
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
40
|
execute: async ({ target }, ctx) =>
|
|
41
41
|
JSON.stringify(
|
|
42
42
|
await resolveWorktree(ctx.directory ?? directory, target, { status: true }),
|
|
@@ -44,11 +44,14 @@ export async function GitwtPlugin({ directory }, config = {}) {
|
|
|
44
44
|
},
|
|
45
45
|
gitwt_remove: {
|
|
46
46
|
description:
|
|
47
|
-
"
|
|
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
48
|
args: {
|
|
49
|
-
target: z.string(),
|
|
50
|
-
force: z.boolean().optional(),
|
|
51
|
-
keepBranch: z
|
|
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"),
|
|
52
55
|
},
|
|
53
56
|
execute: async ({ target, force, keepBranch }, ctx) =>
|
|
54
57
|
JSON.stringify(
|
package/bin/git-wt.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { parseArgs } from "node:util";
|
|
3
|
-
import { loadConfig } from "../src/config.js";
|
|
4
|
-
import {
|
|
5
|
-
createWorktree,
|
|
6
|
-
inspectHooks,
|
|
7
|
-
listWorktrees,
|
|
8
|
-
removeWorktree,
|
|
9
|
-
repository,
|
|
10
|
-
resolveWorktree,
|
|
11
|
-
} from "../src/worktree.js";
|
|
12
|
-
|
|
13
|
-
const machine = process.argv.includes("--json") || process.argv.includes("--porcelain");
|
|
14
|
-
|
|
15
|
-
async function main() {
|
|
16
|
-
const {
|
|
17
|
-
values,
|
|
18
|
-
positionals: [command, target, ...extra],
|
|
19
|
-
} = parseArgs({
|
|
20
|
-
allowPositionals: true,
|
|
21
|
-
options: {
|
|
22
|
-
json: { type: "boolean" },
|
|
23
|
-
porcelain: { type: "boolean" },
|
|
24
|
-
status: { type: "boolean" },
|
|
25
|
-
remote: { type: "string" },
|
|
26
|
-
base: { type: "string" },
|
|
27
|
-
from: { type: "string" },
|
|
28
|
-
prefix: { type: "string" },
|
|
29
|
-
path: { type: "string" },
|
|
30
|
-
dir: { type: "string" },
|
|
31
|
-
"no-hooks": { type: "boolean" },
|
|
32
|
-
force: { type: "boolean" },
|
|
33
|
-
"keep-branch": { type: "boolean" },
|
|
34
|
-
help: { type: "boolean", short: "h" },
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
if (!command || values.help) {
|
|
38
|
-
process.stdout.write(
|
|
39
|
-
"git-wt add <ветка> [--base <remote-ветка> | --from <ref>] [--path <путь> | --dir <каталог>] [--no-hooks] [--json]\ngit-wt list [--status] [--json | --porcelain]\ngit-wt cd <ветка|путь>\ngit-wt hooks <ветка|путь>\ngit-wt remove <ветка|путь> [--force] [--keep-branch]\ngit-wt config\n",
|
|
40
|
-
);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
if (extra.length || (!["add", "cd", "remove", "hooks"].includes(command) && target))
|
|
44
|
-
throw new Error("Лишние позиционные аргументы");
|
|
45
|
-
if (values["no-hooks"] && command !== "add")
|
|
46
|
-
throw new Error("--no-hooks допустим только при создании worktree");
|
|
47
|
-
const cwd = process.cwd();
|
|
48
|
-
const config = {
|
|
49
|
-
remote: values.remote,
|
|
50
|
-
base: values.base,
|
|
51
|
-
prefix: values.prefix,
|
|
52
|
-
worktreeDir: values.dir,
|
|
53
|
-
};
|
|
54
|
-
let result;
|
|
55
|
-
switch (command) {
|
|
56
|
-
case "add":
|
|
57
|
-
result = await createWorktree(cwd, {
|
|
58
|
-
branch: target,
|
|
59
|
-
path: values.path,
|
|
60
|
-
from: values.from,
|
|
61
|
-
noHooks: values["no-hooks"],
|
|
62
|
-
config,
|
|
63
|
-
});
|
|
64
|
-
break;
|
|
65
|
-
case "list":
|
|
66
|
-
result = await listWorktrees(cwd, { status: values.status });
|
|
67
|
-
break;
|
|
68
|
-
case "cd":
|
|
69
|
-
result = await resolveWorktree(cwd, target);
|
|
70
|
-
break;
|
|
71
|
-
case "hooks":
|
|
72
|
-
result = await inspectHooks(cwd, target);
|
|
73
|
-
break;
|
|
74
|
-
case "remove":
|
|
75
|
-
result = await removeWorktree(cwd, target, {
|
|
76
|
-
force: values.force,
|
|
77
|
-
deleteBranch: !values["keep-branch"],
|
|
78
|
-
});
|
|
79
|
-
break;
|
|
80
|
-
case "config":
|
|
81
|
-
result = await loadConfig((await repository(cwd)).root, config);
|
|
82
|
-
break;
|
|
83
|
-
default:
|
|
84
|
-
throw new Error(`Неизвестная команда: ${command}`);
|
|
85
|
-
}
|
|
86
|
-
process.stdout.write(
|
|
87
|
-
command === "cd" && !machine
|
|
88
|
-
? `${result.worktreePath}\n`
|
|
89
|
-
: `${JSON.stringify(result, null, machine ? 0 : 2)}\n`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
main().catch((error) => {
|
|
94
|
-
process.stderr.write(
|
|
95
|
-
machine
|
|
96
|
-
? `${JSON.stringify({ code: error.code ?? "unexpected_error", error: error.message, ...error.details })}\n`
|
|
97
|
-
: `Ошибка: ${error.message}\n`,
|
|
98
|
-
);
|
|
99
|
-
process.exitCode = 1;
|
|
100
|
-
});
|
package/src/config.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { parse } from "jsonc-parser";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { GitwtError } from "./process.js";
|
|
7
|
-
|
|
8
|
-
const command = z.union([z.string().min(1), z.tuple([z.string().min(1)]).rest(z.string())]);
|
|
9
|
-
export const hooksSchema = z
|
|
10
|
-
.object({
|
|
11
|
-
postCreate: z.array(command).default([]),
|
|
12
|
-
preRemove: z.array(command).default([]),
|
|
13
|
-
})
|
|
14
|
-
.strict();
|
|
15
|
-
const schema = z
|
|
16
|
-
.object({
|
|
17
|
-
remote: z
|
|
18
|
-
.string()
|
|
19
|
-
.regex(/^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/)
|
|
20
|
-
.default("origin"),
|
|
21
|
-
base: z.string().min(1).default("auto"),
|
|
22
|
-
prefix: z.string().default(""),
|
|
23
|
-
worktreeDir: z.string().min(1).optional(),
|
|
24
|
-
hooks: hooksSchema.default({ postCreate: [], preRemove: [] }),
|
|
25
|
-
})
|
|
26
|
-
.strict();
|
|
27
|
-
|
|
28
|
-
export function expandPath(value, home = os.homedir(), base = process.cwd()) {
|
|
29
|
-
if (value === "~") return home;
|
|
30
|
-
return path.resolve(
|
|
31
|
-
value.startsWith("~/") ? home : base,
|
|
32
|
-
value.startsWith("~/") ? value.slice(2) : value,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function readConfig(file) {
|
|
37
|
-
try {
|
|
38
|
-
const errors = [];
|
|
39
|
-
const value = parse(await readFile(file, "utf8"), errors, { allowTrailingComma: true });
|
|
40
|
-
if (errors.length || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
41
|
-
throw new Error("Ожидается объект JSONC");
|
|
42
|
-
}
|
|
43
|
-
return value;
|
|
44
|
-
} catch (error) {
|
|
45
|
-
if (error.code === "ENOENT") return {};
|
|
46
|
-
throw new GitwtError("invalid_config", `Не удалось прочитать ${file}: ${error.message}`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function loadConfig(root, overrides = {}, home = os.homedir()) {
|
|
51
|
-
const config = { hooks: {} };
|
|
52
|
-
for (const value of [
|
|
53
|
-
await readConfig(path.join(home, ".config/opencode/gitwt.jsonc")),
|
|
54
|
-
await readConfig(path.join(root, ".opencode/gitwt.jsonc")),
|
|
55
|
-
Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)),
|
|
56
|
-
]) {
|
|
57
|
-
const checked = schema.partial().safeParse(value);
|
|
58
|
-
if (!checked.success)
|
|
59
|
-
throw new GitwtError(
|
|
60
|
-
"invalid_config",
|
|
61
|
-
`Некорректные настройки: ${checked.error.issues.map((issue) => issue.path.join(".") || "неизвестное поле").join(", ")}`,
|
|
62
|
-
);
|
|
63
|
-
const hooks = { ...config.hooks, ...value.hooks };
|
|
64
|
-
Object.assign(config, value, { hooks });
|
|
65
|
-
}
|
|
66
|
-
const resolved = schema.parse(config);
|
|
67
|
-
if (resolved.worktreeDir) resolved.worktreeDir = expandPath(resolved.worktreeDir, home, root);
|
|
68
|
-
return resolved;
|
|
69
|
-
}
|
package/src/environment.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { run } from "./process.js";
|
|
2
|
-
|
|
3
|
-
export async function runHooks(commands, cwd, env, signal) {
|
|
4
|
-
for (const command of commands) {
|
|
5
|
-
const argv = Array.isArray(command)
|
|
6
|
-
? command
|
|
7
|
-
: process.platform === "win32"
|
|
8
|
-
? [process.env.ComSpec ?? "cmd.exe", "/d", "/s", "/c", command]
|
|
9
|
-
: ["bash", "-c", command];
|
|
10
|
-
await run(argv[0], argv.slice(1), { cwd, env, signal });
|
|
11
|
-
}
|
|
12
|
-
}
|
package/src/process.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
|
|
4
|
-
const execute = promisify(execFile);
|
|
5
|
-
|
|
6
|
-
export class GitwtError extends Error {
|
|
7
|
-
constructor(code, message, details = {}) {
|
|
8
|
-
super(message);
|
|
9
|
-
this.name = "GitwtError";
|
|
10
|
-
this.code = code;
|
|
11
|
-
this.details = details;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function run(command, args = [], options = {}) {
|
|
16
|
-
try {
|
|
17
|
-
const result = await execute(command, args, {
|
|
18
|
-
cwd: options.cwd,
|
|
19
|
-
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
20
|
-
signal: options.signal,
|
|
21
|
-
timeout: options.timeout ?? 120_000,
|
|
22
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
23
|
-
encoding: "utf8",
|
|
24
|
-
windowsHide: true,
|
|
25
|
-
});
|
|
26
|
-
return { ...result, code: 0 };
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (options.allowFailure && typeof error.code === "number") {
|
|
29
|
-
return { stdout: error.stdout ?? "", stderr: error.stderr ?? "", code: error.code };
|
|
30
|
-
}
|
|
31
|
-
throw new GitwtError(
|
|
32
|
-
"command_failed",
|
|
33
|
-
`Команда ${command} завершилась с ошибкой: ${error.stderr?.trim() || error.message}`,
|
|
34
|
-
{ command, exitCode: error.code },
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function git(args, cwd, options = {}) {
|
|
40
|
-
return run("git", args, { ...options, cwd });
|
|
41
|
-
}
|
package/src/worktree.js
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { expandPath, hooksSchema, loadConfig } from "./config.js";
|
|
6
|
-
import { runHooks } from "./environment.js";
|
|
7
|
-
import { GitwtError, git } from "./process.js";
|
|
8
|
-
|
|
9
|
-
export async function repository(cwd) {
|
|
10
|
-
const root = await git(["rev-parse", "--show-toplevel"], cwd);
|
|
11
|
-
const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
|
|
12
|
-
return {
|
|
13
|
-
root: await realpath(root.stdout.trim()),
|
|
14
|
-
commonDir: await realpath(common.stdout.trim()),
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function parseWorktrees(text) {
|
|
19
|
-
return text
|
|
20
|
-
.split("\0\0")
|
|
21
|
-
.filter(Boolean)
|
|
22
|
-
.map((block) => {
|
|
23
|
-
const item = {};
|
|
24
|
-
for (const field of block.split("\0").filter(Boolean)) {
|
|
25
|
-
const space = field.indexOf(" ");
|
|
26
|
-
const key = space < 0 ? field : field.slice(0, space);
|
|
27
|
-
const value = space < 0 ? true : field.slice(space + 1);
|
|
28
|
-
if (key === "worktree") item.worktreePath = value;
|
|
29
|
-
if (key === "HEAD") item.head = value;
|
|
30
|
-
if (key === "branch") item.branch = value.replace(/^refs\/heads\//, "");
|
|
31
|
-
if (["detached", "locked", "prunable", "bare"].includes(key)) item[key] = value;
|
|
32
|
-
}
|
|
33
|
-
return item;
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function listWorktrees(cwd, { status = false } = {}) {
|
|
38
|
-
const repo = await repository(cwd);
|
|
39
|
-
const items = parseWorktrees(
|
|
40
|
-
(await git(["worktree", "list", "--porcelain", "-z"], repo.root)).stdout,
|
|
41
|
-
);
|
|
42
|
-
for (const item of items) {
|
|
43
|
-
item.current = item.worktreePath === repo.root;
|
|
44
|
-
if (!status || "prunable" in item || item.bare) continue;
|
|
45
|
-
item.dirty = Boolean((await git(["status", "--porcelain", "-z"], item.worktreePath)).stdout);
|
|
46
|
-
const upstream = await git(
|
|
47
|
-
["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
|
|
48
|
-
item.worktreePath,
|
|
49
|
-
{ allowFailure: true },
|
|
50
|
-
);
|
|
51
|
-
if (upstream.code === 0)
|
|
52
|
-
[item.ahead, item.behind] = upstream.stdout.trim().split(/\s+/).map(Number);
|
|
53
|
-
}
|
|
54
|
-
return items;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function resolveWorktree(cwd, target, options) {
|
|
58
|
-
if (typeof target !== "string" || !target)
|
|
59
|
-
throw new GitwtError("invalid_target", "Укажите ветку или полный путь worktree");
|
|
60
|
-
const expanded = expandPath(target, os.homedir(), cwd);
|
|
61
|
-
const fullPath = await realpath(expanded).catch((error) => {
|
|
62
|
-
if (error.code !== "ENOENT") throw error;
|
|
63
|
-
return expanded;
|
|
64
|
-
});
|
|
65
|
-
const matches = (await listWorktrees(cwd, options)).filter(
|
|
66
|
-
(item) => item.branch === target || item.worktreePath === fullPath,
|
|
67
|
-
);
|
|
68
|
-
if (matches.length !== 1)
|
|
69
|
-
throw new GitwtError("worktree_missing", `Worktree не найден или имя неоднозначно: ${target}`);
|
|
70
|
-
if ("prunable" in matches[0])
|
|
71
|
-
throw new GitwtError(
|
|
72
|
-
"worktree_missing",
|
|
73
|
-
`Каталог worktree недоступен: ${matches[0].worktreePath}`,
|
|
74
|
-
);
|
|
75
|
-
await realpath(matches[0].worktreePath);
|
|
76
|
-
return matches[0];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function validBranch(branch, cwd) {
|
|
80
|
-
if (typeof branch !== "string" || !branch || branch.startsWith("-"))
|
|
81
|
-
throw new GitwtError("invalid_name", "Укажите корректное имя ветки");
|
|
82
|
-
if (
|
|
83
|
-
(await git(["check-ref-format", `refs/heads/${branch}`], cwd, { allowFailure: true })).code !==
|
|
84
|
-
0
|
|
85
|
-
) {
|
|
86
|
-
throw new GitwtError("invalid_name", `Недопустимое имя ветки: ${branch}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function retryGit(args, cwd, signal) {
|
|
91
|
-
let result;
|
|
92
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
93
|
-
result = await git(args, cwd, { allowFailure: true, signal });
|
|
94
|
-
if (result.code === 0) return result;
|
|
95
|
-
signal?.throwIfAborted();
|
|
96
|
-
}
|
|
97
|
-
throw new GitwtError(
|
|
98
|
-
"fetch_failed",
|
|
99
|
-
`Git ${args[0]} завершился с ошибкой после двух попыток: ${result.stderr.trim()}`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function baseReference(cwd, config, { from, signal }) {
|
|
104
|
-
if (from) {
|
|
105
|
-
if (from.startsWith("-")) throw new GitwtError("invalid_base", "Недопустимая стартовая ссылка");
|
|
106
|
-
return (
|
|
107
|
-
await git(["rev-parse", "--verify", `${from}^{commit}`], cwd, { signal })
|
|
108
|
-
).stdout.trim();
|
|
109
|
-
}
|
|
110
|
-
let base = config.base;
|
|
111
|
-
if (base === "auto") {
|
|
112
|
-
const refs = await retryGit(
|
|
113
|
-
["ls-remote", "--heads", config.remote, "trunk", "main", "master"],
|
|
114
|
-
cwd,
|
|
115
|
-
signal,
|
|
116
|
-
);
|
|
117
|
-
base = ["trunk", "main", "master"].find((name) =>
|
|
118
|
-
refs.stdout.split("\n").some((line) => line.endsWith(`refs/heads/${name}`)),
|
|
119
|
-
);
|
|
120
|
-
if (!base) {
|
|
121
|
-
const head = await retryGit(["ls-remote", "--symref", config.remote, "HEAD"], cwd, signal);
|
|
122
|
-
base = head.stdout.match(/^ref: refs\/heads\/(.+)\tHEAD$/m)?.[1];
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (!base)
|
|
126
|
-
throw new GitwtError("base_missing", "Не удалось определить базовую ветку; укажите --base");
|
|
127
|
-
await validBranch(base, cwd);
|
|
128
|
-
const ref = `refs/remotes/${config.remote}/${base}`;
|
|
129
|
-
await retryGit(["fetch", "--no-tags", config.remote, `${base}:${ref}`], cwd, signal);
|
|
130
|
-
return (await git(["rev-parse", "--verify", `${ref}^{commit}`], cwd)).stdout.trim();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function hookFile(cwd) {
|
|
134
|
-
return (
|
|
135
|
-
await git(
|
|
136
|
-
["rev-parse", "--path-format=absolute", "--git-path", "opencode-gitwt-hooks.json"],
|
|
137
|
-
cwd,
|
|
138
|
-
)
|
|
139
|
-
).stdout.trim();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function readHooks(cwd) {
|
|
143
|
-
try {
|
|
144
|
-
return hooksSchema.parse(JSON.parse(await readFile(await hookFile(cwd), "utf8")));
|
|
145
|
-
} catch (error) {
|
|
146
|
-
if (error.code === "ENOENT") return null;
|
|
147
|
-
throw new GitwtError("invalid_hooks", `Не удалось прочитать снимок хуков: ${error.message}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export async function inspectHooks(cwd, target) {
|
|
152
|
-
const item = await resolveWorktree(cwd, target);
|
|
153
|
-
const hooks = await readHooks(item.worktreePath);
|
|
154
|
-
return {
|
|
155
|
-
worktreePath: item.worktreePath,
|
|
156
|
-
snapshot: hooks ? "present" : "absent",
|
|
157
|
-
postCreate: hooks ? (hooks.postCreate.length ? "configured" : "none") : "unknown",
|
|
158
|
-
preRemove: hooks ? (hooks.preRemove.length ? "configured" : "none") : "unknown",
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export async function createWorktree(cwd, options = {}) {
|
|
163
|
-
const repo = await repository(cwd);
|
|
164
|
-
const home = options.home ?? os.homedir();
|
|
165
|
-
const config = await loadConfig(repo.root, options.config, home);
|
|
166
|
-
const branch = `${config.prefix}${options.branch ?? ""}`;
|
|
167
|
-
await validBranch(options.branch, repo.root);
|
|
168
|
-
await validBranch(branch, repo.root);
|
|
169
|
-
if (
|
|
170
|
-
(
|
|
171
|
-
await git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repo.root, {
|
|
172
|
-
allowFailure: true,
|
|
173
|
-
})
|
|
174
|
-
).code === 0
|
|
175
|
-
) {
|
|
176
|
-
throw new GitwtError(
|
|
177
|
-
"branch_exists",
|
|
178
|
-
`Ветка ${branch} уже существует; создайте новую от нужной базы`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
const primary = (await listWorktrees(repo.root))[0].worktreePath;
|
|
182
|
-
const id = createHash("sha256").update(repo.commonDir).digest("hex").slice(0, 8);
|
|
183
|
-
const target = options.path
|
|
184
|
-
? expandPath(options.path, home, repo.root)
|
|
185
|
-
: path.join(
|
|
186
|
-
config.worktreeDir ?? path.join(home, "git-wt"),
|
|
187
|
-
`${path.basename(primary)}-${id}`,
|
|
188
|
-
branch,
|
|
189
|
-
);
|
|
190
|
-
const existing = await lstat(target).catch((error) => {
|
|
191
|
-
if (error.code !== "ENOENT") throw error;
|
|
192
|
-
});
|
|
193
|
-
if (existing || (await listWorktrees(repo.root)).some((item) => item.worktreePath === target)) {
|
|
194
|
-
throw new GitwtError("worktree_exists", `Путь уже занят: ${target}`);
|
|
195
|
-
}
|
|
196
|
-
const head = await baseReference(repo.root, config, options);
|
|
197
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
198
|
-
const worktreePath = path.join(await realpath(path.dirname(target)), path.basename(target));
|
|
199
|
-
const hooks = options.noHooks ? { postCreate: [], preRemove: [] } : config.hooks;
|
|
200
|
-
try {
|
|
201
|
-
await git(
|
|
202
|
-
["worktree", "add", "--no-track", "-b", branch, "--", worktreePath, head],
|
|
203
|
-
repo.root,
|
|
204
|
-
{ signal: options.signal },
|
|
205
|
-
);
|
|
206
|
-
const actualRoot = (await git(["rev-parse", "--show-toplevel"], worktreePath)).stdout.trim();
|
|
207
|
-
const actualBranch = (await git(["branch", "--show-current"], worktreePath)).stdout.trim();
|
|
208
|
-
const actualHead = (await git(["rev-parse", "HEAD"], worktreePath)).stdout.trim();
|
|
209
|
-
const actual = await resolveWorktree(repo.root, worktreePath);
|
|
210
|
-
if (
|
|
211
|
-
(await realpath(actualRoot)) !== worktreePath ||
|
|
212
|
-
actualBranch !== branch ||
|
|
213
|
-
actualHead !== head ||
|
|
214
|
-
actual.branch !== branch ||
|
|
215
|
-
actual.head !== head
|
|
216
|
-
) {
|
|
217
|
-
throw new GitwtError("verification_failed", "Проверка корня, ветки или HEAD не прошла");
|
|
218
|
-
}
|
|
219
|
-
await writeFile(await hookFile(worktreePath), `${JSON.stringify(hooks)}\n`, {
|
|
220
|
-
flag: "wx",
|
|
221
|
-
mode: 0o600,
|
|
222
|
-
});
|
|
223
|
-
} catch (error) {
|
|
224
|
-
throw new GitwtError(
|
|
225
|
-
"worktree_create_failed",
|
|
226
|
-
`Создание ${worktreePath} завершилось ошибкой; проверьте этот путь перед повтором: ${error.message}`,
|
|
227
|
-
{ worktreePath },
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
const warnings = [];
|
|
231
|
-
try {
|
|
232
|
-
await runHooks(
|
|
233
|
-
hooks.postCreate,
|
|
234
|
-
worktreePath,
|
|
235
|
-
{ BRANCH: branch, WORKTREE_PATH: worktreePath, HEAD: head, SOURCE_PATH: repo.root },
|
|
236
|
-
options.signal,
|
|
237
|
-
);
|
|
238
|
-
} catch (error) {
|
|
239
|
-
warnings.push(`Worktree создан, но postCreate завершился с ошибкой: ${error.message}`);
|
|
240
|
-
}
|
|
241
|
-
return { branch, worktreePath, head, warnings };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export async function removeWorktree(cwd, target, options = {}) {
|
|
245
|
-
const repo = await repository(cwd);
|
|
246
|
-
const items = await listWorktrees(repo.root);
|
|
247
|
-
const item = await resolveWorktree(repo.root, target);
|
|
248
|
-
if (item.worktreePath === items[0].worktreePath || item.current) {
|
|
249
|
-
throw new GitwtError(
|
|
250
|
-
"protected_worktree",
|
|
251
|
-
"Нельзя удалить основной или текущий worktree; запустите удаление из другого worktree",
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
const assertClean = async () => {
|
|
255
|
-
if (!options.force && (await git(["status", "--porcelain", "-z"], item.worktreePath)).stdout) {
|
|
256
|
-
throw new GitwtError(
|
|
257
|
-
"worktree_dirty",
|
|
258
|
-
"Worktree содержит изменения; явный --force удалит их",
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
const hooks = await readHooks(item.worktreePath);
|
|
263
|
-
await assertClean();
|
|
264
|
-
await runHooks(
|
|
265
|
-
hooks?.preRemove ?? [],
|
|
266
|
-
item.worktreePath,
|
|
267
|
-
{ BRANCH: item.branch ?? "", WORKTREE_PATH: item.worktreePath, HEAD: item.head },
|
|
268
|
-
options.signal,
|
|
269
|
-
);
|
|
270
|
-
await assertClean();
|
|
271
|
-
await git(
|
|
272
|
-
["worktree", "remove", ...(options.force ? ["--force"] : []), "--", item.worktreePath],
|
|
273
|
-
repo.root,
|
|
274
|
-
{ signal: options.signal },
|
|
275
|
-
);
|
|
276
|
-
const warnings = [];
|
|
277
|
-
let branchDeleted = false;
|
|
278
|
-
if (item.branch && options.deleteBranch !== false) {
|
|
279
|
-
const result = await git(["branch", "-d", "--", item.branch], repo.root, {
|
|
280
|
-
allowFailure: true,
|
|
281
|
-
});
|
|
282
|
-
branchDeleted = result.code === 0;
|
|
283
|
-
if (!branchDeleted)
|
|
284
|
-
warnings.push(`Worktree удалён, ветка ${item.branch} сохранена: ${result.stderr.trim()}`);
|
|
285
|
-
}
|
|
286
|
-
return { worktreePath: item.worktreePath, branch: item.branch, branchDeleted, warnings };
|
|
287
|
-
}
|