@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 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.19/connect-linux.sh)
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.19",
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.19/connect-linux.sh)",
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.19/connect-linux.sh"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.19",
3
+ "version": "0.1.0-alpha.20",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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
 
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../git-main.js";
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 = answerYaml(text).results.map(({ path, op, revision }) =>
287
- op === "remove" ? { path, revision: null, content: null } : { path, revision },
288
- );
289
- return { refused: null, written };
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,
@@ -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 copyAt(directory, repository) {
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
- throw new KitRefusal("kit_working_copy_not_found", repository);
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 submittedPaths(copy) {
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
- return new Set(copy.push.changes.map((change) => change.path.slice(root.length + 1)));
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
- return { ...next, push: { ...push, acknowledged } };
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
- return { done: true, text: `${accepted}; ${name} is at ${short(lines(await git(["rev-parse", "HEAD"]))[0])}.` };
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
- if (outcome.done) await writeCopy(context.directory, without(acknowledged), signal);
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 = [...submittedPaths(copy)];
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 submitted = new Set(push.changes.map((change) => change.path));
775
- const written = answer.written.filter((entry) => submitted.has(entry.path));
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);