draftgo-cli 3.0.33 → 3.0.38
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 +220 -269
- package/package.json +6 -2
- package/resources/skill/SKILL.md +114 -55
- package/resources/skill/init/SKILL.md +29 -15
- package/resources/skill/manifest.json +13 -5
- package/resources/skill/push/SKILL.md +41 -29
- package/resources/skill/references/aihub.md +8 -5
- package/resources/skill/references/api-endpoints.md +5 -3
- package/resources/skill/references/architecture.md +1 -1
- package/resources/skill/references/checkout.md +116 -0
- package/resources/skill/references/custom-services.md +9 -10
- package/resources/skill/references/data.md +4 -2
- package/resources/skill/references/frontend.md +99 -23
- package/resources/skill/references/mcp.md +101 -0
- package/resources/skill/references/modules.md +8 -8
- package/resources/skill/references/parallel.md +6 -3
- package/resources/skill/references/runtime.md +7 -10
- package/resources/skill/scripts/README.md +8 -0
- package/resources/skill/story/SKILL.md +8 -8
- package/src/cli.js +5 -0
- package/src/commandRegistry.js +7 -1
- package/src/commands/api.js +24 -187
- package/src/commands/autoPush.js +48 -17
- package/src/commands/check.js +17 -47
- package/src/commands/checkout.js +18 -0
- package/src/commands/commit.js +21 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +60 -48
- package/src/commands/delete.js +79 -64
- package/src/commands/deploy.js +18 -10
- package/src/commands/diff.js +23 -0
- package/src/commands/help.js +99 -75
- package/src/commands/init.js +4 -10
- package/src/commands/local.js +23 -6
- package/src/commands/map.js +89 -89
- package/src/commands/mcp.js +126 -0
- package/src/commands/sync.js +28 -43
- package/src/commands/verifyUi.js +3 -2
- package/src/localdev/index.js +37 -7
- package/src/localdev/mysqlClient.js +1 -1
- package/src/mcp/client.js +275 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/protocol.js +173 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +37 -0
- package/src/platforms.js +3 -4
- package/src/projectConfig.js +91 -49
- package/src/projectMap.js +123 -460
- package/src/skill.js +6 -28
- package/src/worktree/backend.js +250 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +461 -0
- package/src/worktree/manifest.js +75 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
- package/resources/skill/pull/SKILL.md +0 -33
- package/resources/skill/references/api.json +0 -20248
- package/resources/skill/scripts/draftgo_delete.py +0 -149
- package/resources/skill/scripts/draftgo_init.py +0 -80
- package/resources/skill/scripts/draftgo_pull.py +0 -427
- package/resources/skill/scripts/draftgo_push.py +0 -1022
- package/src/python.js +0 -27
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { loadProjectConfig } = require('../projectConfig');
|
|
7
|
+
const { DraftGoMcpClient } = require('../mcp/client');
|
|
8
|
+
const backendDefaults = require('./backend');
|
|
9
|
+
const { WorktreeError, isVersionConflict } = require('./errors');
|
|
10
|
+
const {
|
|
11
|
+
RESOURCE_TYPES,
|
|
12
|
+
canonicalResourceType,
|
|
13
|
+
entryKey,
|
|
14
|
+
mediaType,
|
|
15
|
+
resourceFileName,
|
|
16
|
+
safeIdSegment,
|
|
17
|
+
} = require('./types');
|
|
18
|
+
const { streamToFiles, hashFile, copyFileAtomic, writeJsonAtomic, normalizeSha256 } = require('./streams');
|
|
19
|
+
const {
|
|
20
|
+
loadManifest,
|
|
21
|
+
getEntry,
|
|
22
|
+
relativePath,
|
|
23
|
+
absolutePath,
|
|
24
|
+
saveManifest,
|
|
25
|
+
} = require('./manifest');
|
|
26
|
+
const { validateContentFile } = require('./validate');
|
|
27
|
+
|
|
28
|
+
const CONFLICT_SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
function backendFor(options) {
|
|
31
|
+
return { ...backendDefaults, ...(options.backend || {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function worktreePaths(projectDir, resourceType, resourceId, extension) {
|
|
35
|
+
const canonical = canonicalResourceType(resourceType);
|
|
36
|
+
const directory = RESOURCE_TYPES[canonical].directory;
|
|
37
|
+
const fileName = resourceFileName(canonical, resourceId, extension);
|
|
38
|
+
return {
|
|
39
|
+
local: path.join(projectDir, '.draftgo', 'worktree', directory, fileName),
|
|
40
|
+
base: path.join(projectDir, '.draftgo', 'worktree', '.base', directory, fileName),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function conflictPaths(projectDir, resourceType, resourceId, extension) {
|
|
45
|
+
const canonical = canonicalResourceType(resourceType);
|
|
46
|
+
const directory = RESOURCE_TYPES[canonical].directory;
|
|
47
|
+
const root = path.join(projectDir, '.draftgo', 'conflicts', directory, safeIdSegment(resourceId));
|
|
48
|
+
return {
|
|
49
|
+
root,
|
|
50
|
+
manifest: path.join(root, 'conflict.json'),
|
|
51
|
+
base: path.join(root, `base${extension}`),
|
|
52
|
+
local: path.join(root, `local${extension}`),
|
|
53
|
+
remote: path.join(root, `remote${extension}`),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function createSession(config, options) {
|
|
58
|
+
if (options.session) return options.session;
|
|
59
|
+
if (options.backend && typeof options.backend.resolveMetadata === 'function') return {};
|
|
60
|
+
const client = options.client || new DraftGoMcpClient(config);
|
|
61
|
+
await client.initialize(options);
|
|
62
|
+
const tools = await client.listAllTools(options);
|
|
63
|
+
return { client, tools };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ensureIds(resourceIds) {
|
|
67
|
+
const ids = resourceIds.map((value) => String(value).trim()).filter(Boolean);
|
|
68
|
+
if (!ids.length) throw new WorktreeError('INVALID_RESOURCE_ID', 'At least one resource id is required.');
|
|
69
|
+
return ids;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function responseContentType(response) {
|
|
73
|
+
return mediaType(response && response.headers && response.headers.get('content-type'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertMetadataIdentity(metadata, resourceType, resourceId) {
|
|
77
|
+
if (metadata.resource_type !== resourceType || String(metadata.resource_id) !== String(resourceId)) {
|
|
78
|
+
throw new WorktreeError('METADATA_RESOURCE_MISMATCH',
|
|
79
|
+
'DraftGo returned metadata for a different resource.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function checkoutResources(projectDir, resourceType, resourceIds, options = {}) {
|
|
84
|
+
const canonical = canonicalResourceType(resourceType);
|
|
85
|
+
const ids = ensureIds(resourceIds);
|
|
86
|
+
const config = options.config || loadProjectConfig(projectDir);
|
|
87
|
+
const backend = backendFor(options);
|
|
88
|
+
const session = await createSession(config, options);
|
|
89
|
+
const manifest = loadManifest(projectDir);
|
|
90
|
+
const results = [];
|
|
91
|
+
|
|
92
|
+
for (const resourceId of ids) {
|
|
93
|
+
const metadata = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
94
|
+
...options,
|
|
95
|
+
...session,
|
|
96
|
+
clientInitialized: true,
|
|
97
|
+
});
|
|
98
|
+
assertMetadataIdentity(metadata, canonical, resourceId);
|
|
99
|
+
|
|
100
|
+
const existing = getEntry(manifest, canonical, resourceId);
|
|
101
|
+
if (existing) {
|
|
102
|
+
const existingLocal = absolutePath(projectDir, existing.local_path);
|
|
103
|
+
if (fs.existsSync(existingLocal)) {
|
|
104
|
+
const current = await hashFile(existingLocal);
|
|
105
|
+
if (current.hash !== existing.base_hash && !options.force) {
|
|
106
|
+
throw new WorktreeError(
|
|
107
|
+
'LOCAL_CHANGES_PRESENT',
|
|
108
|
+
`Refusing to replace locally modified ${canonical} ${resourceId}; commit it or use checkout --force explicitly.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const paths = worktreePaths(projectDir, canonical, resourceId, metadata.file_extension);
|
|
115
|
+
const response = await backend.download(config, metadata, options);
|
|
116
|
+
const actualType = responseContentType(response);
|
|
117
|
+
if (actualType && actualType !== mediaType(metadata.content_type)) {
|
|
118
|
+
throw new WorktreeError('CONTENT_TYPE_MISMATCH', 'Downloaded content type differs from checkout metadata.', {
|
|
119
|
+
expected: metadata.content_type,
|
|
120
|
+
actual: response.headers.get('content-type'),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const downloaded = await streamToFiles(response.body, [paths.local, paths.base], {
|
|
124
|
+
expectedHash: metadata.content_hash,
|
|
125
|
+
expectedSize: metadata.content_size,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const entry = {
|
|
129
|
+
server: config.server,
|
|
130
|
+
resource_type: canonical,
|
|
131
|
+
resource_id: resourceId,
|
|
132
|
+
title: metadata.title,
|
|
133
|
+
route: metadata.route,
|
|
134
|
+
code: metadata.code,
|
|
135
|
+
slug: metadata.slug,
|
|
136
|
+
local_path: relativePath(projectDir, paths.local),
|
|
137
|
+
content_type: metadata.content_type,
|
|
138
|
+
file_extension: metadata.file_extension,
|
|
139
|
+
content_size: downloaded.size,
|
|
140
|
+
base_path: relativePath(projectDir, paths.base),
|
|
141
|
+
base_version: metadata.base_version,
|
|
142
|
+
base_revision: metadata.base_revision,
|
|
143
|
+
base_etag: metadata.etag,
|
|
144
|
+
base_hash: downloaded.hash,
|
|
145
|
+
checked_out_at: new Date().toISOString(),
|
|
146
|
+
updated_at: metadata.updated_at,
|
|
147
|
+
updated_by: metadata.updated_by,
|
|
148
|
+
};
|
|
149
|
+
manifest.entries[entryKey(canonical, resourceId)] = entry;
|
|
150
|
+
await saveManifest(projectDir, manifest);
|
|
151
|
+
results.push(entry);
|
|
152
|
+
}
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeCommitPayload(value) {
|
|
157
|
+
if (!value || typeof value !== 'object') return {};
|
|
158
|
+
if (value.data && typeof value.data === 'object') return normalizeCommitPayload(value.data);
|
|
159
|
+
if (value.metadata && typeof value.metadata === 'object') return normalizeCommitPayload(value.metadata);
|
|
160
|
+
if (value.resource && typeof value.resource === 'object') return normalizeCommitPayload(value.resource);
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function commitVersion(payload) {
|
|
165
|
+
return {
|
|
166
|
+
version: payload.base_version !== undefined ? payload.base_version
|
|
167
|
+
: (payload.version !== undefined ? payload.version : null),
|
|
168
|
+
revision: payload.base_revision !== undefined ? payload.base_revision
|
|
169
|
+
: (payload.revision !== undefined ? payload.revision : null),
|
|
170
|
+
etag: payload.etag || payload.e_tag || null,
|
|
171
|
+
hash: normalizeSha256(payload.content_hash || payload.hash || payload.sha256),
|
|
172
|
+
updated_at: payload.updated_at || null,
|
|
173
|
+
updated_by: payload.updated_by || null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function writeConflict(projectDir, entry, error, config, backend, options, session) {
|
|
178
|
+
const localPath = absolutePath(projectDir, entry.local_path);
|
|
179
|
+
const basePath = absolutePath(projectDir, entry.base_path);
|
|
180
|
+
const paths = conflictPaths(projectDir, entry.resource_type, entry.resource_id, entry.file_extension);
|
|
181
|
+
const remoteMetadata = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
|
|
182
|
+
...options,
|
|
183
|
+
...session,
|
|
184
|
+
clientInitialized: true,
|
|
185
|
+
});
|
|
186
|
+
assertMetadataIdentity(remoteMetadata, entry.resource_type, entry.resource_id);
|
|
187
|
+
const response = await backend.download(config, remoteMetadata, options);
|
|
188
|
+
|
|
189
|
+
await copyFileAtomic(basePath, paths.base, { expectedHash: entry.base_hash });
|
|
190
|
+
const local = await copyFileAtomic(localPath, paths.local);
|
|
191
|
+
const remote = await streamToFiles(response.body, [paths.remote], {
|
|
192
|
+
expectedHash: remoteMetadata.content_hash,
|
|
193
|
+
expectedSize: remoteMetadata.content_size,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const record = {
|
|
197
|
+
schema_version: CONFLICT_SCHEMA_VERSION,
|
|
198
|
+
status: 'unresolved',
|
|
199
|
+
code: 'RESOURCE_VERSION_CONFLICT',
|
|
200
|
+
server: config.server,
|
|
201
|
+
resource_type: entry.resource_type,
|
|
202
|
+
resource_id: entry.resource_id,
|
|
203
|
+
title: entry.title,
|
|
204
|
+
content_type: entry.content_type,
|
|
205
|
+
base_path: relativePath(projectDir, paths.base),
|
|
206
|
+
local_path: relativePath(projectDir, paths.local),
|
|
207
|
+
remote_path: relativePath(projectDir, paths.remote),
|
|
208
|
+
worktree_local_path: entry.local_path,
|
|
209
|
+
expected_version: entry.base_version != null ? entry.base_version : entry.base_revision,
|
|
210
|
+
actual_version: remoteMetadata.base_version != null
|
|
211
|
+
? remoteMetadata.base_version : remoteMetadata.base_revision,
|
|
212
|
+
expected_hash: entry.base_hash,
|
|
213
|
+
local_hash: local.hash,
|
|
214
|
+
actual_hash: remote.hash,
|
|
215
|
+
actual_base_version: remoteMetadata.base_version,
|
|
216
|
+
actual_base_revision: remoteMetadata.base_revision,
|
|
217
|
+
actual_etag: remoteMetadata.etag,
|
|
218
|
+
updated_at: remoteMetadata.updated_at,
|
|
219
|
+
updated_by: remoteMetadata.updated_by,
|
|
220
|
+
backend_error: {
|
|
221
|
+
status: error.status || null,
|
|
222
|
+
code: error.code || 'RESOURCE_VERSION_CONFLICT',
|
|
223
|
+
},
|
|
224
|
+
created_at: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
await writeJsonAtomic(paths.manifest, record);
|
|
227
|
+
return { ...record, manifest_path: relativePath(projectDir, paths.manifest) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function unresolvedConflict(projectDir, resourceType, resourceId) {
|
|
231
|
+
const entry = loadManifest(projectDir).entries[entryKey(resourceType, resourceId)];
|
|
232
|
+
const extension = entry && entry.file_extension || '.bin';
|
|
233
|
+
const file = conflictPaths(projectDir, resourceType, resourceId, extension).manifest;
|
|
234
|
+
if (!fs.existsSync(file)) return null;
|
|
235
|
+
try {
|
|
236
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
237
|
+
return record.status === 'unresolved' ? record : null;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw new WorktreeError('INVALID_CONFLICT_MANIFEST', `Invalid conflict manifest: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function commitResources(projectDir, resourceType, resourceIds, options = {}) {
|
|
244
|
+
const canonical = canonicalResourceType(resourceType);
|
|
245
|
+
const ids = ensureIds(resourceIds);
|
|
246
|
+
const config = options.config || loadProjectConfig(projectDir);
|
|
247
|
+
const backend = backendFor(options);
|
|
248
|
+
const session = await createSession(config, options);
|
|
249
|
+
const manifest = loadManifest(projectDir);
|
|
250
|
+
const results = [];
|
|
251
|
+
|
|
252
|
+
for (const resourceId of ids) {
|
|
253
|
+
const entry = getEntry(manifest, canonical, resourceId);
|
|
254
|
+
if (!entry) {
|
|
255
|
+
throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
|
|
256
|
+
}
|
|
257
|
+
if (entry.server !== config.server) {
|
|
258
|
+
throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
|
|
259
|
+
}
|
|
260
|
+
if (unresolvedConflict(projectDir, canonical, resourceId)) {
|
|
261
|
+
throw new WorktreeError(
|
|
262
|
+
'UNRESOLVED_CONFLICT',
|
|
263
|
+
`${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const localPath = absolutePath(projectDir, entry.local_path);
|
|
268
|
+
const current = await hashFile(localPath);
|
|
269
|
+
if (current.hash === entry.base_hash) {
|
|
270
|
+
results.push({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
validateContentFile(localPath, entry.content_type);
|
|
274
|
+
|
|
275
|
+
const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
276
|
+
...options,
|
|
277
|
+
...session,
|
|
278
|
+
clientInitialized: true,
|
|
279
|
+
});
|
|
280
|
+
assertMetadataIdentity(fresh, canonical, resourceId);
|
|
281
|
+
const commitMetadata = {
|
|
282
|
+
...fresh,
|
|
283
|
+
content_type: entry.content_type,
|
|
284
|
+
base_version: entry.base_version,
|
|
285
|
+
base_revision: entry.base_revision,
|
|
286
|
+
etag: entry.base_etag,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
let responsePayload;
|
|
290
|
+
try {
|
|
291
|
+
responsePayload = await backend.commit(config, commitMetadata, localPath, current, options);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!isVersionConflict(error)) throw error;
|
|
294
|
+
const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
|
|
295
|
+
throw new WorktreeError(
|
|
296
|
+
'RESOURCE_VERSION_CONFLICT',
|
|
297
|
+
`${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
|
|
298
|
+
conflict,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let committed = commitVersion(normalizeCommitPayload(responsePayload));
|
|
303
|
+
if (!committed.hash || (committed.version == null && committed.revision == null)) {
|
|
304
|
+
const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
305
|
+
...options,
|
|
306
|
+
...session,
|
|
307
|
+
clientInitialized: true,
|
|
308
|
+
});
|
|
309
|
+
assertMetadataIdentity(confirmed, canonical, resourceId);
|
|
310
|
+
committed = {
|
|
311
|
+
version: confirmed.base_version,
|
|
312
|
+
revision: confirmed.base_revision,
|
|
313
|
+
etag: confirmed.etag,
|
|
314
|
+
hash: confirmed.content_hash,
|
|
315
|
+
updated_at: confirmed.updated_at,
|
|
316
|
+
updated_by: confirmed.updated_by,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (committed.hash && committed.hash !== current.hash) {
|
|
320
|
+
throw new WorktreeError(
|
|
321
|
+
'HASH_MISMATCH',
|
|
322
|
+
'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
|
|
323
|
+
{ local_hash: current.hash, remote_hash: committed.hash },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
|
|
328
|
+
entry.base_hash = current.hash;
|
|
329
|
+
entry.content_size = current.size;
|
|
330
|
+
entry.base_version = committed.version;
|
|
331
|
+
entry.base_revision = committed.revision;
|
|
332
|
+
entry.base_etag = committed.etag;
|
|
333
|
+
entry.updated_at = committed.updated_at;
|
|
334
|
+
entry.updated_by = committed.updated_by;
|
|
335
|
+
entry.committed_at = new Date().toISOString();
|
|
336
|
+
await saveManifest(projectDir, manifest);
|
|
337
|
+
results.push({
|
|
338
|
+
resource_type: canonical,
|
|
339
|
+
resource_id: resourceId,
|
|
340
|
+
status: 'committed',
|
|
341
|
+
hash: current.hash,
|
|
342
|
+
base_version: entry.base_version,
|
|
343
|
+
base_revision: entry.base_revision,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return results;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function diffResource(projectDir, resourceType, resourceId, options = {}) {
|
|
350
|
+
const entry = getEntry(loadManifest(projectDir), resourceType, resourceId);
|
|
351
|
+
if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${resourceType} ${resourceId} is not checked out.`);
|
|
352
|
+
const base = absolutePath(projectDir, entry.base_path);
|
|
353
|
+
const local = absolutePath(projectDir, entry.local_path);
|
|
354
|
+
const result = spawnSync('git', [
|
|
355
|
+
'diff', '--no-index', '--no-color', '--', base, local,
|
|
356
|
+
], {
|
|
357
|
+
cwd: projectDir,
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
shell: false,
|
|
360
|
+
maxBuffer: options.maxBuffer || 64 * 1024 * 1024,
|
|
361
|
+
});
|
|
362
|
+
if (result.error) throw new WorktreeError('DIFF_UNAVAILABLE', `Unable to run git diff: ${result.error.message}`);
|
|
363
|
+
if (![0, 1].includes(result.status)) {
|
|
364
|
+
throw new WorktreeError('DIFF_FAILED', result.stderr || `git diff exited with ${result.status}.`);
|
|
365
|
+
}
|
|
366
|
+
return { entry, changed: result.status === 1, output: result.stdout || '' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readConflictFile(file) {
|
|
370
|
+
try {
|
|
371
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
372
|
+
if (!record || record.schema_version !== CONFLICT_SCHEMA_VERSION) {
|
|
373
|
+
throw new Error('unsupported schema');
|
|
374
|
+
}
|
|
375
|
+
return record;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throw new WorktreeError('INVALID_CONFLICT_MANIFEST', `Invalid conflict manifest ${file}: ${error.message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function listConflicts(projectDir, options = {}) {
|
|
382
|
+
const root = path.join(projectDir, '.draftgo', 'conflicts');
|
|
383
|
+
if (!fs.existsSync(root)) return [];
|
|
384
|
+
const records = [];
|
|
385
|
+
const visit = (directory) => {
|
|
386
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
387
|
+
const absolute = path.join(directory, item.name);
|
|
388
|
+
if (item.isDirectory()) visit(absolute);
|
|
389
|
+
else if (item.isFile() && item.name === 'conflict.json') {
|
|
390
|
+
const record = readConflictFile(absolute);
|
|
391
|
+
if (options.all || record.status === 'unresolved') {
|
|
392
|
+
records.push({ ...record, manifest_path: relativePath(projectDir, absolute) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
visit(root);
|
|
398
|
+
return records.sort((left, right) => `${left.resource_type}:${left.resource_id}`
|
|
399
|
+
.localeCompare(`${right.resource_type}:${right.resource_id}`));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function showConflict(projectDir, resourceType, resourceId) {
|
|
403
|
+
const canonical = canonicalResourceType(resourceType);
|
|
404
|
+
const match = listConflicts(projectDir, { all: true }).find((record) =>
|
|
405
|
+
record.resource_type === canonical && String(record.resource_id) === String(resourceId));
|
|
406
|
+
if (!match) throw new WorktreeError('CONFLICT_NOT_FOUND', `No conflict found for ${canonical} ${resourceId}.`);
|
|
407
|
+
return match;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function resolveConflict(projectDir, resourceType, resourceId) {
|
|
411
|
+
const canonical = canonicalResourceType(resourceType);
|
|
412
|
+
const manifest = loadManifest(projectDir);
|
|
413
|
+
const entry = getEntry(manifest, canonical, resourceId);
|
|
414
|
+
if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
|
|
415
|
+
const conflict = showConflict(projectDir, canonical, resourceId);
|
|
416
|
+
if (conflict.status !== 'unresolved') {
|
|
417
|
+
throw new WorktreeError('CONFLICT_ALREADY_RESOLVED', `${canonical} ${resourceId} is already resolved.`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const localPath = absolutePath(projectDir, entry.local_path);
|
|
421
|
+
validateContentFile(localPath, entry.content_type);
|
|
422
|
+
const local = await hashFile(localPath);
|
|
423
|
+
const remotePath = absolutePath(projectDir, conflict.remote_path);
|
|
424
|
+
const remote = await hashFile(remotePath);
|
|
425
|
+
if (remote.hash !== conflict.actual_hash) {
|
|
426
|
+
throw new WorktreeError('HASH_MISMATCH', 'Preserved remote conflict content no longer matches its manifest.');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await copyFileAtomic(remotePath, absolutePath(projectDir, entry.base_path), { expectedHash: remote.hash });
|
|
430
|
+
entry.base_hash = remote.hash;
|
|
431
|
+
entry.base_version = conflict.actual_base_version;
|
|
432
|
+
entry.base_revision = conflict.actual_base_revision;
|
|
433
|
+
entry.base_etag = conflict.actual_etag;
|
|
434
|
+
entry.updated_at = conflict.updated_at;
|
|
435
|
+
entry.updated_by = conflict.updated_by;
|
|
436
|
+
entry.conflict_resolved_at = new Date().toISOString();
|
|
437
|
+
await saveManifest(projectDir, manifest);
|
|
438
|
+
|
|
439
|
+
const manifestFile = absolutePath(projectDir, conflict.manifest_path);
|
|
440
|
+
const resolved = {
|
|
441
|
+
...conflict,
|
|
442
|
+
status: 'resolved',
|
|
443
|
+
resolution_local_hash: local.hash,
|
|
444
|
+
resolved_at: new Date().toISOString(),
|
|
445
|
+
};
|
|
446
|
+
delete resolved.manifest_path;
|
|
447
|
+
await writeJsonAtomic(manifestFile, resolved);
|
|
448
|
+
return { ...resolved, manifest_path: relativePath(projectDir, manifestFile) };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
module.exports = {
|
|
452
|
+
CONFLICT_SCHEMA_VERSION,
|
|
453
|
+
worktreePaths,
|
|
454
|
+
conflictPaths,
|
|
455
|
+
checkoutResources,
|
|
456
|
+
commitResources,
|
|
457
|
+
diffResource,
|
|
458
|
+
listConflicts,
|
|
459
|
+
showConflict,
|
|
460
|
+
resolveConflict,
|
|
461
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { WorktreeError } = require('./errors');
|
|
6
|
+
const { entryKey } = require('./types');
|
|
7
|
+
const { writeJsonAtomic } = require('./streams');
|
|
8
|
+
|
|
9
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
function manifestPath(projectDir) {
|
|
12
|
+
return path.join(projectDir, '.draftgo', 'worktree', 'manifest.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function emptyManifest() {
|
|
16
|
+
return { schema_version: MANIFEST_SCHEMA_VERSION, entries: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadManifest(projectDir) {
|
|
20
|
+
const file = manifestPath(projectDir);
|
|
21
|
+
if (!fs.existsSync(file)) return emptyManifest();
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new WorktreeError('INVALID_WORKTREE_MANIFEST', `Invalid checkout manifest: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
if (!parsed || parsed.schema_version !== MANIFEST_SCHEMA_VERSION
|
|
29
|
+
|| !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
30
|
+
throw new WorktreeError('INVALID_WORKTREE_MANIFEST', 'Unsupported or malformed checkout manifest.');
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getEntry(manifest, resourceType, resourceId) {
|
|
36
|
+
return manifest.entries[entryKey(resourceType, resourceId)] || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relativePath(projectDir, absolutePath) {
|
|
40
|
+
const relative = path.relative(path.resolve(projectDir), path.resolve(absolutePath));
|
|
41
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
42
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Checkout paths must stay inside the project.');
|
|
43
|
+
}
|
|
44
|
+
return relative.replace(/\\/g, '/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function absolutePath(projectDir, relative) {
|
|
48
|
+
if (!relative || typeof relative !== 'string' || path.isAbsolute(relative)) {
|
|
49
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Manifest path must be project-relative.');
|
|
50
|
+
}
|
|
51
|
+
const root = path.resolve(projectDir);
|
|
52
|
+
const resolved = path.resolve(root, relative);
|
|
53
|
+
const relation = path.relative(root, resolved);
|
|
54
|
+
if (!relation || relation.startsWith('..') || path.isAbsolute(relation)) {
|
|
55
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Manifest path escapes the project.');
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function saveManifest(projectDir, manifest) {
|
|
61
|
+
manifest.schema_version = MANIFEST_SCHEMA_VERSION;
|
|
62
|
+
manifest.updated_at = new Date().toISOString();
|
|
63
|
+
await writeJsonAtomic(manifestPath(projectDir), manifest);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
MANIFEST_SCHEMA_VERSION,
|
|
68
|
+
manifestPath,
|
|
69
|
+
emptyManifest,
|
|
70
|
+
loadManifest,
|
|
71
|
+
getEntry,
|
|
72
|
+
relativePath,
|
|
73
|
+
absolutePath,
|
|
74
|
+
saveManifest,
|
|
75
|
+
};
|