@agentshouse/kit 0.1.0-alpha.19 → 0.1.0-alpha.20
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 +32 -1
- package/bootstrap.json +3 -3
- package/package.json +1 -1
- package/src/contract.js +1 -0
- package/src/git-main.js +138 -0
- package/src/session-bin/git +2 -0
- package/src/worker.js +10 -5
- package/src/working-copy.js +219 -11
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ On Linux `amd64` or `arm64` with a working Docker Engine, run the published,
|
|
|
10
10
|
versioned bootstrap once:
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
13
|
+
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.20/connect-linux.sh)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
The release asset pins one immutable multi-architecture image digest. It creates
|
|
@@ -1280,6 +1280,37 @@ control lease when a resident runs, and which otherwise acquires and releases
|
|
|
1280
1280
|
its own. A Session refuses `--owner`, and a missing bridge is never replaced by
|
|
1281
1281
|
owner authority.
|
|
1282
1282
|
|
|
1283
|
+
Inside an Agent Session `git` is Kit's shim in front of the installed Git, and
|
|
1284
|
+
every command runs exactly as that Git runs it. In a copy or one of its linked
|
|
1285
|
+
worktrees the shim compares that worktree's index before and after each
|
|
1286
|
+
command. When `git mv` succeeds, Git's own dry run of the same command names
|
|
1287
|
+
which path went where, and the shim hands each move to the resident Kit, which
|
|
1288
|
+
keeps every moved document's successive paths in Kit home. A staged move
|
|
1289
|
+
belongs to its worktree's index: a command there that takes its path out of the
|
|
1290
|
+
index or brings it in anew, such as `git reset`, `git restore --staged`,
|
|
1291
|
+
`git rm`, `git stash` or `git switch`, takes it back, and so does removing the
|
|
1292
|
+
worktree. A `git commit` whose commit carries the move ties it to that commit.
|
|
1293
|
+
A tied move counts for every pushed commit whose history holds one of its
|
|
1294
|
+
commits, whatever the index does meanwhile. `git commit --amend` and
|
|
1295
|
+
`git rebase`, Kit's own rebase onto House's result included, also tie it to
|
|
1296
|
+
their result when the rewritten branch still carries it, so aborting the rebase
|
|
1297
|
+
or resetting back to the original commit keeps it as well. A `git reset` that
|
|
1298
|
+
leaves every commit of the move behind without touching its latest path in the
|
|
1299
|
+
index returns it to that worktree's index, where the next commit ties it again.
|
|
1300
|
+
Any other rewrite, such as a cherry-pick, leaves the move tied to its earlier
|
|
1301
|
+
commits. A push sends House's rename of the same document from the path its
|
|
1302
|
+
House base holds to the latest of its counted paths the pushed commit holds,
|
|
1303
|
+
when the commit no longer has the first and the base did not have the second;
|
|
1304
|
+
a moved file whose content also changed follows as a
|
|
1305
|
+
whole-file replacement at its destination. House keeps the document's identity
|
|
1306
|
+
and rewrites the links and references that name it; Kit reads those rewrites
|
|
1307
|
+
through `POST /kit/door/enumerate` and records them in the acknowledgment.
|
|
1308
|
+
Anything else records no move and the push sends an ordinary removal and
|
|
1309
|
+
creation, which House refuses while another document still refers to the
|
|
1310
|
+
removed one: a failed `git mv`, one undone or never committed through the
|
|
1311
|
+
shim, and a move made with Git outside the shim such as in the owner's own
|
|
1312
|
+
shell. An accepted push drops the moves its commit's history holds.
|
|
1313
|
+
|
|
1283
1314
|
House decides before any local Git step. A refusal changes nothing in House;
|
|
1284
1315
|
after `edit_conflict` Kit records the current content House names for the
|
|
1285
1316
|
refused paths in `refs/house/received`, reading by revision any content the
|
package/bootstrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20",
|
|
4
4
|
"hosts": {
|
|
5
5
|
"linux": {
|
|
6
|
-
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
6
|
+
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.20/connect-linux.sh)",
|
|
7
7
|
"asset": {
|
|
8
8
|
"name": "connect-linux.sh",
|
|
9
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
9
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.20/connect-linux.sh"
|
|
10
10
|
},
|
|
11
11
|
"workspace": {
|
|
12
12
|
"argument": "--workspace",
|
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -305,6 +305,7 @@ export const WORKING_COPY_RECEIVED_REF = "refs/house/received";
|
|
|
305
305
|
export const WORKING_COPY_BRANCH = "main";
|
|
306
306
|
export const ROOM_REFERENCE = /^r_[0-9a-z]+\.[0-9a-z]$/;
|
|
307
307
|
export const WORKING_COPY_PUSH_METHOD = "house/git/push";
|
|
308
|
+
export const WORKING_COPY_INDEX_METHOD = "house/git/index";
|
|
308
309
|
export const WORKING_COPY_PUSH_HELP =
|
|
309
310
|
"Submit a Room commit to House; default HEAD. Use --owner only outside an Agent Session. Native git push is not a House remote.";
|
|
310
311
|
|
package/src/git-main.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { constants as system } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
BRIDGE_NONCE_ENVIRONMENT,
|
|
8
|
+
BRIDGE_SOCKET_ENVIRONMENT,
|
|
9
|
+
WORKING_COPY_INDEX_METHOD,
|
|
10
|
+
WORKING_COPY_RECEIVED_REF,
|
|
11
|
+
} from "./contract.js";
|
|
12
|
+
import { bridgeCall } from "./mcp.js";
|
|
13
|
+
import { formatCommandError, KitRefusal } from "./refusal.js";
|
|
14
|
+
|
|
15
|
+
const SHIM = realpathSync(fileURLToPath(new URL("session-bin/git", import.meta.url)));
|
|
16
|
+
const VALUED = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--attr-source", "--super-prefix"]);
|
|
17
|
+
const RELAYED = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
|
18
|
+
|
|
19
|
+
function nativeGit() {
|
|
20
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
21
|
+
if (directory === "") continue;
|
|
22
|
+
const candidate = join(directory, "git");
|
|
23
|
+
try {
|
|
24
|
+
if (!statSync(candidate).isFile() || realpathSync(candidate) === SHIM) continue;
|
|
25
|
+
accessSync(candidate, constants.X_OK);
|
|
26
|
+
return candidate;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function command(args) {
|
|
33
|
+
let index = 0;
|
|
34
|
+
while (index < args.length && args[index].startsWith("-")) index += VALUED.has(args[index]) ? 2 : 1;
|
|
35
|
+
return { globals: args.slice(0, index), name: args[index], rest: args.slice(index + 1) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function delegated(git, args) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const child = spawn(git, args, { stdio: "inherit" });
|
|
41
|
+
const relay = (signal) => child.kill(signal);
|
|
42
|
+
for (const signal of RELAYED) process.on(signal, relay);
|
|
43
|
+
const settle = (outcome) => {
|
|
44
|
+
for (const signal of RELAYED) process.off(signal, relay);
|
|
45
|
+
resolve(outcome);
|
|
46
|
+
};
|
|
47
|
+
child.once("error", (error) => {
|
|
48
|
+
process.stderr.write(`kit_git_unavailable: ${error.message}\n`);
|
|
49
|
+
settle({ status: 127, signal: null });
|
|
50
|
+
});
|
|
51
|
+
child.once("close", (status, signal) => settle({ status, signal }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function captured(git, args, env = process.env) {
|
|
56
|
+
const run = spawnSync(git, args, { stdio: ["ignore", "pipe", "ignore"], maxBuffer: Infinity, env });
|
|
57
|
+
return run.status === 0 ? run.stdout.toString("utf8") : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function indexed(listing) {
|
|
61
|
+
const entries = new Map();
|
|
62
|
+
for (const record of listing.split("\0")) {
|
|
63
|
+
const tab = record.indexOf("\t");
|
|
64
|
+
if (tab < 0) continue;
|
|
65
|
+
const [mode, object, stage] = record.slice(0, tab).split(" ");
|
|
66
|
+
entries.set(record.slice(tab + 1), stage === "0" ? `${mode} ${object}` : null);
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renamings(output) {
|
|
72
|
+
return (output ?? "").split("\n").filter((line) => line.startsWith("Renaming ")).map((line) => line.slice(9));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function movedPaths(renaming, before, after) {
|
|
76
|
+
for (let at = renaming.indexOf(" to "); at >= 0; at = renaming.indexOf(" to ", at + 1)) {
|
|
77
|
+
const from = renaming.slice(0, at);
|
|
78
|
+
const to = renaming.slice(at + 4);
|
|
79
|
+
if (before.has(from) && !after.has(from) && after.get(to) === before.get(from)) return [{ from, to }];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function recorded(cwd, change) {
|
|
85
|
+
const socket = process.env[BRIDGE_SOCKET_ENVIRONMENT];
|
|
86
|
+
const nonce = process.env[BRIDGE_NONCE_ENVIRONMENT];
|
|
87
|
+
if (socket === undefined || nonce === undefined) return;
|
|
88
|
+
try {
|
|
89
|
+
const frame = await bridgeCall({ socket, nonce }, { method: WORKING_COPY_INDEX_METHOD, params: { cwd, ...change } });
|
|
90
|
+
if (frame.error !== undefined) throw new KitRefusal(frame.error.message ?? "kit_house_refused");
|
|
91
|
+
} catch (refusal) {
|
|
92
|
+
process.stderr.write(`${formatCommandError(refusal)}: house git push will not see this index change\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function observed(git, { globals, name, rest }, args) {
|
|
97
|
+
const located = captured(git, [...globals, "rev-parse", "--show-toplevel", "--show-cdup", WORKING_COPY_RECEIVED_REF, "HEAD"]);
|
|
98
|
+
if (located === null) return delegated(git, args);
|
|
99
|
+
const [top, cdup, , head] = located.split("\n");
|
|
100
|
+
const listing = [...globals, "ls-files", "--stage", "-z", "--full-name", "--", cdup === "" ? "." : cdup];
|
|
101
|
+
const before = captured(git, listing);
|
|
102
|
+
const rehearsal = [...globals, "mv", "--dry-run", ...rest];
|
|
103
|
+
const planned = name === "mv" ? renamings(captured(git, rehearsal, { ...process.env, LC_ALL: "C" })) : [];
|
|
104
|
+
const outcome = await delegated(git, args);
|
|
105
|
+
const after = before === null ? null : captured(git, listing);
|
|
106
|
+
if (after === null) return outcome;
|
|
107
|
+
const [prior, next] = [indexed(before), indexed(after)];
|
|
108
|
+
const moves = outcome.status === 0 ? planned.flatMap((renaming) => movedPaths(renaming, prior, next)) : [];
|
|
109
|
+
const sources = new Set(moves.map((move) => move.from));
|
|
110
|
+
const destinations = new Set(moves.map((move) => move.to));
|
|
111
|
+
const gone = [...prior.keys()].filter((path) => !next.has(path) && !sources.has(path));
|
|
112
|
+
const arrived = [...next.keys()].filter((path) => !prior.has(path) && !destinations.has(path));
|
|
113
|
+
const heads = captured(git, [...globals, "rev-parse", "HEAD", ...(name === "rebase" ? ["ORIG_HEAD"] : [])]);
|
|
114
|
+
const [current, tip = head] = heads?.trim().split("\n") ?? [];
|
|
115
|
+
const commit = current === undefined || current === head ? [] : [tip, current];
|
|
116
|
+
if (moves.length > 0 || gone.length > 0 || arrived.length > 0 || commit.length > 0) {
|
|
117
|
+
await recorded(top, { moves, gone, arrived, commit, command: name });
|
|
118
|
+
}
|
|
119
|
+
return outcome;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function run(args) {
|
|
123
|
+
const git = nativeGit();
|
|
124
|
+
if (git === null) {
|
|
125
|
+
process.stderr.write("kit_git_unavailable\n");
|
|
126
|
+
return { status: 127, signal: null };
|
|
127
|
+
}
|
|
128
|
+
return observed(git, command(args), args);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
run(process.argv.slice(2)).then(({ status, signal }) => {
|
|
132
|
+
if (signal === null) {
|
|
133
|
+
process.exitCode = status;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
process.exitCode = 128 + system.signals[signal];
|
|
137
|
+
process.kill(process.pid, signal);
|
|
138
|
+
});
|
package/src/worker.js
CHANGED
|
@@ -19,7 +19,7 @@ import { localFiles } from "./local-files.js";
|
|
|
19
19
|
import { answerText, answerYaml } from "./mcp.js";
|
|
20
20
|
import { inheritedEnvironment } from "./command.js";
|
|
21
21
|
import { watchKitWork } from "./kit-work-stream.js";
|
|
22
|
-
import { applyWorkingCopies, ownerCaller, pushWorkingCopy, workingCopyPaths } from "./working-copy.js";
|
|
22
|
+
import { applyWorkingCopies, ownerCaller, pushWorkingCopy, recordIndexChange, workingCopyPaths } from "./working-copy.js";
|
|
23
23
|
import { readConfiguration } from "./configuration.js";
|
|
24
24
|
import { launchBase, readyLaunchDirectory } from "./directory.js";
|
|
25
25
|
import {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
WORKER_HEARTBEAT_MS,
|
|
34
34
|
WORKER_INHERITED_ENVIRONMENT,
|
|
35
35
|
WORKER_SUPERVISION_POLL_MS,
|
|
36
|
+
WORKING_COPY_INDEX_METHOD,
|
|
36
37
|
WORKING_COPY_PUSH_METHOD,
|
|
37
38
|
} from "./contract.js";
|
|
38
39
|
import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
|
|
@@ -283,10 +284,12 @@ function refusedWith(text) {
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
function acceptedWith(text) {
|
|
286
|
-
const written =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
287
|
+
const written = new Map();
|
|
288
|
+
for (const { path, op, revision, from } of answerYaml(text).results) {
|
|
289
|
+
if (from !== undefined) written.set(from, { path: from, revision: null, content: null });
|
|
290
|
+
written.set(path, op === "remove" ? { path, revision: null, content: null } : { path, revision });
|
|
291
|
+
}
|
|
292
|
+
return { refused: null, written: [...written.values()] };
|
|
290
293
|
}
|
|
291
294
|
|
|
292
295
|
async function sessionPush(context, bound, authority, frame, signal) {
|
|
@@ -341,6 +344,8 @@ async function openRunBridge(context, bound, authority, start) {
|
|
|
341
344
|
settle: handed((interaction, outcome) => settleInteraction(context, interaction, outcome)),
|
|
342
345
|
forward: handed((frame, signal) => frame.method === WORKING_COPY_PUSH_METHOD
|
|
343
346
|
? sessionPush(context, bound, authority, frame, signal)
|
|
347
|
+
: frame.method === WORKING_COPY_INDEX_METHOD
|
|
348
|
+
? recordIndexChange(context, frame.params, signal).then(() => ({ jsonrpc: "2.0", id: frame.id, result: {} }))
|
|
344
349
|
: localFiles(async (sent) =>
|
|
345
350
|
callHouseMcp(
|
|
346
351
|
context.session,
|
package/src/working-copy.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readBindings } from "./binding.js";
|
|
|
5
5
|
import {
|
|
6
6
|
KIT_DOOR_BATCH_PATH,
|
|
7
7
|
KIT_DOOR_DISCOVER_PATH,
|
|
8
|
+
KIT_DOOR_ENUMERATE_PATH,
|
|
8
9
|
KIT_DOOR_REVISIONS_PATH,
|
|
9
10
|
KIT_WORKING_COPY_SELECTION_PATH,
|
|
10
11
|
ROOM_REFERENCE,
|
|
@@ -510,11 +511,17 @@ async function located(cwd, signal) {
|
|
|
510
511
|
return { repository: await realpath(dirname(common)), worktree };
|
|
511
512
|
}
|
|
512
513
|
|
|
513
|
-
async function
|
|
514
|
+
async function heldAt(directory, repository) {
|
|
514
515
|
for (const copy of await heldCopies(directory)) {
|
|
515
516
|
if ((await realpath(copy.repository).catch(() => null)) === repository) return copy;
|
|
516
517
|
}
|
|
517
|
-
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function copyAt(directory, repository) {
|
|
522
|
+
const copy = await heldAt(directory, repository);
|
|
523
|
+
if (copy === null) throw new KitRefusal("kit_working_copy_not_found", repository);
|
|
524
|
+
return copy;
|
|
518
525
|
}
|
|
519
526
|
|
|
520
527
|
async function treeOf(repository, commit, signal) {
|
|
@@ -563,13 +570,152 @@ async function trustedBase(copy, commit, signal) {
|
|
|
563
570
|
);
|
|
564
571
|
}
|
|
565
572
|
|
|
573
|
+
function withMoves(copy, moves) {
|
|
574
|
+
const { moves: held, ...rest } = copy;
|
|
575
|
+
return moves.length === 0 ? rest : { ...rest, moves };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function carries(before, after, from, to) {
|
|
579
|
+
return before.has(from) && !after.has(from) && after.has(to) && !before.has(to);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function retainedMoves(chains, worktree, moves, touched) {
|
|
583
|
+
const kept = chains.flatMap((chain) => {
|
|
584
|
+
if (chain.at(-1).worktree !== worktree || !touched.has(chain.at(-1).path)) return [chain];
|
|
585
|
+
const committed = chain
|
|
586
|
+
.filter((position, at) => at === 0 || position.commits !== undefined)
|
|
587
|
+
.map(({ path, commits }) => (commits === undefined ? { path } : { path, commits }));
|
|
588
|
+
return committed.length > 1 ? [committed] : [];
|
|
589
|
+
});
|
|
590
|
+
return moves.reduce((held, { from, to }) => {
|
|
591
|
+
const extended =
|
|
592
|
+
held.find((chain) => chain.at(-1).path === from && [undefined, worktree].includes(chain.at(-1).worktree)) ?? [{ path: from }];
|
|
593
|
+
return [...held.filter((chain) => chain !== extended), [...extended, { path: to, worktree }]];
|
|
594
|
+
}, kept);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function reachedFrom(repository, chains, commit, signal) {
|
|
598
|
+
const reached = new Set();
|
|
599
|
+
for (const candidate of new Set(chains.flat().flatMap((position) => position.commits ?? []))) {
|
|
600
|
+
if (await contains(repository, candidate, commit, signal)) reached.add(candidate);
|
|
601
|
+
}
|
|
602
|
+
return reached;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function reaches(position, reached) {
|
|
606
|
+
return (position.commits ?? []).some((commit) => reached.has(commit));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function rewrittenMoves(repository, chains, worktree, [tip, next], { tying, touched }, signal) {
|
|
610
|
+
const held = await reachedFrom(repository, chains, tip, signal);
|
|
611
|
+
const kept = await reachedFrom(repository, chains, next, signal);
|
|
612
|
+
const left = (position) => reaches(position, held) && !reaches(position, kept);
|
|
613
|
+
if (!tying) {
|
|
614
|
+
return chains.map((chain) =>
|
|
615
|
+
touched.has(chain.at(-1).path) ? chain : chain.map((position) => (left(position) ? { ...position, worktree } : position)),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const starts = chains.map((chain) =>
|
|
619
|
+
chain.findLastIndex((position, at) => at === 0 || reaches(position, kept) || (position.worktree !== worktree && !left(position))),
|
|
620
|
+
);
|
|
621
|
+
if (chains.every((chain, index) => starts[index] === chain.length - 1)) return chains;
|
|
622
|
+
const base = await runGit(repository, ["merge-base", tip, next], { capture: true, signal, failure: GIT_FAILED }).then(lines, () => []);
|
|
623
|
+
const before = base.length === 0 ? new Map() : await treeOf(repository, base[0], signal);
|
|
624
|
+
const after = await treeOf(repository, next, signal);
|
|
625
|
+
return chains.map((chain, index) => {
|
|
626
|
+
const start = starts[index];
|
|
627
|
+
if (start === chain.length - 1 || !carries(before, after, chain[start].path, chain.at(-1).path)) return chain;
|
|
628
|
+
const tied = chain.slice(start + 1).map(({ path, commits = [] }) => ({ path, commits: [...commits, next] }));
|
|
629
|
+
return [...chain.slice(0, start + 1), ...tied];
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function reachedPart(chain, reached) {
|
|
634
|
+
const at = chain.findIndex((position, index) => index > 0 && !reaches(position, reached));
|
|
635
|
+
return at < 0 ? chain : chain.slice(0, at);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function remainingMoves(chains, reached) {
|
|
639
|
+
return chains.flatMap((chain) => {
|
|
640
|
+
const [{ path }, ...rest] = chain.slice(reachedPart(chain, reached).length - 1);
|
|
641
|
+
return rest.length > 0 ? [[{ path }, ...rest]] : [];
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function reportedMoves(moves) {
|
|
646
|
+
if (
|
|
647
|
+
!Array.isArray(moves) ||
|
|
648
|
+
moves.some((move) => typeof move?.from !== "string" || typeof move.to !== "string" || move.from === move.to)
|
|
649
|
+
) {
|
|
650
|
+
throw new KitRefusal("kit_house_arguments_invalid", "moves");
|
|
651
|
+
}
|
|
652
|
+
return moves.map(({ from, to }) => ({ from, to }));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function reportedStrings(values, name) {
|
|
656
|
+
if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) {
|
|
657
|
+
throw new KitRefusal("kit_house_arguments_invalid", name);
|
|
658
|
+
}
|
|
659
|
+
return values;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function recordIndexChange(context, request, signal) {
|
|
663
|
+
if (typeof request?.cwd !== "string") throw new KitRefusal("kit_house_arguments_invalid", "cwd");
|
|
664
|
+
const moves = reportedMoves(request.moves);
|
|
665
|
+
const touched = new Set([
|
|
666
|
+
...reportedStrings(request.gone, "gone"),
|
|
667
|
+
...reportedStrings(request.arrived, "arrived"),
|
|
668
|
+
...moves.map((move) => move.to),
|
|
669
|
+
]);
|
|
670
|
+
const commit = reportedStrings(request.commit, "commit");
|
|
671
|
+
const rewrite = commit.length === 2 && ["commit", "rebase", "reset"].includes(request.command);
|
|
672
|
+
const at = await located(request.cwd, signal);
|
|
673
|
+
const held = await heldAt(context.directory, at.repository);
|
|
674
|
+
if (held === null) return;
|
|
675
|
+
const path = copyPath(context.directory, held);
|
|
676
|
+
await exclusively(path, async () => {
|
|
677
|
+
const copy = await readCopy(path);
|
|
678
|
+
if (copy === null || (copy.moves === undefined && moves.length === 0)) return;
|
|
679
|
+
const retained = retainedMoves(copy.moves ?? [], at.worktree, moves, touched);
|
|
680
|
+
const kept = rewrite
|
|
681
|
+
? await rewrittenMoves(copy.repository, retained, at.worktree, commit, { tying: request.command !== "reset", touched }, signal)
|
|
682
|
+
: retained;
|
|
683
|
+
await writeCopy(context.directory, withMoves(copy, kept), signal);
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function renamed(chains, reached, before, after) {
|
|
688
|
+
const pairs = chains.flatMap((chain) => {
|
|
689
|
+
const paths = reachedPart(chain, reached).map(({ path }) => path);
|
|
690
|
+
const from = paths.findIndex((path) => before.has(path));
|
|
691
|
+
const to = paths.findLastIndex((path) => after.has(path));
|
|
692
|
+
return to > from && carries(before, after, paths[from], paths[to]) ? [[paths[from], paths[to]]] : [];
|
|
693
|
+
});
|
|
694
|
+
return new Map(pairs.filter(([from, to]) => pairs.filter((pair) => pair[0] === from || pair[1] === to).length === 1));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function relocated(copy, root, [from, to], prior, next, signal) {
|
|
698
|
+
const base = copy.revisions[from]?.[prior.object];
|
|
699
|
+
const rename = { op: "rename", path: `${root}/${from}`, to: `${root}/${to}`, base };
|
|
700
|
+
if (prior.object === next.object) return [rename];
|
|
701
|
+
return [rename, { op: "replace", path: `${root}/${to}`, base, content: await blobText(copy.repository, next, to, signal) }];
|
|
702
|
+
}
|
|
703
|
+
|
|
566
704
|
async function derived(copy, base, commit, signal) {
|
|
567
705
|
const root = housePath(scopeOf(copy));
|
|
568
706
|
const before = await treeOf(copy.repository, base, signal);
|
|
569
707
|
const after = await treeOf(copy.repository, commit, signal);
|
|
708
|
+
const moved = renamed(copy.moves ?? [], await reachedFrom(copy.repository, copy.moves ?? [], commit, signal), before, after);
|
|
709
|
+
const arrived = new Set(moved.values());
|
|
570
710
|
const changes = [];
|
|
571
711
|
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
712
|
+
if (arrived.has(path)) continue;
|
|
572
713
|
const prior = before.get(path);
|
|
714
|
+
if (moved.has(path)) {
|
|
715
|
+
const to = moved.get(path);
|
|
716
|
+
changes.push(...(await relocated(copy, root, [path, to], prior, after.get(to), signal)));
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
573
719
|
const next = after.get(path);
|
|
574
720
|
if (prior?.object === next?.object) continue;
|
|
575
721
|
const target = `${root}/${path}`;
|
|
@@ -622,9 +768,65 @@ async function recorded(copy, files, message, acknowledging, signal) {
|
|
|
622
768
|
};
|
|
623
769
|
}
|
|
624
770
|
|
|
625
|
-
function
|
|
771
|
+
function pushedPaths(changes) {
|
|
772
|
+
return new Set(changes.flatMap((change) => (change.op === "rename" ? [change.path, change.to] : [change.path])));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function enumerated(context, copy, since, signal) {
|
|
776
|
+
const answer = await door(
|
|
777
|
+
context.session,
|
|
778
|
+
KIT_DOOR_ENUMERATE_PATH,
|
|
779
|
+
{ prefixes: selectedPrefixes(scopeOf(copy)), positions: [{ authority: copy.room_ref, ...since }] },
|
|
780
|
+
signal,
|
|
781
|
+
);
|
|
782
|
+
const view = answer?.authorities?.find((entry) => entry?.authority === copy.room_ref);
|
|
783
|
+
if (
|
|
784
|
+
!Array.isArray(view?.changes) ||
|
|
785
|
+
typeof view.position !== "string" ||
|
|
786
|
+
typeof view.log_epoch !== "string" ||
|
|
787
|
+
view.changes.some(
|
|
788
|
+
(change) =>
|
|
789
|
+
typeof change?.path !== "string" ||
|
|
790
|
+
(change.removed !== true && (typeof change.content !== "string" || typeof change.revision !== "string")),
|
|
791
|
+
)
|
|
792
|
+
) {
|
|
793
|
+
throw rejected("enumerate");
|
|
794
|
+
}
|
|
795
|
+
return view;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function referrers(context, copy, selected, signal) {
|
|
799
|
+
const { push } = copy;
|
|
626
800
|
const root = housePath(scopeOf(copy));
|
|
627
|
-
|
|
801
|
+
const destinations = push.changes.filter((change) => change.op === "rename").map((change) => change.to);
|
|
802
|
+
if (destinations.length === 0) return [];
|
|
803
|
+
const changed = new Map();
|
|
804
|
+
let since = push.since;
|
|
805
|
+
while (!destinations.every((path) => changed.has(path))) {
|
|
806
|
+
const view = await enumerated(context, copy, since, signal);
|
|
807
|
+
for (const change of view.changes) changed.set(`${root}/${change.path}`, change);
|
|
808
|
+
if (view.position === since.position) {
|
|
809
|
+
throw new KitRefusal("kit_working_copy_result_unreadable", destinations.filter((path) => !changed.has(path)).join(", "));
|
|
810
|
+
}
|
|
811
|
+
since = { position: view.position, log_epoch: view.log_epoch };
|
|
812
|
+
}
|
|
813
|
+
const pushed = pushedPaths(push.changes);
|
|
814
|
+
return [...changed.entries()]
|
|
815
|
+
.filter(([path, change]) => !pushed.has(path) && selected.has(change.path))
|
|
816
|
+
.map(([path, change]) =>
|
|
817
|
+
change.removed === true
|
|
818
|
+
? { path, revision: null, content: null }
|
|
819
|
+
: { path, revision: change.revision, content: change.content },
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function housePosition(context, copy, signal) {
|
|
824
|
+
const discovery = discoveryOf(
|
|
825
|
+
await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes: selectedPrefixes(scopeOf(copy)) }, signal),
|
|
826
|
+
);
|
|
827
|
+
const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
|
|
828
|
+
if (typeof authority?.position !== "string" || typeof authority.log_epoch !== "string") throw rejected("discover");
|
|
829
|
+
return { position: authority.position, log_epoch: authority.log_epoch };
|
|
628
830
|
}
|
|
629
831
|
|
|
630
832
|
function short(commit) {
|
|
@@ -633,8 +835,8 @@ function short(commit) {
|
|
|
633
835
|
|
|
634
836
|
async function acknowledge(context, copy, signal) {
|
|
635
837
|
const { push } = copy;
|
|
636
|
-
const files = await materials(context, copy, push.written, signal);
|
|
637
838
|
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
839
|
+
const files = await materials(context, copy, [...push.written, ...(await referrers(context, copy, selected, signal))], signal);
|
|
638
840
|
const edits = [];
|
|
639
841
|
for (const file of files) {
|
|
640
842
|
if ((await blobText(copy.repository, selected.get(file.path), file.path, signal)) !== file.content) edits.push(file);
|
|
@@ -646,7 +848,8 @@ async function acknowledge(context, copy, signal) {
|
|
|
646
848
|
{ commit: push.commit, edits },
|
|
647
849
|
signal,
|
|
648
850
|
);
|
|
649
|
-
|
|
851
|
+
const remaining = remainingMoves(copy.moves ?? [], await reachedFrom(copy.repository, copy.moves ?? [], push.commit, signal));
|
|
852
|
+
return { ...withMoves(next, remaining), push: { ...push, acknowledged } };
|
|
650
853
|
}
|
|
651
854
|
|
|
652
855
|
async function integrated(copy, signal) {
|
|
@@ -680,7 +883,9 @@ async function integrated(copy, signal) {
|
|
|
680
883
|
await git(["rebase", "--abort"]).catch(() => undefined);
|
|
681
884
|
return pending("a conflict");
|
|
682
885
|
}
|
|
683
|
-
|
|
886
|
+
const current = lines(await git(["rev-parse", "HEAD"]))[0];
|
|
887
|
+
const moves = await rewrittenMoves(copy.repository, copy.moves ?? [], worktree, [tip, current], { tying: true }, signal);
|
|
888
|
+
return { done: true, text: `${accepted}; ${name} is at ${short(current)}.`, moves };
|
|
684
889
|
}
|
|
685
890
|
|
|
686
891
|
async function finish(context, copy, signal) {
|
|
@@ -691,7 +896,8 @@ async function finish(context, copy, signal) {
|
|
|
691
896
|
await writeCopy(context.directory, acknowledged, signal);
|
|
692
897
|
}
|
|
693
898
|
const outcome = await integrated(acknowledged, signal);
|
|
694
|
-
|
|
899
|
+
const moves = outcome.moves ?? acknowledged.moves ?? [];
|
|
900
|
+
if (outcome.done) await writeCopy(context.directory, without(withMoves(acknowledged, moves)), signal);
|
|
695
901
|
return { refused: false, text: outcome.text };
|
|
696
902
|
} catch (error) {
|
|
697
903
|
return {
|
|
@@ -733,7 +939,7 @@ async function expired(context, copy, text, signal) {
|
|
|
733
939
|
const confirmed = await receive(context, without(copy), scopeOf(copy), signal);
|
|
734
940
|
const house = await treeOf(copy.repository, confirmed.received.commit, signal);
|
|
735
941
|
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
736
|
-
const paths = [...
|
|
942
|
+
const paths = [...pushedPaths(push.changes)].map((path) => path.slice(root.length + 1));
|
|
737
943
|
if (paths.some((path) => house.get(path)?.object !== selected.get(path)?.object)) {
|
|
738
944
|
await writeCopy(context.directory, confirmed, signal);
|
|
739
945
|
return {
|
|
@@ -771,8 +977,8 @@ async function settle(context, copy, caller, signal) {
|
|
|
771
977
|
const { refused } = answer;
|
|
772
978
|
if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
|
|
773
979
|
if (refused === null) {
|
|
774
|
-
const
|
|
775
|
-
const written = answer.written.filter((entry) =>
|
|
980
|
+
const pushed = pushedPaths(push.changes);
|
|
981
|
+
const written = answer.written.filter((entry) => pushed.has(entry.path));
|
|
776
982
|
const accepted = { ...copy, push: { ...push, state: "accepted", written } };
|
|
777
983
|
await writeCopy(context.directory, accepted, signal);
|
|
778
984
|
return finish(context, accepted, signal);
|
|
@@ -815,6 +1021,7 @@ async function pushed(context, path, at, selector, caller, signal) {
|
|
|
815
1021
|
if (changes.length === 0) return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
|
|
816
1022
|
const branch = await runGit(at.worktree, ["symbolic-ref", "-q", "HEAD"], { capture: true, signal, failure: GIT_FAILED })
|
|
817
1023
|
.then((output) => lines(output)[0], () => null);
|
|
1024
|
+
const since = changes.some((change) => change.op === "rename") ? await housePosition(context, copy, signal) : null;
|
|
818
1025
|
const submitted = {
|
|
819
1026
|
...copy,
|
|
820
1027
|
push: {
|
|
@@ -825,6 +1032,7 @@ async function pushed(context, path, at, selector, caller, signal) {
|
|
|
825
1032
|
commit,
|
|
826
1033
|
worktree: at.worktree,
|
|
827
1034
|
branch,
|
|
1035
|
+
...(since === null ? {} : { since }),
|
|
828
1036
|
},
|
|
829
1037
|
};
|
|
830
1038
|
await writeCopy(context.directory, submitted, signal);
|