@playcraft/cli 0.1.32 → 0.1.33
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.
|
@@ -3,7 +3,7 @@ import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions }
|
|
|
3
3
|
import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
8
8
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
9
9
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -17,7 +17,7 @@ import { NativePreparationError } from '../codex/native-adapter.js';
|
|
|
17
17
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
18
18
|
import { disposeDesktopHost } from './desktop-host.js';
|
|
19
19
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
20
|
-
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
20
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, pullWorkspaceGit, abortWorkspaceMerge, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
21
21
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
22
22
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
23
23
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
@@ -38,6 +38,8 @@ const LOCAL_KINDS = new Set([
|
|
|
38
38
|
'file.write',
|
|
39
39
|
'git.commit',
|
|
40
40
|
'git.push',
|
|
41
|
+
'git.pull',
|
|
42
|
+
'git.merge.abort',
|
|
41
43
|
'git.stage',
|
|
42
44
|
'git.unstage',
|
|
43
45
|
'git.discard',
|
|
@@ -93,6 +95,12 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
93
95
|
if (operation.kind === 'git.push') {
|
|
94
96
|
return executeGitPush(state, scopeKey, current, payload);
|
|
95
97
|
}
|
|
98
|
+
if (operation.kind === 'git.pull') {
|
|
99
|
+
return executeGitPull(state, scopeKey, current, payload);
|
|
100
|
+
}
|
|
101
|
+
if (operation.kind === 'git.merge.abort') {
|
|
102
|
+
return executeGitMergeAbort(state, scopeKey, current, payload);
|
|
103
|
+
}
|
|
96
104
|
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
97
105
|
return executeGitIndex(state, scopeKey, current, payload);
|
|
98
106
|
}
|
|
@@ -858,6 +866,39 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
858
866
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
859
867
|
}
|
|
860
868
|
}
|
|
869
|
+
/** Conflicts complete the pull: the merge stays in progress for the user to resolve. */
|
|
870
|
+
async function executeGitPull(state, scopeKey, operation, payload) {
|
|
871
|
+
if (!gitPullPayloadSchema.safeParse(payload ?? {}).success) {
|
|
872
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const pulled = await pullWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
|
|
876
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
877
|
+
await recordNativeEvents(state, accepted, [{
|
|
878
|
+
type: 'git.pulled',
|
|
879
|
+
payload: { remote: 'origin', outcome: pulled.outcome, conflicts: pulled.conflicts.slice(0, 100) },
|
|
880
|
+
occurredAt: new Date().toISOString(),
|
|
881
|
+
}]);
|
|
882
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async function executeGitMergeAbort(state, scopeKey, operation, payload) {
|
|
889
|
+
if (!gitMergeAbortPayloadSchema.safeParse(payload ?? {}).success) {
|
|
890
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_merge_abort' }));
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
await abortWorkspaceMerge(await workspaceRoot(state, operation.workspaceId, operation.directoryName));
|
|
894
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
895
|
+
await recordNativeEvents(state, accepted, [{ type: 'git.merge.aborted', payload: {}, occurredAt: new Date().toISOString() }]);
|
|
896
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
861
902
|
const GIT_INDEX_CHANGES = {
|
|
862
903
|
'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
|
|
863
904
|
'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
|
|
@@ -44,6 +44,7 @@ export async function readWorkspaceGitStatus(root) {
|
|
|
44
44
|
ahead = Number.parseInt(right ?? '0', 10) || 0;
|
|
45
45
|
}
|
|
46
46
|
const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
|
|
47
|
+
const merge = await mergeState(rootReal);
|
|
47
48
|
// Line counts are a convenience; a repository without commits still lists its files.
|
|
48
49
|
const [staged, unstaged] = await Promise.all([
|
|
49
50
|
gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
|
|
@@ -57,6 +58,7 @@ export async function readWorkspaceGitStatus(root) {
|
|
|
57
58
|
ahead,
|
|
58
59
|
behind,
|
|
59
60
|
hasUpstream: upstream.code === 0,
|
|
61
|
+
...merge,
|
|
60
62
|
files: parsePorcelain(porcelain).map((entry) => ({
|
|
61
63
|
...entry,
|
|
62
64
|
...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
|
|
@@ -131,7 +133,8 @@ export async function diffWorkspaceTrees(root, before, after) {
|
|
|
131
133
|
export async function readWorkspaceGitFile(root, path, ref) {
|
|
132
134
|
const rootReal = await ensureWorkspaceGit(root);
|
|
133
135
|
assertWorkspacePaths([path]);
|
|
134
|
-
const
|
|
136
|
+
const stage = { INDEX: 0, MERGE_BASE: 1, MERGE_OURS: 2, MERGE_THEIRS: 3 }[ref];
|
|
137
|
+
const object = stage === undefined ? `${ref}:${path}` : `:${stage}:${path}`;
|
|
135
138
|
const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
|
|
136
139
|
const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
|
|
137
140
|
if (size.code !== 0)
|
|
@@ -271,6 +274,9 @@ function pushRejection(stdout) {
|
|
|
271
274
|
export async function commitWorkspaceGit(root, payload, boundBranch) {
|
|
272
275
|
const rootReal = await ensureWorkspaceGit(root);
|
|
273
276
|
await assertBoundBranch(rootReal, boundBranch);
|
|
277
|
+
// Adding a conflicted file would mark it resolved with its conflict markers in it.
|
|
278
|
+
if ((await unmergedPaths(rootReal)).length)
|
|
279
|
+
throw new GitWorkspaceError('git_conflicts_unresolved', 'unmerged');
|
|
274
280
|
if (payload.stagedOnly) {
|
|
275
281
|
// The index is the commit; nothing is added.
|
|
276
282
|
}
|
|
@@ -334,6 +340,10 @@ export function isCredentialFile(path) {
|
|
|
334
340
|
export async function saveWorkspaceGit(root, input) {
|
|
335
341
|
const rootReal = await ensureWorkspaceGit(root);
|
|
336
342
|
await assertBoundBranch(rootReal, input.boundBranch);
|
|
343
|
+
// A save during a merge would stage conflict markers as resolutions and conclude the merge. The
|
|
344
|
+
// user (or an agent) finishes the merge with an explicit commit; saves resume after it.
|
|
345
|
+
if ((await mergeState(rootReal)).merging)
|
|
346
|
+
return { pushed: false, withheld: [] };
|
|
337
347
|
await git(rootReal, ['add', '-A'], 60000);
|
|
338
348
|
const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
|
|
339
349
|
const withheld = staged.filter(isCredentialFile);
|
|
@@ -353,6 +363,68 @@ export async function saveWorkspaceGit(root, input) {
|
|
|
353
363
|
await recordPushedHead(rootReal);
|
|
354
364
|
return { ...(commit ? { commit } : {}), pushed: true, withheld };
|
|
355
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* `git pull` with a merge, as VS Code runs it: fetch the bound branch from origin, then merge it.
|
|
368
|
+
* Uncommitted work is kept unless the incoming commits change the same files, in which case Git
|
|
369
|
+
* refuses before touching anything. Conflicts leave the merge in progress for the user to resolve.
|
|
370
|
+
*/
|
|
371
|
+
export async function pullWorkspaceGit(root, boundBranch) {
|
|
372
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
373
|
+
await assertBoundBranch(rootReal, boundBranch);
|
|
374
|
+
if ((await mergeState(rootReal)).merging)
|
|
375
|
+
throw new GitWorkspaceError('git_merge_in_progress', 'merge_in_progress');
|
|
376
|
+
if ((await gitAllowFail(rootReal, ['remote', 'get-url', 'origin'])).code !== 0) {
|
|
377
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
378
|
+
}
|
|
379
|
+
const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
|
|
380
|
+
const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
|
|
381
|
+
if (!branch || !born)
|
|
382
|
+
throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
|
|
383
|
+
// A fork clone has no fetch refspec for its own branch, so the tracking ref is named here.
|
|
384
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
385
|
+
const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
|
|
386
|
+
if (fetched.code !== 0)
|
|
387
|
+
throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_fetch_failed', 'git_fetch_failed');
|
|
388
|
+
if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code === 0) {
|
|
389
|
+
return { outcome: 'up_to_date', conflicts: [] };
|
|
390
|
+
}
|
|
391
|
+
// The short name gives the merge commit the message `git pull` would write.
|
|
392
|
+
const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--no-edit', `origin/${branch}`], 60000);
|
|
393
|
+
if (merged.code === 0)
|
|
394
|
+
return { outcome: 'merged', conflicts: [] };
|
|
395
|
+
const conflicts = await unmergedPaths(rootReal);
|
|
396
|
+
if (conflicts.length)
|
|
397
|
+
return { outcome: 'conflicts', conflicts };
|
|
398
|
+
if (/would be overwritten by merge|untracked working tree files would be (?:overwritten|removed)/i.test(`${merged.stdout}\n${merged.stderr}`)) {
|
|
399
|
+
throw new GitWorkspaceError('git_pull_local_changes', 'local_changes');
|
|
400
|
+
}
|
|
401
|
+
throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
|
|
402
|
+
}
|
|
403
|
+
/** `git merge --abort`: the work tree and index return to how they were before the merge began. */
|
|
404
|
+
export async function abortWorkspaceMerge(root) {
|
|
405
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
406
|
+
if (!(await mergeState(rootReal)).merging)
|
|
407
|
+
throw new GitWorkspaceError('git_merge_not_in_progress', 'no_merge');
|
|
408
|
+
await git(rootReal, ['merge', '--abort'], 60000);
|
|
409
|
+
}
|
|
410
|
+
/** Paths with unmerged index entries, that is, conflicts not yet marked resolved. */
|
|
411
|
+
async function unmergedPaths(rootReal) {
|
|
412
|
+
const listed = await gitAllowFail(rootReal, ['diff', '--name-only', '--diff-filter=U', '-z']);
|
|
413
|
+
return listed.code === 0 ? listed.stdout.split('\0').filter(Boolean) : [];
|
|
414
|
+
}
|
|
415
|
+
async function mergeState(rootReal) {
|
|
416
|
+
try {
|
|
417
|
+
await stat(join(rootReal, '.git', 'MERGE_HEAD'));
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (error.code === 'ENOENT')
|
|
421
|
+
return {};
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
const message = await readFile(join(rootReal, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
|
|
425
|
+
const text = message.split('\n').filter((line) => !line.startsWith('#')).join('\n').trim().slice(0, 4096);
|
|
426
|
+
return { merging: true, ...(text ? { mergeMessage: text } : {}) };
|
|
427
|
+
}
|
|
356
428
|
/**
|
|
357
429
|
* Connects a standalone checkout to the repository it was just given, keeping all its history and
|
|
358
430
|
* uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@gltf-transform/core": "^4.3.0",
|
|
51
51
|
"@gltf-transform/extensions": "^4.3.0",
|
|
52
52
|
"@gltf-transform/functions": "^4.3.0",
|
|
53
|
-
"@playcraft/build": "^0.1.
|
|
54
|
-
"@playcraft/common": "^0.1.
|
|
53
|
+
"@playcraft/build": "^0.1.33",
|
|
54
|
+
"@playcraft/common": "^0.1.33",
|
|
55
55
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
56
56
|
"chokidar": "^4.0.3",
|
|
57
57
|
"commander": "^13.1.0",
|