@playcraft/cli 0.1.32 → 0.1.34
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.
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
|
|
4
|
+
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
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';
|
|
7
|
+
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
8
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
8
9
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
9
10
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -17,7 +18,7 @@ import { NativePreparationError } from '../codex/native-adapter.js';
|
|
|
17
18
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
18
19
|
import { disposeDesktopHost } from './desktop-host.js';
|
|
19
20
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
20
|
-
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
21
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, pullWorkspaceGit, abortWorkspaceMerge, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
21
22
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
22
23
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
23
24
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
@@ -38,6 +39,8 @@ const LOCAL_KINDS = new Set([
|
|
|
38
39
|
'file.write',
|
|
39
40
|
'git.commit',
|
|
40
41
|
'git.push',
|
|
42
|
+
'git.pull',
|
|
43
|
+
'git.merge.abort',
|
|
41
44
|
'git.stage',
|
|
42
45
|
'git.unstage',
|
|
43
46
|
'git.discard',
|
|
@@ -93,6 +96,12 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
93
96
|
if (operation.kind === 'git.push') {
|
|
94
97
|
return executeGitPush(state, scopeKey, current, payload);
|
|
95
98
|
}
|
|
99
|
+
if (operation.kind === 'git.pull') {
|
|
100
|
+
return executeGitPull(state, scopeKey, current, payload);
|
|
101
|
+
}
|
|
102
|
+
if (operation.kind === 'git.merge.abort') {
|
|
103
|
+
return executeGitMergeAbort(state, scopeKey, current, payload);
|
|
104
|
+
}
|
|
96
105
|
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
97
106
|
return executeGitIndex(state, scopeKey, current, payload);
|
|
98
107
|
}
|
|
@@ -781,7 +790,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
781
790
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
|
|
782
791
|
}
|
|
783
792
|
try {
|
|
784
|
-
const sha = await commitWorkspaceGit(await
|
|
793
|
+
const sha = await commitWorkspaceGit(await gitWorkspaceRoot(state, operation), parsed.data, boundBranch(state, operation));
|
|
785
794
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
786
795
|
await recordNativeEvents(state, accepted, [
|
|
787
796
|
{
|
|
@@ -818,7 +827,7 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
818
827
|
}
|
|
819
828
|
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
820
829
|
try {
|
|
821
|
-
const saved = await saveWorkspaceGit(await
|
|
830
|
+
const saved = await saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
|
|
822
831
|
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
823
832
|
push: savePushesBranch(binding?.targetKind, operation.directoryName),
|
|
824
833
|
boundBranch: boundBranch(state, operation),
|
|
@@ -843,7 +852,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
843
852
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
844
853
|
}
|
|
845
854
|
try {
|
|
846
|
-
await pushWorkspaceGit(await
|
|
855
|
+
await pushWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
|
|
847
856
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
848
857
|
await recordNativeEvents(state, accepted, [
|
|
849
858
|
{
|
|
@@ -858,6 +867,39 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
858
867
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
859
868
|
}
|
|
860
869
|
}
|
|
870
|
+
/** Conflicts complete the pull: the merge stays in progress for the user to resolve. */
|
|
871
|
+
async function executeGitPull(state, scopeKey, operation, payload) {
|
|
872
|
+
if (!gitPullPayloadSchema.safeParse(payload ?? {}).success) {
|
|
873
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
const pulled = await pullWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
|
|
877
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
878
|
+
await recordNativeEvents(state, accepted, [{
|
|
879
|
+
type: 'git.pulled',
|
|
880
|
+
payload: { remote: 'origin', outcome: pulled.outcome, conflicts: pulled.conflicts.slice(0, 100) },
|
|
881
|
+
occurredAt: new Date().toISOString(),
|
|
882
|
+
}]);
|
|
883
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
async function executeGitMergeAbort(state, scopeKey, operation, payload) {
|
|
890
|
+
if (!gitMergeAbortPayloadSchema.safeParse(payload ?? {}).success) {
|
|
891
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_merge_abort' }));
|
|
892
|
+
}
|
|
893
|
+
try {
|
|
894
|
+
await abortWorkspaceMerge(await gitWorkspaceRoot(state, operation));
|
|
895
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
896
|
+
await recordNativeEvents(state, accepted, [{ type: 'git.merge.aborted', payload: {}, occurredAt: new Date().toISOString() }]);
|
|
897
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
861
903
|
const GIT_INDEX_CHANGES = {
|
|
862
904
|
'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
|
|
863
905
|
'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
|
|
@@ -871,7 +913,7 @@ async function executeGitIndex(state, scopeKey, operation, payload) {
|
|
|
871
913
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
|
|
872
914
|
}
|
|
873
915
|
try {
|
|
874
|
-
await change.run(await
|
|
916
|
+
await change.run(await gitWorkspaceRoot(state, operation), parsed.data);
|
|
875
917
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
876
918
|
await recordNativeEvents(state, accepted, [
|
|
877
919
|
{ type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
|
|
@@ -1002,8 +1044,16 @@ function boundBranch(state, operation) {
|
|
|
1002
1044
|
: binding.branchName ?? undefined;
|
|
1003
1045
|
}
|
|
1004
1046
|
function gitErrorCode(error) {
|
|
1047
|
+
// The code is all an operation records; Git's own message stays in the runtime log.
|
|
1048
|
+
console.warn('[workspace-runtime] git operation failed:', error instanceof Error ? error.message : error);
|
|
1005
1049
|
return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
|
|
1006
1050
|
}
|
|
1051
|
+
/** The workspace checkout, prepared so this process and the development user can both write it. */
|
|
1052
|
+
async function gitWorkspaceRoot(state, operation) {
|
|
1053
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
1054
|
+
await shareWorkspaceGit(state, root);
|
|
1055
|
+
return root;
|
|
1056
|
+
}
|
|
1007
1057
|
function parameterPatchError(error) {
|
|
1008
1058
|
if (error instanceof ParameterEditError)
|
|
1009
1059
|
return error.code;
|
|
@@ -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
|
|
@@ -443,6 +515,18 @@ export async function ensureWorkspaceGit(root) {
|
|
|
443
515
|
await excludeFromGit(rootReal, '/.tmp/user_uploads/');
|
|
444
516
|
return rootReal;
|
|
445
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* The control user and the development user (terminal, Codex) both write this repository. Git
|
|
520
|
+
* creates directories with the writer's umask, so a directory one user made can refuse the
|
|
521
|
+
* other's objects. With `core.sharedRepository=group` Git makes new directories group-writable for
|
|
522
|
+
* both; the image puts the control user in the development group.
|
|
523
|
+
*/
|
|
524
|
+
export async function shareWorkspaceRepository(root) {
|
|
525
|
+
const rootReal = await existingWorkspaceGit(root);
|
|
526
|
+
const current = await gitAllowFail(rootReal, ['config', '--get', 'core.sharedRepository']);
|
|
527
|
+
if (current.stdout.trim() !== 'group')
|
|
528
|
+
await git(rootReal, ['config', 'core.sharedRepository', 'group']);
|
|
529
|
+
}
|
|
446
530
|
/** Turn snapshots never create a repository; a directory without one simply has no turn changes. */
|
|
447
531
|
async function existingWorkspaceGit(root) {
|
|
448
532
|
const rootReal = await realpath(root);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
4
|
+
import { shareWorkspaceRepository } from './git.js';
|
|
5
|
+
/**
|
|
6
|
+
* Directories under `.git` this process cannot write: another user created them without group
|
|
7
|
+
* write. Only their owner can change that, so they are reported rather than fixed here.
|
|
8
|
+
*/
|
|
9
|
+
export async function foreignUnwritableGitDirectories(root) {
|
|
10
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
11
|
+
const found = [];
|
|
12
|
+
const visit = async (path) => {
|
|
13
|
+
const stat = await lstat(path).catch(() => undefined);
|
|
14
|
+
if (!stat?.isDirectory())
|
|
15
|
+
return;
|
|
16
|
+
if (stat.uid !== uid && (stat.mode & 0o020) === 0)
|
|
17
|
+
found.push(path);
|
|
18
|
+
for (const entry of await readdir(path).catch(() => []))
|
|
19
|
+
await visit(join(path, entry));
|
|
20
|
+
};
|
|
21
|
+
await visit(join(root, '.git'));
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Prepares a workspace repository for a Git write by this process: new directories become group
|
|
26
|
+
* writable, and directories the development user already made without group write are opened by
|
|
27
|
+
* that user through the launcher. Without a launcher (local runs) both users are the same.
|
|
28
|
+
*/
|
|
29
|
+
export async function shareWorkspaceGit(state, root) {
|
|
30
|
+
await shareWorkspaceRepository(root).catch(() => undefined);
|
|
31
|
+
if (!launcherSocket(state) || !(await foreignUnwritableGitDirectories(root)).length)
|
|
32
|
+
return;
|
|
33
|
+
await runDeveloperCommand(state, {
|
|
34
|
+
processId: `git-share-${Date.now().toString(36)}`,
|
|
35
|
+
cwd: root,
|
|
36
|
+
command: 'find .git -type d -uid "$(id -u)" ! -perm -g+w -exec chmod g+ws {} +',
|
|
37
|
+
timeoutMs: 30_000,
|
|
38
|
+
onOutput: () => undefined,
|
|
39
|
+
}).catch(() => undefined);
|
|
40
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TURN_FILES_CHANGED_EVENT, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { diffWorkspaceTrees, retainTurnSnapshots, snapshotWorkspaceTree } from './git.js';
|
|
3
|
+
import { shareWorkspaceGit } from './shared-git.js';
|
|
3
4
|
const TURN_ENDS = new Set(['turn/completed', 'turn.completed', 'turn.interrupted', 'turn.failed']);
|
|
4
5
|
/**
|
|
5
6
|
* Snapshots taken when a session's turn starts, until that turn ends. Kept in memory only: a
|
|
@@ -20,6 +21,7 @@ export async function beginTurnChanges(state, sessionId, input) {
|
|
|
20
21
|
turnsOf(state).delete(sessionId);
|
|
21
22
|
if (input.directoryName === 'root')
|
|
22
23
|
return;
|
|
24
|
+
await shareWorkspaceGit(state, input.root).catch(() => undefined);
|
|
23
25
|
const before = await snapshotWorkspaceTree(input.root).catch(() => undefined);
|
|
24
26
|
if (before)
|
|
25
27
|
turnsOf(state).set(sessionId, { ...input, before });
|
|
@@ -42,6 +44,7 @@ export async function finishTurnChanges(state, event) {
|
|
|
42
44
|
return [];
|
|
43
45
|
turnsOf(state).delete(session.sessionId);
|
|
44
46
|
try {
|
|
47
|
+
await shareWorkspaceGit(state, start.root);
|
|
45
48
|
const after = await snapshotWorkspaceTree(start.root);
|
|
46
49
|
const { files, truncated } = await diffWorkspaceTrees(start.root, start.before, after);
|
|
47
50
|
if (!files.length)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
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.34",
|
|
54
|
+
"@playcraft/common": "^0.1.34",
|
|
55
55
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
56
56
|
"chokidar": "^4.0.3",
|
|
57
57
|
"commander": "^13.1.0",
|