@nitra/cursor 1.37.0 → 1.39.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 +13 -0
- package/README.md +6 -0
- package/bin/n-cursor.js +7 -1
- package/package.json +1 -1
- package/rules/abie/meta.json +1 -0
- package/rules/adr/meta.json +1 -0
- package/rules/bun/meta.json +1 -0
- package/rules/capacitor/meta.json +1 -0
- package/rules/changelog/meta.json +1 -0
- package/rules/ci4/meta.json +1 -0
- package/rules/docker/meta.json +1 -0
- package/rules/efes/meta.json +1 -0
- package/rules/feedback/meta.json +1 -0
- package/rules/ga/meta.json +1 -0
- package/rules/graphql/meta.json +1 -0
- package/rules/hasura/meta.json +1 -0
- package/rules/image-avif/meta.json +1 -0
- package/rules/image-compress/meta.json +1 -0
- package/rules/js-bun-db/meta.json +1 -0
- package/rules/js-bun-redis/meta.json +1 -0
- package/rules/js-lint/meta.json +1 -0
- package/rules/js-mssql/meta.json +1 -0
- package/rules/js-run/meta.json +1 -0
- package/rules/k8s/meta.json +1 -0
- package/rules/nginx-default-tpl/meta.json +1 -0
- package/rules/npm-module/js/rule_meta.mjs +63 -0
- package/rules/npm-module/meta.json +1 -0
- package/rules/php/meta.json +1 -0
- package/rules/rego/meta.json +1 -0
- package/rules/release/meta.json +1 -0
- package/rules/rust/meta.json +1 -0
- package/rules/security/meta.json +1 -0
- package/rules/style-lint/meta.json +1 -0
- package/rules/tauri/meta.json +1 -0
- package/rules/test/meta.json +1 -0
- package/rules/text/meta.json +1 -0
- package/rules/vue/meta.json +1 -0
- package/rules/worktree/fix.mjs +19 -0
- package/rules/worktree/meta.json +1 -0
- package/rules/worktree/worktree.mdc +34 -0
- package/schemas/rule-meta.json +39 -0
- package/schemas/v8r-catalog.json +5 -0
- package/scripts/auto-rules.mjs +151 -449
- package/scripts/lib/rule-meta-helpers.mjs +103 -0
- package/scripts/lib/rule-meta.mjs +66 -0
- package/scripts/lib/rule-predicates.mjs +147 -0
- package/scripts/lib/worktree.mjs +73 -0
- package/scripts/worktree-cli.mjs +200 -0
- package/skills/worktree/SKILL.md +38 -0
- package/skills/worktree/meta.json +1 -0
- package/rules/abie/auto.md +0 -1
- package/rules/adr/auto.md +0 -1
- package/rules/bun/auto.md +0 -1
- package/rules/capacitor/auto.md +0 -1
- package/rules/changelog/auto.md +0 -1
- package/rules/docker/auto.md +0 -1
- package/rules/efes/auto.md +0 -1
- package/rules/ga/auto.md +0 -1
- package/rules/graphql/auto.md +0 -1
- package/rules/hasura/auto.md +0 -1
- package/rules/image-avif/auto.md +0 -1
- package/rules/image-compress/auto.md +0 -1
- package/rules/js-bun-db/auto.md +0 -1
- package/rules/js-bun-redis/auto.md +0 -1
- package/rules/js-lint/auto.md +0 -1
- package/rules/js-mssql/auto.md +0 -1
- package/rules/js-run/auto.md +0 -1
- package/rules/k8s/auto.md +0 -1
- package/rules/nginx-default-tpl/auto.md +0 -1
- package/rules/npm-module/auto.md +0 -1
- package/rules/php/auto.md +0 -1
- package/rules/rego/auto.md +0 -1
- package/rules/rust/auto.md +0 -1
- package/rules/security/auto.md +0 -1
- package/rules/style-lint/auto.md +0 -1
- package/rules/tauri/auto.md +0 -1
- package/rules/test/auto.md +0 -1
- package/rules/text/auto.md +0 -1
- package/rules/vue/auto.md +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Чисті хелпери конфігу/репо для автодетекту правил: id-міграції, нормалізація
|
|
3
|
+
* списків, repository URL, monorepo-детект.
|
|
4
|
+
*
|
|
5
|
+
* Винесені з `auto-rules.mjs`, щоб `rule-predicates.mjs` міг використати
|
|
6
|
+
* `getRepositoryUrl` без циклу імпортів. `auto-rules.mjs` пізніше ре-експортує їх звідси.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Карта міграції застарілих rule-id у `.n-cursor.json` на актуальні.
|
|
11
|
+
* Застосовується автоматично при читанні конфігу (як для `rules`, так і для `disable-rules`).
|
|
12
|
+
* Приклад: `image` → `image-compress` + `image-avif` (правило розщеплене у 1.8.197).
|
|
13
|
+
*/
|
|
14
|
+
export const RULE_MIGRATIONS = Object.freeze(
|
|
15
|
+
/** @type {Record<string, readonly string[]>} */ ({
|
|
16
|
+
image: Object.freeze(['image-compress', 'image-avif'])
|
|
17
|
+
})
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Розгортає застарілі rule-id у списку згідно з `RULE_MIGRATIONS`. Зберігає порядок,
|
|
22
|
+
* дедуплікує. Чистий хелпер: не мутує вхід, не логує.
|
|
23
|
+
* @param {string[]} ids нормалізований список id (як з `normalizeIdList`)
|
|
24
|
+
* @returns {string[]} список з legacy-id, заміненими на нові; решта без змін
|
|
25
|
+
*/
|
|
26
|
+
export function migrateRuleIds(ids) {
|
|
27
|
+
/** @type {string[]} */
|
|
28
|
+
const out = []
|
|
29
|
+
for (const id of ids) {
|
|
30
|
+
const replacement = Object.hasOwn(RULE_MIGRATIONS, id) ? RULE_MIGRATIONS[id] : [id]
|
|
31
|
+
for (const newId of replacement) {
|
|
32
|
+
if (!out.includes(newId)) out.push(newId)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Повертає лише ті legacy rule-id зі списку, для яких є запис у `RULE_MIGRATIONS`.
|
|
40
|
+
* Використовується для людинозрозумілого логування міграції при синхронізації CLI.
|
|
41
|
+
* @param {string[]} ids нормалізований список id
|
|
42
|
+
* @returns {string[]} legacy id, які потребуватимуть заміни у `migrateRuleIds`
|
|
43
|
+
*/
|
|
44
|
+
export function detectLegacyRuleIds(ids) {
|
|
45
|
+
return ids.filter(id => Object.hasOwn(RULE_MIGRATIONS, id))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Нормалізує список ідентифікаторів (trim + lowercase + унікальність збереженням порядку).
|
|
50
|
+
* @param {unknown} value вихідне значення з `.n-cursor.json`
|
|
51
|
+
* @returns {string[]} масив id у нормалізованому вигляді
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeIdList(value) {
|
|
54
|
+
if (!Array.isArray(value)) {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
const out = []
|
|
58
|
+
for (const item of value) {
|
|
59
|
+
const normalized = String(item).trim().toLowerCase()
|
|
60
|
+
if (normalized && !out.includes(normalized)) {
|
|
61
|
+
out.push(normalized)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Повертає URL репозиторію з package.json (`repository` може бути рядком або обʼєктом).
|
|
69
|
+
* @param {unknown} repository значення `packageJson.repository`
|
|
70
|
+
* @returns {string | null} URL або null
|
|
71
|
+
*/
|
|
72
|
+
export function getRepositoryUrl(repository) {
|
|
73
|
+
if (typeof repository === 'string') {
|
|
74
|
+
return repository
|
|
75
|
+
}
|
|
76
|
+
if (repository && typeof repository === 'object' && !Array.isArray(repository)) {
|
|
77
|
+
const url = /** @type {Record<string, unknown>} */ (repository).url
|
|
78
|
+
if (typeof url === 'string') {
|
|
79
|
+
return url
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Чи package.json виглядає як монорепо (поле `workspaces`).
|
|
87
|
+
* @param {unknown} packageJson кореневий package.json як JS-обʼєкт
|
|
88
|
+
* @returns {boolean} true, якщо оголошено workspaces
|
|
89
|
+
*/
|
|
90
|
+
export function isMonorepoPackage(packageJson) {
|
|
91
|
+
if (packageJson === null || typeof packageJson !== 'object' || Array.isArray(packageJson)) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
const workspaces = /** @type {Record<string, unknown>} */ (packageJson).workspaces
|
|
95
|
+
if (Array.isArray(workspaces)) {
|
|
96
|
+
return workspaces.length > 0
|
|
97
|
+
}
|
|
98
|
+
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
|
|
99
|
+
const packages = /** @type {Record<string, unknown>} */ (workspaces).packages
|
|
100
|
+
return Array.isArray(packages) && packages.length > 0
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Парсер метаданих правила з `npm/rules/<id>/meta.json` (data-driven автодетект).
|
|
3
|
+
*
|
|
4
|
+
* `meta.json.auto` має один із чотирьох видів:
|
|
5
|
+
* - `"завжди"` → always-on;
|
|
6
|
+
* - `["rule", …]` → активується, коли всі правила-залежності виявлені;
|
|
7
|
+
* - `{ "glob": "<pat>" | [<pat>] }` → наявність файлів/каталогів за glob (OR);
|
|
8
|
+
* - `{ "predicate": "<name>", "arg"? }` → незводимий предикат із реєстру `rule-predicates.mjs`.
|
|
9
|
+
*
|
|
10
|
+
* Поля `worktree` правило НЕ має (це скілова вісь). Дзеркало `skill-meta.mjs`.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Літерал безумовної активації (українською, як у скілах). */
|
|
16
|
+
export const RULE_ALWAYS = 'завжди'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ always: true } | { rules: string[] } | { glob: string[] } | { predicate: string, arg: unknown }} RuleAutoSpec
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Нормалізує значення `meta.json.auto` у дискриміновану форму.
|
|
24
|
+
* @param {unknown} value значення поля `auto`
|
|
25
|
+
* @returns {RuleAutoSpec | null} `null` — формат не розпізнано (= opt-in)
|
|
26
|
+
*/
|
|
27
|
+
export function parseRuleAutoSpec(value) {
|
|
28
|
+
if (value === RULE_ALWAYS) return { always: true }
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
const rules = value.map(s => String(s).trim()).filter(s => s.length > 0)
|
|
32
|
+
return rules.length > 0 ? { rules } : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (value !== null && typeof value === 'object') {
|
|
36
|
+
const obj = /** @type {Record<string, unknown>} */ (value)
|
|
37
|
+
if ('glob' in obj) {
|
|
38
|
+
const raw = obj.glob
|
|
39
|
+
const globs = (Array.isArray(raw) ? raw : [raw]).filter(g => typeof g === 'string' && g.length > 0)
|
|
40
|
+
return globs.length > 0 ? { glob: /** @type {string[]} */ (globs) } : null
|
|
41
|
+
}
|
|
42
|
+
if ('predicate' in obj) {
|
|
43
|
+
return typeof obj.predicate === 'string' && obj.predicate.length > 0
|
|
44
|
+
? { predicate: obj.predicate, arg: obj.arg }
|
|
45
|
+
: null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Читає й парсить `meta.json` одного правила.
|
|
53
|
+
* @param {string} ruleDir абсолютний шлях до каталогу правила
|
|
54
|
+
* @returns {Record<string, unknown> | null} обʼєкт або `null` (немає файлу / невалідний JSON / не-обʼєкт)
|
|
55
|
+
*/
|
|
56
|
+
export function readRuleMetaRaw(ruleDir) {
|
|
57
|
+
const metaPath = join(ruleDir, 'meta.json')
|
|
58
|
+
if (!existsSync(metaPath)) return null
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
61
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
62
|
+
return /** @type {Record<string, unknown>} */ (parsed)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Реєстр незводимих до даних предикатів автодетекту правил.
|
|
3
|
+
*
|
|
4
|
+
* Прості умови (наявність файлів) живуть як `glob` у `meta.json`; ці предикати —
|
|
5
|
+
* для умов, що вимагають парсингу залежностей, сканування вмісту source чи URL repo.
|
|
6
|
+
* Декларація «який предикат + аргумент» — у `meta.json.auto.predicate`; тут — реалізація.
|
|
7
|
+
*
|
|
8
|
+
* Сигнатури неоднорідні (одні беруть `facts`, інші — `cwd`/`packageJson`), бо предикати
|
|
9
|
+
* читають різні джерела; виклик диспетчиться в `auto-rules.mjs` за іменем предиката.
|
|
10
|
+
*/
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { getRepositoryUrl } from './rule-meta-helpers.mjs'
|
|
15
|
+
|
|
16
|
+
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Чи package.json дерева містить будь-який із зазначених пакетів у dependencies.
|
|
20
|
+
* @param {string} root корінь репо
|
|
21
|
+
* @param {string[]} keys імена пакетів
|
|
22
|
+
* @returns {Promise<boolean>} true, якщо знайдено хоч один
|
|
23
|
+
*/
|
|
24
|
+
async function anyDepInTree(root, keys) {
|
|
25
|
+
const wanted = new Set(keys)
|
|
26
|
+
let found = false
|
|
27
|
+
/** @param {string} dir каталог обходу @returns {Promise<void>} */
|
|
28
|
+
async function walk(dir) {
|
|
29
|
+
if (found) return
|
|
30
|
+
let entries
|
|
31
|
+
try {
|
|
32
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
33
|
+
} catch {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (found) return
|
|
38
|
+
const abs = join(dir, entry.name)
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
|
|
41
|
+
} else if (entry.isFile() && entry.name === 'package.json') {
|
|
42
|
+
try {
|
|
43
|
+
const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
|
|
44
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
|
|
45
|
+
for (const k of wanted) if (Object.hasOwn(deps, k)) found = true
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
/* ігноруємо пошкоджені package.json */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(root)
|
|
54
|
+
return found
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Чи існує вкладений (не кореневий) package.json без `vite` у devDependencies.
|
|
59
|
+
* @param {string} root корінь репо
|
|
60
|
+
* @returns {Promise<boolean>} true, якщо знайдено
|
|
61
|
+
*/
|
|
62
|
+
async function nestedWithoutVite(root) {
|
|
63
|
+
const rootPkg = join(root, 'package.json')
|
|
64
|
+
let result = false
|
|
65
|
+
/** @param {string} dir каталог @returns {Promise<void>} */
|
|
66
|
+
async function walk(dir) {
|
|
67
|
+
if (result) return
|
|
68
|
+
let entries
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
71
|
+
} catch {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (result) return
|
|
76
|
+
const abs = join(dir, entry.name)
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
|
|
79
|
+
} else if (entry.isFile() && entry.name === 'package.json' && abs !== rootPkg) {
|
|
80
|
+
try {
|
|
81
|
+
const dev = JSON.parse(await readFile(abs, 'utf8'))?.devDependencies
|
|
82
|
+
const hasVite = dev && typeof dev === 'object' && !Array.isArray(dev) && Object.hasOwn(dev, 'vite')
|
|
83
|
+
if (!hasVite) result = true
|
|
84
|
+
} catch {
|
|
85
|
+
/* пошкоджений package.json не вважаємо vite-проєктом */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await walk(root)
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Реєстр предикатів: імʼя → реалізація. Виклик за `meta.json.auto.predicate`. */
|
|
95
|
+
export const RULE_PREDICATES = {
|
|
96
|
+
/**
|
|
97
|
+
* @param {unknown} packageJson кореневий package.json
|
|
98
|
+
* @param {string} arg підрядок-маркер URL
|
|
99
|
+
* @returns {boolean} true, якщо repository.url містить маркер
|
|
100
|
+
*/
|
|
101
|
+
repoUrlMarker(packageJson, arg) {
|
|
102
|
+
const url = getRepositoryUrl(
|
|
103
|
+
packageJson && typeof packageJson === 'object' && !Array.isArray(packageJson)
|
|
104
|
+
? /** @type {Record<string, unknown>} */ (packageJson).repository
|
|
105
|
+
: null
|
|
106
|
+
)
|
|
107
|
+
return typeof url === 'string' && url.toLowerCase().includes(String(arg).toLowerCase())
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} cwd корінь репо
|
|
111
|
+
* @param {string[]} arg імена пакетів
|
|
112
|
+
* @returns {Promise<boolean>} true, якщо будь-який пакет у dependencies дерева
|
|
113
|
+
*/
|
|
114
|
+
depInAnyPackageJson(cwd, arg) {
|
|
115
|
+
return anyDepInTree(cwd, Array.isArray(arg) ? arg : [])
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* @param {{ hasGqlTaggedTemplates: boolean }} facts факти
|
|
119
|
+
* @returns {boolean} true, якщо є gql-літерал
|
|
120
|
+
*/
|
|
121
|
+
gqlTaggedTemplate(facts) {
|
|
122
|
+
return facts.hasGqlTaggedTemplates === true
|
|
123
|
+
},
|
|
124
|
+
/**
|
|
125
|
+
* @param {{ hasHasuraConfig: boolean }} facts факти
|
|
126
|
+
* @returns {boolean} true, якщо config.yaml із маркером
|
|
127
|
+
*/
|
|
128
|
+
hasuraConfigMarker(facts) {
|
|
129
|
+
return facts.hasHasuraConfig === true
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} cwd корінь репо
|
|
133
|
+
* @param {{ hasBunSqlImport: boolean }} facts факти
|
|
134
|
+
* @returns {Promise<boolean>} true, якщо deps pg/pg-format/mysql2 або import sql з bun
|
|
135
|
+
*/
|
|
136
|
+
async jsBunDbSignal(cwd, facts) {
|
|
137
|
+
if (facts.hasBunSqlImport === true) return true
|
|
138
|
+
return anyDepInTree(cwd, ['pg', 'pg-format', 'mysql2'])
|
|
139
|
+
},
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} cwd корінь репо
|
|
142
|
+
* @returns {Promise<boolean>} true, якщо вкладений package.json без vite
|
|
143
|
+
*/
|
|
144
|
+
nestedPackageWithoutVite(cwd) {
|
|
145
|
+
return nestedWithoutVite(cwd)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Чиста логіка worktree-tool `n-cursor worktree` (без git/fs side-effects).
|
|
3
|
+
*
|
|
4
|
+
* Тут — детерміновані, тестовані без git функції:
|
|
5
|
+
* - `sanitizeBranch` — імʼя гілки → безпечне імʼя каталогу/файла (слеш та інші
|
|
6
|
+
* небезпечні для шляху символи → дефіс), щоб структура `.worktrees/` лишалась пласкою;
|
|
7
|
+
* - `worktreePaths` — шляхи checkout і файла-опису поруч;
|
|
8
|
+
* - `buildDescription` — текст інвентарного `.worktrees/<name>.md` за конвенцією worktree.mdc;
|
|
9
|
+
* - `findOrphanDescFiles` — `.md`-описи без зареєстрованого worktree (для `prune`).
|
|
10
|
+
*
|
|
11
|
+
* Оркестрація (виклики git, запис файлів, argv) — у `npm/scripts/worktree-cli.mjs`.
|
|
12
|
+
*/
|
|
13
|
+
import { basename, join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Символи, безпечні для імені каталогу/файла; решта → дефіс. */
|
|
16
|
+
const UNSAFE_PATH_CHARS_RE = /[^a-zA-Z0-9._-]+/gu
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Перетворює імʼя git-гілки на безпечне імʼя каталогу/файла для `.worktrees/`.
|
|
20
|
+
* @param {string} branch імʼя git-гілки (наприклад `feat/skill-meta`)
|
|
21
|
+
* @returns {string} пласке імʼя (наприклад `feat-skill-meta`)
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeBranch(branch) {
|
|
24
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
25
|
+
throw new Error('worktree: імʼя гілки обовʼязкове')
|
|
26
|
+
}
|
|
27
|
+
const sanitized = branch.trim().replace(UNSAFE_PATH_CHARS_RE, '-').replace(/^-+|-+$/gu, '')
|
|
28
|
+
if (sanitized === '') {
|
|
29
|
+
throw new Error(`worktree: імʼя гілки "${branch}" не містить допустимих символів`)
|
|
30
|
+
}
|
|
31
|
+
return sanitized
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Детерміновані шляхи checkout і файла-опису для гілки.
|
|
36
|
+
* @param {string} repoRoot абсолютний корінь репозиторію
|
|
37
|
+
* @param {string} branch імʼя git-гілки
|
|
38
|
+
* @returns {{ checkout: string, descFile: string }} абсолютні шляхи
|
|
39
|
+
*/
|
|
40
|
+
export function worktreePaths(repoRoot, branch) {
|
|
41
|
+
const name = sanitizeBranch(branch)
|
|
42
|
+
const dir = join(repoRoot, '.worktrees')
|
|
43
|
+
return { checkout: join(dir, name), descFile: join(dir, `${name}.md`) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Текст інвентарного файла-опису worktree.
|
|
48
|
+
* @param {{ branch: string, task: string, baseCommit: string, date: string }} params поля опису
|
|
49
|
+
* @returns {string} markdown-вміст `.worktrees/<name>.md`
|
|
50
|
+
*/
|
|
51
|
+
export function buildDescription({ branch, task, baseCommit, date }) {
|
|
52
|
+
return [
|
|
53
|
+
`# ${branch}`,
|
|
54
|
+
'',
|
|
55
|
+
`**Задача:** ${task}`,
|
|
56
|
+
`**Дата:** ${date}`,
|
|
57
|
+
`**База (коміт):** ${baseCommit}`,
|
|
58
|
+
'',
|
|
59
|
+
'Прибрати: ' + '`' + `npx @nitra/cursor worktree remove ${branch}` + '`',
|
|
60
|
+
''
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `.md`-описи без відповідного зареєстрованого worktree-checkout.
|
|
66
|
+
* @param {string[]} descFiles абсолютні шляхи `.worktrees/*.md`
|
|
67
|
+
* @param {string[]} registeredCheckouts абсолютні шляхи зареєстрованих worktree-checkout
|
|
68
|
+
* @returns {string[]} осиротілі `.md` (підмножина `descFiles`)
|
|
69
|
+
*/
|
|
70
|
+
export function findOrphanDescFiles(descFiles, registeredCheckouts) {
|
|
71
|
+
const checkoutBasenames = new Set(registeredCheckouts.map(c => basename(c)))
|
|
72
|
+
return descFiles.filter(md => !checkoutBasenames.has(basename(md).replace(/\.md$/u, '')))
|
|
73
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-оркестратор worktree-tool `n-cursor worktree` (виконавець конвенції `.worktrees/`).
|
|
3
|
+
*
|
|
4
|
+
* Підкоманди:
|
|
5
|
+
* add <branch> "<опис>" — git worktree add .worktrees/<sanit> -b <branch> (від HEAD) + .md-опис
|
|
6
|
+
* remove <branch> [--force] — прибрати checkout + .md (гілку лишає)
|
|
7
|
+
* list — git worktree list + вміст .md-описів
|
|
8
|
+
* prune — git worktree prune + видалити осиротілі .md
|
|
9
|
+
*
|
|
10
|
+
* Чисті функції (санітизація, шляхи, текст опису, осиротілі) — у `lib/worktree.mjs`.
|
|
11
|
+
* Тут лише git-виклики, запис файлів, парсинг argv і звіт.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { cwd as processCwd } from 'node:process'
|
|
17
|
+
|
|
18
|
+
import { buildDescription, findOrphanDescFiles, worktreePaths } from './lib/worktree.mjs'
|
|
19
|
+
|
|
20
|
+
const USAGE = [
|
|
21
|
+
'Usage:',
|
|
22
|
+
' npx @nitra/cursor worktree add <branch> "<опис>"',
|
|
23
|
+
' npx @nitra/cursor worktree remove <branch> [--force]',
|
|
24
|
+
' npx @nitra/cursor worktree list',
|
|
25
|
+
' npx @nitra/cursor worktree prune'
|
|
26
|
+
].join('\n')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Запускає git, повертає { status, stdout, stderr }.
|
|
30
|
+
* @param {string[]} args аргументи git
|
|
31
|
+
* @param {string} cwd робочий каталог
|
|
32
|
+
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
33
|
+
*/
|
|
34
|
+
function git(args, cwd) {
|
|
35
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
36
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Поточна дата YYYY-MM-DD (ін'єкція через ctx.now для тестів).
|
|
41
|
+
* @param {() => Date} now фабрика дати
|
|
42
|
+
* @returns {string} дата у форматі YYYY-MM-DD
|
|
43
|
+
*/
|
|
44
|
+
function today(now) {
|
|
45
|
+
return now().toISOString().slice(0, 10)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Реєстровані worktree-checkout (абсолютні шляхи) з `git worktree list --porcelain`.
|
|
50
|
+
* @param {string} cwd корінь репо
|
|
51
|
+
* @returns {string[]} абсолютні шляхи checkout
|
|
52
|
+
*/
|
|
53
|
+
function listRegisteredCheckouts(cwd) {
|
|
54
|
+
return git(['worktree', 'list', '--porcelain'], cwd)
|
|
55
|
+
.stdout.split('\n')
|
|
56
|
+
.filter(line => line.startsWith('worktree '))
|
|
57
|
+
.map(line => line.slice('worktree '.length).trim())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Абсолютні шляхи `.worktrees/*.md`.
|
|
62
|
+
* @param {string} cwd корінь репо
|
|
63
|
+
* @returns {string[]} шляхи файлів-описів
|
|
64
|
+
*/
|
|
65
|
+
function listDescFiles(cwd) {
|
|
66
|
+
const dir = join(cwd, '.worktrees')
|
|
67
|
+
if (!existsSync(dir)) return []
|
|
68
|
+
return readdirSync(dir)
|
|
69
|
+
.filter(n => n.endsWith('.md'))
|
|
70
|
+
.map(n => join(dir, n))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* add: створити worktree від HEAD + .md-опис.
|
|
75
|
+
* @param {string[]} rest [branch, ...descParts]
|
|
76
|
+
* @param {{ cwd: string, log: Function, logError: Function, now: () => Date }} ctx контекст
|
|
77
|
+
* @returns {number} exit code
|
|
78
|
+
*/
|
|
79
|
+
function cmdAdd(rest, ctx) {
|
|
80
|
+
const [branch, ...descParts] = rest
|
|
81
|
+
const task = descParts.join(' ').trim()
|
|
82
|
+
if (!branch) {
|
|
83
|
+
ctx.logError('worktree add: потрібне імʼя гілки')
|
|
84
|
+
ctx.logError(USAGE)
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
if (!task) {
|
|
88
|
+
ctx.logError('worktree add: опис обовʼязковий — `worktree add <branch> "<опис>"`')
|
|
89
|
+
return 1
|
|
90
|
+
}
|
|
91
|
+
let paths
|
|
92
|
+
try {
|
|
93
|
+
paths = worktreePaths(ctx.cwd, branch)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
ctx.logError(error.message)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
const added = git(['worktree', 'add', paths.checkout, '-b', branch], ctx.cwd)
|
|
99
|
+
if (added.status !== 0) {
|
|
100
|
+
ctx.logError(`worktree add не вдався: ${added.stderr.trim()}`)
|
|
101
|
+
return 1
|
|
102
|
+
}
|
|
103
|
+
const baseCommit = git(['rev-parse', '--short', 'HEAD'], ctx.cwd).stdout.trim()
|
|
104
|
+
const md = buildDescription({ branch, task, baseCommit, date: today(ctx.now) })
|
|
105
|
+
writeFileSync(paths.descFile, md, 'utf8')
|
|
106
|
+
ctx.log(`✅ worktree: ${paths.checkout}`)
|
|
107
|
+
ctx.log(` опис: ${paths.descFile}`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* remove: прибрати checkout + .md (гілку лишає).
|
|
113
|
+
* @param {string[]} rest [branch, ...flags]
|
|
114
|
+
* @param {{ cwd: string, log: Function, logError: Function }} ctx контекст
|
|
115
|
+
* @returns {number} exit code
|
|
116
|
+
*/
|
|
117
|
+
function cmdRemove(rest, ctx) {
|
|
118
|
+
const branch = rest.find(a => !a.startsWith('--'))
|
|
119
|
+
const force = rest.includes('--force')
|
|
120
|
+
if (!branch) {
|
|
121
|
+
ctx.logError('worktree remove: потрібне імʼя гілки')
|
|
122
|
+
return 1
|
|
123
|
+
}
|
|
124
|
+
let paths
|
|
125
|
+
try {
|
|
126
|
+
paths = worktreePaths(ctx.cwd, branch)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
ctx.logError(error.message)
|
|
129
|
+
return 1
|
|
130
|
+
}
|
|
131
|
+
const args = ['worktree', 'remove', paths.checkout]
|
|
132
|
+
if (force) args.push('--force')
|
|
133
|
+
const removed = git(args, ctx.cwd)
|
|
134
|
+
if (removed.status !== 0) {
|
|
135
|
+
ctx.logError(`worktree remove не вдався: ${removed.stderr.trim()} (спробуй --force, якщо дерево брудне)`)
|
|
136
|
+
return 1
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
|
|
139
|
+
ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
|
|
140
|
+
return 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* list: git worktree list + вміст .md-описів.
|
|
145
|
+
* @param {{ cwd: string, log: Function }} ctx контекст
|
|
146
|
+
* @returns {number} exit code
|
|
147
|
+
*/
|
|
148
|
+
function cmdList(ctx) {
|
|
149
|
+
ctx.log(git(['worktree', 'list'], ctx.cwd).stdout.trimEnd())
|
|
150
|
+
for (const md of listDescFiles(ctx.cwd)) {
|
|
151
|
+
ctx.log(`\n--- ${md} ---`)
|
|
152
|
+
ctx.log(readFileSync(md, 'utf8').trimEnd())
|
|
153
|
+
}
|
|
154
|
+
return 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* prune: git worktree prune + видалити осиротілі .md.
|
|
159
|
+
* @param {{ cwd: string, log: Function }} ctx контекст
|
|
160
|
+
* @returns {number} exit code
|
|
161
|
+
*/
|
|
162
|
+
function cmdPrune(ctx) {
|
|
163
|
+
git(['worktree', 'prune'], ctx.cwd)
|
|
164
|
+
const orphans = findOrphanDescFiles(listDescFiles(ctx.cwd), listRegisteredCheckouts(ctx.cwd))
|
|
165
|
+
for (const md of orphans) {
|
|
166
|
+
rmSync(md, { force: true })
|
|
167
|
+
ctx.log(`🧹 видалено осиротілий опис: ${md}`)
|
|
168
|
+
}
|
|
169
|
+
ctx.log(`prune завершено (осиротілих описів: ${orphans.length})`)
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Точка входу підкоманди worktree.
|
|
175
|
+
* @param {string[]} argv аргументи після `worktree`
|
|
176
|
+
* @param {{ cwd?: string, log?: Function, logError?: Function, now?: () => Date }} [options] ін'єкція для тестів
|
|
177
|
+
* @returns {Promise<number>} exit code
|
|
178
|
+
*/
|
|
179
|
+
export function runWorktreeCli(argv, options = {}) {
|
|
180
|
+
const ctx = {
|
|
181
|
+
cwd: options.cwd ?? processCwd(),
|
|
182
|
+
log: options.log ?? (line => console.log(line)),
|
|
183
|
+
logError: options.logError ?? (line => console.error(line)),
|
|
184
|
+
now: options.now ?? (() => new Date())
|
|
185
|
+
}
|
|
186
|
+
const [sub, ...rest] = argv
|
|
187
|
+
switch (sub) {
|
|
188
|
+
case 'add':
|
|
189
|
+
return Promise.resolve(cmdAdd(rest, ctx))
|
|
190
|
+
case 'remove':
|
|
191
|
+
return Promise.resolve(cmdRemove(rest, ctx))
|
|
192
|
+
case 'list':
|
|
193
|
+
return Promise.resolve(cmdList(ctx))
|
|
194
|
+
case 'prune':
|
|
195
|
+
return Promise.resolve(cmdPrune(ctx))
|
|
196
|
+
default:
|
|
197
|
+
ctx.logError(USAGE)
|
|
198
|
+
return Promise.resolve(1)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: worktree
|
|
3
|
+
description: >-
|
|
4
|
+
Створення та керування git-worktree через n-cursor worktree CLI: ізольований
|
|
5
|
+
workspace у .worktrees/<branch>/ з інвентарним файлом-описом
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# worktree — ізольований workspace через CLI
|
|
9
|
+
|
|
10
|
+
Для роботи в окремому git-worktree використовуй CLI `n-cursor worktree` — він
|
|
11
|
+
однаковий у Claude і Cursor, кладе worktree у `.worktrees/` (gitignored) і сам
|
|
12
|
+
створює інвентарний файл-опис поруч.
|
|
13
|
+
|
|
14
|
+
## Команди
|
|
15
|
+
|
|
16
|
+
- Створити (опис **обовʼязковий**):
|
|
17
|
+
`npx @nitra/cursor worktree add <branch> "<навіщо цей worktree>"`
|
|
18
|
+
- Список активних з описами:
|
|
19
|
+
`npx @nitra/cursor worktree list`
|
|
20
|
+
- Прибрати (гілку лишає; `--force` для брудного дерева):
|
|
21
|
+
`npx @nitra/cursor worktree remove <branch> [--force]`
|
|
22
|
+
- Прибрати осиротілі описи / метадані:
|
|
23
|
+
`npx @nitra/cursor worktree prune`
|
|
24
|
+
|
|
25
|
+
## Приклад
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @nitra/cursor worktree add feat/skill-meta "реалізація Spec A: meta.json"
|
|
29
|
+
cd .worktrees/feat-skill-meta
|
|
30
|
+
# … робота в ізоляції …
|
|
31
|
+
cd -
|
|
32
|
+
npx @nitra/cursor worktree remove feat/skill-meta
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Слеш у гілці перетворюється на дефіс для пласкої структури: `feat/skill-meta`
|
|
36
|
+
→ `.worktrees/feat-skill-meta/`. Git-гілка лишається `feat/skill-meta`.
|
|
37
|
+
|
|
38
|
+
Конвенція й заборони (де НЕ створювати worktree) — `.cursor/rules/n-worktree.mdc`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": false }
|
package/rules/abie/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в кореневому package.json в секції "repository" присутній текст "<https://github.com/abinbevefes/**/>"
|
package/rules/adr/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
завжди
|
package/rules/bun/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в корені проекту є package.json
|
package/rules/capacitor/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в проекті є хоч один файл capacitor.config.json
|
package/rules/changelog/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[bun]
|
package/rules/docker/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в проекті є хоч один Dockerfile
|
package/rules/efes/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в кореневому package.json в секції "repository" присутній текст "<https://github.com/efes-cloud/**/>"
|
package/rules/ga/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо присутня директорія .github/workflows
|
package/rules/graphql/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо хоч в одному js або vue файлі присутній gql` темплейт літерал
|
package/rules/hasura/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в директорії присутній config.yaml, який містить рядок `metadata_directory: metadata`
|
package/rules/image-avif/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[vue, image-compress]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[bun]
|
package/rules/js-bun-db/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
|