@shardflux/cli 0.20.0 → 0.23.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 +312 -1
- package/README.md +242 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +13 -5
- package/dist/acp-login.js +37 -8
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +29 -19
- package/dist/acp.js +120 -14
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +110 -42
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +73 -31
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +95 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-hub.d.ts +61 -25
- package/dist/cli-proxy-hub.js +202 -54
- package/dist/cli-proxy.d.ts +44 -1
- package/dist/cli-proxy.js +177 -28
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +14 -4
- package/dist/codex-cloud.js +135 -31
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +18 -5
- package/dist/command-center-model.d.ts +2 -0
- package/dist/command-center-model.js +6 -1
- package/dist/command-center.js +3 -3
- package/dist/commands.js +173 -24
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +78 -13
- package/dist/conversations.d.ts +86 -1
- package/dist/conversations.js +306 -23
- package/dist/desktop-linux.js +16 -3
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +29 -2
- package/dist/errors.js +50 -7
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +133 -57
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +38 -28
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +69 -0
- package/dist/move-worktrees.js +252 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +111 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +63 -29
- package/dist/prepared-repository.d.ts +204 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1307 -0
- package/dist/project-preparation.d.ts +62 -0
- package/dist/project-preparation.js +316 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +22 -4
- package/dist/project-sync.js +34 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +12 -6
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +168 -0
- package/dist/terminal-apps.js +574 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.d.ts +2 -0
- package/dist/update.js +43 -9
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
package/dist/menubar.js
CHANGED
|
@@ -16,7 +16,7 @@ const execute = promisify(execFile);
|
|
|
16
16
|
* whether or not a built Shardflux.app is in the package.
|
|
17
17
|
*/
|
|
18
18
|
export const nativeTools = {
|
|
19
|
-
run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
|
|
19
|
+
run: async (tool, args, signal, env) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal, ...(env ? { env: { ...process.env, ...env } } : {}) })).stdout,
|
|
20
20
|
};
|
|
21
21
|
const BUNDLE_ID = 'dev.shardflux.menubar';
|
|
22
22
|
export function compareBundleVersions(left, right) {
|
|
@@ -123,7 +123,7 @@ export class MenubarManager {
|
|
|
123
123
|
constructor(options = {}) {
|
|
124
124
|
this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
|
|
125
125
|
this.#platform = options.platform ?? process.platform;
|
|
126
|
-
this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
|
|
126
|
+
this.#run = options.run ?? ((tool, args, env) => nativeTools.run(tool, args, options.signal, env));
|
|
127
127
|
this.#packageApp = options.packageApp ?? (() => {
|
|
128
128
|
const app = packagedAppPath();
|
|
129
129
|
if (!app)
|
|
@@ -160,43 +160,51 @@ export class MenubarManager {
|
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The app's --control for `configDir`: its pids are the app running for that config directory, wherever it runs from
|
|
165
|
+
* (menu bar 0.8.4+, one app per config directory), and any app running from the installed bundle.
|
|
166
|
+
*/
|
|
167
|
+
async #control(configDir, action, ...args) {
|
|
164
168
|
makeExecutable(this.app);
|
|
165
|
-
|
|
169
|
+
const env = configDir ? { SHARDFLUX_CONFIG_DIR: configDir } : undefined;
|
|
170
|
+
return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args], env));
|
|
166
171
|
}
|
|
167
|
-
async status() {
|
|
172
|
+
async status(configDir) {
|
|
168
173
|
this.#requireMac();
|
|
169
174
|
if (!existsSync(this.app))
|
|
170
175
|
return { installed: false, version: null, running: false, pids: [], login: 'off' };
|
|
171
176
|
const version = await this.#version(this.app);
|
|
172
|
-
return { installed: true, version, ...await this.#control('status') };
|
|
177
|
+
return { installed: true, version, ...await this.#control(configDir, 'status') };
|
|
173
178
|
}
|
|
174
179
|
async #stripQuarantine() {
|
|
175
180
|
const attributes = await this.#run('/usr/bin/xattr', ['-lr', this.app]);
|
|
176
181
|
if (attributes.includes('com.apple.quarantine'))
|
|
177
182
|
await this.#run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', this.app]);
|
|
178
183
|
}
|
|
179
|
-
async stop() {
|
|
180
|
-
const current = await this.status();
|
|
184
|
+
async stop(configDir) {
|
|
185
|
+
const current = await this.status(configDir);
|
|
181
186
|
if (!current.installed || !current.running)
|
|
182
187
|
return current;
|
|
183
|
-
await this.#control('stop');
|
|
188
|
+
await this.#control(configDir, 'stop');
|
|
184
189
|
for (let i = 0; i < 20; i++) {
|
|
185
|
-
const status = await this.status();
|
|
190
|
+
const status = await this.status(configDir);
|
|
186
191
|
if (!status.running)
|
|
187
192
|
return status;
|
|
188
193
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
189
194
|
}
|
|
190
195
|
throw new UsageError('Shardflux is still stopping. Run shard menubar status before retrying.');
|
|
191
196
|
}
|
|
192
|
-
async login(value) {
|
|
193
|
-
if (!(await this.status()).installed)
|
|
197
|
+
async login(value, configDir) {
|
|
198
|
+
if (!(await this.status(configDir)).installed)
|
|
194
199
|
throw new UsageError('Run shard menubar start before changing launch at login.');
|
|
195
|
-
await this.#control('login', value);
|
|
196
|
-
return this.status();
|
|
200
|
+
await this.#control(configDir, 'login', value);
|
|
201
|
+
return this.status(configDir);
|
|
197
202
|
}
|
|
198
|
-
/**
|
|
199
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Replaces the installed app with `source` (verified first), stopping it while it is swapped: the app running from
|
|
205
|
+
* the bundle and the one running for `configDir` from elsewhere, so no older app stays behind.
|
|
206
|
+
*/
|
|
207
|
+
async #install(source, current, configDir) {
|
|
200
208
|
// Verify the source before stopping or replacing any existing app.
|
|
201
209
|
await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
|
|
202
210
|
mkdirSync(dirname(this.app), { recursive: true });
|
|
@@ -206,7 +214,7 @@ export class MenubarManager {
|
|
|
206
214
|
try {
|
|
207
215
|
cpSync(source, staged, { recursive: true, dereference: false });
|
|
208
216
|
if (current.running)
|
|
209
|
-
await this.stop();
|
|
217
|
+
await this.stop(configDir);
|
|
210
218
|
if (current.installed)
|
|
211
219
|
renameSync(this.app, backup);
|
|
212
220
|
makeExecutable(staged);
|
|
@@ -235,17 +243,19 @@ export class MenubarManager {
|
|
|
235
243
|
async start(configDir, apiUrl) {
|
|
236
244
|
this.#requireMac();
|
|
237
245
|
const source = this.#packageApp(), sourceVersion = await this.#version(source);
|
|
238
|
-
const current = await this.status();
|
|
246
|
+
const current = await this.status(configDir);
|
|
239
247
|
if (!current.installed || compareAppVersions(current.version, sourceVersion) < 0)
|
|
240
|
-
await this.#install(source, current);
|
|
248
|
+
await this.#install(source, current, configDir);
|
|
241
249
|
await this.#stripQuarantine();
|
|
242
|
-
if (!(await this.status()).running) {
|
|
250
|
+
if (!(await this.status(configDir)).running) {
|
|
243
251
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
244
252
|
const log = join(configDir, 'menubar.log');
|
|
245
|
-
|
|
253
|
+
// A start that races another one for this config directory hands over to it without opening the launcher.
|
|
254
|
+
await this.#run('/usr/bin/open', ['-g', this.app, '--env', `SHARDFLUX_CONFIG_DIR=${configDir}`, '--env', `SHARDFLUX_API_URL=${apiUrl}`,
|
|
255
|
+
'--env', 'SHARDFLUX_MENUBAR_LAUNCH=background', '--stdout', log, '--stderr', log]);
|
|
246
256
|
}
|
|
247
257
|
for (let i = 0; i < 20; i++) {
|
|
248
|
-
const status = await this.status();
|
|
258
|
+
const status = await this.status(configDir);
|
|
249
259
|
if (status.running)
|
|
250
260
|
return status;
|
|
251
261
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -260,7 +270,7 @@ export class MenubarManager {
|
|
|
260
270
|
*/
|
|
261
271
|
async refresh(configDir, apiUrl) {
|
|
262
272
|
this.#requireMac();
|
|
263
|
-
const current = await this.status();
|
|
273
|
+
const current = await this.status(configDir);
|
|
264
274
|
if (!current.installed || !current.version)
|
|
265
275
|
return { result: 'not_installed', from: null, to: null };
|
|
266
276
|
const to = await this.packagedVersion(), from = current.version;
|
|
@@ -274,7 +284,7 @@ export class MenubarManager {
|
|
|
274
284
|
await this.start(configDir, apiUrl);
|
|
275
285
|
return { result: 'restarted', from, to };
|
|
276
286
|
}
|
|
277
|
-
await this.#install(this.#packageApp(), current);
|
|
287
|
+
await this.#install(this.#packageApp(), current, configDir);
|
|
278
288
|
await this.#stripQuarantine();
|
|
279
289
|
return { result: 'installed', from, to };
|
|
280
290
|
}
|
|
@@ -301,7 +311,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
|
|
|
301
311
|
if (store.read().menubar_stopped === true)
|
|
302
312
|
return undefined;
|
|
303
313
|
try {
|
|
304
|
-
const status = await manager.status();
|
|
314
|
+
const status = await manager.status(dir);
|
|
305
315
|
// A running app is brought up to the one this CLI carries (after `npm i -g shardflux`) by the background refresh
|
|
306
316
|
// every command starts (refreshMenubarInBackground), at a moment when it shows no window.
|
|
307
317
|
if (status.running)
|
|
@@ -310,7 +320,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
|
|
|
310
320
|
if (!started.running)
|
|
311
321
|
return undefined;
|
|
312
322
|
if (started.login !== 'on')
|
|
313
|
-
await manager.login('on').catch(() => undefined);
|
|
323
|
+
await manager.login('on', dir).catch(() => undefined);
|
|
314
324
|
return 'Menu bar started · shard menubar stop to turn it off';
|
|
315
325
|
}
|
|
316
326
|
catch {
|
|
@@ -468,7 +478,7 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
|
|
|
468
478
|
if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
|
|
469
479
|
throw new UsageError('--login must be on or off.');
|
|
470
480
|
const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
|
|
471
|
-
let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop() : await manager.status();
|
|
481
|
+
let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop(ctx.store.dir) : await manager.status(ctx.store.dir);
|
|
472
482
|
// An explicit stop keeps `shard` from starting the menu bar again; an explicit start re-enables it.
|
|
473
483
|
if (action !== 'status')
|
|
474
484
|
new AgentStateStore(ctx.store.dir).update(doc => { if (action === 'stop')
|
|
@@ -476,7 +486,7 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
|
|
|
476
486
|
else
|
|
477
487
|
delete doc.menubar_stopped; });
|
|
478
488
|
if (values.login === 'on' || values.login === 'off')
|
|
479
|
-
status = await manager.login(values.login);
|
|
489
|
+
status = await manager.login(values.login, ctx.store.dir);
|
|
480
490
|
ctx.io.stdout.write(ctx.json ? json(status) : `Menu bar: ${status.running ? 'running' : 'stopped'}${status.installed ? ` · app ${status.version}` : ' · not installed'} · login ${status.login}\n`);
|
|
481
491
|
return 0;
|
|
482
492
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Restore an owned migration into an EMPTY folder; never overwrite continued work.
|
|
2
|
+
|
|
3
|
+
The immutable archives retain original Git metadata. Only location pointers in the
|
|
4
|
+
restored copy change. Can be run again with a new empty root for recovery.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
|
|
15
|
+
manifest = Path(sys.argv[1]).resolve()
|
|
16
|
+
plan = json.loads(manifest.read_text())
|
|
17
|
+
root = Path(sys.argv[2]).absolute()
|
|
18
|
+
if root.is_symlink() or (root.exists() and any(root.iterdir())):
|
|
19
|
+
raise ValueError("Restore requires a new empty folder; existing work was not replaced")
|
|
20
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def destination(target):
|
|
23
|
+
parts = PurePosixPath(target).parts
|
|
24
|
+
if target != "." and (target.startswith("/") or any(p in ("", "..") for p in parts)):
|
|
25
|
+
raise ValueError("Unsafe worktree destination")
|
|
26
|
+
return root if target == "." else root.joinpath(*parts)
|
|
27
|
+
|
|
28
|
+
targets = {t["source"]: destination(t["target"]) for t in plan["trees"]}
|
|
29
|
+
expected = plan["bytes"] + plan["files"] * 1024
|
|
30
|
+
if shutil.disk_usage(root).free < expected:
|
|
31
|
+
raise ValueError(f"Restore requires at least {expected} free bytes on the target filesystem")
|
|
32
|
+
|
|
33
|
+
def parents_safe(path, base):
|
|
34
|
+
parent = path.parent
|
|
35
|
+
while parent != base:
|
|
36
|
+
if parent.is_symlink():
|
|
37
|
+
raise ValueError("Archive parent is a symlink")
|
|
38
|
+
if parent == parent.parent:
|
|
39
|
+
raise ValueError("Archive escaped its root")
|
|
40
|
+
parent = parent.parent
|
|
41
|
+
if base.is_symlink():
|
|
42
|
+
raise ValueError("Archive root is a symlink")
|
|
43
|
+
|
|
44
|
+
for i, tree in enumerate(plan["trees"]):
|
|
45
|
+
base = targets[tree["source"]]
|
|
46
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
directories = []
|
|
48
|
+
with tarfile.open(manifest.parent / f"{i}.tar", "r:") as archive:
|
|
49
|
+
for member in archive:
|
|
50
|
+
parts = PurePosixPath(member.name).parts
|
|
51
|
+
if member.name in (".", "./"):
|
|
52
|
+
continue
|
|
53
|
+
if member.name.startswith("/") or any(p in ("", "..") for p in parts):
|
|
54
|
+
raise ValueError("Unsafe archive member")
|
|
55
|
+
path = base.joinpath(*parts)
|
|
56
|
+
parents_safe(path, base)
|
|
57
|
+
if member.isdir():
|
|
58
|
+
if path.is_symlink():
|
|
59
|
+
raise ValueError("Archive directory is a symlink")
|
|
60
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
directories.append((path, member.mode & 0o777, member.mtime))
|
|
62
|
+
elif member.issym():
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.symlink_to(member.linkname)
|
|
65
|
+
elif member.isreg() or member.islnk():
|
|
66
|
+
# Expand hard links: Git index/content is preserved, no link may escape.
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
with archive.extractfile(member) as src, path.open("xb") as out:
|
|
69
|
+
shutil.copyfileobj(src, out, 1024 * 1024)
|
|
70
|
+
path.chmod(member.mode & 0o777)
|
|
71
|
+
os.utime(path, (member.mtime, member.mtime))
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError("Special archive member is not supported")
|
|
74
|
+
# Read-only directory modes must be applied after writing their contents.
|
|
75
|
+
for path, mode, mtime in reversed(directories):
|
|
76
|
+
path.chmod(mode)
|
|
77
|
+
os.utime(path, (mtime, mtime))
|
|
78
|
+
for entry in tree["files"]:
|
|
79
|
+
path = base / entry["path"]
|
|
80
|
+
if "link" in entry:
|
|
81
|
+
if not path.is_symlink() or os.readlink(path) != entry["link"]:
|
|
82
|
+
raise ValueError(f"Symlink verification failed: {path}")
|
|
83
|
+
digest = hashlib.sha256(("symlink\0" + entry["link"]).encode()).hexdigest()
|
|
84
|
+
else:
|
|
85
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size != entry["size"]:
|
|
86
|
+
raise ValueError(f"File size/type verification failed: {path}")
|
|
87
|
+
sha = hashlib.sha256()
|
|
88
|
+
with path.open("rb") as src:
|
|
89
|
+
for chunk in iter(lambda: src.read(1024 * 1024), b""):
|
|
90
|
+
sha.update(chunk)
|
|
91
|
+
digest = sha.hexdigest()
|
|
92
|
+
if path.stat().st_mode & 0o777 != entry["mode"]:
|
|
93
|
+
raise ValueError(f"File mode verification failed: {path}")
|
|
94
|
+
if digest != entry["sha256"]:
|
|
95
|
+
raise ValueError(f"File hash verification failed: {path}")
|
|
96
|
+
|
|
97
|
+
common = targets[plan["main"]] / ".git"
|
|
98
|
+
env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
|
|
99
|
+
def git(tree, *args):
|
|
100
|
+
return subprocess.run(["git", "-C", str(tree), "-c", "core.hooksPath=/dev/null", *args],
|
|
101
|
+
env=env, check=True, stdout=subprocess.PIPE).stdout
|
|
102
|
+
|
|
103
|
+
# Repair forward/back pointers without running checkout/reset or refreshing an index.
|
|
104
|
+
for tree in plan["trees"]:
|
|
105
|
+
dest = targets[tree["source"]]
|
|
106
|
+
if tree["gitDir"] != plan["commonDir"]:
|
|
107
|
+
gitdir = common / Path(tree["gitDir"]).relative_to(plan["commonDir"])
|
|
108
|
+
(dest / ".git").write_text(f"gitdir: {gitdir}\n")
|
|
109
|
+
(gitdir / "gitdir").write_text(f"{dest / '.git'}\n")
|
|
110
|
+
(gitdir / "commondir").write_text(os.path.relpath(common, gitdir) + "\n")
|
|
111
|
+
worktree_config = gitdir / "config.worktree"
|
|
112
|
+
if worktree_config.exists():
|
|
113
|
+
subprocess.run(["git", "config", "--file", str(worktree_config), "core.worktree", str(dest)], check=True)
|
|
114
|
+
git(targets[plan["main"]], "config", "core.worktree", str(targets[plan["main"]]))
|
|
115
|
+
# External registered worktrees live in this private recovery area, outside the
|
|
116
|
+
# selected checkout's tracked content. Hide only that explicit location.
|
|
117
|
+
exclude = common / "info/exclude"
|
|
118
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
with exclude.open("a") as out:
|
|
120
|
+
out.write("\n/.shard/preserved-worktrees/\n")
|
|
121
|
+
|
|
122
|
+
selected = targets[plan["selected"]]
|
|
123
|
+
refs = git(selected, "for-each-ref", "--format=%(objectname) %(refname)").decode()
|
|
124
|
+
if refs != plan["refs"]:
|
|
125
|
+
raise ValueError("Local refs did not survive migration")
|
|
126
|
+
for tree in plan["trees"]:
|
|
127
|
+
dest = targets[tree["source"]]
|
|
128
|
+
if git(dest, "rev-parse", "HEAD").decode().strip() != tree["head"]:
|
|
129
|
+
raise ValueError(f"HEAD did not survive migration: {dest}")
|
|
130
|
+
status = git(dest, "status", "--porcelain=v1", "-z", "--untracked-files=all").decode()
|
|
131
|
+
if status != tree["status"]:
|
|
132
|
+
raise ValueError(f"Git status changed during migration: {dest}")
|
|
133
|
+
|
|
134
|
+
# The platform tracks this conversation by its own branch; the original branch
|
|
135
|
+
# and raw HEAD remain in refs/archives. Index and working bytes stay untouched.
|
|
136
|
+
branch = plan["branch"]
|
|
137
|
+
git(selected, "check-ref-format", branch)
|
|
138
|
+
if any(line.endswith(" " + branch) for line in refs.splitlines()):
|
|
139
|
+
raise ValueError("Conversation branch collides with a preserved local ref")
|
|
140
|
+
if "--defer-conversation-branch" not in sys.argv[3:]:
|
|
141
|
+
git(selected, "update-ref", branch, git(selected, "rev-parse", "HEAD").decode().strip())
|
|
142
|
+
git(selected, "symbolic-ref", "HEAD", branch)
|
|
143
|
+
print(json.dumps({"worktrees": len(plan["trees"]), "files": plan["files"], "bytes": plan["bytes"],
|
|
144
|
+
"root": str(root), "refs": len(refs.splitlines()), "status": "matched"}))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SyncCell } from './project-sync-transfer.js';
|
|
2
|
+
export interface MigrationFile {
|
|
3
|
+
path: string;
|
|
4
|
+
size: number;
|
|
5
|
+
mode: number;
|
|
6
|
+
sha256: string;
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationTree {
|
|
10
|
+
source: string;
|
|
11
|
+
spellings: string[];
|
|
12
|
+
target: string;
|
|
13
|
+
head: string;
|
|
14
|
+
branch: string;
|
|
15
|
+
gitDir: string;
|
|
16
|
+
status: string;
|
|
17
|
+
files: MigrationFile[];
|
|
18
|
+
exclude: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface WorktreeMigration {
|
|
21
|
+
version: 1;
|
|
22
|
+
selected: string;
|
|
23
|
+
commonDir: string;
|
|
24
|
+
main: string;
|
|
25
|
+
refs: string;
|
|
26
|
+
trees: MigrationTree[];
|
|
27
|
+
bytes: number;
|
|
28
|
+
files: number;
|
|
29
|
+
referenced: string[];
|
|
30
|
+
/** Registrations Git reports as prunable (their folder or its .git is gone): nothing to copy, never a refusal. */
|
|
31
|
+
stale: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
|
|
35
|
+
* this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
|
|
36
|
+
*/
|
|
37
|
+
export declare function referencedWorktrees(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<string[]>;
|
|
38
|
+
/** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
|
|
39
|
+
export declare function inspectWorktreeMigration(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<WorktreeMigration | null>;
|
|
40
|
+
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
41
|
+
export declare function captureWorktreeMigration(plan: WorktreeMigration, signal?: AbortSignal): Promise<void>;
|
|
42
|
+
export declare function migrationScope(plan: WorktreeMigration): {
|
|
43
|
+
local_refs: string[];
|
|
44
|
+
bytes: number;
|
|
45
|
+
files: number;
|
|
46
|
+
scope: string;
|
|
47
|
+
size_basis: string;
|
|
48
|
+
stale_registrations?: string[] | undefined;
|
|
49
|
+
worktrees: {
|
|
50
|
+
path: string;
|
|
51
|
+
branch: string;
|
|
52
|
+
target: string;
|
|
53
|
+
}[];
|
|
54
|
+
referenced: string[];
|
|
55
|
+
};
|
|
56
|
+
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
57
|
+
export declare function rewriteWorktreeTranscript(bytes: Buffer, plan: WorktreeMigration, root: string): Buffer;
|
|
58
|
+
export interface MigrationResult {
|
|
59
|
+
snapshot: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
worktrees: number;
|
|
62
|
+
files: number;
|
|
63
|
+
}
|
|
64
|
+
/** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
|
|
65
|
+
export declare function preserveWorktreeMigration(cell: SyncCell, plan: WorktreeMigration, c: {
|
|
66
|
+
id: string;
|
|
67
|
+
branch: string;
|
|
68
|
+
worktree_path: string;
|
|
69
|
+
}, signal?: AbortSignal): Promise<MigrationResult>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/** An explicit, recoverable cold copy of a repository and its registered worktrees. Never writes to local Git. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises';
|
|
6
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
7
|
+
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
8
|
+
import { projectGit } from "./project-sync-manifest.js";
|
|
9
|
+
import { UsageError } from "./errors.js";
|
|
10
|
+
import { rewriteSessionPaths } from "./local-sessions.js";
|
|
11
|
+
const inside = (root, path) => path === root || path.startsWith(`${root}/`);
|
|
12
|
+
/** `git worktree list --porcelain -z`: each registration's path and whether Git calls it prunable. */
|
|
13
|
+
async function registeredWorktrees(folder, signal) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const field of (await git(folder, ['worktree', 'list', '--porcelain', '-z'], signal)).split('\0')) {
|
|
16
|
+
if (field.startsWith('worktree '))
|
|
17
|
+
out.push({ path: field.slice(9), prunable: false });
|
|
18
|
+
else if ((field === 'prunable' || field.startsWith('prunable ')) && out.length)
|
|
19
|
+
out.at(-1).prunable = true;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether the transcript names `path` itself (or a file below it), not just a longer registered worktree path that
|
|
25
|
+
* starts with it (the main checkout is a prefix of every worktree nested inside it). JSON-escaped spellings count.
|
|
26
|
+
*/
|
|
27
|
+
function names(text, path, registered) {
|
|
28
|
+
const longer = registered.filter(p => p.length > path.length && p.startsWith(path));
|
|
29
|
+
for (const spelled of new Set([path, JSON.stringify(path).slice(1, -1)])) {
|
|
30
|
+
for (let at = text.indexOf(spelled); at >= 0; at = text.indexOf(spelled, at + 1)) {
|
|
31
|
+
const next = text[at + spelled.length];
|
|
32
|
+
if (next !== undefined && /[\w.-]/.test(next))
|
|
33
|
+
continue; // a sibling folder: /repo-old for /repo
|
|
34
|
+
if (longer.some(p => text.startsWith(p, at) || text.startsWith(JSON.stringify(p).slice(1, -1), at)))
|
|
35
|
+
continue;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
|
|
43
|
+
* this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
|
|
44
|
+
*/
|
|
45
|
+
export async function referencedWorktrees(folder, transcript, signal) {
|
|
46
|
+
try {
|
|
47
|
+
const selected = await realpath(folder), text = transcript.toString('utf8');
|
|
48
|
+
const live = (await registeredWorktrees(folder, signal)).filter(w => !w.prunable).map(w => w.path);
|
|
49
|
+
const all = [...new Set([...live, selected])];
|
|
50
|
+
const others = [];
|
|
51
|
+
for (const path of live) {
|
|
52
|
+
const real = await realpath(path).catch(() => '');
|
|
53
|
+
if (!real || real === selected)
|
|
54
|
+
continue;
|
|
55
|
+
if (names(text, path, all) || (real !== path && names(text, real, all)))
|
|
56
|
+
others.push(real);
|
|
57
|
+
}
|
|
58
|
+
return others;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw e;
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const git = (folder, args, signal) => projectGit(folder, args, signal);
|
|
67
|
+
const hash = async (file, signal) => {
|
|
68
|
+
const sha = createHash('sha256');
|
|
69
|
+
for await (const chunk of createReadStream(file, { signal }))
|
|
70
|
+
sha.update(chunk);
|
|
71
|
+
return sha.digest('hex');
|
|
72
|
+
};
|
|
73
|
+
/** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
|
|
74
|
+
export async function inspectWorktreeMigration(folder, transcript, signal) {
|
|
75
|
+
let commonDir;
|
|
76
|
+
try {
|
|
77
|
+
commonDir = (await git(folder, ['rev-parse', '--path-format=absolute', '--git-common-dir'], signal)).trim();
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (signal?.aborted || e.code !== 128)
|
|
81
|
+
throw e;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
folder = await realpath(folder);
|
|
85
|
+
commonDir = await realpath(commonDir);
|
|
86
|
+
try {
|
|
87
|
+
await git(folder, ['rev-parse', '--verify', 'HEAD'], signal);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (signal?.aborted || e.code !== 128)
|
|
91
|
+
throw e;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const main = dirname(commonDir);
|
|
95
|
+
if (basename(commonDir) !== '.git' || !(await lstat(commonDir)).isDirectory())
|
|
96
|
+
throw new UsageError('Preserve-all needs a repository with its own .git directory; separate Git directories are not supported.');
|
|
97
|
+
if ((await git(main, ['rev-parse', '--show-toplevel'], signal)).trim() !== main)
|
|
98
|
+
throw new UsageError('The main checkout is unavailable; restore it before moving all worktrees.');
|
|
99
|
+
// External alternate stores cannot be represented by this bounded copy.
|
|
100
|
+
const alternates = join(commonDir, 'objects/info/alternates');
|
|
101
|
+
if (await readFile(alternates, 'utf8').then(s => !!s.trim(), (e) => { if (e.code !== 'ENOENT')
|
|
102
|
+
throw e; return false; }))
|
|
103
|
+
throw new UsageError('This repository borrows an external Git object store. Make it self-contained before preserving all worktrees.');
|
|
104
|
+
// A registration whose folder (or its .git) is gone has nothing to copy: Git itself calls it prunable. It is
|
|
105
|
+
// reported, never a reason to refuse; its branch is a local ref and moves with the others.
|
|
106
|
+
const registered = [], sources = [], stale = [];
|
|
107
|
+
for (const w of await registeredWorktrees(folder, signal)) {
|
|
108
|
+
const real = w.prunable ? '' : await realpath(w.path).catch(() => '');
|
|
109
|
+
if (!real) {
|
|
110
|
+
stale.push(w.path);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
registered.push(w.path);
|
|
114
|
+
sources.push(real);
|
|
115
|
+
}
|
|
116
|
+
if (!sources.includes(folder) || !sources.includes(main))
|
|
117
|
+
throw new UsageError('Git did not list the selected and main worktrees.');
|
|
118
|
+
const trees = [];
|
|
119
|
+
for (const [i, source] of sources.entries()) {
|
|
120
|
+
// Worktrees inside the selected folder keep their relative paths, including ignored .worktrees.
|
|
121
|
+
const target = source === folder ? '.' : inside(folder, source) ? relative(folder, source) : `.shard/preserved-worktrees/${i}-${basename(source)}`;
|
|
122
|
+
const gitDir = (await git(source, ['rev-parse', '--absolute-git-dir'], signal)).trim();
|
|
123
|
+
if (!inside(commonDir, gitDir))
|
|
124
|
+
throw new UsageError(`Worktree ${source} uses an unrelated Git store.`);
|
|
125
|
+
const branch = await git(source, ['symbolic-ref', '-q', 'HEAD'], signal).then(s => s.trim(), (e) => { if (e.code !== 1)
|
|
126
|
+
throw e; return ''; });
|
|
127
|
+
const spellings = [...new Set([source, registered[i]])];
|
|
128
|
+
trees.push({ source, spellings, target, gitDir, branch, head: (await git(source, ['rev-parse', 'HEAD'], signal)).trim(),
|
|
129
|
+
status: await git(source, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], signal), files: [],
|
|
130
|
+
exclude: sources.filter(p => p !== source && inside(source, p)).map(p => relative(source, p)) });
|
|
131
|
+
}
|
|
132
|
+
const text = transcript.toString('utf8'), all = trees.flatMap(t => t.spellings);
|
|
133
|
+
const referenced = trees.filter(t => t.source !== folder && t.spellings.some(p => names(text, p, all))).map(t => t.source);
|
|
134
|
+
return { version: 1, selected: folder, commonDir, main, trees, referenced, stale,
|
|
135
|
+
refs: await git(folder, ['for-each-ref', '--format=%(objectname) %(refname)'], signal), bytes: 0, files: 0 };
|
|
136
|
+
}
|
|
137
|
+
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
138
|
+
export async function captureWorktreeMigration(plan, signal) {
|
|
139
|
+
plan.bytes = 0;
|
|
140
|
+
plan.files = 0;
|
|
141
|
+
for (const tree of plan.trees) {
|
|
142
|
+
tree.files = [];
|
|
143
|
+
const walk = async (path) => {
|
|
144
|
+
signal?.throwIfAborted();
|
|
145
|
+
if (tree.exclude.some(p => inside(p, path)))
|
|
146
|
+
return;
|
|
147
|
+
if (path.endsWith('/.git'))
|
|
148
|
+
throw new UsageError(`Nested repository ${join(tree.source, path)} is not a registered worktree. Migrate it separately before preserving all worktrees.`);
|
|
149
|
+
const file = join(tree.source, path), st = await lstat(file);
|
|
150
|
+
if (st.isDirectory()) {
|
|
151
|
+
for (const name of (await readdir(file)).sort())
|
|
152
|
+
await walk(path ? `${path}/${name}` : name);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!st.isFile() && !st.isSymbolicLink())
|
|
156
|
+
throw new UsageError(`Cannot preserve special file ${file}. Remove it from this migration scope before retrying.`);
|
|
157
|
+
if (path.endsWith('.lock') && inside('.git', path))
|
|
158
|
+
throw new UsageError(`Git is writing ${file}; finish the Git operation before migrating.`);
|
|
159
|
+
const link = st.isSymbolicLink() ? await readlink(file) : undefined;
|
|
160
|
+
const sha256 = link === undefined ? await hash(file, signal) : createHash('sha256').update(`symlink\0${link}`).digest('hex');
|
|
161
|
+
tree.files.push({ path, size: st.size, mode: st.mode & 0o777, sha256, ...(link !== undefined ? { link } : {}) });
|
|
162
|
+
plan.bytes += st.size;
|
|
163
|
+
plan.files++;
|
|
164
|
+
};
|
|
165
|
+
await walk('');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function migrationScope(plan) {
|
|
169
|
+
return { worktrees: plan.trees.map(t => ({ path: t.source, branch: t.branch || '(detached)', target: t.target })), referenced: plan.referenced,
|
|
170
|
+
...(plan.stale.length ? { stale_registrations: plan.stale } : {}),
|
|
171
|
+
local_refs: plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1)),
|
|
172
|
+
bytes: plan.bytes, files: plan.files, scope: 'All files, including ignored files and Git metadata, inside these registered worktrees. Symlink targets and unrelated sibling folders are not uploaded.',
|
|
173
|
+
size_basis: 'File bytes before tar headers; archive bytes and free-space requirement are larger. Ignored dependency/build caches are included.' };
|
|
174
|
+
}
|
|
175
|
+
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
176
|
+
export function rewriteWorktreeTranscript(bytes, plan, root) {
|
|
177
|
+
const mappings = plan.trees.flatMap(tree => tree.spellings.map(from => ({ from, to: tree.target === '.' ? root : `${root}/${tree.target}` })));
|
|
178
|
+
for (const { from, to } of mappings.sort((a, b) => b.from.length - a.from.length))
|
|
179
|
+
bytes = rewriteSessionPaths(bytes, from, to);
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
182
|
+
async function uploadTree(cell, tree, dest, signal) {
|
|
183
|
+
const child = spawn('tar', ['-cf', '-', ...tree.exclude.map(p => `--exclude=./${p}`), '-C', tree.source, '.'], { stdio: ['ignore', 'pipe', 'pipe'], signal, env: { ...process.env, COPYFILE_DISABLE: '1' } });
|
|
184
|
+
let stderr = '';
|
|
185
|
+
child.stderr.on('data', (b) => { stderr = (stderr + b.toString()).slice(-2000); });
|
|
186
|
+
const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code ?? 1)); });
|
|
187
|
+
// Attach a rejection handler while reading the stream.
|
|
188
|
+
void exited.catch(() => undefined);
|
|
189
|
+
let total = 0, size = 0, pending = [];
|
|
190
|
+
const flush = async () => { if (!size)
|
|
191
|
+
return; const bytes = Buffer.concat(pending, size); await cell.files.write(dest, bytes, { mode: '0600', append: total > 0 }); total += size; size = 0; pending = []; };
|
|
192
|
+
try {
|
|
193
|
+
for await (const chunk of child.stdout) {
|
|
194
|
+
let b = chunk;
|
|
195
|
+
while (b.length) {
|
|
196
|
+
const n = Math.min(b.length, SYNC_CHUNK_BYTES - size);
|
|
197
|
+
pending.push(b.subarray(0, n));
|
|
198
|
+
size += n;
|
|
199
|
+
b = b.subarray(n);
|
|
200
|
+
if (size === SYNC_CHUNK_BYTES)
|
|
201
|
+
await flush();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await flush();
|
|
205
|
+
if (await exited !== 0)
|
|
206
|
+
throw new Error(`Worktree archive failed: ${stderr}`);
|
|
207
|
+
return total;
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
child.kill();
|
|
211
|
+
await exited.catch(() => undefined);
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
|
|
216
|
+
export async function preserveWorktreeMigration(cell, plan, c, signal) {
|
|
217
|
+
if (!/^conv_[a-z0-9]{26}$/.test(c.id))
|
|
218
|
+
throw new Error('Invalid migration identity');
|
|
219
|
+
const snapshot = `/shardflux/project/migrations/${c.id}`;
|
|
220
|
+
const mounted = await cell.exec.run(['test', '-d', '/shardflux/project/git/hub.git'], { timeoutMs: 10_000, signal });
|
|
221
|
+
if (mounted.exitCode !== 0)
|
|
222
|
+
throw new Error('The durable project space is unavailable; preserve-all did not upload or resume the session.');
|
|
223
|
+
await cell.files.mkdir(snapshot, { parents: true });
|
|
224
|
+
const space = await cell.exec.run(['python3', '-c', 'import os,shutil,sys; a,b,n=sys.argv[1:]; os.makedirs(b,exist_ok=True); need=int(n); assert shutil.disk_usage(a).free>=need and shutil.disk_usage(b).free>=need, "Migration needs more free disk space for archives and restored files"', snapshot, c.worktree_path,
|
|
225
|
+
String(2 * (plan.bytes + plan.files * 1024))], { timeoutMs: 10_000, signal });
|
|
226
|
+
if (space.exitCode !== 0)
|
|
227
|
+
throw new UsageError(space.stderr.trim());
|
|
228
|
+
let bytes = 0;
|
|
229
|
+
for (const [i, tree] of plan.trees.entries())
|
|
230
|
+
bytes += await uploadTree(cell, tree, `${snapshot}/${i}.tar`, signal);
|
|
231
|
+
const script = await readFile(new URL('./move-worktrees-restore.py', import.meta.url), 'utf8');
|
|
232
|
+
await cell.files.write(`${snapshot}/restore.py`, script, { mode: '0600' });
|
|
233
|
+
const branch = c.branch.startsWith('refs/heads/') ? c.branch : `refs/heads/${c.branch}`;
|
|
234
|
+
await cell.files.write(`${snapshot}/manifest.json`, JSON.stringify({ ...plan, branch, root: c.worktree_path }), { mode: '0600' });
|
|
235
|
+
const applied = await cell.exec.run(['python3', `${snapshot}/restore.py`, `${snapshot}/manifest.json`, c.worktree_path, '--defer-conversation-branch'], { timeoutMs: 600_000, maxOutputBytes: 8192, signal });
|
|
236
|
+
if (applied.exitCode !== 0)
|
|
237
|
+
throw new Error(`Preserve-all verification failed; the local source is unchanged: ${applied.stderr.trim()}`);
|
|
238
|
+
// No move is recorded or resumed if another local process changed a tree during upload.
|
|
239
|
+
const source = (await inspectWorktreeMigration(plan.selected, Buffer.alloc(0), signal));
|
|
240
|
+
await captureWorktreeMigration(source, signal);
|
|
241
|
+
if (source.refs !== plan.refs || JSON.stringify(source.trees.map(t => [t.source, t.head, t.branch, t.status, t.files])) !== JSON.stringify(plan.trees.map(t => [t.source, t.head, t.branch, t.status, t.files]))) {
|
|
242
|
+
throw new UsageError('A local worktree changed during migration; no session was resumed. Finish its work and retry. The captured recovery archive is retained.');
|
|
243
|
+
}
|
|
244
|
+
await cell.files.write(`${snapshot}/ready.json`, JSON.stringify({ bytes, worktrees: plan.trees.length, files: plan.files }), { mode: '0600' });
|
|
245
|
+
// MovedInReady in the guest observes this symbolic branch. It is the last
|
|
246
|
+
// readiness transition, after all copies and both local/remote verification.
|
|
247
|
+
const ready = await cell.exec.run(['sh', '-c', 'set -e; git -C "$1" -c core.hooksPath=/dev/null update-ref "$2" HEAD; git -C "$1" symbolic-ref HEAD "$2"',
|
|
248
|
+
'shard-migration-ready', c.worktree_path, branch], { timeoutMs: 10_000, signal });
|
|
249
|
+
if (ready.exitCode !== 0)
|
|
250
|
+
throw new Error(`Migration branch could not be activated: ${ready.stderr.trim()}`);
|
|
251
|
+
return { snapshot, bytes, worktrees: plan.trees.length, files: plan.files };
|
|
252
|
+
}
|