opencode-gitwt 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 voischev
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,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # opencode-gitwt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Тонкая обёртка над `git worktree` для OpenCode и CLI. Подход как в `arc-wt`:
4
+ создать отдельный checkout, проверить его, получить путь и продолжить работу
5
+ в текущей сессии. Git остаётся единственным реестром worktree.
6
+
7
+ Требования: Node.js от 22.14, Git, OpenCode от 1.19.3 до 2.0.
8
+
9
+ ## Подключение
10
+
11
+ После первого релиза в npm добавьте в `opencode.json`:
12
+
13
+ ```json
14
+ {
15
+ "$schema": "https://opencode.ai/config.json",
16
+ "plugin": ["opencode-gitwt@0.1.0"]
17
+ }
18
+ ```
19
+
20
+ Для установки из SourceCraft используйте Git-spec вместо имени пакета:
21
+
22
+ ```text
23
+ git+https://git@git.sourcecraft.dev/voischev/opencode-gitwt.git#<commit>
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` переопределяют настройки.
73
+
74
+ `cd` печатает только каталог. `--json` выдаёт один JSON-объект или массив,
75
+ ошибки идут в stderr с ненулевым exit code. `list --porcelain` также выдаёт
76
+ JSON-массив. Неизвестные ahead/behind отсутствуют в ответе, а не равны нулю.
77
+
78
+ ## Опциональные хуки
79
+
80
+ Один формат настроек: JSONC. Приоритет: значения по умолчанию →
81
+ `~/.config/opencode/gitwt.jsonc` → `.opencode/gitwt.jsonc` исходного checkout →
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
+ ```
131
+
132
+ Тесты используют реальные временные Git-репозитории. При наличии OpenCode
133
+ 1.19.3 smoke-тест проверяет регистрацию инструментов в изолированном сервере
134
+ без LLM. Для сквозной Git-установки задайте `GITWT_TEST_PLUGIN_SPEC` равным
135
+ Git-spec и запустите `node --test test/plugin.test.js`.
136
+
137
+ SourceCraft CI проверяет PR и main. Для публикации добавьте granular npm-токен
138
+ в секрет репозитория `NPM_TOKEN`, влейте PR и отправьте тег версии `v0.1.0`.
139
+ Workflow проверяет тесты, линт, тег/HEAD, историю origin/main и tarball перед
140
+ публикацией. Токен доступен только кубику publish, временный `.npmrc` создаётся
141
+ вне репозитория. npm Trusted Publishing для SourceCraft не поддерживается.
142
+
143
+ Репозиторий:
144
+ https://sourcecraft.dev/voischev/opencode-gitwt
package/bin/git-wt.js ADDED
@@ -0,0 +1,100 @@
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/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "opencode-gitwt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Git worktree для OpenCode: создать, проверить, вернуть путь и явно удалить",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./server": "./src/index.js"
10
+ },
11
+ "bin": {
12
+ "git-wt": "./bin/git-wt.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "bin",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22.14.0",
22
+ "opencode": ">=1.19.3 <2"
23
+ },
24
+ "scripts": {
25
+ "check": "biome check .",
26
+ "test": "node --test",
27
+ "release": "node scripts/release.js"
28
+ },
29
+ "dependencies": {
30
+ "jsonc-parser": "3.3.1",
31
+ "zod": "4.1.8"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "2.6.3"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://git@git.sourcecraft.dev/voischev/opencode-gitwt.git"
39
+ },
40
+ "homepage": "https://sourcecraft.dev/voischev/opencode-gitwt",
41
+ "bugs": {
42
+ "url": "https://sourcecraft.dev/voischev/opencode-gitwt/issues"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "keywords": [
49
+ "opencode",
50
+ "opencode-plugin",
51
+ "git",
52
+ "worktree"
53
+ ],
54
+ "license": "MIT"
55
+ }
package/src/config.js ADDED
@@ -0,0 +1,69 @@
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
+ }
@@ -0,0 +1,12 @@
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/index.js ADDED
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { createWorktree, listWorktrees, removeWorktree, resolveWorktree } from "./worktree.js";
3
+
4
+ export async function GitwtPlugin({ directory }, config = {}) {
5
+ return {
6
+ tool: {
7
+ gitwt_create: {
8
+ description:
9
+ "Создать и проверить новый worktree. Продолжайте текущую сессию, используя возвращённый worktreePath в командах и файловых операциях. Сохраняйте worktree при паузе и передаче задачи.",
10
+ args: {
11
+ branch: z.string().describe("Имя новой ветки"),
12
+ base: z.string().optional().describe("Базовая remote-ветка"),
13
+ from: z
14
+ .string()
15
+ .optional()
16
+ .describe("Явная локальная ссылка или проверенный commit вместо remote-базы"),
17
+ path: z.string().optional().describe("Полный путь нового worktree"),
18
+ noHooks: z.boolean().optional().describe("Явно отключить хуки для создаваемого worktree"),
19
+ },
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
+ "Показать worktree из реестра Git. status добавляет dirty и доступные ahead/behind; отсутствие этих полей означает неизвестное состояние.",
32
+ args: { status: z.boolean().optional() },
33
+ execute: async (args, ctx) =>
34
+ JSON.stringify(await listWorktrees(ctx.directory ?? directory, args)),
35
+ },
36
+ gitwt_cd: {
37
+ description:
38
+ "Найти worktree по точной ветке или полному пути и вернуть его путь, HEAD и статус. Каталог текущей сессии не меняется: используйте worktreePath явно.",
39
+ args: { target: z.string() },
40
+ execute: async ({ target }, ctx) =>
41
+ JSON.stringify(
42
+ await resolveWorktree(ctx.directory ?? directory, target, { status: true }),
43
+ ),
44
+ },
45
+ gitwt_remove: {
46
+ description:
47
+ "Только по явной просьбе удалить указанный worktree, убедившись, что он больше не используется. force разрешает потерю изменений, но не обходит ошибку preRemove. Без автокоммитов и отложенной очистки.",
48
+ args: {
49
+ target: z.string(),
50
+ force: z.boolean().optional(),
51
+ keepBranch: z.boolean().optional(),
52
+ },
53
+ execute: async ({ target, force, keepBranch }, ctx) =>
54
+ JSON.stringify(
55
+ await removeWorktree(ctx.directory ?? directory, target, {
56
+ force,
57
+ deleteBranch: !keepBranch,
58
+ signal: ctx.abort,
59
+ }),
60
+ ),
61
+ },
62
+ },
63
+ };
64
+ }
65
+
66
+ export default { id: "opencode-gitwt", server: GitwtPlugin };
package/src/process.js ADDED
@@ -0,0 +1,41 @@
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
+ }
@@ -0,0 +1,287 @@
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
+ }