@agentshouse/kit 0.1.0-alpha.16 → 0.1.0-alpha.18
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/README.md +69 -17
- package/bin/connect-linux.sh +14 -4
- package/bootstrap.json +3 -3
- package/package.json +2 -1
- package/src/adapter-mcp.js +0 -13
- package/src/bridge.js +1 -1
- package/src/contract.js +10 -2
- package/src/house-main.js +65 -8
- package/src/house.js +98 -86
- package/src/inbound.js +2 -18
- package/src/launcher.js +2 -3
- package/src/local-files.js +3 -11
- package/src/mcp.js +13 -0
- package/src/sync-library.js +7 -11
- package/src/sync.js +1 -1
- package/src/worker.js +81 -15
- package/src/working-copy.js +516 -47
package/src/working-copy.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { readBindings } from "./binding.js";
|
|
4
5
|
import {
|
|
6
|
+
KIT_DOOR_BATCH_PATH,
|
|
5
7
|
KIT_DOOR_DISCOVER_PATH,
|
|
8
|
+
KIT_DOOR_REVISIONS_PATH,
|
|
6
9
|
KIT_WORKING_COPY_SELECTION_PATH,
|
|
7
10
|
ROOM_REFERENCE,
|
|
8
11
|
WORKING_COPIES_HOME,
|
|
@@ -13,13 +16,16 @@ import {
|
|
|
13
16
|
WORKING_COPY_VERSION,
|
|
14
17
|
} from "./contract.js";
|
|
15
18
|
import { runGit } from "./git.js";
|
|
16
|
-
import {
|
|
19
|
+
import { operationId } from "./operation.js";
|
|
20
|
+
import { formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
|
|
17
21
|
import { readNote, writeNote, writeText } from "./state.js";
|
|
18
|
-
import { bootstrapBundle, discoveryOf, door } from "./sync.js";
|
|
19
|
-
import { sidecarPath } from "./sync-library.js";
|
|
22
|
+
import { bootstrapBundle, discoveryOf, door, withSession } from "./sync.js";
|
|
23
|
+
import { sidecarPath, structuredOrReplace } from "./sync-library.js";
|
|
20
24
|
import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
|
|
21
25
|
|
|
22
|
-
const
|
|
26
|
+
const COMMITTER_NAME = "House Kit";
|
|
27
|
+
const COMMITTER_EMAIL = "house-kit@localhost";
|
|
28
|
+
const COMMITTER = `${COMMITTER_NAME} <${COMMITTER_EMAIL}>`;
|
|
23
29
|
const GIT_FAILED = "kit_working_copy_git_failed";
|
|
24
30
|
|
|
25
31
|
function rejected(detail = null) {
|
|
@@ -149,25 +155,53 @@ function quotedPath(path) {
|
|
|
149
155
|
return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const lines = [
|
|
155
|
-
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
158
|
+
function importCommit(message, parents) {
|
|
159
|
+
return [
|
|
156
160
|
Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
|
|
157
161
|
...importData(message),
|
|
158
|
-
...(parent ===
|
|
159
|
-
Buffer.from("deleteall\n"),
|
|
162
|
+
...parents.map((parent, index) => Buffer.from(`${index === 0 ? "from" : "merge"} ${parent}\n`)),
|
|
160
163
|
];
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function importFile(path, content) {
|
|
167
|
+
if (content === null) return [Buffer.from(`D ${quotedPath(path)}\n`)];
|
|
168
|
+
return [Buffer.from(`M 100644 inline ${quotedPath(path)}\n`), ...importData(content)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function importReceived(repository, { message, parents, files, whole = false, acknowledging = null }, signal) {
|
|
172
|
+
const stream = [];
|
|
173
|
+
const merged = [...parents];
|
|
174
|
+
if (acknowledging !== null && acknowledging.edits.length === 0) merged.push(acknowledging.commit);
|
|
175
|
+
if (acknowledging !== null && acknowledging.edits.length > 0) {
|
|
176
|
+
stream.push(
|
|
177
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\nmark :1\n`),
|
|
178
|
+
...importCommit(`House accepted ${acknowledging.commit}`, [acknowledging.commit]),
|
|
179
|
+
...acknowledging.edits.flatMap((edit) => importFile(edit.path, edit.content)),
|
|
180
|
+
Buffer.from("\n"),
|
|
181
|
+
);
|
|
182
|
+
merged.push(":1");
|
|
163
183
|
}
|
|
164
|
-
|
|
184
|
+
stream.push(
|
|
185
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
186
|
+
...importCommit(message, merged),
|
|
187
|
+
...(whole ? [Buffer.from("deleteall\n")] : []),
|
|
188
|
+
...files.flatMap((file) => importFile(file.path, file.content)),
|
|
189
|
+
Buffer.from("\n"),
|
|
190
|
+
);
|
|
165
191
|
await runGit(repository, ["fast-import", "--quiet", "--force"], {
|
|
166
|
-
input: Buffer.concat(
|
|
192
|
+
input: Buffer.concat(stream),
|
|
167
193
|
signal,
|
|
168
194
|
failure: GIT_FAILED,
|
|
169
195
|
});
|
|
170
|
-
const
|
|
196
|
+
const received = await commitOf(repository, WORKING_COPY_RECEIVED_REF, signal);
|
|
197
|
+
if (acknowledging === null) return { received, acknowledged: null };
|
|
198
|
+
const acknowledged =
|
|
199
|
+
acknowledging.edits.length === 0 ? acknowledging.commit : await commitOf(repository, `${received}^2`, signal);
|
|
200
|
+
return { received, acknowledged };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function commitOf(repository, name, signal) {
|
|
204
|
+
const commit = await runGit(repository, ["rev-parse", "--verify", `${name}^{commit}`], {
|
|
171
205
|
capture: true,
|
|
172
206
|
signal,
|
|
173
207
|
failure: GIT_FAILED,
|
|
@@ -175,6 +209,16 @@ async function importReceived(repository, scope, read, parent, signal) {
|
|
|
175
209
|
return commit.toString("utf8").trim();
|
|
176
210
|
}
|
|
177
211
|
|
|
212
|
+
async function remembered(repository, revisions, commit, files, signal) {
|
|
213
|
+
const tree = await treeOf(repository, commit, signal);
|
|
214
|
+
const next = { ...revisions };
|
|
215
|
+
for (const { path, revision } of files) {
|
|
216
|
+
const entry = tree.get(path);
|
|
217
|
+
if (entry !== undefined && revision !== null) next[path] = { ...next[path], [entry.object]: revision };
|
|
218
|
+
}
|
|
219
|
+
return next;
|
|
220
|
+
}
|
|
221
|
+
|
|
178
222
|
function readOnlyContents(read) {
|
|
179
223
|
const contents = new Map();
|
|
180
224
|
for (const file of read.files) {
|
|
@@ -268,13 +312,21 @@ function manifest(read, commit) {
|
|
|
268
312
|
commit,
|
|
269
313
|
position: read.position,
|
|
270
314
|
log_epoch: read.logEpoch,
|
|
271
|
-
files: read.files.map(({ path, revision, sha256, writable }) => ({ path, revision, sha256, writable })),
|
|
272
315
|
read_only: [...readOnlyContents(read).keys()],
|
|
273
316
|
};
|
|
274
317
|
}
|
|
275
318
|
|
|
319
|
+
function copyPath(directory, copy) {
|
|
320
|
+
return statePath(directory, { kind: copy.scope, ref: copy.room_ref });
|
|
321
|
+
}
|
|
322
|
+
|
|
276
323
|
async function writeCopy(directory, copy, signal) {
|
|
277
|
-
await writeNote(
|
|
324
|
+
await writeNote(copyPath(directory, copy), copy, signal);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readCopy(path) {
|
|
328
|
+
const copy = await readNote(path, null);
|
|
329
|
+
return copy?.version === WORKING_COPY_VERSION ? copy : null;
|
|
278
330
|
}
|
|
279
331
|
|
|
280
332
|
async function heldCopies(directory) {
|
|
@@ -287,10 +339,30 @@ async function heldCopies(directory) {
|
|
|
287
339
|
}
|
|
288
340
|
const held = [];
|
|
289
341
|
for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
290
|
-
held.push(await
|
|
342
|
+
held.push(await readCopy(join(rooms, name)));
|
|
291
343
|
}
|
|
292
|
-
held.push(await
|
|
293
|
-
return held.filter((copy) => copy
|
|
344
|
+
held.push(await readCopy(statePath(directory, { kind: "private" })));
|
|
345
|
+
return held.filter((copy) => copy !== null);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const turns = new Map();
|
|
349
|
+
|
|
350
|
+
function exclusively(path, work) {
|
|
351
|
+
const turn = (turns.get(path) ?? Promise.resolve()).then(work);
|
|
352
|
+
const settled = turn.then(
|
|
353
|
+
() => undefined,
|
|
354
|
+
() => undefined,
|
|
355
|
+
);
|
|
356
|
+
turns.set(path, settled);
|
|
357
|
+
void settled.then(() => {
|
|
358
|
+
if (turns.get(path) === settled) turns.delete(path);
|
|
359
|
+
});
|
|
360
|
+
return turn;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function received(scope, read, parents) {
|
|
364
|
+
const files = read.files.filter((file) => file.writable);
|
|
365
|
+
return { message: `House ${housePath(scope)} at position ${read.position}`, parents, files, whole: true };
|
|
294
366
|
}
|
|
295
367
|
|
|
296
368
|
async function materialize(context, scope, signal) {
|
|
@@ -300,10 +372,13 @@ async function materialize(context, scope, signal) {
|
|
|
300
372
|
if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
|
|
301
373
|
const staging = join(dirname(repository), `.${randomUUID()}.staging`);
|
|
302
374
|
await mkdir(staging, { recursive: true });
|
|
375
|
+
const imported = received(scope, read, []);
|
|
303
376
|
let commit;
|
|
377
|
+
let revisions;
|
|
304
378
|
try {
|
|
305
379
|
await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
|
|
306
|
-
commit = await importReceived(staging,
|
|
380
|
+
commit = (await importReceived(staging, imported, signal)).received;
|
|
381
|
+
revisions = await remembered(staging, {}, commit, imported.files, signal);
|
|
307
382
|
await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
|
|
308
383
|
signal,
|
|
309
384
|
failure: GIT_FAILED,
|
|
@@ -316,7 +391,6 @@ async function materialize(context, scope, signal) {
|
|
|
316
391
|
await rm(staging, { recursive: true, force: true });
|
|
317
392
|
throw error;
|
|
318
393
|
}
|
|
319
|
-
const received = manifest(read, commit);
|
|
320
394
|
await writeCopy(context.directory, {
|
|
321
395
|
version: WORKING_COPY_VERSION,
|
|
322
396
|
scope: scope.kind,
|
|
@@ -324,27 +398,33 @@ async function materialize(context, scope, signal) {
|
|
|
324
398
|
room_handle: scope.handle,
|
|
325
399
|
repository,
|
|
326
400
|
active: true,
|
|
327
|
-
|
|
328
|
-
|
|
401
|
+
received: manifest(read, commit),
|
|
402
|
+
revisions,
|
|
329
403
|
});
|
|
330
404
|
}
|
|
331
405
|
|
|
406
|
+
async function receive(context, copy, scope, signal) {
|
|
407
|
+
const read = await readScope(context.session, scope, signal);
|
|
408
|
+
if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
|
|
409
|
+
const imported = received(scope, read, [copy.received.commit]);
|
|
410
|
+
const commit = (await importReceived(copy.repository, imported, signal)).received;
|
|
411
|
+
await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
|
|
412
|
+
await writeIgnoreRules(copy.repository, read, signal);
|
|
413
|
+
return {
|
|
414
|
+
...copy,
|
|
415
|
+
received: manifest(read, commit),
|
|
416
|
+
revisions: await remembered(copy.repository, copy.revisions, commit, imported.files, signal),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
332
420
|
async function refresh(context, copy, scope, signal) {
|
|
333
421
|
if (!(await present(copy.repository))) {
|
|
334
|
-
await rm(
|
|
422
|
+
await rm(copyPath(context.directory, copy), { force: true });
|
|
335
423
|
await materialize(context, scope, signal);
|
|
336
424
|
return;
|
|
337
425
|
}
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
const commit = await importReceived(copy.repository, scope, read, copy.received.commit, signal);
|
|
341
|
-
await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
|
|
342
|
-
await writeIgnoreRules(copy.repository, read, signal);
|
|
343
|
-
await writeCopy(
|
|
344
|
-
context.directory,
|
|
345
|
-
{ ...copy, room_handle: scope.handle, active: true, received: manifest(read, commit) },
|
|
346
|
-
signal,
|
|
347
|
-
);
|
|
426
|
+
const next = await receive(context, copy, scope, signal);
|
|
427
|
+
await writeCopy(context.directory, { ...next, room_handle: scope.handle, active: true }, signal);
|
|
348
428
|
}
|
|
349
429
|
|
|
350
430
|
function scopeRefused(error) {
|
|
@@ -355,21 +435,26 @@ export async function applyWorkingCopies(context, signal) {
|
|
|
355
435
|
const selected = (await savedSelection(context.session, signal)).filter(
|
|
356
436
|
(scope) => scope.kind === "private" || scope.handle !== null,
|
|
357
437
|
);
|
|
358
|
-
const held
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
438
|
+
for (const held of await heldCopies(context.directory)) {
|
|
439
|
+
if (!held.active || selected.some((scope) => sameScope(held, scope))) continue;
|
|
440
|
+
const path = copyPath(context.directory, held);
|
|
441
|
+
await exclusively(path, async () => {
|
|
442
|
+
const copy = await readCopy(path);
|
|
443
|
+
if (copy !== null) await writeCopy(context.directory, { ...copy, active: false }, signal);
|
|
444
|
+
});
|
|
363
445
|
}
|
|
364
446
|
const refused = [];
|
|
365
447
|
for (const scope of selected) {
|
|
366
|
-
const
|
|
448
|
+
const path = statePath(context.directory, scope);
|
|
367
449
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
await
|
|
372
|
-
|
|
450
|
+
await exclusively(path, async () => {
|
|
451
|
+
const copy = await readCopy(path);
|
|
452
|
+
if (copy === null) await materialize(context, scope, signal);
|
|
453
|
+
else if (!copy.active) await refresh(context, copy, scope, signal);
|
|
454
|
+
else if (copy.room_handle !== scope.handle) {
|
|
455
|
+
await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
373
458
|
} catch (error) {
|
|
374
459
|
if (!scopeRefused(error)) throw error;
|
|
375
460
|
refused.push({ path: housePath(scope), code: error.code });
|
|
@@ -387,3 +472,387 @@ export async function workingCopyPaths(directory) {
|
|
|
387
472
|
}))
|
|
388
473
|
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
389
474
|
}
|
|
475
|
+
|
|
476
|
+
function scopeOf(copy) {
|
|
477
|
+
return { kind: copy.scope, ref: copy.room_ref, handle: copy.room_handle };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function without(copy) {
|
|
481
|
+
const { push, ...settled } = copy;
|
|
482
|
+
return settled;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function lines(output) {
|
|
486
|
+
return output.toString("utf8").split("\n").filter((line) => line !== "");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function succeeded(git) {
|
|
490
|
+
return git.then(
|
|
491
|
+
() => true,
|
|
492
|
+
() => false,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function contains(repository, ancestor, descendant, signal) {
|
|
497
|
+
return succeeded(
|
|
498
|
+
runGit(repository, ["merge-base", "--is-ancestor", ancestor, descendant], { signal, failure: GIT_FAILED }),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function located(cwd, signal) {
|
|
503
|
+
const [common, worktree] = lines(
|
|
504
|
+
await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"], {
|
|
505
|
+
capture: true,
|
|
506
|
+
signal,
|
|
507
|
+
failure: "kit_working_copy_unresolved",
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
return { repository: await realpath(dirname(common)), worktree };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function copyAt(directory, repository) {
|
|
514
|
+
for (const copy of await heldCopies(directory)) {
|
|
515
|
+
if ((await realpath(copy.repository).catch(() => null)) === repository) return copy;
|
|
516
|
+
}
|
|
517
|
+
throw new KitRefusal("kit_working_copy_not_found", repository);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function treeOf(repository, commit, signal) {
|
|
521
|
+
const listed = await runGit(repository, ["ls-tree", "-r", "-z", commit], {
|
|
522
|
+
capture: true,
|
|
523
|
+
signal,
|
|
524
|
+
failure: GIT_FAILED,
|
|
525
|
+
});
|
|
526
|
+
const tree = new Map();
|
|
527
|
+
for (const entry of listed.toString("utf8").split("\0").filter((line) => line !== "")) {
|
|
528
|
+
const tab = entry.indexOf("\t");
|
|
529
|
+
const [, type, object] = entry.slice(0, tab).split(" ");
|
|
530
|
+
tree.set(entry.slice(tab + 1), { type, object });
|
|
531
|
+
}
|
|
532
|
+
return tree;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function blobText(repository, entry, path, signal) {
|
|
536
|
+
if (entry === undefined) return null;
|
|
537
|
+
if (entry.type !== "blob") throw new KitRefusal("kit_working_copy_not_text", path);
|
|
538
|
+
const bytes = await runGit(repository, ["cat-file", "blob", entry.object], {
|
|
539
|
+
capture: true,
|
|
540
|
+
signal,
|
|
541
|
+
failure: GIT_FAILED,
|
|
542
|
+
});
|
|
543
|
+
try {
|
|
544
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
545
|
+
} catch {
|
|
546
|
+
throw new KitRefusal("kit_working_copy_not_text", path);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function trustedBase(copy, commit, signal) {
|
|
551
|
+
const git = (args) => runGit(copy.repository, args, { capture: true, signal, failure: GIT_FAILED });
|
|
552
|
+
const house = new Set(
|
|
553
|
+
lines(await git(["rev-list", "--first-parent", "--parents", copy.received.commit])).flatMap((line) => {
|
|
554
|
+
const [received, , acknowledged] = line.split(" ");
|
|
555
|
+
return acknowledged === undefined ? [received] : [received, acknowledged];
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
const base = lines(await git(["rev-list", "--topo-order", commit])).find((ancestor) => house.has(ancestor));
|
|
559
|
+
if (base !== undefined) return base;
|
|
560
|
+
throw new KitRefusal(
|
|
561
|
+
"kit_working_copy_base_unknown",
|
|
562
|
+
`${commit} contains no House state; merge or rebase onto ${WORKING_COPY_RECEIVED_REF}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function derived(copy, base, commit, signal) {
|
|
567
|
+
const root = housePath(scopeOf(copy));
|
|
568
|
+
const before = await treeOf(copy.repository, base, signal);
|
|
569
|
+
const after = await treeOf(copy.repository, commit, signal);
|
|
570
|
+
const changes = [];
|
|
571
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
572
|
+
const prior = before.get(path);
|
|
573
|
+
const next = after.get(path);
|
|
574
|
+
if (prior?.object === next?.object) continue;
|
|
575
|
+
const target = `${root}/${path}`;
|
|
576
|
+
const revision = copy.revisions[path]?.[prior?.object];
|
|
577
|
+
if (next === undefined) {
|
|
578
|
+
changes.push({ op: "remove", path: target, base: revision });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const content = await blobText(copy.repository, next, path, signal);
|
|
582
|
+
if (prior === undefined) {
|
|
583
|
+
changes.push({ op: "create", path: target, content });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const recorded = { content: await blobText(copy.repository, prior, path, signal), revision };
|
|
587
|
+
changes.push(...structuredOrReplace(root, path, recorded, content));
|
|
588
|
+
}
|
|
589
|
+
return changes;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function revisionText(session, { path, revision }, signal) {
|
|
593
|
+
const page = await door(session, KIT_DOOR_REVISIONS_PATH, { path, revision }, signal);
|
|
594
|
+
const content = page?.revisions?.find((entry) => entry?.revision === revision)?.content;
|
|
595
|
+
if (typeof content !== "string") throw new KitRefusal("kit_working_copy_result_unreadable", `${path} at ${revision}`);
|
|
596
|
+
return content;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function materials(context, copy, entries, signal) {
|
|
600
|
+
const root = housePath(scopeOf(copy));
|
|
601
|
+
const files = [];
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
const content = entry.content === undefined ? await revisionText(context.session, entry, signal) : entry.content;
|
|
604
|
+
files.push({ path: entry.path.slice(root.length + 1), revision: entry.revision, content });
|
|
605
|
+
}
|
|
606
|
+
return files;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function recorded(copy, files, message, acknowledging, signal) {
|
|
610
|
+
const imported = await importReceived(
|
|
611
|
+
copy.repository,
|
|
612
|
+
{ message, parents: [copy.received.commit], files, acknowledging },
|
|
613
|
+
signal,
|
|
614
|
+
);
|
|
615
|
+
return {
|
|
616
|
+
copy: {
|
|
617
|
+
...copy,
|
|
618
|
+
received: { ...copy.received, commit: imported.received },
|
|
619
|
+
revisions: await remembered(copy.repository, copy.revisions, imported.received, files, signal),
|
|
620
|
+
},
|
|
621
|
+
acknowledged: imported.acknowledged,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function submittedPaths(copy) {
|
|
626
|
+
const root = housePath(scopeOf(copy));
|
|
627
|
+
return new Set(copy.push.changes.map((change) => change.path.slice(root.length + 1)));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function short(commit) {
|
|
631
|
+
return commit.slice(0, 12);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function acknowledge(context, copy, signal) {
|
|
635
|
+
const { push } = copy;
|
|
636
|
+
const files = await materials(context, copy, push.written, signal);
|
|
637
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
638
|
+
const edits = [];
|
|
639
|
+
for (const file of files) {
|
|
640
|
+
if ((await blobText(copy.repository, selected.get(file.path), file.path, signal)) !== file.content) edits.push(file);
|
|
641
|
+
}
|
|
642
|
+
const { copy: next, acknowledged } = await recorded(
|
|
643
|
+
copy,
|
|
644
|
+
files,
|
|
645
|
+
`House ${housePath(scopeOf(copy))} with ${push.commit} accepted`,
|
|
646
|
+
{ commit: push.commit, edits },
|
|
647
|
+
signal,
|
|
648
|
+
);
|
|
649
|
+
return { ...next, push: { ...push, acknowledged } };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function integrated(copy, signal) {
|
|
653
|
+
const { commit, acknowledged, worktree, branch } = copy.push;
|
|
654
|
+
const accepted = `House accepted ${short(commit)}`;
|
|
655
|
+
if (acknowledged === commit) return { done: true, text: `${accepted}.` };
|
|
656
|
+
const pending = (reason) => ({
|
|
657
|
+
done: false,
|
|
658
|
+
text: `${accepted}; local integration is pending (${reason}): ${acknowledged} holds House's result.`,
|
|
659
|
+
});
|
|
660
|
+
const git = (args) => runGit(worktree, args, { capture: true, signal, failure: GIT_FAILED });
|
|
661
|
+
const head = await git(["symbolic-ref", "-q", "HEAD"]).then(
|
|
662
|
+
(output) => lines(output)[0],
|
|
663
|
+
() => null,
|
|
664
|
+
);
|
|
665
|
+
if (branch === null || head !== branch) return pending("the branch changed");
|
|
666
|
+
const tip = lines(await git(["rev-parse", "HEAD"]))[0];
|
|
667
|
+
const name = branch.replace(/^refs\/heads\//, "");
|
|
668
|
+
if (await contains(worktree, acknowledged, tip, signal)) return { done: true, text: `${accepted}; ${name} holds it.` };
|
|
669
|
+
if (!(await contains(worktree, commit, tip, signal))) return pending("the branch changed");
|
|
670
|
+
if (lines(await git(["status", "--porcelain", "--untracked-files=no"])).length > 0) {
|
|
671
|
+
return pending("uncommitted changes");
|
|
672
|
+
}
|
|
673
|
+
if (tip === commit) {
|
|
674
|
+
if (!(await succeeded(git(["merge", "--ff-only", "-q", acknowledged])))) return pending("a conflict");
|
|
675
|
+
} else if (
|
|
676
|
+
!(await succeeded(
|
|
677
|
+
git(["-c", `user.name=${COMMITTER_NAME}`, "-c", `user.email=${COMMITTER_EMAIL}`, "rebase", "-q", "--onto", acknowledged, commit]),
|
|
678
|
+
))
|
|
679
|
+
) {
|
|
680
|
+
await git(["rebase", "--abort"]).catch(() => undefined);
|
|
681
|
+
return pending("a conflict");
|
|
682
|
+
}
|
|
683
|
+
return { done: true, text: `${accepted}; ${name} is at ${short(lines(await git(["rev-parse", "HEAD"]))[0])}.` };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function finish(context, copy, signal) {
|
|
687
|
+
try {
|
|
688
|
+
let acknowledged = copy;
|
|
689
|
+
if (copy.push.acknowledged === undefined) {
|
|
690
|
+
acknowledged = await acknowledge(context, copy, signal);
|
|
691
|
+
await writeCopy(context.directory, acknowledged, signal);
|
|
692
|
+
}
|
|
693
|
+
const outcome = await integrated(acknowledged, signal);
|
|
694
|
+
if (outcome.done) await writeCopy(context.directory, without(acknowledged), signal);
|
|
695
|
+
return { refused: false, text: outcome.text };
|
|
696
|
+
} catch (error) {
|
|
697
|
+
return {
|
|
698
|
+
refused: false,
|
|
699
|
+
text: `House accepted ${short(copy.push.commit)}; local integration is pending (${formatCommandError(error)}); run house git push again to finish it.`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sameCaller(left, right) {
|
|
705
|
+
return left.session === right.session && left.owner === right.owner;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function definite(error) {
|
|
709
|
+
return (
|
|
710
|
+
error instanceof KitRefusal &&
|
|
711
|
+
error.code !== "kit_command_cancelled" &&
|
|
712
|
+
!houseFailed(error) &&
|
|
713
|
+
!(error.status >= 500)
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function orphaned(context, copy, signal) {
|
|
718
|
+
const held = copy.push.caller.session;
|
|
719
|
+
const live = (binding) => binding.session === held && binding.stopped === null && binding.reported === null;
|
|
720
|
+
if (held === undefined || (await readBindings(context.directory)).some(live)) {
|
|
721
|
+
throw new KitRefusal("kit_working_copy_push_pending", "another caller's push awaits its recovery");
|
|
722
|
+
}
|
|
723
|
+
await writeCopy(context.directory, await receive(context, without(copy), scopeOf(copy), signal), signal);
|
|
724
|
+
throw new KitRefusal(
|
|
725
|
+
"kit_working_copy_push_unrecoverable",
|
|
726
|
+
`the Session that pushed ended before its outcome arrived; ${WORKING_COPY_RECEIVED_REF} holds House's current state`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function expired(context, copy, text, signal) {
|
|
731
|
+
const { push } = copy;
|
|
732
|
+
const root = housePath(scopeOf(copy));
|
|
733
|
+
const confirmed = await receive(context, without(copy), scopeOf(copy), signal);
|
|
734
|
+
const house = await treeOf(copy.repository, confirmed.received.commit, signal);
|
|
735
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
736
|
+
const paths = [...submittedPaths(copy)];
|
|
737
|
+
if (paths.some((path) => house.get(path)?.object !== selected.get(path)?.object)) {
|
|
738
|
+
await writeCopy(context.directory, confirmed, signal);
|
|
739
|
+
return {
|
|
740
|
+
refused: true,
|
|
741
|
+
text: `${text}\nHouse does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const written = [];
|
|
745
|
+
for (const path of paths) {
|
|
746
|
+
const entry = house.get(path);
|
|
747
|
+
written.push({
|
|
748
|
+
path: `${root}/${path}`,
|
|
749
|
+
revision: entry === undefined ? null : confirmed.revisions[path][entry.object],
|
|
750
|
+
content: await blobText(copy.repository, entry, path, signal),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const accepted = { ...confirmed, push: { ...push, state: "accepted", written } };
|
|
754
|
+
await writeCopy(context.directory, accepted, signal);
|
|
755
|
+
return finish(context, accepted, signal);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function settle(context, copy, caller, signal) {
|
|
759
|
+
const { push } = copy;
|
|
760
|
+
if (!sameCaller(push.caller, caller.binding)) return orphaned(context, copy, signal);
|
|
761
|
+
let answer;
|
|
762
|
+
try {
|
|
763
|
+
answer = await caller.submit({ operation_id: push.operation_id, changes: push.changes }, signal);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (!definite(error)) {
|
|
766
|
+
throw new KitRefusal("kit_working_copy_push_uncertain", "run house git push again to recover its outcome");
|
|
767
|
+
}
|
|
768
|
+
const current = error.payload?.error?.current;
|
|
769
|
+
answer = { refused: { code: error.code, text: formatCommandError(error), current: Array.isArray(current) ? current : null } };
|
|
770
|
+
}
|
|
771
|
+
const { refused } = answer;
|
|
772
|
+
if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
|
|
773
|
+
if (refused === null) {
|
|
774
|
+
const submitted = new Set(push.changes.map((change) => change.path));
|
|
775
|
+
const written = answer.written.filter((entry) => submitted.has(entry.path));
|
|
776
|
+
const accepted = { ...copy, push: { ...push, state: "accepted", written } };
|
|
777
|
+
await writeCopy(context.directory, accepted, signal);
|
|
778
|
+
return finish(context, accepted, signal);
|
|
779
|
+
}
|
|
780
|
+
const settled = without(copy);
|
|
781
|
+
await writeCopy(context.directory, settled, signal);
|
|
782
|
+
if (refused.current === null) return { refused: true, text: refused.text };
|
|
783
|
+
try {
|
|
784
|
+
const files = await materials(context, settled, refused.current, signal);
|
|
785
|
+
const message = `House ${housePath(scopeOf(settled))} current where ${short(push.commit)} was refused`;
|
|
786
|
+
await writeCopy(context.directory, (await recorded(settled, files, message, null, signal)).copy, signal);
|
|
787
|
+
return { refused: true, text: refused.text };
|
|
788
|
+
} catch (error) {
|
|
789
|
+
return { refused: true, text: `${refused.text}\n${WORKING_COPY_RECEIVED_REF} is unchanged: ${formatCommandError(error)}` };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function pushed(context, path, at, selector, caller, signal) {
|
|
794
|
+
let copy = await readCopy(path);
|
|
795
|
+
if (copy.push?.state === "submitted") return settle(context, copy, caller, signal);
|
|
796
|
+
const commit = (
|
|
797
|
+
await runGit(at.worktree, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${selector}^{commit}`], {
|
|
798
|
+
capture: true,
|
|
799
|
+
signal,
|
|
800
|
+
failure: "kit_working_copy_commit_unknown",
|
|
801
|
+
})
|
|
802
|
+
)
|
|
803
|
+
.toString("utf8")
|
|
804
|
+
.trim();
|
|
805
|
+
if (copy.push !== undefined) {
|
|
806
|
+
const held = copy.push.acknowledged;
|
|
807
|
+
if (held === undefined || !(await contains(copy.repository, held, commit, signal))) {
|
|
808
|
+
return finish(context, copy, signal);
|
|
809
|
+
}
|
|
810
|
+
copy = without(copy);
|
|
811
|
+
await writeCopy(context.directory, copy, signal);
|
|
812
|
+
}
|
|
813
|
+
if (!copy.active) throw new KitRefusal("kit_working_copy_inactive", housePath(scopeOf(copy)));
|
|
814
|
+
const changes = await derived(copy, await trustedBase(copy, commit, signal), commit, signal);
|
|
815
|
+
if (changes.length === 0) return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
|
|
816
|
+
const branch = await runGit(at.worktree, ["symbolic-ref", "-q", "HEAD"], { capture: true, signal, failure: GIT_FAILED })
|
|
817
|
+
.then((output) => lines(output)[0], () => null);
|
|
818
|
+
const submitted = {
|
|
819
|
+
...copy,
|
|
820
|
+
push: {
|
|
821
|
+
state: "submitted",
|
|
822
|
+
caller: caller.binding,
|
|
823
|
+
operation_id: operationId(),
|
|
824
|
+
changes,
|
|
825
|
+
commit,
|
|
826
|
+
worktree: at.worktree,
|
|
827
|
+
branch,
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
await writeCopy(context.directory, submitted, signal);
|
|
831
|
+
return settle(context, submitted, caller, signal);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export async function pushWorkingCopy(context, request, caller, signal) {
|
|
835
|
+
if (typeof request?.cwd !== "string" || typeof request.commit !== "string") {
|
|
836
|
+
throw new KitRefusal("kit_house_arguments_invalid", "cwd and commit");
|
|
837
|
+
}
|
|
838
|
+
const at = await located(request.cwd, signal);
|
|
839
|
+
const path = copyPath(context.directory, await copyAt(context.directory, at.repository));
|
|
840
|
+
return exclusively(path, () => pushed(context, path, at, request.commit, caller, signal));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function ownerCaller(session) {
|
|
844
|
+
return {
|
|
845
|
+
binding: { owner: true },
|
|
846
|
+
async submit(body, signal) {
|
|
847
|
+
return { refused: null, written: (await door(session, KIT_DOOR_BATCH_PATH, body, signal)).contents };
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function ownerPush(options, request, dependencies = {}) {
|
|
853
|
+
return withSession(options, dependencies, (session, signal) =>
|
|
854
|
+
session.resident === undefined
|
|
855
|
+
? pushWorkingCopy({ directory: options.directory, session }, request, ownerCaller(session), signal)
|
|
856
|
+
: session.push(request, signal),
|
|
857
|
+
);
|
|
858
|
+
}
|