wendkeep 0.75.3 → 0.76.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 +18 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/docs/en/commands/getting-started.md +3 -1
- package/docs/en/commands/maintenance-and-diagnostics.md +3 -0
- package/docs/en/commands/worktrees.md +93 -0
- package/docs/pt-BR/commands/getting-started.md +3 -1
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +2 -0
- package/docs/pt-BR/commands/worktrees.md +91 -0
- package/hooks/flow-protected-policy.mjs +1 -1
- package/package.json +2 -1
- package/packages/cli/src/index.mjs +15 -3
- package/packages/vault/src/index.mjs +1 -0
- package/packages/vault/src/project-vault.mjs +25 -2
- package/packages/vault/src/worktree-metadata.mjs +274 -0
- package/src/doctor.mjs +8 -0
- package/src/init.mjs +6 -0
- package/src/sync.mjs +4 -1
- package/src/worktree.mjs +708 -0
package/src/worktree.mjs
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { resolveProjectVault } from './project-vault.mjs';
|
|
13
|
+
import { getLocale } from '../packages/vault/src/locale.mjs';
|
|
14
|
+
import {
|
|
15
|
+
discoverWorktreeRepository,
|
|
16
|
+
ensureWorktreeMetadata,
|
|
17
|
+
mutateWorktreeRegistry,
|
|
18
|
+
readWorktreeRegistry,
|
|
19
|
+
worktreeIdentity,
|
|
20
|
+
} from '../packages/vault/src/worktree-metadata.mjs';
|
|
21
|
+
|
|
22
|
+
function worktreeError(code, message, details = {}) {
|
|
23
|
+
const error = new Error(message);
|
|
24
|
+
error.code = code;
|
|
25
|
+
Object.assign(error, details);
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function git(repositoryRoot, args, { ok = true, spawn = spawnSync } = {}) {
|
|
30
|
+
const result = spawn('git', args, {
|
|
31
|
+
cwd: repositoryRoot,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
});
|
|
35
|
+
if (ok && result.status !== 0) {
|
|
36
|
+
throw worktreeError(
|
|
37
|
+
'WENDKEEP_WORKTREE_GIT_FAILED',
|
|
38
|
+
String(result.stderr || result.error?.message || `git ${args[0]} falhou`).trim(),
|
|
39
|
+
{ gitArgs: [...args], status: result.status },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertSlug(slug) {
|
|
46
|
+
const value = String(slug || '');
|
|
47
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value) || value === '.' || value === '..') {
|
|
48
|
+
throw worktreeError(
|
|
49
|
+
'WENDKEEP_WORKTREE_SLUG_INVALID',
|
|
50
|
+
`Slug de worktree inválido: "${value}".`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertContained(root, target) {
|
|
57
|
+
const rel = relative(root, target);
|
|
58
|
+
if (!rel || rel.startsWith('..') || resolve(root, rel) !== target) {
|
|
59
|
+
throw worktreeError(
|
|
60
|
+
'WENDKEEP_WORKTREE_PATH_OUTSIDE_ROOT',
|
|
61
|
+
`Path de worktree fora da raiz permitida: "${target}".`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertNoSymlinkEscape(root, target) {
|
|
67
|
+
let current = target;
|
|
68
|
+
while (true) {
|
|
69
|
+
try {
|
|
70
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
71
|
+
throw worktreeError(
|
|
72
|
+
'WENDKEEP_WORKTREE_PATH_SYMLINK_ESCAPE',
|
|
73
|
+
`Path de worktree atravessa link simbólico ou junction: "${current}".`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
78
|
+
}
|
|
79
|
+
if (current === root) return;
|
|
80
|
+
const parent = dirname(current);
|
|
81
|
+
if (parent === current) {
|
|
82
|
+
throw worktreeError(
|
|
83
|
+
'WENDKEEP_WORKTREE_PATH_OUTSIDE_ROOT',
|
|
84
|
+
`Path de worktree fora da raiz permitida: "${target}".`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
current = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function refExists(repositoryRoot, ref, spawn) {
|
|
92
|
+
return git(repositoryRoot, ['show-ref', '--verify', '--quiet', ref], { ok: false, spawn }).status === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultBase(repositoryRoot, spawn) {
|
|
96
|
+
const remoteHead = git(
|
|
97
|
+
repositoryRoot,
|
|
98
|
+
['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
|
|
99
|
+
{ ok: false, spawn },
|
|
100
|
+
);
|
|
101
|
+
if (remoteHead.status === 0) return String(remoteHead.stdout).trim().replace(/^[^/]+\//, '');
|
|
102
|
+
for (const candidate of ['main', 'master']) {
|
|
103
|
+
if (refExists(repositoryRoot, `refs/heads/${candidate}`, spawn)) return candidate;
|
|
104
|
+
}
|
|
105
|
+
const configured = git(repositoryRoot, ['config', '--get', 'init.defaultBranch'], { ok: false, spawn });
|
|
106
|
+
if (configured.status === 0 && String(configured.stdout).trim()) return String(configured.stdout).trim();
|
|
107
|
+
const current = git(repositoryRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { ok: false, spawn });
|
|
108
|
+
if (current.status === 0 && String(current.stdout).trim()) return String(current.stdout).trim();
|
|
109
|
+
throw worktreeError(
|
|
110
|
+
'WENDKEEP_WORKTREE_BASE_UNRESOLVED',
|
|
111
|
+
'Não foi possível resolver a branch base do repositório.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ensurePrivateExclude(repository, value, { directory = true } = {}) {
|
|
116
|
+
const excludePath = join(repository.commonDir, 'info', 'exclude');
|
|
117
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
118
|
+
const bare = String(value).replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
119
|
+
const normalized = directory ? `${bare}/` : bare;
|
|
120
|
+
const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
|
|
121
|
+
const lines = current.split(/\r?\n/).map((line) => line.trim());
|
|
122
|
+
if (lines.includes(normalized)) return false;
|
|
123
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
124
|
+
writeFileSync(excludePath, `${current}${prefix}${normalized}\n`, 'utf8');
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function vscodeWorktreeTasks() {
|
|
129
|
+
return {
|
|
130
|
+
version: '2.0.0',
|
|
131
|
+
tasks: [
|
|
132
|
+
{
|
|
133
|
+
label: 'WendKeep: Create worktree',
|
|
134
|
+
type: 'process',
|
|
135
|
+
command: 'npx',
|
|
136
|
+
args: ['--no-install', 'wendkeep', 'worktree', 'create', '${input:wendkeepWorktreeSlug}', '--open', 'vscode'],
|
|
137
|
+
problemMatcher: [],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
label: 'WendKeep: List worktrees',
|
|
141
|
+
type: 'process',
|
|
142
|
+
command: 'npx',
|
|
143
|
+
args: ['--no-install', 'wendkeep', 'worktree', 'list'],
|
|
144
|
+
problemMatcher: [],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
label: 'WendKeep: Open worktree',
|
|
148
|
+
type: 'process',
|
|
149
|
+
command: 'npx',
|
|
150
|
+
args: ['--no-install', 'wendkeep', 'worktree', 'open', '${input:wendkeepWorktreeSlug}'],
|
|
151
|
+
problemMatcher: [],
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
inputs: [{
|
|
155
|
+
id: 'wendkeepWorktreeSlug',
|
|
156
|
+
type: 'promptString',
|
|
157
|
+
description: 'Managed worktree slug',
|
|
158
|
+
}],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function installVscodeWorktreeTasks({ projectRoot = process.cwd(), spawn = spawnSync } = {}) {
|
|
163
|
+
const repository = discoverWorktreeRepository({ startDir: projectRoot, spawn });
|
|
164
|
+
const tasksPath = join(resolve(projectRoot), '.vscode', 'tasks.json');
|
|
165
|
+
const rendered = `${JSON.stringify(vscodeWorktreeTasks(), null, 2)}\n`;
|
|
166
|
+
const tracked = git(resolve(projectRoot), [
|
|
167
|
+
'ls-files', '--error-unmatch', '--', '.vscode/tasks.json',
|
|
168
|
+
], { ok: false, spawn });
|
|
169
|
+
if (tracked.status === 0) {
|
|
170
|
+
return { path: tasksPath, state: 'conflict' };
|
|
171
|
+
}
|
|
172
|
+
if (existsSync(tasksPath)) {
|
|
173
|
+
return {
|
|
174
|
+
path: tasksPath,
|
|
175
|
+
state: readFileSync(tasksPath, 'utf8') === rendered ? 'unchanged' : 'conflict',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(dirname(tasksPath), { recursive: true });
|
|
179
|
+
writeFileSync(tasksPath, rendered, 'utf8');
|
|
180
|
+
ensurePrivateExclude(repository, '.vscode/tasks.json', { directory: false });
|
|
181
|
+
return { path: tasksPath, state: 'created' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function matchingReadyEntry(entry, { path, branch }) {
|
|
185
|
+
return entry?.state === 'ready'
|
|
186
|
+
&& resolve(entry.path) === path
|
|
187
|
+
&& entry.branch === branch
|
|
188
|
+
&& existsSync(path);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveWorktreeProjectBinding(repository, startDir) {
|
|
192
|
+
if (comparablePath(repository.repoRoot) === comparablePath(repository.mainWorktree)) {
|
|
193
|
+
return resolveProjectVault({ startDir });
|
|
194
|
+
}
|
|
195
|
+
const mainBinding = resolveProjectVault({ startDir: repository.mainWorktree });
|
|
196
|
+
const linkedBinding = resolveProjectVault({ startDir, validateIdentity: false });
|
|
197
|
+
const equivalent = comparablePath(linkedBinding.projectRoot) === comparablePath(repository.repoRoot)
|
|
198
|
+
&& linkedBinding.projectId === mainBinding.projectId
|
|
199
|
+
&& linkedBinding.config?.vault === mainBinding.config?.vault;
|
|
200
|
+
if (!equivalent) {
|
|
201
|
+
throw worktreeError(
|
|
202
|
+
'WENDKEEP_WORKTREE_BINDING_INVALID',
|
|
203
|
+
'O binding versionado da linked worktree diverge da worktree principal.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return resolveProjectVault({ startDir });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error?.code !== 'WENDKEEP_VAULT_MARKER_MISSING') throw error;
|
|
210
|
+
return mainBinding;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function comparablePath(value) {
|
|
215
|
+
let normalized = resolve(String(value || ''));
|
|
216
|
+
try { normalized = realpathSync.native(normalized); } catch { /* unresolved suffix */ }
|
|
217
|
+
normalized = normalized.replaceAll('\\', '/');
|
|
218
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function bindingHealth(startDir, expectedProjectId) {
|
|
222
|
+
try {
|
|
223
|
+
const binding = resolveProjectVault({ startDir });
|
|
224
|
+
return {
|
|
225
|
+
healthy: binding.projectId === expectedProjectId,
|
|
226
|
+
projectId: binding.projectId,
|
|
227
|
+
source: binding.source,
|
|
228
|
+
...(binding.projectId === expectedProjectId
|
|
229
|
+
? {}
|
|
230
|
+
: { errorCode: 'WENDKEEP_WORKTREE_PROJECT_MISMATCH' }),
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
healthy: false,
|
|
235
|
+
projectId: expectedProjectId,
|
|
236
|
+
source: 'unresolved',
|
|
237
|
+
errorCode: error?.code || 'WENDKEEP_WORKTREE_BINDING_INVALID',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function listManagedWorktrees({ startDir = process.cwd(), spawn = spawnSync } = {}) {
|
|
243
|
+
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
244
|
+
const { registry } = readWorktreeRegistry(repository);
|
|
245
|
+
if (!registry) {
|
|
246
|
+
throw worktreeError(
|
|
247
|
+
'WENDKEEP_WORKTREE_REGISTRY_MISSING',
|
|
248
|
+
'Registry de worktrees ainda não foi inicializado neste repositório.',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const gitByPath = new Map(repository.worktrees.map((entry) => [comparablePath(entry.path), entry]));
|
|
252
|
+
const worktrees = Object.values(registry.entries)
|
|
253
|
+
.sort((left, right) => left.slug.localeCompare(right.slug))
|
|
254
|
+
.map((entry) => {
|
|
255
|
+
const gitEntry = gitByPath.get(comparablePath(entry.path)) || null;
|
|
256
|
+
const present = Boolean(gitEntry);
|
|
257
|
+
const state = present ? entry.state : (entry.state === 'failed' ? 'failed' : 'missing');
|
|
258
|
+
return {
|
|
259
|
+
slug: entry.slug,
|
|
260
|
+
worktreeId: entry.worktreeId || '',
|
|
261
|
+
path: entry.path,
|
|
262
|
+
branch: gitEntry?.branch || entry.branch || '',
|
|
263
|
+
head: gitEntry?.head || entry.head || '',
|
|
264
|
+
base: entry.base || '',
|
|
265
|
+
state,
|
|
266
|
+
git: {
|
|
267
|
+
present,
|
|
268
|
+
detached: Boolean(gitEntry?.detached),
|
|
269
|
+
},
|
|
270
|
+
binding: bindingHealth(present ? entry.path : startDir, registry.projectId),
|
|
271
|
+
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
272
|
+
...(entry.recovery ? { recovery: entry.recovery } : {}),
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: registry.schemaVersion,
|
|
277
|
+
repositoryId: registry.repositoryId,
|
|
278
|
+
projectId: registry.projectId,
|
|
279
|
+
worktrees,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function managedWorktreeStatus({
|
|
284
|
+
startDir = process.cwd(),
|
|
285
|
+
slug,
|
|
286
|
+
spawn = spawnSync,
|
|
287
|
+
} = {}) {
|
|
288
|
+
const listed = listManagedWorktrees({ startDir, spawn });
|
|
289
|
+
const safeSlug = slug ? assertSlug(slug) : '';
|
|
290
|
+
const currentRepository = safeSlug ? null : discoverWorktreeRepository({ startDir, spawn });
|
|
291
|
+
const found = safeSlug
|
|
292
|
+
? listed.worktrees.find((entry) => entry.slug === safeSlug)
|
|
293
|
+
: listed.worktrees.find((entry) => comparablePath(entry.path) === comparablePath(currentRepository.repoRoot));
|
|
294
|
+
if (!found) {
|
|
295
|
+
throw worktreeError(
|
|
296
|
+
'WENDKEEP_WORKTREE_NOT_FOUND',
|
|
297
|
+
`Worktree gerenciada não encontrada: "${safeSlug}".`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return found;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function diagnoseManagedWorktrees({ startDir = process.cwd(), spawn = spawnSync } = {}) {
|
|
304
|
+
let listed;
|
|
305
|
+
try {
|
|
306
|
+
listed = listManagedWorktrees({ startDir, spawn });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error?.code === 'WENDKEEP_WORKTREE_REGISTRY_MISSING'
|
|
309
|
+
|| error?.code === 'WENDKEEP_WORKTREE_GIT_FAILED') {
|
|
310
|
+
return { initialized: false, issues: [] };
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
const issues = listed.worktrees
|
|
315
|
+
.filter((entry) => entry.state !== 'ready' || !entry.binding.healthy)
|
|
316
|
+
.map((entry) => ({
|
|
317
|
+
slug: entry.slug,
|
|
318
|
+
state: entry.state,
|
|
319
|
+
errorCode: entry.errorCode || entry.binding.errorCode || 'WENDKEEP_WORKTREE_UNHEALTHY',
|
|
320
|
+
repair: entry.recovery || `wendkeep worktree status ${entry.slug}`,
|
|
321
|
+
}));
|
|
322
|
+
return { initialized: true, issues };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const WORKTREE_VALUE_OPTIONS = new Set(['--base', '--branch', '--open', '--editor', '--project']);
|
|
326
|
+
const WORKTREE_FLAG_OPTIONS = new Set(['--json']);
|
|
327
|
+
|
|
328
|
+
function parseWorktreeArgv(argv) {
|
|
329
|
+
const values = new Map();
|
|
330
|
+
const flags = new Set();
|
|
331
|
+
const positional = [];
|
|
332
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
333
|
+
const arg = argv[index];
|
|
334
|
+
if (WORKTREE_FLAG_OPTIONS.has(arg)) {
|
|
335
|
+
if (flags.has(arg)) throw worktreeError('WENDKEEP_WORKTREE_USAGE', `Flag repetida: ${arg}.`);
|
|
336
|
+
flags.add(arg);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const inline = [...WORKTREE_VALUE_OPTIONS].find((name) => arg.startsWith(`${name}=`));
|
|
340
|
+
if (inline) {
|
|
341
|
+
if (values.has(inline)) throw worktreeError('WENDKEEP_WORKTREE_USAGE', `Opção repetida: ${inline}.`);
|
|
342
|
+
values.set(inline, arg.slice(inline.length + 1));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (WORKTREE_VALUE_OPTIONS.has(arg)) {
|
|
346
|
+
if (values.has(arg) || index + 1 >= argv.length || argv[index + 1].startsWith('--')) {
|
|
347
|
+
throw worktreeError('WENDKEEP_WORKTREE_USAGE', `Opção inválida ou repetida: ${arg}.`);
|
|
348
|
+
}
|
|
349
|
+
values.set(arg, argv[index + 1]);
|
|
350
|
+
index += 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (arg.startsWith('-')) {
|
|
354
|
+
throw worktreeError('WENDKEEP_WORKTREE_USAGE', `Opção desconhecida: ${arg}.`);
|
|
355
|
+
}
|
|
356
|
+
positional.push(arg);
|
|
357
|
+
}
|
|
358
|
+
return { values, flags, positional };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function assertCommandOptions(command, parsed) {
|
|
362
|
+
const allowed = {
|
|
363
|
+
create: new Set(['--base', '--branch', '--open', '--project']),
|
|
364
|
+
list: new Set(['--project']),
|
|
365
|
+
status: new Set(['--project']),
|
|
366
|
+
open: new Set(['--editor', '--project']),
|
|
367
|
+
}[command];
|
|
368
|
+
if (!allowed) return;
|
|
369
|
+
for (const name of parsed.values.keys()) {
|
|
370
|
+
if (!allowed.has(name)) {
|
|
371
|
+
throw worktreeError('WENDKEEP_WORKTREE_USAGE', `${name} não é válido para worktree ${command}.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const ERROR_TEXT = {
|
|
377
|
+
en: {
|
|
378
|
+
WENDKEEP_WORKTREE_FAILED: 'Worktree command failed.',
|
|
379
|
+
WENDKEEP_WORKTREE_GIT_FAILED: 'Git command failed.',
|
|
380
|
+
WENDKEEP_WORKTREE_BASE_UNRESOLVED: 'The repository base branch could not be resolved.',
|
|
381
|
+
WENDKEEP_WORKTREE_MAIN_UNRESOLVED: 'The main worktree could not be resolved.',
|
|
382
|
+
WENDKEEP_WORKTREE_REPOSITORY_INVALID: 'The Git repository is invalid.',
|
|
383
|
+
WENDKEEP_WORKTREE_REGISTRY_MISSING: 'The worktree registry has not been initialized.',
|
|
384
|
+
WENDKEEP_WORKTREE_REGISTRY_INVALID: 'The worktree registry is invalid.',
|
|
385
|
+
WENDKEEP_WORKTREE_REGISTRY_BUSY: 'The worktree registry is busy.',
|
|
386
|
+
WENDKEEP_WORKTREE_PROJECT_MISMATCH: 'The worktree project identity does not match.',
|
|
387
|
+
WENDKEEP_WORKTREE_VAULT_MISMATCH: 'The worktree Vault binding does not match.',
|
|
388
|
+
WENDKEEP_WORKTREE_BINDING_INVALID: 'The worktree project binding is invalid.',
|
|
389
|
+
WENDKEEP_VAULT_CONFIG_INVALID: 'The WendKeep project binding is invalid.',
|
|
390
|
+
WENDKEEP_VAULT_MARKER_MISSING: 'The bound WendKeep Vault marker was not found.',
|
|
391
|
+
WENDKEEP_VAULT_PROJECT_MISMATCH: 'The bound WendKeep Vault belongs to another project.',
|
|
392
|
+
WENDKEEP_VAULT_UNCONFIGURED: 'No WendKeep Vault is bound to this project.',
|
|
393
|
+
WENDKEEP_WORKTREE_SLUG_INVALID: 'Invalid worktree slug.',
|
|
394
|
+
WENDKEEP_WORKTREE_BRANCH_INVALID: 'Invalid worktree branch.',
|
|
395
|
+
WENDKEEP_WORKTREE_PATH_OUTSIDE_ROOT: 'Worktree path is outside the configured root.',
|
|
396
|
+
WENDKEEP_WORKTREE_PATH_SYMLINK_ESCAPE: 'Worktree path crosses a symbolic link or junction.',
|
|
397
|
+
WENDKEEP_WORKTREE_ROOT_INVALID: 'The configured worktree root is invalid.',
|
|
398
|
+
WENDKEEP_WORKTREE_COLLISION: 'Worktree slug collides with existing state.',
|
|
399
|
+
WENDKEEP_WORKTREE_EDITOR_NOT_FOUND: 'VS Code command `code` was not found.',
|
|
400
|
+
WENDKEEP_WORKTREE_EDITOR_OPEN_FAILED: 'VS Code could not open the worktree.',
|
|
401
|
+
WENDKEEP_WORKTREE_EDITOR_UNSUPPORTED: 'Unsupported worktree editor.',
|
|
402
|
+
WENDKEEP_WORKTREE_NOT_FOUND: 'Managed worktree was not found.',
|
|
403
|
+
WENDKEEP_WORKTREE_NOT_READY: 'Managed worktree is not ready.',
|
|
404
|
+
WENDKEEP_WORKTREE_USAGE: 'Invalid worktree command usage.',
|
|
405
|
+
},
|
|
406
|
+
'pt-BR': {
|
|
407
|
+
WENDKEEP_WORKTREE_FAILED: 'Falha no comando worktree.',
|
|
408
|
+
WENDKEEP_WORKTREE_GIT_FAILED: 'Comando Git falhou.',
|
|
409
|
+
WENDKEEP_WORKTREE_BASE_UNRESOLVED: 'Não foi possível resolver a branch base do repositório.',
|
|
410
|
+
WENDKEEP_WORKTREE_MAIN_UNRESOLVED: 'Não foi possível resolver a worktree principal.',
|
|
411
|
+
WENDKEEP_WORKTREE_REPOSITORY_INVALID: 'O repositório Git é inválido.',
|
|
412
|
+
WENDKEEP_WORKTREE_REGISTRY_MISSING: 'O registry de worktrees ainda não foi inicializado.',
|
|
413
|
+
WENDKEEP_WORKTREE_REGISTRY_INVALID: 'O registry de worktrees é inválido.',
|
|
414
|
+
WENDKEEP_WORKTREE_REGISTRY_BUSY: 'O registry de worktrees está ocupado.',
|
|
415
|
+
WENDKEEP_WORKTREE_PROJECT_MISMATCH: 'A identidade de projeto da worktree não corresponde.',
|
|
416
|
+
WENDKEEP_WORKTREE_VAULT_MISMATCH: 'O vínculo de Vault da worktree não corresponde.',
|
|
417
|
+
WENDKEEP_WORKTREE_BINDING_INVALID: 'O binding de projeto da worktree é inválido.',
|
|
418
|
+
WENDKEEP_VAULT_CONFIG_INVALID: 'O binding de projeto WendKeep é inválido.',
|
|
419
|
+
WENDKEEP_VAULT_MARKER_MISSING: 'O marcador do Vault WendKeep vinculado não foi encontrado.',
|
|
420
|
+
WENDKEEP_VAULT_PROJECT_MISMATCH: 'O Vault WendKeep vinculado pertence a outro projeto.',
|
|
421
|
+
WENDKEEP_VAULT_UNCONFIGURED: 'Nenhum Vault WendKeep está vinculado a este projeto.',
|
|
422
|
+
WENDKEEP_WORKTREE_SLUG_INVALID: 'Slug de worktree inválido.',
|
|
423
|
+
WENDKEEP_WORKTREE_BRANCH_INVALID: 'Branch de worktree inválida.',
|
|
424
|
+
WENDKEEP_WORKTREE_PATH_OUTSIDE_ROOT: 'Path de worktree fora da raiz configurada.',
|
|
425
|
+
WENDKEEP_WORKTREE_PATH_SYMLINK_ESCAPE: 'Path de worktree atravessa link simbólico ou junction.',
|
|
426
|
+
WENDKEEP_WORKTREE_ROOT_INVALID: 'A raiz configurada de worktrees é inválida.',
|
|
427
|
+
WENDKEEP_WORKTREE_COLLISION: 'Slug colide com estado de worktree existente.',
|
|
428
|
+
WENDKEEP_WORKTREE_EDITOR_NOT_FOUND: 'Comando `code` do VS Code não foi encontrado.',
|
|
429
|
+
WENDKEEP_WORKTREE_EDITOR_OPEN_FAILED: 'O VS Code não conseguiu abrir a worktree.',
|
|
430
|
+
WENDKEEP_WORKTREE_EDITOR_UNSUPPORTED: 'Editor de worktree não suportado.',
|
|
431
|
+
WENDKEEP_WORKTREE_NOT_FOUND: 'Worktree gerenciada não encontrada.',
|
|
432
|
+
WENDKEEP_WORKTREE_NOT_READY: 'Worktree gerenciada ainda não está pronta.',
|
|
433
|
+
WENDKEEP_WORKTREE_USAGE: 'Uso inválido do comando worktree.',
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
function commandLocale(startDir) {
|
|
438
|
+
try { return getLocale(resolveProjectVault({ startDir }).base).id; }
|
|
439
|
+
catch { return 'pt-BR'; }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderWorktreeError(error, locale) {
|
|
443
|
+
const code = error?.code || 'WENDKEEP_WORKTREE_FAILED';
|
|
444
|
+
return `${code}: ${ERROR_TEXT[locale]?.[code] || ERROR_TEXT[locale]?.WENDKEEP_WORKTREE_FAILED}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderWorktreeLine(worktree) {
|
|
448
|
+
const binding = worktree.binding
|
|
449
|
+
? (worktree.binding.healthy ? 'healthy' : (worktree.binding.errorCode || 'unhealthy'))
|
|
450
|
+
: 'unknown';
|
|
451
|
+
return [
|
|
452
|
+
`slug=${worktree.slug || ''}`,
|
|
453
|
+
`identity=${worktree.worktreeId || ''}`,
|
|
454
|
+
`path=${worktree.path || ''}`,
|
|
455
|
+
`branch=${worktree.branch || ''}`,
|
|
456
|
+
`head=${worktree.head || ''}`,
|
|
457
|
+
`state=${worktree.state || ''}`,
|
|
458
|
+
`binding=${binding}`,
|
|
459
|
+
].join(' ');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function writeWorktreeResult(payload, { json, locale, action }) {
|
|
463
|
+
if (json) {
|
|
464
|
+
process.stdout.write(`${JSON.stringify({ ok: true, ...payload })}\n`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const subject = payload.worktree?.slug || `${payload.worktrees?.length || 0}`;
|
|
468
|
+
const messages = locale === 'en'
|
|
469
|
+
? { create: `worktree created: ${subject}`, list: `managed worktrees: ${subject}`, status: `worktree status: ${subject}`, open: `worktree opened: ${subject}` }
|
|
470
|
+
: { create: `worktree criada: ${subject}`, list: `worktrees gerenciadas: ${subject}`, status: `status da worktree: ${subject}`, open: `worktree aberta: ${subject}` };
|
|
471
|
+
const details = action === 'list'
|
|
472
|
+
? payload.worktrees.map(renderWorktreeLine)
|
|
473
|
+
: (action === 'status' ? [renderWorktreeLine(payload.worktree)] : []);
|
|
474
|
+
process.stdout.write(`${messages[action]}${details.length ? `\n${details.join('\n')}` : ''}\n`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function runWorktree(argv = []) {
|
|
478
|
+
let parsed;
|
|
479
|
+
let locale = 'pt-BR';
|
|
480
|
+
try {
|
|
481
|
+
parsed = parseWorktreeArgv(argv);
|
|
482
|
+
const [command, ...positionals] = parsed.positional;
|
|
483
|
+
assertCommandOptions(command, parsed);
|
|
484
|
+
const startDir = parsed.values.get('--project') || process.cwd();
|
|
485
|
+
locale = commandLocale(startDir);
|
|
486
|
+
const json = parsed.flags.has('--json');
|
|
487
|
+
if (command === 'create') {
|
|
488
|
+
if (positionals.length !== 1) throw worktreeError('WENDKEEP_WORKTREE_USAGE', 'create requer <slug>.');
|
|
489
|
+
const worktree = createManagedWorktree({
|
|
490
|
+
startDir,
|
|
491
|
+
slug: positionals[0],
|
|
492
|
+
base: parsed.values.get('--base') || '',
|
|
493
|
+
branch: parsed.values.get('--branch') || '',
|
|
494
|
+
open: parsed.values.get('--open') || 'none',
|
|
495
|
+
});
|
|
496
|
+
writeWorktreeResult({ worktree }, { json, locale, action: 'create' });
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
if (command === 'list') {
|
|
500
|
+
if (positionals.length) throw worktreeError('WENDKEEP_WORKTREE_USAGE', 'list não aceita slug.');
|
|
501
|
+
const listed = listManagedWorktrees({ startDir });
|
|
502
|
+
writeWorktreeResult(listed, { json, locale, action: 'list' });
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
if (command === 'status') {
|
|
506
|
+
if (positionals.length > 1) throw worktreeError('WENDKEEP_WORKTREE_USAGE', 'status aceita no máximo um slug.');
|
|
507
|
+
const worktree = managedWorktreeStatus({ startDir, slug: positionals[0] || '' });
|
|
508
|
+
writeWorktreeResult({ worktree }, { json, locale, action: 'status' });
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
if (command === 'open') {
|
|
512
|
+
if (positionals.length !== 1) throw worktreeError('WENDKEEP_WORKTREE_USAGE', 'open requer <slug>.');
|
|
513
|
+
const worktree = openManagedWorktree({
|
|
514
|
+
startDir,
|
|
515
|
+
slug: positionals[0],
|
|
516
|
+
editor: parsed.values.get('--editor') || 'vscode',
|
|
517
|
+
});
|
|
518
|
+
writeWorktreeResult({ worktree }, { json, locale, action: 'open' });
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
throw worktreeError('WENDKEEP_WORKTREE_USAGE', 'Use create, list, status ou open.');
|
|
522
|
+
} catch (error) {
|
|
523
|
+
process.stderr.write(`${renderWorktreeError(error, locale)}\n`);
|
|
524
|
+
return 2;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function openWorktreePath(path, editor, spawn) {
|
|
529
|
+
if (editor !== 'vscode') {
|
|
530
|
+
throw worktreeError(
|
|
531
|
+
'WENDKEEP_WORKTREE_EDITOR_UNSUPPORTED',
|
|
532
|
+
`Editor não suportado: "${editor}".`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const probe = spawn('code', ['--version'], {
|
|
536
|
+
cwd: path,
|
|
537
|
+
encoding: 'utf8',
|
|
538
|
+
windowsHide: true,
|
|
539
|
+
});
|
|
540
|
+
if (probe.status !== 0) {
|
|
541
|
+
throw worktreeError(
|
|
542
|
+
'WENDKEEP_WORKTREE_EDITOR_NOT_FOUND',
|
|
543
|
+
'VS Code não encontrado no PATH; instale o comando `code` ou use `--open none`.',
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const opened = spawn('code', ['-n', path], {
|
|
547
|
+
cwd: path,
|
|
548
|
+
encoding: 'utf8',
|
|
549
|
+
windowsHide: true,
|
|
550
|
+
});
|
|
551
|
+
if (opened.status !== 0) {
|
|
552
|
+
throw worktreeError(
|
|
553
|
+
'WENDKEEP_WORKTREE_EDITOR_OPEN_FAILED',
|
|
554
|
+
String(opened.stderr || 'Falha ao abrir a worktree no VS Code.').trim(),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return { opened: true, editor: 'vscode', path };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function openManagedWorktree({
|
|
561
|
+
startDir = process.cwd(),
|
|
562
|
+
slug,
|
|
563
|
+
editor = 'vscode',
|
|
564
|
+
spawn = spawnSync,
|
|
565
|
+
} = {}) {
|
|
566
|
+
const status = managedWorktreeStatus({ startDir, slug, spawn });
|
|
567
|
+
if (!status.git.present || status.state !== 'ready') {
|
|
568
|
+
throw worktreeError(
|
|
569
|
+
'WENDKEEP_WORKTREE_NOT_READY',
|
|
570
|
+
`Worktree "${status.slug}" não está pronta para abrir.`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return { ...status, ...openWorktreePath(status.path, editor, spawn) };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function createManagedWorktree({
|
|
577
|
+
startDir = process.cwd(),
|
|
578
|
+
slug,
|
|
579
|
+
base = '',
|
|
580
|
+
branch = '',
|
|
581
|
+
open = 'none',
|
|
582
|
+
spawn = spawnSync,
|
|
583
|
+
now = () => new Date().toISOString(),
|
|
584
|
+
} = {}) {
|
|
585
|
+
const safeSlug = assertSlug(slug);
|
|
586
|
+
if (!['none', 'vscode'].includes(open)) {
|
|
587
|
+
throw worktreeError('WENDKEEP_WORKTREE_EDITOR_UNSUPPORTED', `Editor não suportado: "${open}".`);
|
|
588
|
+
}
|
|
589
|
+
const repository = discoverWorktreeRepository({ startDir, spawn });
|
|
590
|
+
const binding = resolveWorktreeProjectBinding(repository, startDir);
|
|
591
|
+
const configuredRoot = binding.config?.worktrees?.root;
|
|
592
|
+
const rootSetting = configuredRoot === undefined ? '.worktrees' : configuredRoot;
|
|
593
|
+
if (typeof rootSetting !== 'string' || !rootSetting.trim()) {
|
|
594
|
+
throw worktreeError(
|
|
595
|
+
'WENDKEEP_WORKTREE_ROOT_INVALID',
|
|
596
|
+
'A configuração worktrees.root deve ser um path relativo não vazio.',
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
const worktreesRoot = resolve(repository.mainWorktree, rootSetting);
|
|
600
|
+
assertContained(repository.mainWorktree, worktreesRoot);
|
|
601
|
+
const targetPath = resolve(worktreesRoot, safeSlug);
|
|
602
|
+
assertContained(worktreesRoot, targetPath);
|
|
603
|
+
assertNoSymlinkEscape(repository.mainWorktree, targetPath);
|
|
604
|
+
const selectedBranch = branch || `wk/${safeSlug}`;
|
|
605
|
+
const branchCheck = git(repository.mainWorktree, ['check-ref-format', '--branch', selectedBranch], {
|
|
606
|
+
ok: false,
|
|
607
|
+
spawn,
|
|
608
|
+
});
|
|
609
|
+
if (branchCheck.status !== 0) {
|
|
610
|
+
throw worktreeError(
|
|
611
|
+
'WENDKEEP_WORKTREE_BRANCH_INVALID',
|
|
612
|
+
`Branch de worktree inválida: "${selectedBranch}".`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const selectedBase = base || defaultBase(repository.mainWorktree, spawn);
|
|
616
|
+
const metadata = ensureWorktreeMetadata({
|
|
617
|
+
repository,
|
|
618
|
+
projectId: binding.projectId,
|
|
619
|
+
vaultPath: binding.base,
|
|
620
|
+
worktreesRoot: rootSetting,
|
|
621
|
+
});
|
|
622
|
+
const existing = readWorktreeRegistry(repository).registry?.entries?.[safeSlug];
|
|
623
|
+
if (matchingReadyEntry(existing, { path: targetPath, branch: selectedBranch })) {
|
|
624
|
+
const opened = open === 'vscode' ? openWorktreePath(existing.path, open, spawn) : { opened: false };
|
|
625
|
+
return { ...existing, ...opened, idempotent: true };
|
|
626
|
+
}
|
|
627
|
+
const existingGitEntry = repository.worktrees.find(
|
|
628
|
+
(entry) => comparablePath(entry.path) === comparablePath(targetPath),
|
|
629
|
+
);
|
|
630
|
+
const matchesReservation = Boolean(existing?.path)
|
|
631
|
+
&& comparablePath(existing.path) === comparablePath(targetPath)
|
|
632
|
+
&& existing?.branch === selectedBranch
|
|
633
|
+
&& existing?.base === selectedBase;
|
|
634
|
+
const retryingFailed = existing?.state === 'failed'
|
|
635
|
+
&& matchesReservation
|
|
636
|
+
&& (!existsSync(targetPath) || existingGitEntry?.branch === selectedBranch);
|
|
637
|
+
if (existing && !retryingFailed) {
|
|
638
|
+
throw worktreeError(
|
|
639
|
+
'WENDKEEP_WORKTREE_COLLISION',
|
|
640
|
+
`Slug "${safeSlug}" já possui estado divergente no registry.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
ensurePrivateExclude(repository, rootSetting);
|
|
645
|
+
mkdirSync(worktreesRoot, { recursive: true });
|
|
646
|
+
const createdAt = existing?.createdAt || now();
|
|
647
|
+
mutateWorktreeRegistry(repository, (registry) => ({
|
|
648
|
+
...registry,
|
|
649
|
+
entries: {
|
|
650
|
+
...registry.entries,
|
|
651
|
+
[safeSlug]: {
|
|
652
|
+
slug: safeSlug,
|
|
653
|
+
path: targetPath,
|
|
654
|
+
branch: selectedBranch,
|
|
655
|
+
base: selectedBase,
|
|
656
|
+
state: 'creating',
|
|
657
|
+
createdAt,
|
|
658
|
+
updatedAt: createdAt,
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
let ready;
|
|
664
|
+
try {
|
|
665
|
+
if (!existingGitEntry) {
|
|
666
|
+
const branchExists = refExists(repository.mainWorktree, `refs/heads/${selectedBranch}`, spawn);
|
|
667
|
+
const args = branchExists
|
|
668
|
+
? ['worktree', 'add', targetPath, selectedBranch]
|
|
669
|
+
: ['worktree', 'add', targetPath, '-b', selectedBranch, selectedBase];
|
|
670
|
+
git(repository.mainWorktree, args, { spawn });
|
|
671
|
+
}
|
|
672
|
+
const targetRepository = discoverWorktreeRepository({ startDir: targetPath, spawn });
|
|
673
|
+
const readyAt = now();
|
|
674
|
+
ready = {
|
|
675
|
+
slug: safeSlug,
|
|
676
|
+
path: targetPath,
|
|
677
|
+
branch: selectedBranch,
|
|
678
|
+
base: selectedBase,
|
|
679
|
+
head: String(git(targetPath, ['rev-parse', 'HEAD'], { spawn }).stdout).trim(),
|
|
680
|
+
state: 'ready',
|
|
681
|
+
worktreeId: worktreeIdentity(metadata.repositoryId, targetRepository.gitDir),
|
|
682
|
+
createdAt,
|
|
683
|
+
updatedAt: readyAt,
|
|
684
|
+
};
|
|
685
|
+
mutateWorktreeRegistry(repository, (registry) => ({
|
|
686
|
+
...registry,
|
|
687
|
+
entries: { ...registry.entries, [safeSlug]: ready },
|
|
688
|
+
}));
|
|
689
|
+
} catch (error) {
|
|
690
|
+
const failedAt = now();
|
|
691
|
+
mutateWorktreeRegistry(repository, (registry) => ({
|
|
692
|
+
...registry,
|
|
693
|
+
entries: {
|
|
694
|
+
...registry.entries,
|
|
695
|
+
[safeSlug]: {
|
|
696
|
+
...registry.entries[safeSlug],
|
|
697
|
+
state: 'failed',
|
|
698
|
+
errorCode: error?.code || 'WENDKEEP_WORKTREE_GIT_FAILED',
|
|
699
|
+
recovery: `wendkeep worktree create ${safeSlug} --base ${selectedBase} --branch ${selectedBranch} --open none`,
|
|
700
|
+
updatedAt: failedAt,
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
}));
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
const opened = open === 'vscode' ? openWorktreePath(ready.path, open, spawn) : { opened: false };
|
|
707
|
+
return { ...ready, ...opened, idempotent: false };
|
|
708
|
+
}
|