@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
|
@@ -16,16 +16,20 @@
|
|
|
16
16
|
import { createHash, randomBytes } from 'node:crypto';
|
|
17
17
|
import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
19
20
|
import { basename, dirname, extname, join } from 'node:path';
|
|
20
21
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
21
|
-
import { listConversations, localConversations } from "./conversations.js";
|
|
22
|
+
import { conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
|
|
22
23
|
import { MEMORY_PATH, localConfigPath, memoryRoot, returnablePath } from "./agent-config-files.js";
|
|
23
24
|
import { writePrivateFile } from "./credentials.js";
|
|
24
25
|
import { conversationClient } from "./conversation-commands.js";
|
|
25
26
|
import { configManifests } from "./agent-config-sync.js";
|
|
26
27
|
import { savedMachine } from "./filesystem-state.js";
|
|
27
28
|
import { openMachine } from "./filesystem-commands.js";
|
|
29
|
+
import { thisComputer } from "./this-computer.js";
|
|
28
30
|
export const RETURN_ROOT = '/shardflux/project/agent-config';
|
|
31
|
+
/** The latest agent-config line ({notice, at}), for the Linux app's top-bar panel (CLI 0.20.2+). */
|
|
32
|
+
export const AGENT_CONFIG_NOTICE = 'agent-config-notice.json';
|
|
29
33
|
const HASH = /^[0-9a-f]{64}$/;
|
|
30
34
|
const MAX_BYTES = 16 << 20;
|
|
31
35
|
const READ_CHUNK = 8 << 20;
|
|
@@ -328,7 +332,7 @@ function list(names) {
|
|
|
328
332
|
*/
|
|
329
333
|
export function returnNotice(app, items, platform = process.platform) {
|
|
330
334
|
const who = app === 'codex' ? 'Codex' : 'Claude';
|
|
331
|
-
const here = platform
|
|
335
|
+
const here = thisComputer(platform);
|
|
332
336
|
// A memory's MEMORY.md index line is part of saving that memory, not a memory of its own.
|
|
333
337
|
const memoryNotes = items.some(i => MEMORY_PATH.test(i.vm_path) && !i.vm_path.endsWith('/memory/MEMORY.md') && ['created', 'updated', 'trashed'].includes(i.action));
|
|
334
338
|
const saved = items.filter(i => (i.action === 'created' || i.action === 'updated') && !(memoryNotes && i.vm_path.endsWith('/memory/MEMORY.md'))).map(i => itemName(i.vm_path).name);
|
|
@@ -379,9 +383,104 @@ async function projectCell(ctx, c, all) {
|
|
|
379
383
|
}
|
|
380
384
|
/** How often the device helper looks for agent config to bring back: its own loop, never behind a files or transcript pass. */
|
|
381
385
|
export const AGENT_CONFIG_POLL_MS = 2000;
|
|
382
|
-
/**
|
|
386
|
+
/** With none of this user's conversations active, the full listing backs off (doubling) to this. */
|
|
387
|
+
export const AGENT_CONFIG_IDLE_MS = 60_000;
|
|
388
|
+
/** While backed off, one cheap request (`state=working`, usually empty) this often notices a turn starting anywhere. */
|
|
389
|
+
export const AGENT_CONFIG_PROBE_MS = 10_000;
|
|
390
|
+
/** A conversation counts as active for this long after its last activity (a turn's start or end). */
|
|
391
|
+
export const AGENT_CONFIG_ACTIVE_WINDOW_MS = 5 * 60_000;
|
|
392
|
+
/**
|
|
393
|
+
* A working conversation counts as active only this long after its last activity: one left "working" by a VM that went
|
|
394
|
+
* away (staging QA FE1: 60 such, all more than a day old) must not hold every computer at the fast cadence. A longer
|
|
395
|
+
* turn still comes back, at the idle cadence.
|
|
396
|
+
*/
|
|
397
|
+
export const AGENT_CONFIG_WORKING_WINDOW_MS = 60 * 60_000;
|
|
398
|
+
/** This user's conversations that are working (recently) or ended a turn within the active window. */
|
|
399
|
+
export function activeConversations(all, records, me, now = Date.now()) {
|
|
400
|
+
return all.filter(c => {
|
|
401
|
+
if (c.route === 'codex-cloud' || c.ended_at || !ownConversation(c, records, me))
|
|
402
|
+
return false;
|
|
403
|
+
const idle = now - Date.parse(c.last_activity_at ?? c.created_at);
|
|
404
|
+
return idle < (c.state === 'working' ? AGENT_CONFIG_WORKING_WINDOW_MS : AGENT_CONFIG_ACTIVE_WINDOW_MS);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
export const CADENCE = { activeMs: AGENT_CONFIG_POLL_MS, idleMs: AGENT_CONFIG_IDLE_MS, probeMs: AGENT_CONFIG_PROBE_MS };
|
|
408
|
+
/**
|
|
409
|
+
* When the next listing is due. Active (one of this user's conversations works or just ended a turn): every 2 s.
|
|
410
|
+
* Idle: the interval doubles to 60 s, with a cheap working-only probe every 10 s and a check of this computer's own
|
|
411
|
+
* conversation records each second; either snaps it back to 2 s at once.
|
|
412
|
+
*/
|
|
413
|
+
export class AgentConfigCadence {
|
|
414
|
+
options;
|
|
415
|
+
interval;
|
|
416
|
+
fullAt = 0;
|
|
417
|
+
probeAt = 0;
|
|
418
|
+
recordsMtime = 0;
|
|
419
|
+
constructor(options = CADENCE) { this.options = options; this.interval = options.activeMs; }
|
|
420
|
+
/** After a full pass: active keeps 2 s, idle doubles toward the idle ceiling. */
|
|
421
|
+
listed(active, now = Date.now()) {
|
|
422
|
+
this.fullAt = now;
|
|
423
|
+
this.probeAt = now;
|
|
424
|
+
this.interval = active ? this.options.activeMs : Math.min(this.options.idleMs, Math.max(this.options.activeMs, this.interval * 2));
|
|
425
|
+
}
|
|
426
|
+
snap() { this.interval = this.options.activeMs; this.fullAt = 0; }
|
|
427
|
+
fullDue(now = Date.now()) { return now - this.fullAt >= this.interval; }
|
|
428
|
+
probeDue(now = Date.now()) { return this.interval > this.options.probeMs && now - this.probeAt >= this.options.probeMs; }
|
|
429
|
+
}
|
|
430
|
+
/** One full pass: list the account's conversations, apply what came back; true while one of this user's is active. */
|
|
383
431
|
export async function returnAgentConfigsOnce(ctx, state) {
|
|
384
|
-
|
|
432
|
+
const records = localConversations(ctx), all = await listConversations(ctx);
|
|
433
|
+
await returnAgentConfigs(ctx, records, all, state);
|
|
434
|
+
const me = all.some(c => c.started_by?.user_id) ? await myUserId(ctx, state) : undefined;
|
|
435
|
+
return activeConversations(all, records, me).length > 0;
|
|
436
|
+
}
|
|
437
|
+
/** The cheap probe: is any of this user's conversations working right now (one request, usually an empty page)? */
|
|
438
|
+
export async function workingProbe(ctx, state) {
|
|
439
|
+
const page = await conversationClient(ctx).conversations.list({ state: 'working', limit: 200 });
|
|
440
|
+
const working = page.data;
|
|
441
|
+
const me = working.some(c => c.started_by?.user_id) ? await myUserId(ctx, state) : undefined;
|
|
442
|
+
return activeConversations(working, localConversations(ctx), me).length > 0;
|
|
443
|
+
}
|
|
444
|
+
/** The device helper's agent-config loop: activity-driven cadence, its own backoff on errors, never another loop's. */
|
|
445
|
+
export async function runAgentConfigLoop(ctx, state, signal, cadence = new AgentConfigCadence(), tickMs = 1000) {
|
|
446
|
+
let failures = 0, retryAt = 0;
|
|
447
|
+
const reported = new Set();
|
|
448
|
+
const agents = join(ctx.store.dir, 'agents.json');
|
|
449
|
+
while (!signal.aborted) {
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
// A conversation started on this computer is recorded in agents.json: back to 2 s before its first turn ends.
|
|
452
|
+
try {
|
|
453
|
+
const m = statSync(agents).mtimeMs;
|
|
454
|
+
if (cadence.recordsMtime && m !== cadence.recordsMtime)
|
|
455
|
+
cadence.snap();
|
|
456
|
+
cadence.recordsMtime = m;
|
|
457
|
+
}
|
|
458
|
+
catch { /* none yet */ }
|
|
459
|
+
if (now >= retryAt) {
|
|
460
|
+
try {
|
|
461
|
+
if (cadence.fullDue(now))
|
|
462
|
+
cadence.listed(await returnAgentConfigsOnce(ctx, state));
|
|
463
|
+
else if (cadence.probeDue(now)) {
|
|
464
|
+
cadence.probeAt = now;
|
|
465
|
+
if (await workingProbe(ctx, state))
|
|
466
|
+
cadence.snap();
|
|
467
|
+
}
|
|
468
|
+
failures = 0;
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
if (signal.aborted)
|
|
472
|
+
break;
|
|
473
|
+
const wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
|
|
474
|
+
retryAt = Date.now() + wait;
|
|
475
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
476
|
+
if (!conversationsUnavailable(e) && !reported.has(message)) {
|
|
477
|
+
reported.add(message);
|
|
478
|
+
ctx.io.stderr.write(`Agent config: ${message} (retrying in ${wait / 1000}s).\n`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
await delay(tickMs, undefined, { signal }).catch(() => undefined);
|
|
483
|
+
}
|
|
385
484
|
}
|
|
386
485
|
/**
|
|
387
486
|
* This computer's checkout of a conversation's repository: the folder it was started from here, else the folder of
|
|
@@ -498,6 +597,13 @@ export async function returnAgentConfigs(ctx, records, all, state) {
|
|
|
498
597
|
if (line) {
|
|
499
598
|
ctx.io.stdout.write(JSON.stringify({ type: 'agent_config', notice: line }) + '\n');
|
|
500
599
|
ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
|
|
600
|
+
// The Linux app reads the line here (its helper runs under systemd, whose standard output no app sees).
|
|
601
|
+
try {
|
|
602
|
+
writePrivateFile(ctx.store.dir, join(ctx.store.dir, AGENT_CONFIG_NOTICE), JSON.stringify({ notice: line, at: new Date().toISOString() }) + '\n');
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
ctx.io.stderr.write(`Agent config notice: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
606
|
+
}
|
|
501
607
|
}
|
|
502
608
|
// SHARDFLUX_AGENT_CONFIG_FOLD=off holds machine folds (an operator testing that machine); the queue keeps them.
|
|
503
609
|
if (!state.folding && ctx.io.env.SHARDFLUX_AGENT_CONFIG_FOLD !== 'off')
|
|
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { json } from "./format.js";
|
|
5
5
|
import { UsageError } from "./errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { claudeTerminal, cloudClaudeLogin, loginClaudeInWorkspace } from "./claude-cloud-login.js";
|
|
7
|
+
import { askTimeout } from "./secret-ask.js";
|
|
7
8
|
import { resolveWorkspace } from "./commands.js";
|
|
8
9
|
import { projectIdentity, projectLabels } from "./project-identity.js";
|
|
9
10
|
async function optionalFile(file) {
|
|
@@ -74,7 +75,10 @@ export const AGENT_CREDENTIAL_HANDLERS = {
|
|
|
74
75
|
output(ctx, existing);
|
|
75
76
|
return 0;
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
// In a coding agent's shell, with --json, without a terminal or with --ask (0.20.1+): the claude.ai sign-in and a
|
|
79
|
+
// page for its code open in the browser. On a terminal the sign-in attaches to it.
|
|
80
|
+
const headless = values.ask === true || !claudeTerminal(ctx);
|
|
81
|
+
const timeoutMs = askTimeout(values);
|
|
78
82
|
const project = projectIdentity('./');
|
|
79
83
|
let ws;
|
|
80
84
|
if (typeof values.workspace === 'string')
|
|
@@ -85,7 +89,7 @@ export const AGENT_CREDENTIAL_HANDLERS = {
|
|
|
85
89
|
const current = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
|
|
86
90
|
ws = await cloud.workspaces.open({ key, template: 'coding', labels: { ...current?.labels, ...projectLabels(project, 'main', 'claude') }, wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
|
|
87
91
|
}
|
|
88
|
-
output(ctx, await loginClaudeInWorkspace(ctx, ws));
|
|
92
|
+
output(ctx, await loginClaudeInWorkspace(ctx, ws, headless ? { headless: { timeoutMs } } : {}));
|
|
89
93
|
return 0;
|
|
90
94
|
},
|
|
91
95
|
'agents credentials sync': async (ctx) => { output(ctx, await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: (value) => ctx.addSecret(value) })); return 0; },
|
|
@@ -22,12 +22,21 @@ export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: str
|
|
|
22
22
|
populate?: boolean;
|
|
23
23
|
initialFiles?: readonly string[];
|
|
24
24
|
onInitialFiles?: () => void;
|
|
25
|
+
changes?: Promise<LocalChanges>;
|
|
26
|
+
background?: (started: Promise<unknown>) => void;
|
|
27
|
+
beforeHistory?: Promise<unknown>;
|
|
25
28
|
}): Promise<void>;
|
|
26
29
|
/**
|
|
27
30
|
* Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
|
|
28
31
|
* blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
|
|
29
32
|
*/
|
|
30
33
|
export declare const ORIGIN_FIRST_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* A Git step of the seed that writes the workspace repository's index, refs or config: run again, for up to ~5 s,
|
|
36
|
+
* while another Git process in the VM holds the lock (a conversation's agent starts as soon as its repository appears,
|
|
37
|
+
* and its own `git status` takes the index lock). Every step passed here is safe to repeat.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runGitStep(cell: Pick<SyncCell, 'exec'>, argv: string[], options: Parameters<SyncCell['exec']['run']>[1] & object, attempts?: number): Promise<Awaited<ReturnType<SyncCell['exec']['run']>>>;
|
|
31
40
|
/** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
|
|
32
41
|
export declare function historyEstimate(folder: string, signal?: AbortSignal): Promise<number>;
|
|
33
42
|
export declare const FETCH_ORIGIN: string;
|
|
@@ -39,7 +48,10 @@ export declare const CHECKOUT_HEAD: string;
|
|
|
39
48
|
* from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
|
|
40
49
|
* lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
|
|
41
50
|
*/
|
|
42
|
-
export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void
|
|
51
|
+
export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void, configure?: {
|
|
52
|
+
settings: string[];
|
|
53
|
+
excludes: string;
|
|
54
|
+
}): Promise<{
|
|
43
55
|
partial: string | null;
|
|
44
56
|
}>;
|
|
45
57
|
/**
|
|
@@ -47,11 +59,12 @@ export declare function importProjectHistory(cell: SyncCell, folder: string, roo
|
|
|
47
59
|
* untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
|
|
48
60
|
* and deletions. Ignored files, nested repositories and submodule contents stay out.
|
|
49
61
|
*/
|
|
50
|
-
export
|
|
62
|
+
export type LocalChanges = {
|
|
51
63
|
copy: string[];
|
|
52
64
|
remove: string[];
|
|
53
65
|
skipped: {
|
|
54
66
|
path: string;
|
|
55
67
|
reason: string;
|
|
56
68
|
}[];
|
|
57
|
-
}
|
|
69
|
+
};
|
|
70
|
+
export declare function localChanges(folder: string, signal?: AbortSignal): Promise<LocalChanges>;
|
package/dist/agent-repository.js
CHANGED
|
@@ -54,13 +54,12 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
|
|
|
54
54
|
const state = new AgentStateStore(ctx.store.dir);
|
|
55
55
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); seeds[ws.id] = { api_url: ctx.apiUrl, folder: project.folder }; doc.git_seeding = seeds; });
|
|
56
56
|
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
57
|
-
await cell.files.mkdir(root, { parents: true });
|
|
58
57
|
let initialReady = false;
|
|
59
58
|
const initial = options.onInitialFiles ? { paths: new Set(options.initialFiles ?? []), ready: () => { if (!initialReady) {
|
|
60
59
|
initialReady = true;
|
|
61
60
|
options.onInitialFiles();
|
|
62
61
|
} } } : undefined;
|
|
63
|
-
await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); }, initial);
|
|
62
|
+
await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); }, initial, options.changes, options.background, options.beforeHistory);
|
|
64
63
|
initial?.ready();
|
|
65
64
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
|
|
66
65
|
}
|
|
@@ -80,6 +79,22 @@ async function optionalGit(folder, args, signal) {
|
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
81
|
const tail = (text) => text.trim().split('\n').slice(-3).join(' ').slice(-400);
|
|
82
|
+
/** Another Git process in the VM holds this repository's index or config lock (the agent starting there, its hook). */
|
|
83
|
+
const LOCKED = /\.lock'?: File exists|could not lock config file/;
|
|
84
|
+
/**
|
|
85
|
+
* A Git step of the seed that writes the workspace repository's index, refs or config: run again, for up to ~5 s,
|
|
86
|
+
* while another Git process in the VM holds the lock (a conversation's agent starts as soon as its repository appears,
|
|
87
|
+
* and its own `git status` takes the index lock). Every step passed here is safe to repeat.
|
|
88
|
+
*/
|
|
89
|
+
export async function runGitStep(cell, argv, options, attempts = 50) {
|
|
90
|
+
for (let attempt = 1;; attempt++) {
|
|
91
|
+
const result = await cell.exec.run(argv, options);
|
|
92
|
+
if (result.exitCode === 0 || attempt >= attempts || !LOCKED.test(result.stderr))
|
|
93
|
+
return result;
|
|
94
|
+
options.signal?.throwIfAborted();
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
83
98
|
/** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
|
|
84
99
|
export async function historyEstimate(folder, signal) {
|
|
85
100
|
const counts = new Map((await optionalGit(folder, ['count-objects', '-v'], signal)).split('\n').map(line => line.split(': ')));
|
|
@@ -89,18 +104,37 @@ export async function historyEstimate(folder, signal) {
|
|
|
89
104
|
// every commit and tree. Alongside it, a depth-1 fetch of the same ref brings the tip's files in one pack, moved in
|
|
90
105
|
// beside the history, so the checkout reads them locally instead of asking for each blob. Prints the commit. A failed
|
|
91
106
|
// history fetch leaves a plain repository, so the bundle fallback is not a partial clone of an origin it cannot reach.
|
|
107
|
+
// The VM's own prefetch of this worktree (a guest that starts one when its conversation waits for the seed, coding 53+;
|
|
108
|
+
// $HOME/.cache/shardflux/prefetch/<16 hex of sha256(worktree)>: history/ and tip/ bare repositories, remote, commit)
|
|
109
|
+
// is waited for and its packs adopted (0.21.1+): when its commit is $4 (this computer's upstream), nothing more is
|
|
110
|
+
// fetched; otherwise only what is newer, and the checkout fetches any missing tip files in one request.
|
|
92
111
|
export const FETCH_ORIGIN = [
|
|
93
112
|
'set -eu',
|
|
94
113
|
'git init -q "$1"; git -C "$1" config remote.origin.url "$2"',
|
|
95
114
|
'case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac',
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
-
'
|
|
115
|
+
'key=$(printf %s "$1" | sha256sum 2>/dev/null | cut -c1-16); pre=; have=',
|
|
116
|
+
'case "$key" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) pre="${HOME:-/home/user}/.cache/shardflux/prefetch/$key" ;; esac',
|
|
117
|
+
'if [ -n "$pre" ] && { [ -e "$pre.lock" ] || [ -d "$pre" ]; }; then',
|
|
118
|
+
' exec 8>"$pre.lock"; flock -w 30 8 || true',
|
|
119
|
+
' if [ -d "$pre/history" ] && [ "$(cat "$pre/remote" 2>/dev/null)" = "$2" ] && have=$(cat "$pre/commit" 2>/dev/null) && [ -n "$have" ]; then',
|
|
120
|
+
' for p in "$pre"/history/objects/pack/pack-* "$pre"/tip/objects/pack/pack-*; do if [ -f "$p" ]; then mv "$p" "$1/.git/objects/pack/"; fi; done',
|
|
121
|
+
' git -C "$1" config remote.origin.promisor true; git -C "$1" config remote.origin.partialclonefilter blob:none',
|
|
122
|
+
' git -C "$1" update-ref "$dst" "$have" || have=',
|
|
123
|
+
' else have=; fi',
|
|
124
|
+
' rm -rf "$pre" "$pre.lock"; exec 8>&-',
|
|
125
|
+
// No prefetch yet: one starting now would only fetch the same again (the guest's sweep removes this marker).
|
|
126
|
+
'elif [ -n "$pre" ]; then mkdir -p "$pre" 2>/dev/null || true; fi',
|
|
127
|
+
'if [ -n "$have" ] && [ "$have" = "${4:-}" ]; then git -C "$1" rev-parse --verify "$dst^{commit}"; exit 0; fi',
|
|
128
|
+
'tip="$1/.git/shard-sync-tip"; rm -rf "$tip"; job=',
|
|
129
|
+
'if [ -z "$have" ]; then git init -q --bare "$tip"',
|
|
130
|
+
' git -C "$tip" -c core.hooksPath=/dev/null -c credential.interactive=false -c fetch.unpackLimit=1 fetch -q --no-tags --depth=1 "$2" "+$3:refs/tip" >/dev/null 2>&1 &',
|
|
131
|
+
' job=$!; fi',
|
|
99
132
|
'if ! git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --filter=blob:none origin "+$3:$dst" >&2; then',
|
|
100
|
-
' wait "$job" || true; rm -rf "$tip"',
|
|
133
|
+
' if [ -n "$job" ]; then wait "$job" || true; fi; rm -rf "$tip"',
|
|
134
|
+
' if [ -n "$have" ]; then git -C "$1" rev-parse --verify "$dst^{commit}"; exit 0; fi',
|
|
101
135
|
' git -C "$1" config --unset remote.origin.promisor || true; git -C "$1" config --unset remote.origin.partialclonefilter || true; exit 1',
|
|
102
136
|
'fi',
|
|
103
|
-
'if wait "$job"; then for pack in "$tip"/objects/pack/pack-*.pack; do [ -f "$pack" ] || continue; base=${pack%.pack}',
|
|
137
|
+
'if [ -n "$job" ] && wait "$job"; then for pack in "$tip"/objects/pack/pack-*.pack; do [ -f "$pack" ] || continue; base=${pack%.pack}',
|
|
104
138
|
' for ext in idx rev pack; do if [ -f "$base.$ext" ]; then mv "$base.$ext" "$1/.git/objects/pack/"; fi; done; done; fi',
|
|
105
139
|
'rm -rf "$tip"',
|
|
106
140
|
'git -C "$1" rev-parse --verify "$dst^{commit}"',
|
|
@@ -110,7 +144,8 @@ export const FETCH_ORIGIN = [
|
|
|
110
144
|
export const BACKFILL = 'set -eu; cd "$1"; git -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --no-write-fetch-head --refetch --no-filter origin "$2"; ' +
|
|
111
145
|
'git config --unset remote.origin.partialclonefilter || true; git config --unset remote.origin.promisor || true';
|
|
112
146
|
// Applies an optional bundle of HEAD (plus its branch and upstream) and points the workspace HEAD at the laptop's.
|
|
113
|
-
|
|
147
|
+
// $5: a shallow clone's boundary (its .git/shallow), written before the fetch so the bundle's cut history is accepted.
|
|
148
|
+
const APPLY_HISTORY = 'set -eu; git init -q "$1"; if [ -n "${5:-}" ]; then cp "$5" "$1/.git/shallow"; fi; if [ -n "$2" ]; then git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial ' +
|
|
114
149
|
'"+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*" "+refs/remotes/*:refs/remotes/*"; fi; ' +
|
|
115
150
|
'if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; ' +
|
|
116
151
|
'for ref in refs/shard-sync/initial refs/shard-sync/origin; do if git -C "$1" rev-parse -q --verify "$ref" >/dev/null; then git -C "$1" update-ref -d "$ref"; fi; done';
|
|
@@ -131,7 +166,7 @@ if missing and git('config','--bool','--default','false','remote.origin.promisor
|
|
|
131
166
|
absent={l[1:] for l in git('rev-list','--objects','--no-walk','--missing=print','HEAD').split(b'\n') if l.startswith(b'?')}
|
|
132
167
|
want&=absent
|
|
133
168
|
if want: git('-c','fetch.negotiationAlgorithm=noop','fetch','-q','--no-tags','--no-write-fetch-head','--recurse-submodules=no','--filter=blob:none','--stdin','origin',data=b'\n'.join(sorted(want))+b'\n')
|
|
134
|
-
for at in range(0,len(missing),4096): git('checkout-index','-z','--stdin',data=b'\0'.join(missing[at:at+4096])+b'\0')
|
|
169
|
+
for at in range(0,len(missing),4096): git('-c','checkout.workers=0','checkout-index','-z','--stdin',data=b'\0'.join(missing[at:at+4096])+b'\0')
|
|
135
170
|
print(len(missing))
|
|
136
171
|
`;
|
|
137
172
|
/** `git bundle create` streamed straight into the workspace in chunks: any size, no temporary file, no time limit. */
|
|
@@ -168,16 +203,19 @@ async function streamBundle(cell, folder, remote, revs, signal) {
|
|
|
168
203
|
* from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
|
|
169
204
|
* lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
|
|
170
205
|
*/
|
|
171
|
-
export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, originFirst = ORIGIN_FIRST_BYTES, report = () => undefined) {
|
|
206
|
+
export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, originFirst = ORIGIN_FIRST_BYTES, report = () => undefined, configure) {
|
|
172
207
|
const upstream = branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
|
|
173
208
|
const tracked = upstream.startsWith('refs/remotes/') ? upstream : '';
|
|
174
209
|
const tips = ['HEAD', ...(branch ? [branch] : []), ...(tracked ? [tracked] : [])];
|
|
175
210
|
const remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
|
|
211
|
+
let shallowFile = '', bundled = false;
|
|
176
212
|
try {
|
|
177
213
|
let revs = tips, partial = null;
|
|
178
214
|
if (origin && await historyEstimate(folder, signal) > originFirst) {
|
|
179
215
|
const ref = upstream.startsWith('refs/remotes/origin/') ? `refs/heads/${upstream.slice(20)}` : 'HEAD';
|
|
180
|
-
|
|
216
|
+
// This computer's view of that branch on origin: a prefetch at that commit needs no fetch at all.
|
|
217
|
+
const expected = ref !== 'HEAD' ? await optionalGit(folder, ['rev-parse', '--verify', `${upstream}^{commit}`], signal) : '';
|
|
218
|
+
const fetched = await cell.exec.run(['sh', '-c', FETCH_ORIGIN, 'shard-sync', root, origin, ref, expected], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
|
|
181
219
|
seedPhase('origin_fetch');
|
|
182
220
|
const base = fetched.stdout.trim();
|
|
183
221
|
if (fetched.exitCode === 0 && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
|
|
@@ -201,20 +239,75 @@ export async function importProjectHistory(cell, folder, root, head, branch, ori
|
|
|
201
239
|
report(`Sending the history from this computer: the workspace could not fetch ${origin} (${tail(fetched.stderr)}).`);
|
|
202
240
|
}
|
|
203
241
|
if (revs) {
|
|
242
|
+
bundled = true;
|
|
204
243
|
await streamBundle(cell, folder, remote, revs, signal);
|
|
205
244
|
seedPhase('bundle');
|
|
206
245
|
}
|
|
207
|
-
|
|
246
|
+
// A shallow clone (git clone --depth N) has no parents for its oldest commits: Git refuses its bundle ("did not send
|
|
247
|
+
// all necessary objects") unless the workspace repository has the same shallow boundary.
|
|
248
|
+
const boundary = revs ? await shallowBoundary(folder, signal) : null;
|
|
249
|
+
if (boundary) {
|
|
250
|
+
shallowFile = `${remote}.shallow`;
|
|
251
|
+
await cell.files.write(shallowFile, boundary, { mode: '0600' });
|
|
252
|
+
}
|
|
253
|
+
// With `configure`, the repository's config and exclude rules are set in the same request (0.21.1+).
|
|
254
|
+
const apply = ['sh', '-c', APPLY_HISTORY, 'shard-sync', root, revs ? remote : '', head, branch, shallowFile];
|
|
255
|
+
const result = configure
|
|
256
|
+
? await runGitStep(cell, ['sh', '-c', APPLY_THEN_CONFIGURE, 'shard-sync', root, revs ? remote : '', head, branch, shallowFile, root, ...configure.settings], { stdin: configure.excludes, env: { SHARD_APPLY: APPLY_HISTORY, SHARD_CONFIGURE: CONFIGURE_GIT }, timeoutMs: 600_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) })
|
|
257
|
+
: await runGitStep(cell, apply, { timeoutMs: 600_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) });
|
|
208
258
|
if (result.exitCode !== 0)
|
|
209
259
|
throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
|
|
210
260
|
seedPhase('apply_history');
|
|
211
261
|
return { partial };
|
|
212
262
|
}
|
|
213
263
|
finally {
|
|
214
|
-
|
|
264
|
+
if (bundled)
|
|
265
|
+
await cell.files.remove(remote).catch(() => undefined);
|
|
266
|
+
if (shallowFile)
|
|
267
|
+
await cell.files.remove(shallowFile).catch(() => undefined);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** A shallow clone's boundary commits (its shallow file), or null for a complete history. */
|
|
271
|
+
async function shallowBoundary(folder, signal) {
|
|
272
|
+
if (await optionalGit(folder, ['rev-parse', '--is-shallow-repository'], signal) !== 'true')
|
|
273
|
+
return null;
|
|
274
|
+
const file = await optionalGit(folder, ['rev-parse', '--git-path', 'shallow'], signal);
|
|
275
|
+
return file ? await readFile(resolve(folder, file)).catch(() => null) : null;
|
|
276
|
+
}
|
|
277
|
+
// APPLY_HISTORY ($1-$5), then CONFIGURE_GIT (the rest; the exclude rules on standard input), in one request.
|
|
278
|
+
const APPLY_THEN_CONFIGURE = 'set -eu; sh -c "$SHARD_APPLY" shard-sync "$1" "$2" "$3" "$4" "$5" </dev/null; shift 5; exec sh -c "$SHARD_CONFIGURE" shard-sync "$@"';
|
|
279
|
+
// Sets the workspace repository's Git config (key value pairs) and its exclude file (standard input) in one request.
|
|
280
|
+
const CONFIGURE_GIT = 'set -eu; umask 077; root=$1; shift; f="$root/.git/shard-sync-excludes"; cat > "$f"; git -C "$root" config core.excludesFile "$f"; ' +
|
|
281
|
+
'while [ $# -ge 2 ]; do git -C "$root" config "$1" "$2"; shift 2; done';
|
|
282
|
+
/** Git's info/exclude and this computer's excludes file: selection metadata, never credentials or .git data. */
|
|
283
|
+
async function localExcludes(folder, signal) {
|
|
284
|
+
const readOptional = async (path) => {
|
|
285
|
+
try {
|
|
286
|
+
return await readFile(path, 'utf8');
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error.code === 'ENOENT')
|
|
290
|
+
return '';
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const info = (await projectGit(folder, ['rev-parse', '--git-path', 'info/exclude'], signal)).trim();
|
|
295
|
+
let global = '';
|
|
296
|
+
try {
|
|
297
|
+
global = (await projectGit(folder, ['config', '--path', '--get', 'core.excludesFile'], signal)).trim();
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (signal?.aborted)
|
|
301
|
+
throw error;
|
|
215
302
|
}
|
|
303
|
+
return await readOptional(resolve(folder, info)) + '\n' + await readOptional(global ? resolve(folder, global) : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'git', 'ignore'));
|
|
304
|
+
}
|
|
305
|
+
async function configureWorkspaceGit(cell, root, settings, excludes, signal) {
|
|
306
|
+
const result = await runGitStep(cell, ['sh', '-c', CONFIGURE_GIT, 'shard-sync', root, ...settings], { stdin: excludes, timeoutMs: 10_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) });
|
|
307
|
+
if (result.exitCode !== 0)
|
|
308
|
+
throw new Error(`could not configure the workspace Git repository: ${tail(result.stderr)}`);
|
|
216
309
|
}
|
|
217
|
-
async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined, initial) {
|
|
310
|
+
async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined, initial, changes, background, beforeHistory) {
|
|
218
311
|
let top;
|
|
219
312
|
try {
|
|
220
313
|
top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
|
|
@@ -222,16 +315,20 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
222
315
|
catch (error) {
|
|
223
316
|
if (signal?.aborted || error.code !== 128)
|
|
224
317
|
throw error;
|
|
318
|
+
await cell.files.mkdir(root, { parents: true });
|
|
225
319
|
if (populate)
|
|
226
320
|
await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, await selectedProjectPaths(folder, signal), signal), [], signal, report, initial, false);
|
|
227
321
|
return;
|
|
228
322
|
}
|
|
229
323
|
if (top !== folder)
|
|
230
324
|
throw new UsageError('project sync requires the Git repository root; run shard there');
|
|
231
|
-
|
|
325
|
+
// The workspace folder and whether it already has history, in one request.
|
|
326
|
+
const existing = await cell.exec.run(['sh', '-c', 'mkdir -p "$1" && exec git -C "$1" rev-parse --verify HEAD', 'shard-sync', root], { timeoutMs: 5000, signal });
|
|
232
327
|
if (existing.exitCode !== 0 && existing.exitCode !== 128)
|
|
233
328
|
throw new Error('could not check workspace Git history');
|
|
234
329
|
if (existing.exitCode === 128 || retry) {
|
|
330
|
+
// What must be in the VM before its repository appears (a conversation's setup intent, read when its agent starts).
|
|
331
|
+
await beforeHistory;
|
|
235
332
|
const importHistory = existing.exitCode === 128;
|
|
236
333
|
let head = '', branch = '';
|
|
237
334
|
try {
|
|
@@ -248,16 +345,9 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
248
345
|
if (signal?.aborted)
|
|
249
346
|
throw error;
|
|
250
347
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
({ partial } = await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal, ORIGIN_FIRST_BYTES, report));
|
|
255
|
-
}
|
|
256
|
-
else if (importHistory) {
|
|
257
|
-
const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
|
|
258
|
-
if (result.exitCode !== 0)
|
|
259
|
-
throw new Error(`initial Git repository failed: ${result.stderr.trim()}`);
|
|
260
|
-
}
|
|
348
|
+
// The author identity, origin, the branch's upstream (it keeps tracking its origin branch, as on this computer) and
|
|
349
|
+
// the exclude rules: set with the history's import, else in a request of their own.
|
|
350
|
+
const settings = [];
|
|
261
351
|
for (const key of ['user.name', 'user.email']) {
|
|
262
352
|
let value = '';
|
|
263
353
|
try {
|
|
@@ -267,61 +357,50 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
267
357
|
if (signal?.aborted)
|
|
268
358
|
throw error;
|
|
269
359
|
}
|
|
270
|
-
if (value)
|
|
271
|
-
|
|
272
|
-
if (result.exitCode !== 0)
|
|
273
|
-
throw new Error(`could not copy Git ${key}`);
|
|
274
|
-
}
|
|
360
|
+
if (value)
|
|
361
|
+
settings.push(key, value);
|
|
275
362
|
}
|
|
276
363
|
const origin = projectIdentity(folder).remote;
|
|
277
364
|
if (origin && !origin.startsWith('file:')) {
|
|
278
|
-
|
|
279
|
-
if (result.exitCode !== 0)
|
|
280
|
-
throw new Error('could not copy Git origin');
|
|
281
|
-
// The imported branch keeps tracking its origin branch, as on this computer.
|
|
365
|
+
settings.push('remote.origin.url', origin);
|
|
282
366
|
const upstream = importHistory && branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
|
|
283
367
|
if (upstream.startsWith('refs/remotes/origin/'))
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
368
|
+
settings.push(`branch.${branch.slice(11)}.remote`, 'origin', `branch.${branch.slice(11)}.merge`, `refs/heads/${upstream.slice(20)}`);
|
|
369
|
+
}
|
|
370
|
+
const excludes = await localExcludes(folder, signal);
|
|
371
|
+
let partial = null;
|
|
372
|
+
if (head && importHistory) {
|
|
373
|
+
const remote = projectIdentity(folder).remote;
|
|
374
|
+
({ partial } = await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal, ORIGIN_FIRST_BYTES, report, { settings, excludes }));
|
|
289
375
|
}
|
|
376
|
+
else {
|
|
377
|
+
if (importHistory) {
|
|
378
|
+
const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
|
|
379
|
+
if (result.exitCode !== 0)
|
|
380
|
+
throw new Error(`initial Git repository failed: ${result.stderr.trim()}`);
|
|
381
|
+
}
|
|
382
|
+
await configureWorkspaceGit(cell, root, settings, excludes, signal);
|
|
383
|
+
}
|
|
384
|
+
seedPhase('git_config');
|
|
290
385
|
if (head)
|
|
291
|
-
await seedFromHead(cell, folder, root, populate, signal, report, initial);
|
|
386
|
+
await seedFromHead(cell, folder, root, populate, signal, report, initial, changes);
|
|
292
387
|
else
|
|
293
388
|
await seedUnborn(cell, folder, root, populate, signal, report, initial);
|
|
294
389
|
// Optional work, after the start's own: the lowest priority, and a failure only leaves the clone partial.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return await readFile(path, 'utf8');
|
|
303
|
-
}
|
|
304
|
-
catch (error) {
|
|
305
|
-
if (error.code === 'ENOENT')
|
|
306
|
-
return '';
|
|
307
|
-
throw error;
|
|
390
|
+
// A caller that collects background work (a conversation start) gets the request instead of waiting for it.
|
|
391
|
+
if (partial) {
|
|
392
|
+
const started = cell.exec.start({ argv: ['nice', '-n', '19', 'sh', '-c', BACKFILL, 'shard-backfill', root, partial], env: { GIT_TERMINAL_PROMPT: '0' }, timeout_ms: 3_600_000 }, signal).catch(() => undefined);
|
|
393
|
+
if (background)
|
|
394
|
+
background(started);
|
|
395
|
+
else
|
|
396
|
+
await started;
|
|
308
397
|
}
|
|
309
|
-
};
|
|
310
|
-
const info = (await projectGit(folder, ['rev-parse', '--git-path', 'info/exclude'], signal)).trim();
|
|
311
|
-
let global = '';
|
|
312
|
-
try {
|
|
313
|
-
global = (await projectGit(folder, ['config', '--path', '--get', 'core.excludesFile'], signal)).trim();
|
|
314
398
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
399
|
+
else {
|
|
400
|
+
// An existing repository keeps its config; its exclude rules follow this computer's.
|
|
401
|
+
await configureWorkspaceGit(cell, root, [], await localExcludes(folder, signal), signal);
|
|
402
|
+
seedPhase('git_config');
|
|
318
403
|
}
|
|
319
|
-
const excludes = await readOptional(resolve(folder, info)) + '\n' + await readOptional(global ? resolve(folder, global) : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'git', 'ignore'));
|
|
320
|
-
await cell.files.write(`${root}/.git/shard-sync-excludes`, excludes, { mode: '0600' });
|
|
321
|
-
const result = await cell.exec.run(['git', '-C', root, 'config', 'core.excludesFile', `${root}/.git/shard-sync-excludes`], { timeoutMs: 5000, signal });
|
|
322
|
-
if (result.exitCode !== 0)
|
|
323
|
-
throw new Error('could not apply project Git exclude rules');
|
|
324
|
-
seedPhase('excludes');
|
|
325
404
|
}
|
|
326
405
|
const nul = (text) => text.split('\0').filter(Boolean);
|
|
327
406
|
/** Git's NUL-separated output of any size, streamed (no output buffer or time bound). */
|
|
@@ -330,11 +409,6 @@ async function gitList(folder, args, signal) {
|
|
|
330
409
|
await projectGitPaths(folder, args, path => { out.push(path); }, { signal });
|
|
331
410
|
return out;
|
|
332
411
|
}
|
|
333
|
-
/**
|
|
334
|
-
* What differs between HEAD and this computer's working tree, from Git's own status (0.19.0+): changed, added and
|
|
335
|
-
* untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
|
|
336
|
-
* and deletions. Ignored files, nested repositories and submodule contents stay out.
|
|
337
|
-
*/
|
|
338
412
|
export async function localChanges(folder, signal) {
|
|
339
413
|
const [fields, untracked, env, lfs] = await Promise.all([
|
|
340
414
|
gitList(folder, ['diff', '--name-status', '-z', '--no-renames', '--ignore-submodules=all', 'HEAD', '--'], signal),
|
|
@@ -374,7 +448,7 @@ export async function localChanges(folder, signal) {
|
|
|
374
448
|
return { copy, remove, skipped };
|
|
375
449
|
}
|
|
376
450
|
/** HEAD's files come from the workspace's own Git; then only this computer's changes are copied. */
|
|
377
|
-
async function seedFromHead(cell, folder, root, populate, signal, report, initial) {
|
|
451
|
+
async function seedFromHead(cell, folder, root, populate, signal, report, initial, precomputed) {
|
|
378
452
|
if (!populate) {
|
|
379
453
|
// Ignore files must exist before the first remote scan; otherwise a template's dependency outputs would look like
|
|
380
454
|
// agent-created source.
|
|
@@ -382,7 +456,8 @@ async function seedFromHead(cell, folder, root, populate, signal, report, initia
|
|
|
382
456
|
await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, ignores, signal), [], signal, report, undefined, false);
|
|
383
457
|
return;
|
|
384
458
|
}
|
|
385
|
-
|
|
459
|
+
// A conversation start reads them while its VM starts (0.21.1+).
|
|
460
|
+
const changes = await (precomputed ?? localChanges(folder, signal));
|
|
386
461
|
seedPhase('local_changes');
|
|
387
462
|
const skip = changes.remove.length ? `/tmp/shard-sync-${randomUUID()}.skip` : '';
|
|
388
463
|
try {
|