@agentshouse/kit 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +41 -1671
- package/bin/connect-linux.sh +501 -53
- package/bin/connect-windows.ps1 +430 -0
- package/bin/kit.js +200 -159
- package/bootstrap.json +28 -0
- package/docs/configuration.md +104 -0
- package/docs/google.md +33 -0
- package/docs/linux.md +77 -0
- package/docs/macos.md +26 -0
- package/docs/telegram.md +29 -0
- package/docs/windows.md +36 -0
- package/package.json +7 -2
- package/src/adapter-mcp.js +92 -12
- package/src/agents.js +63 -8
- package/src/binding.js +52 -0
- package/src/blocks.js +2 -26
- package/src/bridge.js +28 -14
- package/src/bytes.js +19 -8
- package/src/configuration.js +14 -211
- package/src/connector-login.js +3 -84
- package/src/contract.js +44 -12
- package/src/conversation-originals.js +1 -3
- package/src/delivery.js +4 -7
- package/src/directory.js +6 -26
- package/src/draft.js +4 -82
- package/src/git-main.js +138 -0
- package/src/git.js +62 -0
- package/src/google/calendar.js +39 -45
- package/src/google/drive.js +61 -186
- package/src/google/entries.js +349 -0
- package/src/google/gateway.js +3 -6
- package/src/google/gmail.js +33 -23
- package/src/google/oauth.js +106 -66
- package/src/google/source.js +21 -23
- package/src/host.js +6 -2
- package/src/house-main.js +65 -8
- package/src/house.js +446 -127
- package/src/how-we-work.md +25 -0
- package/src/inbound.js +2 -18
- package/src/kit-work-stream.js +120 -0
- package/src/launcher.js +46 -10
- package/src/local-files.js +3 -11
- package/src/login.js +27 -14
- package/src/mcp.js +18 -0
- package/src/mount.js +9 -18
- package/src/original.js +4 -4
- package/src/pacing.js +41 -0
- package/src/plan.js +37 -45
- package/src/repository.js +3 -61
- package/src/session-bin/git +2 -0
- package/src/setup.js +14 -15
- package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
- package/src/skill-set/ask-matt/SKILL.md +91 -0
- package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
- package/src/skill-set/code-review/LICENSE +21 -0
- package/src/skill-set/code-review/SKILL.md +89 -0
- package/src/skill-set/code-review/agents/openai.yaml +3 -0
- package/src/skill-set/codebase-design/DEEPENING.md +37 -0
- package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/src/skill-set/codebase-design/LICENSE +21 -0
- package/src/skill-set/codebase-design/SKILL.md +116 -0
- package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
- package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
- package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
- package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
- package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
- package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
- package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/src/skill-set/domain-modeling/LICENSE +21 -0
- package/src/skill-set/domain-modeling/SKILL.md +76 -0
- package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
- package/src/skill-set/grill-me/LICENSE +21 -0
- package/src/skill-set/grill-me/SKILL.md +8 -0
- package/src/skill-set/grill-me/agents/openai.yaml +4 -0
- package/src/skill-set/grill-with-docs/LICENSE +21 -0
- package/src/skill-set/grill-with-docs/SKILL.md +8 -0
- package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
- package/src/skill-set/grilling/LICENSE +21 -0
- package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
- package/src/skill-set/handoff/LICENSE +21 -0
- package/src/skill-set/handoff/SKILL.md +17 -0
- package/src/skill-set/handoff/agents/openai.yaml +4 -0
- package/src/skill-set/implement/LICENSE +21 -0
- package/src/skill-set/implement/SKILL.md +16 -0
- package/src/skill-set/implement/agents/openai.yaml +4 -0
- package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
- package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
- package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
- package/src/skill-set/prototype/LICENSE +21 -0
- package/src/skill-set/prototype/LOGIC.md +67 -0
- package/src/skill-set/prototype/SKILL.md +28 -0
- package/src/skill-set/prototype/UI.md +112 -0
- package/src/skill-set/prototype/agents/openai.yaml +3 -0
- package/src/skill-set/research/LICENSE +21 -0
- package/src/skill-set/research/SKILL.md +14 -0
- package/src/skill-set/research/agents/openai.yaml +3 -0
- package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
- package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
- package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
- package/src/skill-set/tdd/LICENSE +21 -0
- package/src/skill-set/tdd/SKILL.md +40 -0
- package/src/skill-set/tdd/agents/openai.yaml +3 -0
- package/src/skill-set/tdd/mocking.md +59 -0
- package/src/skill-set/tdd/tests.md +77 -0
- package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
- package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
- package/src/skill-set/teach/LICENSE +21 -0
- package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
- package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
- package/src/skill-set/teach/SKILL.md +141 -0
- package/src/skill-set/teach/agents/openai.yaml +4 -0
- package/src/skill-set/to-questionnaire/LICENSE +21 -0
- package/src/skill-set/to-questionnaire/SKILL.md +55 -0
- package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
- package/src/skill-set/to-spec/LICENSE +21 -0
- package/src/skill-set/to-spec/SKILL.md +76 -0
- package/src/skill-set/to-spec/agents/openai.yaml +4 -0
- package/src/skill-set/to-tickets/LICENSE +21 -0
- package/src/skill-set/to-tickets/SKILL.md +106 -0
- package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
- package/src/skill-set/wait-what/LICENSE +21 -0
- package/src/skill-set/wait-what/SKILL.md +8 -0
- package/src/skill-set/wait-what/agents/openai.yaml +4 -0
- package/src/skill-set/wayfinder/LICENSE +21 -0
- package/src/skill-set/wayfinder/SKILL.md +129 -0
- package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
- package/src/skill-set/writing-for-agents/LICENSE +21 -0
- package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/src/skill-set/writing-for-agents/SKILL.md +83 -0
- package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
- package/src/skill-set.js +85 -0
- package/src/source.js +93 -54
- package/src/state.js +19 -1
- package/src/sync-library.js +7 -11
- package/src/sync-state.js +12 -3
- package/src/sync.js +142 -43
- package/src/tasks.js +92 -0
- package/src/telegram-view.js +217 -0
- package/src/telegram.js +204 -69
- package/src/turn-frames.js +69 -0
- package/src/worker.js +738 -189
- package/src/working-copy.js +1274 -0
- package/src/workspace.js +299 -88
- package/src/google/declared.js +0 -146
- package/src/google/id.js +0 -1
- package/src/instance/google.js +0 -1
- package/src/starter-set.js +0 -17
- /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
- /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readBindings } from "./binding.js";
|
|
5
|
+
import {
|
|
6
|
+
KIT_DOOR_BATCH_PATH,
|
|
7
|
+
KIT_DOOR_DISCOVER_PATH,
|
|
8
|
+
KIT_DOOR_ENUMERATE_PATH,
|
|
9
|
+
KIT_DOOR_REVISIONS_PATH,
|
|
10
|
+
KIT_WORKING_COPY_SELECTION_PATH,
|
|
11
|
+
ROOM_REFERENCE,
|
|
12
|
+
WORKING_COPIES_HOME,
|
|
13
|
+
WORKING_COPY_BRANCH,
|
|
14
|
+
WORKING_COPY_PRIVATE_HOMES,
|
|
15
|
+
WORKING_COPY_RECEIVED_REF,
|
|
16
|
+
WORKING_COPY_ROOM_HOMES,
|
|
17
|
+
WORKING_COPY_VERSION,
|
|
18
|
+
} from "./contract.js";
|
|
19
|
+
import { runGit } from "./git.js";
|
|
20
|
+
import { operationId } from "./operation.js";
|
|
21
|
+
import { formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
|
|
22
|
+
import { readNote, writeNote, writeText } from "./state.js";
|
|
23
|
+
import { bootstrapBundle, discoveryOf, door, withSession } from "./sync.js";
|
|
24
|
+
import { isGeneratedProvenance, sidecarPath, structuredOrReplace } from "./sync-library.js";
|
|
25
|
+
import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
|
|
26
|
+
import { exclusively } from "./tasks.js";
|
|
27
|
+
|
|
28
|
+
const COMMITTER_NAME = "House Kit";
|
|
29
|
+
const COMMITTER_EMAIL = "house-kit@localhost";
|
|
30
|
+
const COMMITTER = `${COMMITTER_NAME} <${COMMITTER_EMAIL}>`;
|
|
31
|
+
const GIT_FAILED = "kit_working_copy_git_failed";
|
|
32
|
+
|
|
33
|
+
function rejected(detail = null) {
|
|
34
|
+
return new KitRefusal("kit_working_copy_rejected", detail);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stateHome(directory) {
|
|
38
|
+
return join(directory, "state", "working-copies");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statePath(directory, scope) {
|
|
42
|
+
if (scope.kind === "private") return join(stateHome(directory), "private.json");
|
|
43
|
+
if (!ROOM_REFERENCE.test(scope.ref)) throw new KitRefusal("kit_state_name_invalid", scope.ref);
|
|
44
|
+
return join(stateHome(directory), "rooms", `${scope.ref}.json`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function repositoryPath(base, scope) {
|
|
48
|
+
const root = join(base, WORKING_COPIES_HOME);
|
|
49
|
+
return scope.kind === "private" ? join(root, "private") : join(root, "rooms", scope.ref);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function housePath(scope) {
|
|
53
|
+
return scope.kind === "private" ? "/private" : `/rooms/${scope.handle}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sameScope(copy, scope) {
|
|
57
|
+
return copy.scope === scope.kind && (scope.kind === "private" || copy.room_ref === scope.ref);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectedScope(room) {
|
|
61
|
+
if (
|
|
62
|
+
typeof room?.room_ref !== "string" ||
|
|
63
|
+
!ROOM_REFERENCE.test(room.room_ref) ||
|
|
64
|
+
(room.room_handle !== null && (typeof room.room_handle !== "string" || room.room_handle === ""))
|
|
65
|
+
) {
|
|
66
|
+
throw rejected("selection");
|
|
67
|
+
}
|
|
68
|
+
return { kind: "room", ref: room.room_ref, handle: room.room_handle };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function savedSelection(session, signal) {
|
|
72
|
+
const answer = await door(session, KIT_WORKING_COPY_SELECTION_PATH, {}, signal);
|
|
73
|
+
const copy = answer?.working_copy;
|
|
74
|
+
if (copy === null) return [];
|
|
75
|
+
if (!Array.isArray(copy?.rooms) || typeof copy.private !== "boolean") throw rejected("selection");
|
|
76
|
+
return [
|
|
77
|
+
...copy.rooms.map(selectedScope),
|
|
78
|
+
...(copy.private ? [{ kind: "private", ref: null, handle: null }] : []),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function selectedPrefixes(scope) {
|
|
83
|
+
const homes = scope.kind === "private" ? WORKING_COPY_PRIVATE_HOMES : WORKING_COPY_ROOM_HOMES;
|
|
84
|
+
return homes.map((home) => `${housePath(scope)}/${home}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function receivedFiles(view) {
|
|
88
|
+
const manifest = view?.manifest;
|
|
89
|
+
if (
|
|
90
|
+
!Array.isArray(manifest?.files) ||
|
|
91
|
+
!Array.isArray(view.contents) ||
|
|
92
|
+
typeof manifest.anchor !== "string" ||
|
|
93
|
+
typeof manifest.position !== "string" ||
|
|
94
|
+
typeof manifest.log_epoch !== "string"
|
|
95
|
+
) {
|
|
96
|
+
throw rejected("bootstrap");
|
|
97
|
+
}
|
|
98
|
+
const contents = new Map();
|
|
99
|
+
for (const entry of view.contents) {
|
|
100
|
+
if (typeof entry?.path !== "string" || typeof entry.content !== "string") throw rejected("bootstrap");
|
|
101
|
+
contents.set(entry.path, entry.content);
|
|
102
|
+
}
|
|
103
|
+
return manifest.files.map((file) => {
|
|
104
|
+
const content = contents.get(file?.path);
|
|
105
|
+
if (
|
|
106
|
+
content === undefined ||
|
|
107
|
+
typeof file.revision !== "string" ||
|
|
108
|
+
typeof file.sha256 !== "string" ||
|
|
109
|
+
typeof file.writable !== "boolean"
|
|
110
|
+
) {
|
|
111
|
+
throw rejected("bootstrap");
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
path: file.path,
|
|
115
|
+
revision: file.revision,
|
|
116
|
+
sha256: file.sha256,
|
|
117
|
+
writable: file.writable,
|
|
118
|
+
content,
|
|
119
|
+
provenance: typeof file.provenance === "string" ? file.provenance : null,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readScope(session, scope, signal) {
|
|
125
|
+
const prefixes = selectedPrefixes(scope);
|
|
126
|
+
const discovery = discoveryOf(await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
|
|
127
|
+
const bundle = await bootstrapBundle(session, prefixes, signal);
|
|
128
|
+
if (bundle.scopes.length === 0) throw new KitRefusal("kit_working_copy_unavailable", housePath(scope));
|
|
129
|
+
if (bundle.scopes.length !== 1) throw rejected("bootstrap");
|
|
130
|
+
const [view] = bundle.scopes;
|
|
131
|
+
const files = receivedFiles(view);
|
|
132
|
+
const authority = discovery.authorities.find(
|
|
133
|
+
(entry) => entry?.room_ref === view.manifest.anchor,
|
|
134
|
+
);
|
|
135
|
+
if (
|
|
136
|
+
authority === undefined ||
|
|
137
|
+
!Array.isArray(authority.protected_paths) ||
|
|
138
|
+
!Array.isArray(authority.operations?.write) ||
|
|
139
|
+
(scope.kind === "room" && view.manifest.anchor !== scope.ref)
|
|
140
|
+
) {
|
|
141
|
+
throw rejected("discover");
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
anchor: view.manifest.anchor,
|
|
145
|
+
position: view.manifest.position,
|
|
146
|
+
logEpoch: view.manifest.log_epoch,
|
|
147
|
+
protectedPaths: authority.protected_paths,
|
|
148
|
+
declared: declaration(authority),
|
|
149
|
+
files,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function declaration(authority) {
|
|
154
|
+
return { write: authority.operations.write, protected: authority.protected_paths };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function importData(text) {
|
|
158
|
+
const bytes = Buffer.from(text, "utf8");
|
|
159
|
+
return [Buffer.from(`data ${bytes.length}\n`), bytes, Buffer.from("\n")];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function quotedPath(path) {
|
|
163
|
+
return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function importCommit(message, parents) {
|
|
167
|
+
return [
|
|
168
|
+
Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
|
|
169
|
+
...importData(message),
|
|
170
|
+
...parents.map((parent, index) => Buffer.from(`${index === 0 ? "from" : "merge"} ${parent}\n`)),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function importFile(path, content) {
|
|
175
|
+
if (content === null) return [Buffer.from(`D ${quotedPath(path)}\n`)];
|
|
176
|
+
return [Buffer.from(`M 100644 inline ${quotedPath(path)}\n`), ...importData(content)];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function importReceived(repository, { message, parents, files, whole = false, acknowledging = null }, signal) {
|
|
180
|
+
const stream = [];
|
|
181
|
+
const merged = [...parents];
|
|
182
|
+
if (acknowledging !== null && acknowledging.edits.length === 0) merged.push(acknowledging.commit);
|
|
183
|
+
if (acknowledging !== null && acknowledging.edits.length > 0) {
|
|
184
|
+
stream.push(
|
|
185
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\nmark :1\n`),
|
|
186
|
+
...importCommit(`House accepted ${acknowledging.commit}`, [acknowledging.commit]),
|
|
187
|
+
...acknowledging.edits.flatMap((edit) => importFile(edit.path, edit.content)),
|
|
188
|
+
Buffer.from("\n"),
|
|
189
|
+
);
|
|
190
|
+
merged.push(":1");
|
|
191
|
+
}
|
|
192
|
+
stream.push(
|
|
193
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
194
|
+
...importCommit(message, merged),
|
|
195
|
+
...(whole ? [Buffer.from("deleteall\n")] : []),
|
|
196
|
+
...files.flatMap((file) => importFile(file.path, file.content)),
|
|
197
|
+
Buffer.from("\n"),
|
|
198
|
+
);
|
|
199
|
+
await runGit(repository, ["fast-import", "--quiet", "--force"], {
|
|
200
|
+
input: Buffer.concat(stream),
|
|
201
|
+
signal,
|
|
202
|
+
failure: GIT_FAILED,
|
|
203
|
+
});
|
|
204
|
+
const received = await commitOf(repository, WORKING_COPY_RECEIVED_REF, signal);
|
|
205
|
+
if (acknowledging === null) return { received, acknowledged: null };
|
|
206
|
+
const acknowledged =
|
|
207
|
+
acknowledging.edits.length === 0 ? acknowledging.commit : await commitOf(repository, `${received}^2`, signal);
|
|
208
|
+
return { received, acknowledged };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function commitOf(repository, name, signal) {
|
|
212
|
+
const commit = await runGit(repository, ["rev-parse", "--verify", `${name}^{commit}`], {
|
|
213
|
+
capture: true,
|
|
214
|
+
signal,
|
|
215
|
+
failure: GIT_FAILED,
|
|
216
|
+
});
|
|
217
|
+
return commit.toString("utf8").trim();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function remembered(repository, revisions, commit, files, signal, acknowledged = false) {
|
|
221
|
+
const tree = await treeOf(repository, commit, signal);
|
|
222
|
+
const next = { ...revisions };
|
|
223
|
+
for (const { path, revision } of files) {
|
|
224
|
+
const entry = tree.get(path);
|
|
225
|
+
if (entry === undefined || revision === null) continue;
|
|
226
|
+
const seen = next[path]?.[entry.object] ?? [];
|
|
227
|
+
if (acknowledged || seen.at(-1)?.revision !== revision) {
|
|
228
|
+
next[path] = { ...next[path], [entry.object]: [...seen, { commit, revision }] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return next;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function revisionAt(copy, path, entry, base, signal) {
|
|
235
|
+
const seen = entry === undefined ? [] : (copy.revisions[path]?.[entry.object] ?? []);
|
|
236
|
+
for (const known of seen.toReversed()) {
|
|
237
|
+
if (seen.length === 1 || (await contains(copy.repository, known.commit, base, signal))) return known.revision;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readOnlyContents(read) {
|
|
243
|
+
const contents = new Map();
|
|
244
|
+
for (const file of read.files) {
|
|
245
|
+
if (!file.writable) contents.set(file.path, file.content);
|
|
246
|
+
if (file.provenance !== null) contents.set(sidecarPath(file.path), file.provenance);
|
|
247
|
+
}
|
|
248
|
+
return contents;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function present(path) {
|
|
252
|
+
try {
|
|
253
|
+
await stat(path);
|
|
254
|
+
return true;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error.code === "ENOENT") return false;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function trackedPaths(repository, signal) {
|
|
262
|
+
const listed = await runGit(repository, ["ls-files", "-z"], { capture: true, signal, failure: GIT_FAILED });
|
|
263
|
+
return new Set(listed.toString("utf8").split("\0").filter((path) => path !== ""));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function replaceReadOnly(repository, held, written, removed, signal) {
|
|
267
|
+
const tracked = await trackedPaths(repository, signal);
|
|
268
|
+
const holding = new Set(held);
|
|
269
|
+
for (const path of removed) {
|
|
270
|
+
if (!held.has(path)) continue;
|
|
271
|
+
if (!tracked.has(path)) await removeReplicaFile(repository, "", path);
|
|
272
|
+
holding.delete(path);
|
|
273
|
+
}
|
|
274
|
+
for (const [path, content] of written) {
|
|
275
|
+
if (tracked.has(path)) continue;
|
|
276
|
+
const target = locate(repository, "", path);
|
|
277
|
+
if (!held.has(path) && (await present(target))) continue;
|
|
278
|
+
await writeReplicaFile(target, content, signal);
|
|
279
|
+
holding.add(path);
|
|
280
|
+
}
|
|
281
|
+
return [...holding];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function covers(path, prefix) {
|
|
285
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function ignorePattern(path) {
|
|
289
|
+
return `/${path.replace(/[\\*?[]/g, "\\$&").replace(/ +$/, (spaces) => spaces.replace(/ /g, "\\ "))}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ignoreGroups(read) {
|
|
293
|
+
const writable = read.files.filter((file) => file.writable).map((file) => file.path);
|
|
294
|
+
const readOnly = read.files.filter((file) => !file.writable).map((file) => file.path);
|
|
295
|
+
const whole = read.protectedPaths.filter((path) => !writable.some((file) => covers(file, path)));
|
|
296
|
+
const protectedPath = (path) => read.protectedPaths.some((prefix) => covers(path, prefix));
|
|
297
|
+
const house = [
|
|
298
|
+
...whole,
|
|
299
|
+
...readOnly.filter((path) => protectedPath(path) && !whole.some((prefix) => covers(path, prefix))),
|
|
300
|
+
];
|
|
301
|
+
const granted = readOnly.filter((path) => !protectedPath(path));
|
|
302
|
+
return { house: house.map(ignorePattern), granted: granted.map(ignorePattern) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function writeIgnoreRules(repository, read, signal) {
|
|
306
|
+
const { house, granted } = ignoreGroups(read);
|
|
307
|
+
const gitignore = [
|
|
308
|
+
"# House Kit writes this file and .ignore. The paths below are read-only House",
|
|
309
|
+
"# content: Git does not track them, and House refuses a change to them even",
|
|
310
|
+
"# when this file is edited or a path is force-added.",
|
|
311
|
+
"/.gitignore",
|
|
312
|
+
"/.ignore",
|
|
313
|
+
"_provenance",
|
|
314
|
+
"",
|
|
315
|
+
"# House maintains or protects these paths.",
|
|
316
|
+
...house,
|
|
317
|
+
"",
|
|
318
|
+
"# Your current House grant does not allow writing these paths.",
|
|
319
|
+
...granted,
|
|
320
|
+
"",
|
|
321
|
+
];
|
|
322
|
+
const ignore = [
|
|
323
|
+
"# House Kit writes this file so native search still reads the read-only House",
|
|
324
|
+
"# content that .gitignore keeps out of Git.",
|
|
325
|
+
"!_provenance",
|
|
326
|
+
...[...house, ...granted].map((pattern) => `!${pattern}`),
|
|
327
|
+
"",
|
|
328
|
+
];
|
|
329
|
+
await ruled(join(repository, ".gitignore"), gitignore.join("\n"), signal);
|
|
330
|
+
await ruled(join(repository, ".ignore"), ignore.join("\n"), signal);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function ruled(path, rules, signal) {
|
|
334
|
+
if ((await readFile(path, "utf8").catch(() => null)) !== rules) await writeText(path, rules, signal, 0o644);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function manifest(read, commit, readOnly) {
|
|
338
|
+
return { commit, position: read.position, log_epoch: read.logEpoch, read_only: readOnly, declared: read.declared };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function copyPath(directory, copy) {
|
|
342
|
+
return statePath(directory, { kind: copy.scope, ref: copy.room_ref });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function writeCopy(directory, copy, signal) {
|
|
346
|
+
await writeNote(copyPath(directory, copy), copy, signal);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readCopy(path) {
|
|
350
|
+
const copy = await readNote(path, null);
|
|
351
|
+
return copy?.version === WORKING_COPY_VERSION ? copy : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function heldCopies(directory) {
|
|
355
|
+
const rooms = join(stateHome(directory), "rooms");
|
|
356
|
+
let names = [];
|
|
357
|
+
try {
|
|
358
|
+
names = await readdir(rooms);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", rooms);
|
|
361
|
+
}
|
|
362
|
+
const held = [];
|
|
363
|
+
for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
364
|
+
held.push(await readCopy(join(rooms, name)));
|
|
365
|
+
}
|
|
366
|
+
held.push(await readCopy(statePath(directory, { kind: "private" })));
|
|
367
|
+
return held.filter((copy) => copy !== null);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function received(scope, read, parents) {
|
|
371
|
+
const files = read.files.filter((file) => file.writable);
|
|
372
|
+
return { message: `House ${housePath(scope)} at position ${read.position}`, parents, files, whole: true };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function materialize(context, scope, signal) {
|
|
376
|
+
const read = await readScope(context.session, scope, signal);
|
|
377
|
+
const identity = { kind: scope.kind, ref: read.anchor };
|
|
378
|
+
const repository = repositoryPath(context.launchBase, identity);
|
|
379
|
+
if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
|
|
380
|
+
const staging = join(dirname(repository), `.${randomUUID()}.staging`);
|
|
381
|
+
await mkdir(staging, { recursive: true });
|
|
382
|
+
const imported = received(scope, read, []);
|
|
383
|
+
let commit;
|
|
384
|
+
let revisions;
|
|
385
|
+
let readOnly;
|
|
386
|
+
try {
|
|
387
|
+
await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
|
|
388
|
+
commit = (await importReceived(staging, imported, signal)).received;
|
|
389
|
+
revisions = await remembered(staging, {}, commit, imported.files, signal);
|
|
390
|
+
await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
|
|
391
|
+
signal,
|
|
392
|
+
failure: GIT_FAILED,
|
|
393
|
+
});
|
|
394
|
+
await runGit(staging, ["read-tree", "-m", "-u", "HEAD"], { signal, failure: GIT_FAILED });
|
|
395
|
+
readOnly = await replaceReadOnly(staging, new Set(), readOnlyContents(read), [], signal);
|
|
396
|
+
await writeIgnoreRules(staging, read, signal);
|
|
397
|
+
await rename(staging, repository);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
await rm(staging, { recursive: true, force: true });
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
await writeCopy(context.directory, {
|
|
403
|
+
version: WORKING_COPY_VERSION,
|
|
404
|
+
scope: scope.kind,
|
|
405
|
+
room_ref: read.anchor,
|
|
406
|
+
room_handle: scope.handle,
|
|
407
|
+
repository,
|
|
408
|
+
active: true,
|
|
409
|
+
received: manifest(read, commit, readOnly),
|
|
410
|
+
revisions,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function receive(context, copy, scope, signal) {
|
|
415
|
+
const read = await readScope(context.session, scope, signal);
|
|
416
|
+
if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
|
|
417
|
+
const imported = received(scope, read, [copy.received.commit]);
|
|
418
|
+
const commit = (await importReceived(copy.repository, imported, signal)).received;
|
|
419
|
+
const next = readOnlyContents(read);
|
|
420
|
+
const held = copy.received.read_only;
|
|
421
|
+
const removed = held.filter((path) => !next.has(path));
|
|
422
|
+
const readOnly = await replaceReadOnly(copy.repository, new Set(held), next, removed, signal);
|
|
423
|
+
await writeIgnoreRules(copy.repository, read, signal);
|
|
424
|
+
return {
|
|
425
|
+
...copy,
|
|
426
|
+
received: manifest(read, commit, readOnly),
|
|
427
|
+
revisions: await remembered(copy.repository, copy.revisions, commit, imported.files, signal),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function refresh(context, copy, scope, signal) {
|
|
432
|
+
if (!(await present(copy.repository))) {
|
|
433
|
+
await rm(copyPath(context.directory, copy), { force: true });
|
|
434
|
+
await materialize(context, scope, signal);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const next = await receive(context, copy, scope, signal);
|
|
438
|
+
await writeCopy(context.directory, { ...next, room_handle: scope.handle, active: true }, signal);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function scopeRefused(error) {
|
|
442
|
+
return error instanceof KitRefusal && (houseAnswered(error) || error.code.startsWith("kit_working_copy_"));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function declaredWritable(path, authority) {
|
|
446
|
+
const deepest = (scopes) =>
|
|
447
|
+
Math.max(-1, ...scopes.filter((scope) => scope === "" || covers(path, scope)).map((scope) => scope.length));
|
|
448
|
+
return deepest(authority.operations.write) > deepest(authority.protected_paths);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function incorporated(copy, view, authority, signal) {
|
|
452
|
+
const tree = await treeOf(copy.repository, copy.received.commit, signal);
|
|
453
|
+
const held = new Set(copy.received.read_only);
|
|
454
|
+
const files = [];
|
|
455
|
+
const written = new Map();
|
|
456
|
+
const removed = [];
|
|
457
|
+
for (const change of view.changes) {
|
|
458
|
+
const gone = change.removed === true;
|
|
459
|
+
if (tree.has(change.path) || (!gone && !held.has(change.path) && declaredWritable(change.path, authority))) {
|
|
460
|
+
files.push({ path: change.path, revision: gone ? null : change.revision, content: gone ? null : change.content });
|
|
461
|
+
} else if (gone) {
|
|
462
|
+
removed.push(change.path, sidecarPath(change.path));
|
|
463
|
+
} else {
|
|
464
|
+
written.set(change.path, change.content);
|
|
465
|
+
if (typeof change.provenance === "string") written.set(sidecarPath(change.path), change.provenance);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const message = `House ${housePath(scopeOf(copy))} at position ${view.position}`;
|
|
469
|
+
const commit =
|
|
470
|
+
files.length === 0
|
|
471
|
+
? copy.received.commit
|
|
472
|
+
: (await importReceived(copy.repository, { message, parents: [copy.received.commit], files }, signal)).received;
|
|
473
|
+
const readOnly = await replaceReadOnly(copy.repository, held, written, removed, signal);
|
|
474
|
+
if (view.changes.length > 0) {
|
|
475
|
+
const listed = [
|
|
476
|
+
...[...(await treeOf(copy.repository, commit, signal)).keys()].map((path) => ({ path, writable: true })),
|
|
477
|
+
...readOnly.filter((path) => !isGeneratedProvenance(path)).map((path) => ({ path, writable: false })),
|
|
478
|
+
].sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
479
|
+
await writeIgnoreRules(copy.repository, { files: listed, protectedPaths: authority.protected_paths }, signal);
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
...copy,
|
|
483
|
+
received: { ...copy.received, commit, position: view.position, log_epoch: view.log_epoch, read_only: readOnly },
|
|
484
|
+
revisions: await remembered(copy.repository, copy.revisions, commit, files, signal),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function deliver(context, path, authority, signal) {
|
|
489
|
+
if (
|
|
490
|
+
typeof authority.position !== "string" ||
|
|
491
|
+
typeof authority.log_epoch !== "string" ||
|
|
492
|
+
!Array.isArray(authority.protected_paths) ||
|
|
493
|
+
!Array.isArray(authority.operations?.write)
|
|
494
|
+
) {
|
|
495
|
+
throw rejected("discover");
|
|
496
|
+
}
|
|
497
|
+
let copy = await readCopy(path);
|
|
498
|
+
if (copy?.active && JSON.stringify(copy.received.declared) !== JSON.stringify(declaration(authority))) {
|
|
499
|
+
await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
while (
|
|
503
|
+
copy?.active &&
|
|
504
|
+
(copy.received.log_epoch !== authority.log_epoch || BigInt(copy.received.position) < BigInt(authority.position))
|
|
505
|
+
) {
|
|
506
|
+
const since = { position: copy.received.position, log_epoch: copy.received.log_epoch };
|
|
507
|
+
let view;
|
|
508
|
+
try {
|
|
509
|
+
view = await enumerated(context, copy, since, signal);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error.code === "replica_behind") return true;
|
|
512
|
+
if (error.code !== "position_expired") throw error;
|
|
513
|
+
await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
if (view.position === since.position) return false;
|
|
517
|
+
copy = await incorporated(copy, view, authority, signal);
|
|
518
|
+
await writeCopy(context.directory, copy, signal);
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function delivering(context, signal) {
|
|
524
|
+
const copies = (await heldCopies(context.directory)).filter((copy) => copy.active);
|
|
525
|
+
if (copies.length === 0) return { refused: [], lost: false, behind: false };
|
|
526
|
+
const prefixes = copies.flatMap((copy) => selectedPrefixes(scopeOf(copy)));
|
|
527
|
+
const discovery = discoveryOf(await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
|
|
528
|
+
const refused = [];
|
|
529
|
+
let lost = false;
|
|
530
|
+
let behind = false;
|
|
531
|
+
for (const copy of copies) {
|
|
532
|
+
const at = housePath(scopeOf(copy));
|
|
533
|
+
const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
|
|
534
|
+
if (authority === undefined) {
|
|
535
|
+
lost = true;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (authority.managed_replication !== "admitted") {
|
|
539
|
+
refused.push({ path: at, code: "operation_denied" });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const path = copyPath(context.directory, copy);
|
|
543
|
+
try {
|
|
544
|
+
if (await exclusively(path, () => deliver(context, path, authority, signal))) behind = true;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (!scopeRefused(error)) throw error;
|
|
547
|
+
refused.push({ path: at, code: error.code });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return { refused, lost, behind };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function deliveredOutcome(refused, behind) {
|
|
554
|
+
return behind ? { refused, behind } : { refused };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function deliverWorkingCopies(context, signal) {
|
|
558
|
+
const delivered = await delivering(context, signal);
|
|
559
|
+
return delivered.lost
|
|
560
|
+
? applyWorkingCopies(context, signal)
|
|
561
|
+
: deliveredOutcome(delivered.refused, delivered.behind);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function streamChange(entry) {
|
|
565
|
+
if (entry?.kind === "removed") return { path: entry.path, removed: true };
|
|
566
|
+
return { path: entry?.path, revision: entry?.revision, content: entry?.text, provenance: entry?.provenance };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function followsOn(copy, frame, since, changes) {
|
|
570
|
+
const { received } = copy;
|
|
571
|
+
return (
|
|
572
|
+
since !== null &&
|
|
573
|
+
received.log_epoch === frame.log_epoch &&
|
|
574
|
+
BigInt(received.position) >= BigInt(since) &&
|
|
575
|
+
!unreadableChanges(changes)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function carriedEntries(context, path, frame, since, signal) {
|
|
580
|
+
const copy = await readCopy(path);
|
|
581
|
+
if (!copy?.active) return true;
|
|
582
|
+
const { received } = copy;
|
|
583
|
+
if (received.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(received.position)) return true;
|
|
584
|
+
const homes = copy.scope === "private" ? WORKING_COPY_PRIVATE_HOMES : WORKING_COPY_ROOM_HOMES;
|
|
585
|
+
const changes = frame.entries
|
|
586
|
+
.map(streamChange)
|
|
587
|
+
.filter((change) => typeof change.path !== "string" || homes.some((home) => covers(change.path, home)));
|
|
588
|
+
if (!followsOn(copy, frame, since, changes)) return false;
|
|
589
|
+
const view = { changes, position: frame.position, log_epoch: frame.log_epoch };
|
|
590
|
+
const authority = { operations: { write: received.declared.write }, protected_paths: received.declared.protected };
|
|
591
|
+
await writeCopy(context.directory, await incorporated(copy, view, authority, signal), signal);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function receiveWorkingCopyEntries(context, frame, since, signal) {
|
|
596
|
+
if (
|
|
597
|
+
typeof frame.authority !== "string" ||
|
|
598
|
+
typeof frame.log_epoch !== "string" ||
|
|
599
|
+
!/^[0-9]+$/.test(frame.position) ||
|
|
600
|
+
!Array.isArray(frame.entries) ||
|
|
601
|
+
(since !== null && !/^[0-9]+$/.test(since))
|
|
602
|
+
) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
const copy = (await heldCopies(context.directory)).find(
|
|
606
|
+
(held) => held.active && held.room_ref === frame.authority,
|
|
607
|
+
);
|
|
608
|
+
if (copy === undefined) return true;
|
|
609
|
+
const path = copyPath(context.directory, copy);
|
|
610
|
+
return exclusively(path, () => carriedEntries(context, path, frame, since, signal));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export async function applyWorkingCopies(context, signal) {
|
|
614
|
+
const selected = (await savedSelection(context.session, signal)).filter(
|
|
615
|
+
(scope) => scope.kind === "private" || scope.handle !== null,
|
|
616
|
+
);
|
|
617
|
+
for (const held of await heldCopies(context.directory)) {
|
|
618
|
+
if (!held.active || selected.some((scope) => sameScope(held, scope))) continue;
|
|
619
|
+
const path = copyPath(context.directory, held);
|
|
620
|
+
await exclusively(path, async () => {
|
|
621
|
+
const copy = await readCopy(path);
|
|
622
|
+
if (copy !== null) await writeCopy(context.directory, { ...copy, active: false }, signal);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const refused = [];
|
|
626
|
+
for (const scope of selected) {
|
|
627
|
+
const path = statePath(context.directory, scope);
|
|
628
|
+
try {
|
|
629
|
+
await exclusively(path, async () => {
|
|
630
|
+
const copy = await readCopy(path);
|
|
631
|
+
if (copy === null) await materialize(context, scope, signal);
|
|
632
|
+
else if (!copy.active) await refresh(context, copy, scope, signal);
|
|
633
|
+
else if (copy.room_handle !== scope.handle) {
|
|
634
|
+
await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (!scopeRefused(error)) throw error;
|
|
639
|
+
refused.push({ path: housePath(scope), code: error.code });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const delivered = await delivering(context, signal);
|
|
643
|
+
return deliveredOutcome([...refused, ...delivered.refused], delivered.behind);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function workingCopyPaths(directory) {
|
|
647
|
+
return (await heldCopies(directory))
|
|
648
|
+
.filter((copy) => copy.active)
|
|
649
|
+
.map((copy) => ({
|
|
650
|
+
path: housePath({ kind: copy.scope, handle: copy.room_handle }),
|
|
651
|
+
repository: copy.repository,
|
|
652
|
+
}))
|
|
653
|
+
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function scopeOf(copy) {
|
|
657
|
+
return { kind: copy.scope, ref: copy.room_ref, handle: copy.room_handle };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function without(copy) {
|
|
661
|
+
const { push, ...settled } = copy;
|
|
662
|
+
return settled;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function lines(output) {
|
|
666
|
+
return output.toString("utf8").split("\n").filter((line) => line !== "");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function succeeded(git) {
|
|
670
|
+
return git.then(
|
|
671
|
+
() => true,
|
|
672
|
+
() => false,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function contains(repository, ancestor, descendant, signal) {
|
|
677
|
+
return succeeded(
|
|
678
|
+
runGit(repository, ["merge-base", "--is-ancestor", ancestor, descendant], { signal, failure: GIT_FAILED }),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function located(cwd, signal) {
|
|
683
|
+
const [common, worktree] = lines(
|
|
684
|
+
await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"], {
|
|
685
|
+
capture: true,
|
|
686
|
+
signal,
|
|
687
|
+
failure: "kit_working_copy_unresolved",
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
return { repository: await realpath(dirname(common)), worktree };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function heldAt(directory, repository) {
|
|
694
|
+
for (const copy of await heldCopies(directory)) {
|
|
695
|
+
if ((await realpath(copy.repository).catch(() => null)) === repository) return copy;
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function copyAt(directory, repository) {
|
|
701
|
+
const copy = await heldAt(directory, repository);
|
|
702
|
+
if (copy === null) throw new KitRefusal("kit_working_copy_not_found", repository);
|
|
703
|
+
return copy;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function treeOf(repository, commit, signal) {
|
|
707
|
+
const listed = await runGit(repository, ["ls-tree", "-r", "-z", commit], {
|
|
708
|
+
capture: true,
|
|
709
|
+
signal,
|
|
710
|
+
failure: GIT_FAILED,
|
|
711
|
+
});
|
|
712
|
+
const tree = new Map();
|
|
713
|
+
for (const entry of listed.toString("utf8").split("\0").filter((line) => line !== "")) {
|
|
714
|
+
const tab = entry.indexOf("\t");
|
|
715
|
+
const [, type, object] = entry.slice(0, tab).split(" ");
|
|
716
|
+
tree.set(entry.slice(tab + 1), { type, object });
|
|
717
|
+
}
|
|
718
|
+
return tree;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function blobText(repository, entry, path, signal) {
|
|
722
|
+
if (entry === undefined) return null;
|
|
723
|
+
if (entry.type !== "blob") throw new KitRefusal("kit_working_copy_not_text", path);
|
|
724
|
+
const bytes = await runGit(repository, ["cat-file", "blob", entry.object], {
|
|
725
|
+
capture: true,
|
|
726
|
+
signal,
|
|
727
|
+
failure: GIT_FAILED,
|
|
728
|
+
});
|
|
729
|
+
try {
|
|
730
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
731
|
+
} catch {
|
|
732
|
+
throw new KitRefusal("kit_working_copy_not_text", path);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function trustedBase(copy, commit, signal) {
|
|
737
|
+
const git = (args) => runGit(copy.repository, args, { capture: true, signal, failure: GIT_FAILED });
|
|
738
|
+
const house = new Set(
|
|
739
|
+
lines(await git(["rev-list", "--first-parent", "--parents", copy.received.commit])).flatMap((line) => {
|
|
740
|
+
const [received, , acknowledged] = line.split(" ");
|
|
741
|
+
return acknowledged === undefined ? [received] : [received, acknowledged];
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
const base = lines(await git(["rev-list", "--topo-order", commit])).find((ancestor) => house.has(ancestor));
|
|
745
|
+
if (base !== undefined) return base;
|
|
746
|
+
throw new KitRefusal(
|
|
747
|
+
"kit_working_copy_base_unknown",
|
|
748
|
+
`${commit} contains no House state; merge or rebase onto ${WORKING_COPY_RECEIVED_REF}`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function withMoves(copy, moves) {
|
|
753
|
+
const { moves: held, ...rest } = copy;
|
|
754
|
+
return moves.length === 0 ? rest : { ...rest, moves };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function carries(before, after, from, to) {
|
|
758
|
+
return before.has(from) && !after.has(from) && after.has(to) && !before.has(to);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function retainedMoves(chains, worktree, moves, touched) {
|
|
762
|
+
const kept = chains.flatMap((chain) => {
|
|
763
|
+
if (chain.at(-1).worktree !== worktree || !touched.has(chain.at(-1).path)) return [chain];
|
|
764
|
+
const committed = chain
|
|
765
|
+
.filter((position, at) => at === 0 || position.commits !== undefined)
|
|
766
|
+
.map(({ path, commits }) => (commits === undefined ? { path } : { path, commits }));
|
|
767
|
+
return committed.length > 1 ? [committed] : [];
|
|
768
|
+
});
|
|
769
|
+
return moves.reduce((held, { from, to }) => {
|
|
770
|
+
const extended =
|
|
771
|
+
held.find((chain) => chain.at(-1).path === from && [undefined, worktree].includes(chain.at(-1).worktree)) ?? [{ path: from }];
|
|
772
|
+
return [...held.filter((chain) => chain !== extended), [...extended, { path: to, worktree }]];
|
|
773
|
+
}, kept);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function reachedFrom(repository, chains, commit, signal) {
|
|
777
|
+
const reached = new Set();
|
|
778
|
+
for (const candidate of new Set(chains.flat().flatMap((position) => position.commits ?? []))) {
|
|
779
|
+
if (await contains(repository, candidate, commit, signal)) reached.add(candidate);
|
|
780
|
+
}
|
|
781
|
+
return reached;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function reaches(position, reached) {
|
|
785
|
+
return (position.commits ?? []).some((commit) => reached.has(commit));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function rewrittenMoves(repository, chains, worktree, [tip, next], { tying, touched }, signal) {
|
|
789
|
+
const held = await reachedFrom(repository, chains, tip, signal);
|
|
790
|
+
const kept = await reachedFrom(repository, chains, next, signal);
|
|
791
|
+
const left = (position) => reaches(position, held) && !reaches(position, kept);
|
|
792
|
+
if (!tying) {
|
|
793
|
+
return chains.map((chain) =>
|
|
794
|
+
touched.has(chain.at(-1).path) ? chain : chain.map((position) => (left(position) ? { ...position, worktree } : position)),
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const starts = chains.map((chain) =>
|
|
798
|
+
chain.findLastIndex((position, at) => at === 0 || reaches(position, kept) || (position.worktree !== worktree && !left(position))),
|
|
799
|
+
);
|
|
800
|
+
if (chains.every((chain, index) => starts[index] === chain.length - 1)) return chains;
|
|
801
|
+
const base = await runGit(repository, ["merge-base", tip, next], { capture: true, signal, failure: GIT_FAILED }).then(lines, () => []);
|
|
802
|
+
const before = base.length === 0 ? new Map() : await treeOf(repository, base[0], signal);
|
|
803
|
+
const after = await treeOf(repository, next, signal);
|
|
804
|
+
return chains.map((chain, index) => {
|
|
805
|
+
const start = starts[index];
|
|
806
|
+
if (start === chain.length - 1 || !carries(before, after, chain[start].path, chain.at(-1).path)) return chain;
|
|
807
|
+
const tied = chain.slice(start + 1).map(({ path, commits = [] }) => ({ path, commits: [...commits, next] }));
|
|
808
|
+
return [...chain.slice(0, start + 1), ...tied];
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function reachedPart(chain, reached) {
|
|
813
|
+
const at = chain.findIndex((position, index) => index > 0 && !reaches(position, reached));
|
|
814
|
+
return at < 0 ? chain : chain.slice(0, at);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function remainingMoves(chains, reached) {
|
|
818
|
+
return chains.flatMap((chain) => {
|
|
819
|
+
const [{ path }, ...rest] = chain.slice(reachedPart(chain, reached).length - 1);
|
|
820
|
+
return rest.length > 0 ? [[{ path }, ...rest]] : [];
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function reportedMoves(moves) {
|
|
825
|
+
if (
|
|
826
|
+
!Array.isArray(moves) ||
|
|
827
|
+
moves.some((move) => typeof move?.from !== "string" || typeof move.to !== "string" || move.from === move.to)
|
|
828
|
+
) {
|
|
829
|
+
throw new KitRefusal("kit_house_arguments_invalid", "moves");
|
|
830
|
+
}
|
|
831
|
+
return moves.map(({ from, to }) => ({ from, to }));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function reportedStrings(values, name) {
|
|
835
|
+
if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) {
|
|
836
|
+
throw new KitRefusal("kit_house_arguments_invalid", name);
|
|
837
|
+
}
|
|
838
|
+
return values;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export async function recordIndexChange(context, request, signal) {
|
|
842
|
+
if (typeof request?.cwd !== "string") throw new KitRefusal("kit_house_arguments_invalid", "cwd");
|
|
843
|
+
const moves = reportedMoves(request.moves);
|
|
844
|
+
const touched = new Set([
|
|
845
|
+
...reportedStrings(request.gone, "gone"),
|
|
846
|
+
...reportedStrings(request.arrived, "arrived"),
|
|
847
|
+
...moves.map((move) => move.to),
|
|
848
|
+
]);
|
|
849
|
+
const commit = reportedStrings(request.commit, "commit");
|
|
850
|
+
const rewrite = commit.length === 2 && ["commit", "rebase", "reset"].includes(request.command);
|
|
851
|
+
const at = await located(request.cwd, signal);
|
|
852
|
+
const held = await heldAt(context.directory, at.repository);
|
|
853
|
+
if (held === null) return;
|
|
854
|
+
const path = copyPath(context.directory, held);
|
|
855
|
+
await exclusively(path, async () => {
|
|
856
|
+
const copy = await readCopy(path);
|
|
857
|
+
if (copy === null || (copy.moves === undefined && moves.length === 0)) return;
|
|
858
|
+
const retained = retainedMoves(copy.moves ?? [], at.worktree, moves, touched);
|
|
859
|
+
const kept = rewrite
|
|
860
|
+
? await rewrittenMoves(copy.repository, retained, at.worktree, commit, { tying: request.command !== "reset", touched }, signal)
|
|
861
|
+
: retained;
|
|
862
|
+
await writeCopy(context.directory, withMoves(copy, kept), signal);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renamed(chains, reached, before, after) {
|
|
867
|
+
const pairs = chains.flatMap((chain) => {
|
|
868
|
+
const paths = reachedPart(chain, reached).map(({ path }) => path);
|
|
869
|
+
const from = paths.findIndex((path) => before.has(path));
|
|
870
|
+
const to = paths.findLastIndex((path) => after.has(path));
|
|
871
|
+
return to > from && carries(before, after, paths[from], paths[to]) ? [[paths[from], paths[to]]] : [];
|
|
872
|
+
});
|
|
873
|
+
return new Map(pairs.filter(([from, to]) => pairs.filter((pair) => pair[0] === from || pair[1] === to).length === 1));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function relocated(copy, root, [from, to], [prior, next], house, signal) {
|
|
877
|
+
const base = await revisionAt(copy, from, prior, house, signal);
|
|
878
|
+
const rename = { op: "rename", path: `${root}/${from}`, to: `${root}/${to}`, base };
|
|
879
|
+
if (prior.object === next.object) return [rename];
|
|
880
|
+
return [rename, { op: "replace", path: `${root}/${to}`, base, content: await blobText(copy.repository, next, to, signal) }];
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function derived(copy, base, commit, signal) {
|
|
884
|
+
const root = housePath(scopeOf(copy));
|
|
885
|
+
const before = await treeOf(copy.repository, base, signal);
|
|
886
|
+
const after = await treeOf(copy.repository, commit, signal);
|
|
887
|
+
const moved = renamed(copy.moves ?? [], await reachedFrom(copy.repository, copy.moves ?? [], commit, signal), before, after);
|
|
888
|
+
const arrived = new Set(moved.values());
|
|
889
|
+
const changes = [];
|
|
890
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
891
|
+
if (arrived.has(path)) continue;
|
|
892
|
+
const prior = before.get(path);
|
|
893
|
+
if (moved.has(path)) {
|
|
894
|
+
const to = moved.get(path);
|
|
895
|
+
changes.push(...(await relocated(copy, root, [path, to], [prior, after.get(to)], base, signal)));
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const next = after.get(path);
|
|
899
|
+
if (prior?.object === next?.object) continue;
|
|
900
|
+
const target = `${root}/${path}`;
|
|
901
|
+
const revision = await revisionAt(copy, path, prior, base, signal);
|
|
902
|
+
if (next === undefined) {
|
|
903
|
+
changes.push({ op: "remove", path: target, base: revision });
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const content = await blobText(copy.repository, next, path, signal);
|
|
907
|
+
if (prior === undefined) {
|
|
908
|
+
changes.push({ op: "create", path: target, content });
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const recorded = { content: await blobText(copy.repository, prior, path, signal), revision };
|
|
912
|
+
changes.push(...structuredOrReplace(root, path, recorded, content));
|
|
913
|
+
}
|
|
914
|
+
return changes;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function revisionText(session, { path, revision }, signal) {
|
|
918
|
+
const page = await door(session, KIT_DOOR_REVISIONS_PATH, { path, revision }, signal);
|
|
919
|
+
const content = page?.revisions?.find((entry) => entry?.revision === revision)?.content;
|
|
920
|
+
if (typeof content !== "string") throw new KitRefusal("kit_working_copy_result_unreadable", `${path} at ${revision}`);
|
|
921
|
+
return content;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function materials(context, copy, entries, signal) {
|
|
925
|
+
const root = housePath(scopeOf(copy));
|
|
926
|
+
const files = [];
|
|
927
|
+
for (const entry of entries) {
|
|
928
|
+
const content = entry.content === undefined ? await revisionText(context.session, entry, signal) : entry.content;
|
|
929
|
+
files.push({ path: entry.path.slice(root.length + 1), revision: entry.revision, content });
|
|
930
|
+
}
|
|
931
|
+
return files;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function recorded(copy, files, message, acknowledging, signal) {
|
|
935
|
+
const imported = await importReceived(
|
|
936
|
+
copy.repository,
|
|
937
|
+
{ message, parents: [copy.received.commit], files, acknowledging },
|
|
938
|
+
signal,
|
|
939
|
+
);
|
|
940
|
+
return {
|
|
941
|
+
copy: {
|
|
942
|
+
...copy,
|
|
943
|
+
received: { ...copy.received, commit: imported.received, ...(acknowledging === null ? {} : copy.push.from) },
|
|
944
|
+
revisions: await remembered(
|
|
945
|
+
copy.repository,
|
|
946
|
+
copy.revisions,
|
|
947
|
+
imported.acknowledged ?? imported.received,
|
|
948
|
+
files,
|
|
949
|
+
signal,
|
|
950
|
+
imported.acknowledged !== null,
|
|
951
|
+
),
|
|
952
|
+
},
|
|
953
|
+
acknowledged: imported.acknowledged,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function pushedPaths(changes) {
|
|
958
|
+
return new Set(changes.flatMap((change) => (change.op === "rename" ? [change.path, change.to] : [change.path])));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function uploadedPaths(changes) {
|
|
962
|
+
return [...pushedPaths(changes)].sort();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function unreadableChanges(changes) {
|
|
966
|
+
return changes.some(
|
|
967
|
+
(change) =>
|
|
968
|
+
typeof change?.path !== "string" ||
|
|
969
|
+
(change.removed !== true && (typeof change.content !== "string" || typeof change.revision !== "string")),
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function enumerated(context, copy, since, signal) {
|
|
974
|
+
const answer = await door(
|
|
975
|
+
context.session,
|
|
976
|
+
KIT_DOOR_ENUMERATE_PATH,
|
|
977
|
+
{ prefixes: selectedPrefixes(scopeOf(copy)), positions: [{ authority: copy.room_ref, ...since }] },
|
|
978
|
+
signal,
|
|
979
|
+
);
|
|
980
|
+
const view = answer?.authorities?.find((entry) => entry?.authority === copy.room_ref);
|
|
981
|
+
if (
|
|
982
|
+
!Array.isArray(view?.changes) ||
|
|
983
|
+
typeof view.position !== "string" ||
|
|
984
|
+
typeof view.log_epoch !== "string" ||
|
|
985
|
+
unreadableChanges(view.changes)
|
|
986
|
+
) {
|
|
987
|
+
throw rejected("enumerate");
|
|
988
|
+
}
|
|
989
|
+
return view;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function referrers(context, copy, selected, signal) {
|
|
993
|
+
const { push } = copy;
|
|
994
|
+
const root = housePath(scopeOf(copy));
|
|
995
|
+
const destinations = push.changes.filter((change) => change.op === "rename").map((change) => change.to);
|
|
996
|
+
if (destinations.length === 0) return [];
|
|
997
|
+
const changed = new Map();
|
|
998
|
+
let since = push.since;
|
|
999
|
+
while (!destinations.every((path) => changed.has(path))) {
|
|
1000
|
+
const view = await enumerated(context, copy, since, signal);
|
|
1001
|
+
for (const change of view.changes) changed.set(`${root}/${change.path}`, change);
|
|
1002
|
+
if (view.position === since.position) {
|
|
1003
|
+
throw new KitRefusal("kit_working_copy_result_unreadable", destinations.filter((path) => !changed.has(path)).join(", "));
|
|
1004
|
+
}
|
|
1005
|
+
since = { position: view.position, log_epoch: view.log_epoch };
|
|
1006
|
+
}
|
|
1007
|
+
const pushed = pushedPaths(push.changes);
|
|
1008
|
+
return [...changed.entries()]
|
|
1009
|
+
.filter(([path, change]) => !pushed.has(path) && selected.has(change.path))
|
|
1010
|
+
.map(([path, change]) =>
|
|
1011
|
+
change.removed === true
|
|
1012
|
+
? { path, revision: null, content: null }
|
|
1013
|
+
: { path, revision: change.revision, content: change.content },
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function housePosition(context, copy, signal) {
|
|
1018
|
+
const discovery = discoveryOf(
|
|
1019
|
+
await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes: selectedPrefixes(scopeOf(copy)) }, signal),
|
|
1020
|
+
);
|
|
1021
|
+
const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
|
|
1022
|
+
if (typeof authority?.position !== "string" || typeof authority.log_epoch !== "string") throw rejected("discover");
|
|
1023
|
+
return { position: authority.position, log_epoch: authority.log_epoch };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function short(commit) {
|
|
1027
|
+
return commit.slice(0, 12);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function acknowledge(context, copy, signal) {
|
|
1031
|
+
const { push } = copy;
|
|
1032
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
1033
|
+
const files = await materials(context, copy, [...push.written, ...(await referrers(context, copy, selected, signal))], signal);
|
|
1034
|
+
const edits = [];
|
|
1035
|
+
for (const file of files) {
|
|
1036
|
+
if ((await blobText(copy.repository, selected.get(file.path), file.path, signal)) !== file.content) edits.push(file);
|
|
1037
|
+
}
|
|
1038
|
+
const { copy: next, acknowledged } = await recorded(
|
|
1039
|
+
copy,
|
|
1040
|
+
files,
|
|
1041
|
+
`House ${housePath(scopeOf(copy))} with ${push.commit} accepted`,
|
|
1042
|
+
{ commit: push.commit, edits },
|
|
1043
|
+
signal,
|
|
1044
|
+
);
|
|
1045
|
+
const remaining = remainingMoves(copy.moves ?? [], await reachedFrom(copy.repository, copy.moves ?? [], push.commit, signal));
|
|
1046
|
+
return { ...withMoves(next, remaining), push: { ...push, acknowledged } };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function integrated(copy, signal) {
|
|
1050
|
+
const { commit, acknowledged, worktree, branch } = copy.push;
|
|
1051
|
+
const accepted = `House accepted ${short(commit)}`;
|
|
1052
|
+
if (acknowledged === commit) return { done: true, text: `${accepted}.` };
|
|
1053
|
+
const pending = (reason) => ({
|
|
1054
|
+
done: false,
|
|
1055
|
+
text: `${accepted}; local integration is pending (${reason}): ${acknowledged} holds House's result.`,
|
|
1056
|
+
});
|
|
1057
|
+
const git = (args) => runGit(worktree, args, { capture: true, signal, failure: GIT_FAILED });
|
|
1058
|
+
const head = await git(["symbolic-ref", "-q", "HEAD"]).then(
|
|
1059
|
+
(output) => lines(output)[0],
|
|
1060
|
+
() => null,
|
|
1061
|
+
);
|
|
1062
|
+
if (branch === null || head !== branch) return pending("the branch changed");
|
|
1063
|
+
const tip = lines(await git(["rev-parse", "HEAD"]))[0];
|
|
1064
|
+
const name = branch.replace(/^refs\/heads\//, "");
|
|
1065
|
+
if (await contains(worktree, acknowledged, tip, signal)) return { done: true, text: `${accepted}; ${name} holds it.` };
|
|
1066
|
+
if (!(await contains(worktree, commit, tip, signal))) return pending("the branch changed");
|
|
1067
|
+
if (lines(await git(["status", "--porcelain", "--untracked-files=no"])).length > 0) {
|
|
1068
|
+
return pending("uncommitted changes");
|
|
1069
|
+
}
|
|
1070
|
+
if (tip === commit) {
|
|
1071
|
+
if (!(await succeeded(git(["merge", "--ff-only", "-q", acknowledged])))) return pending("a conflict");
|
|
1072
|
+
} else if (
|
|
1073
|
+
!(await succeeded(
|
|
1074
|
+
git(["-c", `user.name=${COMMITTER_NAME}`, "-c", `user.email=${COMMITTER_EMAIL}`, "rebase", "-q", "--onto", acknowledged, commit]),
|
|
1075
|
+
))
|
|
1076
|
+
) {
|
|
1077
|
+
await git(["rebase", "--abort"]).catch(() => undefined);
|
|
1078
|
+
return pending("a conflict");
|
|
1079
|
+
}
|
|
1080
|
+
const current = lines(await git(["rev-parse", "HEAD"]))[0];
|
|
1081
|
+
const moves = await rewrittenMoves(copy.repository, copy.moves ?? [], worktree, [tip, current], { tying: true }, signal);
|
|
1082
|
+
return { done: true, text: `${accepted}; ${name} is at ${short(current)}.`, moves };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function finish(context, copy, signal) {
|
|
1086
|
+
try {
|
|
1087
|
+
let acknowledged = copy;
|
|
1088
|
+
if (copy.push.acknowledged === undefined) {
|
|
1089
|
+
acknowledged = await acknowledge(context, copy, signal);
|
|
1090
|
+
await writeCopy(context.directory, acknowledged, signal);
|
|
1091
|
+
}
|
|
1092
|
+
const outcome = await integrated(acknowledged, signal);
|
|
1093
|
+
const moves = outcome.moves ?? acknowledged.moves ?? [];
|
|
1094
|
+
if (outcome.done) await writeCopy(context.directory, without(withMoves(acknowledged, moves)), signal);
|
|
1095
|
+
return { refused: false, text: outcome.text };
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
return {
|
|
1098
|
+
refused: false,
|
|
1099
|
+
text: `House accepted ${short(copy.push.commit)}; local integration is pending (${formatCommandError(error)}); run house git push again to finish it.`,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function sameCaller(left, right) {
|
|
1105
|
+
return left.session === right.session && left.owner === right.owner;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function definite(error) {
|
|
1109
|
+
return (
|
|
1110
|
+
error instanceof KitRefusal &&
|
|
1111
|
+
error.code !== "kit_command_cancelled" &&
|
|
1112
|
+
!houseFailed(error) &&
|
|
1113
|
+
!(error.status >= 500)
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function orphaned(context, copy, signal) {
|
|
1118
|
+
const held = copy.push.caller.session;
|
|
1119
|
+
const live = (binding) => binding.session === held && binding.stopped === null && binding.reported === null;
|
|
1120
|
+
if (held === undefined || (await readBindings(context.directory)).some(live)) {
|
|
1121
|
+
throw new KitRefusal("kit_working_copy_push_pending", "another caller's push awaits its recovery");
|
|
1122
|
+
}
|
|
1123
|
+
await writeCopy(context.directory, await receive(context, without(copy), scopeOf(copy), signal), signal);
|
|
1124
|
+
throw new KitRefusal(
|
|
1125
|
+
"kit_working_copy_push_unrecoverable",
|
|
1126
|
+
`the Session that pushed ended before its outcome arrived; ${WORKING_COPY_RECEIVED_REF} holds House's current state`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function expired(context, copy, text, signal) {
|
|
1131
|
+
const { push } = copy;
|
|
1132
|
+
const root = housePath(scopeOf(copy));
|
|
1133
|
+
const confirmed = await receive(context, without(copy), scopeOf(copy), signal);
|
|
1134
|
+
const house = await treeOf(copy.repository, confirmed.received.commit, signal);
|
|
1135
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
1136
|
+
const paths = [...pushedPaths(push.changes)].map((path) => path.slice(root.length + 1));
|
|
1137
|
+
if (paths.some((path) => house.get(path)?.object !== selected.get(path)?.object)) {
|
|
1138
|
+
await writeCopy(context.directory, confirmed, signal);
|
|
1139
|
+
return {
|
|
1140
|
+
refused: true,
|
|
1141
|
+
text: `${text}\nHouse does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
const written = [];
|
|
1145
|
+
for (const path of paths) {
|
|
1146
|
+
const entry = house.get(path);
|
|
1147
|
+
written.push({
|
|
1148
|
+
path: `${root}/${path}`,
|
|
1149
|
+
revision: entry === undefined ? null : await revisionAt(confirmed, path, entry, confirmed.received.commit, signal),
|
|
1150
|
+
content: await blobText(copy.repository, entry, path, signal),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
const from = { position: confirmed.received.position, log_epoch: confirmed.received.log_epoch };
|
|
1154
|
+
const accepted = { ...confirmed, push: { ...push, state: "accepted", written, from } };
|
|
1155
|
+
await writeCopy(context.directory, accepted, signal);
|
|
1156
|
+
return finish(context, accepted, signal);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function settle(context, copy, caller, signal, restaged = false) {
|
|
1160
|
+
const { push } = copy;
|
|
1161
|
+
if (!sameCaller(push.caller, caller.binding)) return orphaned(context, copy, signal);
|
|
1162
|
+
let staged = push.upload !== undefined;
|
|
1163
|
+
const retain = async (upload) => {
|
|
1164
|
+
staged = true;
|
|
1165
|
+
await writeCopy(context.directory, { ...copy, push: { ...push, upload } }, signal);
|
|
1166
|
+
};
|
|
1167
|
+
let answer;
|
|
1168
|
+
try {
|
|
1169
|
+
const { operation_id, changes, upload } = push;
|
|
1170
|
+
answer = await caller.submit({ operation_id, changes, ...(upload === undefined ? {} : { upload }) }, signal, retain);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
if (!definite(error)) {
|
|
1173
|
+
throw new KitRefusal("kit_working_copy_push_uncertain", "run house git push again to recover its outcome");
|
|
1174
|
+
}
|
|
1175
|
+
const current = error.payload?.error?.current;
|
|
1176
|
+
answer = { refused: { code: error.code, text: formatCommandError(error), current: Array.isArray(current) ? current : null } };
|
|
1177
|
+
}
|
|
1178
|
+
const { refused } = answer;
|
|
1179
|
+
if (refused?.code === "upload_unavailable" && staged && !restaged) {
|
|
1180
|
+
const { upload, ...unstaged } = push;
|
|
1181
|
+
const cleared = { ...copy, push: unstaged };
|
|
1182
|
+
await writeCopy(context.directory, cleared, signal);
|
|
1183
|
+
return settle(context, cleared, caller, signal, true);
|
|
1184
|
+
}
|
|
1185
|
+
if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
|
|
1186
|
+
if (refused === null) {
|
|
1187
|
+
const pushed = pushedPaths(push.changes);
|
|
1188
|
+
const written = answer.written.filter((entry) => pushed.has(entry.path));
|
|
1189
|
+
const accepted = { ...copy, push: { ...push, state: "accepted", written } };
|
|
1190
|
+
await writeCopy(context.directory, accepted, signal);
|
|
1191
|
+
return finish(context, accepted, signal);
|
|
1192
|
+
}
|
|
1193
|
+
const settled = without(copy);
|
|
1194
|
+
await writeCopy(context.directory, settled, signal);
|
|
1195
|
+
if (refused.current === null) return { refused: true, text: refused.text };
|
|
1196
|
+
try {
|
|
1197
|
+
const files = await materials(context, settled, refused.current, signal);
|
|
1198
|
+
const message = `House ${housePath(scopeOf(settled))} current where ${short(push.commit)} was refused`;
|
|
1199
|
+
await writeCopy(context.directory, (await recorded(settled, files, message, null, signal)).copy, signal);
|
|
1200
|
+
return { refused: true, text: refused.text };
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
return { refused: true, text: `${refused.text}\n${WORKING_COPY_RECEIVED_REF} is unchanged: ${formatCommandError(error)}` };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function pushed(context, path, at, selector, caller, signal) {
|
|
1207
|
+
let copy = await readCopy(path);
|
|
1208
|
+
if (copy.push?.state === "submitted") return settle(context, copy, caller, signal);
|
|
1209
|
+
const commit = (
|
|
1210
|
+
await runGit(at.worktree, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${selector}^{commit}`], {
|
|
1211
|
+
capture: true,
|
|
1212
|
+
signal,
|
|
1213
|
+
failure: "kit_working_copy_commit_unknown",
|
|
1214
|
+
})
|
|
1215
|
+
)
|
|
1216
|
+
.toString("utf8")
|
|
1217
|
+
.trim();
|
|
1218
|
+
if (copy.push !== undefined) {
|
|
1219
|
+
const held = copy.push.acknowledged;
|
|
1220
|
+
if (held === undefined || !(await contains(copy.repository, held, commit, signal))) {
|
|
1221
|
+
return finish(context, copy, signal);
|
|
1222
|
+
}
|
|
1223
|
+
copy = without(copy);
|
|
1224
|
+
await writeCopy(context.directory, copy, signal);
|
|
1225
|
+
}
|
|
1226
|
+
if (!copy.active) throw new KitRefusal("kit_working_copy_inactive", housePath(scopeOf(copy)));
|
|
1227
|
+
const changes = await derived(copy, await trustedBase(copy, commit, signal), commit, signal);
|
|
1228
|
+
if (changes.length === 0) return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
|
|
1229
|
+
const branch = await runGit(at.worktree, ["symbolic-ref", "-q", "HEAD"], { capture: true, signal, failure: GIT_FAILED })
|
|
1230
|
+
.then((output) => lines(output)[0], () => null);
|
|
1231
|
+
const since = changes.some((change) => change.op === "rename") ? await housePosition(context, copy, signal) : null;
|
|
1232
|
+
const submitted = {
|
|
1233
|
+
...copy,
|
|
1234
|
+
push: {
|
|
1235
|
+
state: "submitted",
|
|
1236
|
+
caller: caller.binding,
|
|
1237
|
+
operation_id: operationId(),
|
|
1238
|
+
changes,
|
|
1239
|
+
commit,
|
|
1240
|
+
from: { position: copy.received.position, log_epoch: copy.received.log_epoch },
|
|
1241
|
+
worktree: at.worktree,
|
|
1242
|
+
branch,
|
|
1243
|
+
...(since === null ? {} : { since }),
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
await writeCopy(context.directory, submitted, signal);
|
|
1247
|
+
return settle(context, submitted, caller, signal);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
export async function pushWorkingCopy(context, request, caller, signal) {
|
|
1251
|
+
if (typeof request?.cwd !== "string" || typeof request.commit !== "string") {
|
|
1252
|
+
throw new KitRefusal("kit_house_arguments_invalid", "cwd and commit");
|
|
1253
|
+
}
|
|
1254
|
+
const at = await located(request.cwd, signal);
|
|
1255
|
+
const path = copyPath(context.directory, await copyAt(context.directory, at.repository));
|
|
1256
|
+
return exclusively(path, () => pushed(context, path, at, request.commit, caller, signal));
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export function ownerCaller(session) {
|
|
1260
|
+
return {
|
|
1261
|
+
binding: { owner: true },
|
|
1262
|
+
async submit(body, signal) {
|
|
1263
|
+
return { refused: null, written: (await door(session, KIT_DOOR_BATCH_PATH, body, signal)).contents };
|
|
1264
|
+
},
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export function ownerPush(options, request, dependencies = {}) {
|
|
1269
|
+
return withSession(options, dependencies, (session, signal) =>
|
|
1270
|
+
session.resident === undefined
|
|
1271
|
+
? pushWorkingCopy({ directory: options.directory, session }, request, ownerCaller(session), signal)
|
|
1272
|
+
: session.push(request, signal),
|
|
1273
|
+
);
|
|
1274
|
+
}
|