@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 object = ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
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.32",
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.32",
54
- "@playcraft/common": "^0.1.32",
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",