@nitra/cursor 3.4.0 → 3.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - python: нове правило (uv-only, без Poetry) — автоактивація за pyproject.toml, заборона [tool.poetry]/poetry.lock, PEP 621 [project], lint-python (uv lock --check + uv sync --frozen + опц. ruff auto-fix: check --fix & format, mypy), CI workflow з astral-sh/setup-uv
8
+
9
+ ## [3.4.1] - 2026-06-01
10
+
11
+ ### Fixed
12
+
13
+ - worktree add: перевіряє зайнятість назви й автоматично обирає вільну (base, base2, base3, …) замість падіння на 'a branch named … already exists'
14
+
3
15
  ## [3.4.0] - 2026-06-01
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,19 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
2
+ import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
3
+
4
+ /**
5
+ * Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
6
+ * Library mode: викликається CLI orchestration через `import + run(ctx)`.
7
+ * @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
8
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
9
+ */
10
+ export function run(ctx) {
11
+ return runStandardRule(import.meta.dirname, ctx)
12
+ }
13
+
14
+ if (isRunAsCli(import.meta.url)) {
15
+ // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
16
+ // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
+ process.exit(await runRuleCli(import.meta.dirname))
19
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Перевіряє FS-вимоги правила python.mdc для Python-проєктів на uv.
3
+ *
4
+ * **Що тут лишилося** (FS-existence — не покривається conftest):
5
+ * - наявність `pyproject.toml` у корені (тригер правила);
6
+ * - наявність `uv.lock` поруч (uv-проєкт коммітить lock-файл);
7
+ * - наявність кореневого `package.json` (для `bun run lint-python`);
8
+ * - наявність `.github/workflows/lint-python.yml`;
9
+ * - заборона Poetry-артефактів `poetry.lock` / `poetry.toml` (міграція на uv).
10
+ *
11
+ * **Що покрила Rego** (`npx \@nitra/cursor fix python`):
12
+ * - `python/pyproject_toml/` — заборона `[tool.poetry]` + вимога PEP 621 `[project].name/version`;
13
+ * - `python/package_json/` — наявність скрипта `lint-python` у `package.json`;
14
+ * - `python/lint_python_yml/` — `uses`/`run`-кроки канонічного workflow.
15
+ *
16
+ * `.venv/` навмисно НЕ перевіряється: uv теж створює `.venv`, тож його наявність
17
+ * не є ознакою Poetry й давала б хибні спрацювання.
18
+ */
19
+ import { existsSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
23
+
24
+ /**
25
+ * Перевіряє відповідність проєкту правилам python.mdc.
26
+ * @param {string} [cwd] корінь репозиторію (`process.cwd()` у звичайному прогоні)
27
+ * @returns {number} 0 — все OK, 1 — є проблеми
28
+ */
29
+ export function check(cwd = process.cwd()) {
30
+ const reporter = createCheckReporter()
31
+ const { pass, fail } = reporter
32
+
33
+ if (!existsSync(join(cwd, 'pyproject.toml'))) {
34
+ pass('pyproject.toml не знайдено в корені — правило python не застосовне')
35
+ return reporter.getExitCode()
36
+ }
37
+ pass('pyproject.toml існує — застосовую python.mdc')
38
+
39
+ if (existsSync(join(cwd, 'uv.lock'))) {
40
+ pass('uv.lock є')
41
+ } else {
42
+ fail('uv.lock не знайдено — згенеруй `uv lock` (python.mdc, без Poetry)')
43
+ }
44
+
45
+ // Poetry-артефакти заборонені: uv є єдиним пакет-менеджером (python.mdc).
46
+ for (const poetryFile of ['poetry.lock', 'poetry.toml']) {
47
+ if (existsSync(join(cwd, poetryFile))) {
48
+ fail(`${poetryFile} знайдено — прибери Poetry, мігруй на uv (python.mdc)`)
49
+ } else {
50
+ pass(`${poetryFile} відсутній`)
51
+ }
52
+ }
53
+
54
+ if (existsSync(join(cwd, 'package.json'))) {
55
+ pass('package.json є (наявність lint-python перевіряє fix → python.package_json)')
56
+ } else {
57
+ fail('package.json не знайдено в корені — додай для `bun run lint-python` (python.mdc)')
58
+ }
59
+
60
+ const wfPath = '.github/workflows/lint-python.yml'
61
+ if (existsSync(join(cwd, wfPath))) {
62
+ pass(`${wfPath} є (структуру перевіряє fix → python.lint_python_yml)`)
63
+ } else {
64
+ fail(`${wfPath} не існує — створи згідно python.mdc`)
65
+ }
66
+
67
+ return reporter.getExitCode()
68
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Запуск `lint-python` за правилом python.mdc на базі [uv](https://docs.astral.sh/uv/).
3
+ *
4
+ * Якщо `pyproject.toml` у корені відсутній — вихід 0 без запуску інструментів.
5
+ * Якщо `pyproject.toml` є, але `uv` не знайдено в PATH — це помилка (uv — єдиний
6
+ * пакет-менеджер, без Poetry).
7
+ *
8
+ * Обовʼязкові кроки (uv):
9
+ * - `uv lock --check` — lock-файл актуальний щодо `pyproject.toml`;
10
+ * - `uv sync --frozen` — середовище зібране строго з `uv.lock`.
11
+ *
12
+ * Опційні лінтери запускаються лише якщо доступні через `uv run` (інакше крок
13
+ * пропускається з повідомленням, як optional vendor-tools у php.mdc). `ruff`
14
+ * запускається в auto-fix-режимі (мутує робоче дерево, як `markdownlint-cli2 --fix`
15
+ * у lint-text / `clippy --fix` у lint-rust):
16
+ * - `uv run ruff check --fix .`
17
+ * - `uv run ruff format .`
18
+ * - `uv run mypy .`
19
+ *
20
+ * Канон патерну `lint-*` (серіалізація через `runStandardLint`, без прямого `withLock`) —
21
+ * `.cursor/rules/scripts.mdc`, секція «Серіалізація важких CLI-команд».
22
+ */
23
+ import { spawnSync } from 'node:child_process'
24
+ import { existsSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+
27
+ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
28
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
29
+ import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
30
+ import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
31
+
32
+ /**
33
+ * Запускає CLI-крок і репортить результат.
34
+ * @param {string} label назва кроку для повідомлень
35
+ * @param {string} cmd абсолютний шлях до CLI
36
+ * @param {string[]} args аргументи
37
+ * @param {(msg: string) => void} pass callback pass
38
+ * @param {(msg: string) => void} fail callback fail
39
+ * @returns {boolean} true якщо крок успішний
40
+ */
41
+ function runTool(label, cmd, args, pass, fail) {
42
+ const r = spawnSync(cmd, args, { stdio: 'inherit', shell: false })
43
+ if (r.status === 0) {
44
+ pass(`lint-python: ${label} — OK`)
45
+ return true
46
+ }
47
+ const code = typeof r.status === 'number' ? r.status : 1
48
+ fail(`lint-python: ${label} — помилка (код ${code}, python.mdc)`)
49
+ return false
50
+ }
51
+
52
+ /**
53
+ * Чи доступний інструмент усередині uv-середовища (`uv run --frozen <tool> --version`).
54
+ * @param {string} uv абсолютний шлях до `uv`
55
+ * @param {string} tool назва бінарника (`ruff`, `mypy`)
56
+ * @returns {boolean} true якщо інструмент відповідає на `--version`
57
+ */
58
+ function uvToolAvailable(uv, tool) {
59
+ const r = spawnSync(uv, ['run', '--frozen', tool, '--version'], { stdio: 'ignore', shell: false })
60
+ return r.status === 0
61
+ }
62
+
63
+ /**
64
+ * Внутрішні кроки `lint-python` без локу.
65
+ * @param {string} [cwd] корінь репозиторію
66
+ * @returns {number} 0 — OK, 1 — є помилки
67
+ */
68
+ export function runLintPythonSteps(cwd = process.cwd()) {
69
+ const reporter = createCheckReporter()
70
+ const { pass, fail } = reporter
71
+
72
+ if (!existsSync(join(cwd, 'pyproject.toml'))) {
73
+ pass('lint-python: немає pyproject.toml у корені — кроки Python пропущено')
74
+ return reporter.getExitCode()
75
+ }
76
+
77
+ const uv = resolveCmd('uv')
78
+ if (!uv) {
79
+ fail('lint-python: `uv` не знайдено в PATH (потрібен при наявному pyproject.toml, python.mdc)')
80
+ return reporter.getExitCode()
81
+ }
82
+
83
+ if (!runTool('uv lock --check', uv, ['lock', '--check'], pass, fail)) return reporter.getExitCode()
84
+ if (!runTool('uv sync --frozen', uv, ['sync', '--frozen'], pass, fail)) return reporter.getExitCode()
85
+
86
+ /**
87
+ * Запускає лінтер через `uv run`, якщо він доступний у середовищі.
88
+ * @param {string} tool назва бінарника
89
+ * @param {string} label назва кроку
90
+ * @param {string[]} args аргументи інструмента
91
+ * @returns {boolean} true, якщо крок успішний або пропущений
92
+ */
93
+ function runOptionalUvTool(tool, label, args) {
94
+ if (!uvToolAvailable(uv, tool)) {
95
+ pass(`lint-python: ${tool} недоступний у uv-середовищі — крок пропущено`)
96
+ return true
97
+ }
98
+ return runTool(label, uv, ['run', '--frozen', tool, ...args], pass, fail)
99
+ }
100
+
101
+ if (!runOptionalUvTool('ruff', 'ruff check --fix', ['check', '--fix', '.'])) return reporter.getExitCode()
102
+ if (!runOptionalUvTool('ruff', 'ruff format', ['format', '.'])) return reporter.getExitCode()
103
+ if (!runOptionalUvTool('mypy', 'mypy', ['.'])) return reporter.getExitCode()
104
+
105
+ return reporter.getExitCode()
106
+ }
107
+
108
+ /**
109
+ * Публічна CLI-форма: серіалізує через `withLock('lint-python')` + дедуп за станом git-дерева.
110
+ * @returns {Promise<number>} код виходу
111
+ */
112
+ export const runLintPython = () => runStandardLint(import.meta.dirname, runLintPythonSteps)
113
+
114
+ if (isRunAsCli(import.meta.url)) {
115
+ process.exitCode = await runLintPython()
116
+ }
@@ -0,0 +1 @@
1
+ { "auto": { "glob": "pyproject.toml" } }
@@ -0,0 +1,59 @@
1
+ # Перевірка `.github/workflows/lint-python.yml` для правила python (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "snippet": ... } }
4
+ # Структура --data сформована з template/lint-python.yml.snippet.yml.
5
+ # Перевіряємо (drift-safe — усе ведеться з template, без inline-літералів):
6
+ # - кожен `uses` з template (підмножина): actions/checkout@v6,
7
+ # ./.github/actions/setup-bun-deps, astral-sh/setup-uv@v8.0.0;
8
+ # - кожен `run` з template має бути присутнім (як substring) серед run-кроків
9
+ # input'а: `uv sync --frozen`, `bun run lint-python`.
10
+ # Заборона Poetry-кроків (snok/install-poetry, `poetry install`) — через відсутність
11
+ # у каноні: правило вимагає uv-кроки, а нав'язаних poetry-кроків у template немає.
12
+ # Універсальні workflow-перевірки (name, concurrency, branches) — у `ga.workflow_common`.
13
+ package python.lint_python_yml
14
+
15
+ import rego.v1
16
+
17
+ # Усі `uses` з канону workflow (по всіх job'ах template).
18
+ expected_uses contains u if {
19
+ some job in data.template.snippet.jobs
20
+ some step in job.steps
21
+ u := object.get(step, "uses", "")
22
+ u != ""
23
+ }
24
+
25
+ # Усі `uses` з input workflow.
26
+ actual_uses contains u if {
27
+ some job in object.get(input, "jobs", {})
28
+ some step in object.get(job, "steps", [])
29
+ u := object.get(step, "uses", "")
30
+ u != ""
31
+ }
32
+
33
+ # Конкатенація всіх `run`-кроків з input workflow.
34
+ all_run_text := concat("\n", [run_text |
35
+ some job in object.get(input, "jobs", {})
36
+ some step in object.get(job, "steps", [])
37
+ run_text := step_run_to_text(step)
38
+ ])
39
+
40
+ deny contains msg if {
41
+ some required_use in expected_uses
42
+ not required_use in actual_uses
43
+ msg := sprintf("lint-python.yml: відсутній step з `uses: %s` (python.mdc)", [required_use])
44
+ }
45
+
46
+ deny contains msg if {
47
+ some job in data.template.snippet.jobs
48
+ some step in job.steps
49
+ expected_run := object.get(step, "run", "")
50
+ expected_run != ""
51
+ not contains(all_run_text, expected_run)
52
+ msg := sprintf("lint-python.yml: жоден крок run не містить %q (python.mdc)", [expected_run])
53
+ }
54
+
55
+ step_run_to_text(step) := step.run if is_string(step.run)
56
+
57
+ else := concat("\n", [s | some s in step.run]) if is_array(step.run)
58
+
59
+ else := ""
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": ".github/workflows/lint-python.yml" }
4
+ }
@@ -0,0 +1,41 @@
1
+ name: Lint Python
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - dev
7
+ - main
8
+ paths:
9
+ - '**/*.py'
10
+ - 'pyproject.toml'
11
+ - 'uv.lock'
12
+ - '.github/workflows/lint-python.yml'
13
+
14
+ pull_request:
15
+ branches:
16
+ - dev
17
+ - main
18
+
19
+ concurrency:
20
+ group: ${{ github.ref }}-${{ github.workflow }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ python:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ with:
31
+ persist-credentials: false
32
+
33
+ - uses: ./.github/actions/setup-bun-deps
34
+
35
+ - uses: astral-sh/setup-uv@v8.0.0
36
+
37
+ - name: Install dependencies (uv)
38
+ run: uv sync --frozen
39
+
40
+ - name: Lint Python
41
+ run: bun run lint-python
@@ -0,0 +1,16 @@
1
+ # Перевірка `package.json` (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "contains": ... } }
4
+ # Структура --data сформована з template/package.json.contains.json.
5
+ # FS-перевірки (наявність `package.json` як такого) — у JS.
6
+ package python.package_json
7
+
8
+ import rego.v1
9
+
10
+ deny contains msg if {
11
+ some script_name, needles in data.template.contains.scripts
12
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
13
+ some needle in needles
14
+ not contains(actual, needle)
15
+ msg := sprintf("package.json: scripts.%s має містити %q (python.mdc)", [script_name, needle])
16
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "package.json" }
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "scripts": {
3
+ "lint-python": ["bun"]
4
+ }
5
+ }
@@ -0,0 +1,39 @@
1
+ # Перевірка `pyproject.toml` (python.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "deny": ... } }
4
+ # Структура --data сформована з template/pyproject.toml.deny.toml.
5
+ # FS-перевірки (`poetry.lock`, `poetry.toml`, `uv.lock`, `package.json`) — у JS.
6
+ #
7
+ # Дві групи правил:
8
+ # 1. Заборона Poetry — `[tool.poetry]` (та інші заборонені під-таблиці `tool`)
9
+ # керується deny-template, drift-safe.
10
+ # 2. PEP 621 — `[project].name` і `[project].version` обовʼязкові (структурна
11
+ # вимога без канонічного літералу, тому inline).
12
+ package python.pyproject_toml
13
+
14
+ import rego.v1
15
+
16
+ # ── Заборона Poetry (deny-template керує переліком) ──────────────────────────
17
+
18
+ deny contains msg if {
19
+ some key, reason in object.get(data.template.deny, "tool", {})
20
+ key in object.keys(object.get(input, "tool", {}))
21
+ msg := sprintf("pyproject.toml: [tool.%s] — %s", [key, reason])
22
+ }
23
+
24
+ # ── PEP 621: обовʼязкові [project].name / [project].version ──────────────────
25
+
26
+ deny contains msg if {
27
+ not project_field_set("name")
28
+ msg := "pyproject.toml: відсутній [project].name (PEP 621 — мігруй з [tool.poetry], python.mdc)"
29
+ }
30
+
31
+ deny contains msg if {
32
+ not project_field_set("version")
33
+ msg := "pyproject.toml: відсутній статичний [project].version (PEP 621, python.mdc)"
34
+ }
35
+
36
+ project_field_set(key) if {
37
+ value := object.get(object.get(input, "project", {}), key, "")
38
+ value != ""
39
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "pyproject.toml" }
4
+ }
@@ -0,0 +1,2 @@
1
+ [tool]
2
+ poetry = "Poetry заборонено: мігруй на uv + PEP 621 [project] (python.mdc)"
@@ -0,0 +1,12 @@
1
+ # Канонічний цільовий вигляд після міграції з Poetry на uv (PEP 621).
2
+ # Метадані — у [project] (а не [tool.poetry]); dev-залежності додавай через
3
+ # `uv add --dev <pkg>` (uv тримає їх у [dependency-groups].dev або [tool.uv]).
4
+ [project]
5
+ name = "your-package"
6
+ version = "0.1.0"
7
+ requires-python = ">=3.12"
8
+ dependencies = []
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
@@ -0,0 +1,47 @@
1
+ ---
2
+ description: Python — пакет-менеджер uv (без Poetry), PEP 621, lint-python
3
+ globs: "**/*.py,pyproject.toml,uv.lock,.github/workflows/lint-python.yml"
4
+ alwaysApply: false
5
+ version: '1.0'
6
+ ---
7
+
8
+ Python-проєкти ведуться **виключно** на [uv](https://docs.astral.sh/uv/) — єдиний пакет-менеджер і резолвер. **Poetry заборонено.**
9
+
10
+ - Метадані проєкту — у секції **`[project]`** (PEP 621), а **не** в `[tool.poetry]`.
11
+ - Lock-файл — **`uv.lock`** (коммітиться). `poetry.lock` / `poetry.toml` мають бути відсутні.
12
+ - Залежності: `uv add <pkg>`; dev-залежності: `uv add --dev <pkg>`.
13
+ - Середовище: `uv sync --frozen` (строго з `uv.lock`).
14
+
15
+ `uv` / `ruff` / `mypy` **не** додаються в кореневі `devDependencies` споживача — це окремий toolchain і ставиться через `astral-sh/setup-uv` у CI або локально (як `composer` / `regal`).
16
+
17
+ ## Міграція з Poetry на uv
18
+
19
+ 1. Прибери `[tool.poetry]` і `poetry.lock` / `poetry.toml`.
20
+ 2. Перенеси метадані в `[project]` (name, version, requires-python, dependencies) за PEP 621.
21
+ 3. Згенеруй lock: `uv lock` → `uv.lock`.
22
+ 4. Dev-залежності: `uv add --dev ruff mypy …`.
23
+
24
+ Канонічний цільовий вигляд `pyproject.toml`: [pyproject.toml.snippet.toml](./policy/pyproject_toml/template/pyproject.toml.snippet.toml)
25
+
26
+ Заборонені під-таблиці `[tool.*]` (Poetry): [pyproject.toml.deny.toml](./policy/pyproject_toml/template/pyproject.toml.deny.toml)
27
+
28
+ ## lint-python
29
+
30
+ Інструменти uv-екосистеми не мають єдиного CLI, що сам обходить репозиторій, тому `lint-python` делегується у JS-скрипт-обгортку (як `lint-php`, `lint-docker`).
31
+
32
+ - Канон `package.json#scripts.lint-python` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
33
+
34
+ Скрипт `rules/python/lint/lint.mjs`:
35
+
36
+ - якщо `pyproject.toml` у корені відсутній — вихід 0 (перевірка пропущена);
37
+ - якщо `pyproject.toml` є, але `uv` не знайдено в PATH — це помилка;
38
+ - `uv lock --check` і `uv sync --frozen` — обовʼязкові;
39
+ - `uv run ruff check --fix .` + `uv run ruff format .` — auto-fix (мутують робоче дерево, як `markdownlint-cli2 --fix` у lint-text);
40
+ - `uv run mypy .` — статична перевірка типів;
41
+ - усі `ruff`/`mypy`-кроки запускаються лише якщо інструмент доступний у середовищі (інакше крок пропускається з повідомленням).
42
+
43
+ ## CI: `.github/workflows/lint-python.yml`
44
+
45
+ - Канон: [lint-python.yml.snippet.yml](./policy/lint_python_yml/template/lint-python.yml.snippet.yml)
46
+
47
+ Без кроків `poetry install` / `snok/install-poetry` — лише `astral-sh/setup-uv@v8.0.0` + `uv sync --frozen`.
@@ -24,13 +24,39 @@ export function sanitizeBranch(branch) {
24
24
  if (typeof branch !== 'string' || branch.trim() === '') {
25
25
  throw new Error('worktree: імʼя гілки обовʼязкове')
26
26
  }
27
- const sanitized = branch.trim().replace(UNSAFE_PATH_CHARS_RE, '-').replace(/^-+|-+$/gu, '')
27
+ const sanitized = branch
28
+ .trim()
29
+ .replace(UNSAFE_PATH_CHARS_RE, '-')
30
+ .replace(/^-+|-+$/gu, '')
28
31
  if (sanitized === '') {
29
32
  throw new Error(`worktree: імʼя гілки "${branch}" не містить допустимих символів`)
30
33
  }
31
34
  return sanitized
32
35
  }
33
36
 
37
+ /**
38
+ * Перша вільна назва гілки за конвенцією `base`, `base2`, `base3`, … —
39
+ * суфікс просто число без розділювача (як `main-fix` → `main-fix2`).
40
+ * Дає змогу `worktree add` спершу перевірити зайнятість і обрати назву,
41
+ * що спрацює, замість падіння на `fatal: a branch named '…' already exists`.
42
+ * @param {string} branch бажане імʼя гілки
43
+ * @param {(candidate: string) => boolean} isTaken чи зайнята назва (гілка/worktree вже існують)
44
+ * @param {number} [limit] стеля кількості спроб (захист від нескінченного циклу)
45
+ * @returns {string} перша вільна назва (= `branch`, якщо вона вільна)
46
+ */
47
+ export function firstFreeBranch(branch, isTaken, limit = 1000) {
48
+ if (typeof branch !== 'string' || branch.trim() === '') {
49
+ throw new Error('worktree: імʼя гілки обовʼязкове')
50
+ }
51
+ const base = branch.trim()
52
+ if (!isTaken(base)) return base
53
+ for (let n = 2; n <= limit; n++) {
54
+ const candidate = `${base}${n}`
55
+ if (!isTaken(candidate)) return candidate
56
+ }
57
+ throw new Error(`worktree: не знайдено вільної назви для "${base}" за ${limit} спроб`)
58
+ }
59
+
34
60
  /**
35
61
  * Детерміновані шляхи checkout і файла-опису для гілки.
36
62
  * @param {string} repoRoot абсолютний корінь репозиторію
@@ -16,7 +16,7 @@ import { join } from 'node:path'
16
16
  import { cwd as processCwd } from 'node:process'
17
17
 
18
18
  import { cleanupFlowSiblings } from './dispatcher/lib/state-store.mjs'
19
- import { buildDescription, findOrphanDescFiles, worktreePaths } from './lib/worktree.mjs'
19
+ import { buildDescription, findOrphanDescFiles, firstFreeBranch, worktreePaths } from './lib/worktree.mjs'
20
20
 
21
21
  const USAGE = [
22
22
  'Usage:',
@@ -89,20 +89,34 @@ function cmdAdd(rest, ctx) {
89
89
  ctx.logError('worktree add: опис обовʼязковий — `worktree add <branch> "<опис>"`')
90
90
  return 1
91
91
  }
92
+ // Зайнята, якщо вже є git-гілка з такою назвою або checkout-каталог `.worktrees/<sanit>`.
93
+ const isTaken = name => {
94
+ if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`], ctx.cwd).status === 0) return true
95
+ try {
96
+ return existsSync(worktreePaths(ctx.cwd, name).checkout)
97
+ } catch {
98
+ return false // невалідна для шляху назва — впаде нижче на worktreePaths(chosen) з людинозрозумілим текстом
99
+ }
100
+ }
101
+ let chosen
92
102
  let paths
93
103
  try {
94
- paths = worktreePaths(ctx.cwd, branch)
104
+ chosen = firstFreeBranch(branch, isTaken)
105
+ paths = worktreePaths(ctx.cwd, chosen)
95
106
  } catch (error) {
96
107
  ctx.logError(error.message)
97
108
  return 1
98
109
  }
99
- const added = git(['worktree', 'add', paths.checkout, '-b', branch], ctx.cwd)
110
+ if (chosen !== branch) {
111
+ ctx.log(`ℹ️ гілка/worktree "${branch}" уже існує — обрано вільну назву "${chosen}"`)
112
+ }
113
+ const added = git(['worktree', 'add', paths.checkout, '-b', chosen], ctx.cwd)
100
114
  if (added.status !== 0) {
101
115
  ctx.logError(`worktree add не вдався: ${added.stderr.trim()}`)
102
116
  return 1
103
117
  }
104
118
  const baseCommit = git(['rev-parse', '--short', 'HEAD'], ctx.cwd).stdout.trim()
105
- const md = buildDescription({ branch, task, baseCommit, date: today(ctx.now) })
119
+ const md = buildDescription({ branch: chosen, task, baseCommit, date: today(ctx.now) })
106
120
  writeFileSync(paths.descFile, md, 'utf8')
107
121
  ctx.log(`✅ worktree: ${paths.checkout}`)
108
122
  ctx.log(` опис: ${paths.descFile}`)