opencode-gitwt 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,144 +1,53 @@
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
- ```
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.
62
23
 
63
- `add` всегда создаёт новую ветку. Для существующей работы укажите проверенный
64
- ref или commit в `--from`, а новой копии дайте другое имя. По умолчанию база
65
- обновляется из remote: `trunk`, затем `main`, затем `master`, затем remote HEAD.
66
- Fetch повторяется один раз при ошибке. Грязный исходный checkout не мешает
67
- созданию, его изменения не переносятся. Ошибка создания может оставить копию:
68
- сначала осмотрите сообщённый путь, не повторяйте создание вслепую.
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.
69
30
 
70
- `--path` — полный путь копии; `--dir` — базовый каталог. Без них используется
71
- `~/git-wt/<имя-репо>-<id>/<ветка>`. Имена веток сохраняются и проверяются Git.
72
- `--remote` и `--prefix` переопределяют настройки.
31
+ ## Tools
73
32
 
74
- `cd` печатает только каталог. `--json` выдаёт один JSON-объект или массив,
75
- ошибки идут в stderr с ненулевым exit code. `list --porcelain` также выдаёт
76
- JSON-массив. Неизвестные ahead/behind отсутствуют в ответе, а не равны нулю.
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`.
77
37
 
78
- ## Опциональные хуки
38
+ ## Configuration
79
39
 
80
- Один формат настроек: JSONC. Приоритет: значения по умолчанию →
81
- `~/.config/opencode/gitwt.jsonc` → `.opencode/gitwt.jsonc` исходного checkout →
82
- параметры плагина → аргументы операции.
40
+ The plugin uses the shared gitwt configuration: `.gitwt.jsonc` in the project
41
+ and `~/.config/gitwt/config.jsonc` globally. Options include `remote`, `base`,
42
+ `prefix`, `worktreeDir`, and `hooks`. See the core repository for details.
83
43
 
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
- ```
44
+ For the standalone `gitwt` command, run `npm install -g @voischev/gitwt`.
45
+ Upgrading from 0.1.x: move `.opencode/gitwt.jsonc` to `.gitwt.jsonc` and global
46
+ settings from `~/.config/opencode/gitwt.jsonc` to `~/.config/gitwt/config.jsonc`.
131
47
 
132
- Тесты используют реальные временные Git-репозитории. При наличии OpenCode
133
- 1.19.3 smoke-тест проверяет регистрацию инструментов в изолированном сервере
134
- без LLM. Для сквозной Git-установки задайте `GITWT_TEST_PLUGIN_SPEC` равным
135
- Git-spec и запустите `node --test test/plugin.test.js`.
48
+ ## Development
136
49
 
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 не поддерживается.
50
+ GitHub is the primary repository; SourceCraft is a mirror. Releases publish to
51
+ npm from GitHub with `NPM_TOKEN`.
142
52
 
143
- Репозиторий:
144
- https://sourcecraft.dev/voischev/opencode-gitwt
53
+ Run `npm ci --ignore-scripts`, `npm run check`, and `npm test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitwt",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git worktree для OpenCode: создать, проверить, вернуть путь и явно удалить",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -8,12 +8,8 @@
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.3",
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,5 +1,5 @@
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 {
@@ -28,7 +28,7 @@ export async function GitwtPlugin({ directory }, config = {}) {
28
28
  },
29
29
  gitwt_list: {
30
30
  description:
31
- "Показать worktree из реестра Git. status добавляет dirty и доступные ahead/behind; отсутствие этих полей означает неизвестное состояние.",
31
+ "Показать worktree из реестра Git. status проверяет dirty и доступные ahead/behind; null означает неизвестное состояние.",
32
32
  args: { status: z.boolean().optional() },
33
33
  execute: async (args, ctx) =>
34
34
  JSON.stringify(await listWorktrees(ctx.directory ?? directory, args)),
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
- }