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 CHANGED
@@ -1,144 +1,45 @@
1
1
  # opencode-gitwt
2
2
 
3
- Тонкая обёртка над `git worktree` для OpenCode и CLI. Подход как в `arc-wt`:
4
- создать отдельный checkout, проверить его, получить путь и продолжить работу
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
- Требования: Node.js от 22.14, Git, OpenCode от 1.19.3 до 2.0.
6
+ Requires Node.js 22.14+ and Git. Supports OpenCode 1.19.3–<2.
8
7
 
9
- ## Подключение
8
+ ## Use in OpenCode
10
9
 
11
- После первого релиза в npm добавьте в `opencode.json`:
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@0.1.0"]
15
+ "plugin": ["opencode-gitwt@latest"]
17
16
  }
18
17
  ```
19
18
 
20
- Для установки из SourceCraft используйте Git-spec вместо имени пакета:
19
+ Ask the agent in chat, for example:
21
20
 
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` переопределяют настройки.
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
- `cd` печатает только каталог. `--json` выдаёт один JSON-объект или массив,
75
- ошибки идут в stderr с ненулевым exit code. `list --porcelain` также выдаёт
76
- JSON-массив. Неизвестные ahead/behind отсутствуют в ответе, а не равны нулю.
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
- Один формат настроек: 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
- ```
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
- Тесты используют реальные временные Git-репозитории. При наличии OpenCode
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
- 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 не поддерживается.
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
- https://sourcecraft.dev/voischev/opencode-gitwt
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.0",
4
- "description": "Git worktree для OpenCode: создать, проверить, вернуть путь и явно удалить",
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
- "jsonc-parser": "3.3.1",
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://git@git.sourcecraft.dev/voischev/opencode-gitwt.git"
34
+ "url": "git+https://github.com/voischev/opencode-gitwt.git"
39
35
  },
40
- "homepage": "https://sourcecraft.dev/voischev/opencode-gitwt",
36
+ "homepage": "https://github.com/voischev/opencode-gitwt",
41
37
  "bugs": {
42
- "url": "https://sourcecraft.dev/voischev/opencode-gitwt/issues"
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
- "Создать и проверить новый worktree. Продолжайте текущую сессию, используя возвращённый worktreePath в командах и файловых операциях. Сохраняйте worktree при паузе и передаче задачи.",
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("Базовая remote-ветка"),
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("Явная локальная ссылка или проверенный commit вместо remote-базы"),
17
- path: z.string().optional().describe("Полный путь нового worktree"),
18
- noHooks: z.boolean().optional().describe("Явно отключить хуки для создаваемого worktree"),
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
- "Показать worktree из реестра Git. status добавляет dirty и доступные ahead/behind; отсутствие этих полей означает неизвестное состояние.",
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
- "Найти worktree по точной ветке или полному пути и вернуть его путь, HEAD и статус. Каталог текущей сессии не меняется: используйте worktreePath явно.",
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
- "Только по явной просьбе удалить указанный worktree, убедившись, что он больше не используется. force разрешает потерю изменений, но не обходит ошибку preRemove. Без автокоммитов и отложенной очистки.",
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.boolean().optional(),
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
- }
@@ -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
- }