@chipschat/harness 0.0.0-stage → 1.0.1
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/LICENSE +201 -0
- package/README.md +66 -2
- package/dist/asks.d.ts +75 -0
- package/dist/asks.d.ts.map +1 -0
- package/dist/asks.js +103 -0
- package/dist/asks.js.map +1 -0
- package/dist/connect.d.ts +406 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +883 -0
- package/dist/connect.js.map +1 -0
- package/dist/events.d.ts +89 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +36 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +34 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +78 -0
- package/dist/files.js.map +1 -0
- package/dist/heard.d.ts +22 -0
- package/dist/heard.d.ts.map +1 -0
- package/dist/heard.js +32 -0
- package/dist/heard.js.map +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/join.d.ts +101 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +289 -0
- package/dist/join.js.map +1 -0
- package/dist/keep.d.ts +33 -0
- package/dist/keep.d.ts.map +1 -0
- package/dist/keep.js +160 -0
- package/dist/keep.js.map +1 -0
- package/dist/lane.d.ts +73 -0
- package/dist/lane.d.ts.map +1 -0
- package/dist/lane.js +105 -0
- package/dist/lane.js.map +1 -0
- package/dist/outbox.d.ts +50 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +52 -0
- package/dist/outbox.js.map +1 -0
- package/dist/project.d.ts +224 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +619 -0
- package/dist/project.js.map +1 -0
- package/dist/prompt.d.ts +61 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +87 -0
- package/dist/prompt.js.map +1 -0
- package/dist/rules.d.ts +18 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +25 -0
- package/dist/rules.js.map +1 -0
- package/dist/run.d.ts +246 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +295 -0
- package/dist/run.js.map +1 -0
- package/dist/sending.d.ts +78 -0
- package/dist/sending.d.ts.map +1 -0
- package/dist/sending.js +149 -0
- package/dist/sending.js.map +1 -0
- package/dist/service.d.ts +124 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +349 -0
- package/dist/service.js.map +1 -0
- package/dist/settings.d.ts +137 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +228 -0
- package/dist/settings.js.map +1 -0
- package/dist/steps.d.ts +27 -0
- package/dist/steps.d.ts.map +1 -0
- package/dist/steps.js +26 -0
- package/dist/steps.js.map +1 -0
- package/dist/store.d.ts +35 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +52 -0
- package/dist/store.js.map +1 -0
- package/package.json +38 -3
package/dist/project.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A room's project, for one agent (ADR 0221): the agent works in a copy of its own, `<its
|
|
3
|
+
* folder>/room`, and the bridge does the git, never the agent.
|
|
4
|
+
*
|
|
5
|
+
* - **Before a turn, `pull`.** The copy is made on first use, its leave-out list written into its
|
|
6
|
+
* own `info/exclude`, and brought up to date with the room's `main`. What a turn left unsent goes
|
|
7
|
+
* in first, so nothing of it is lost. What others changed since the agent's last turn, and who, is
|
|
8
|
+
* what the turn is told, with a clash still to settle or a key to take out (`renderProjectNote`).
|
|
9
|
+
* - **After a turn, `push`.** Everything the agent changed is committed under its name, with the
|
|
10
|
+
* first line of its words for the subject, and sent. When somebody sent first, the agent's commits
|
|
11
|
+
* are replayed on top of theirs and sent again. When the two changes overlap, the replay stops and
|
|
12
|
+
* the two are merged instead, so that both sides are marked in the agent's copy, labelled `yours`
|
|
13
|
+
* and with whose they clashed with; nothing is sent until the agent has settled them.
|
|
14
|
+
* - **What is never committed.** The leave-out list, and a file whose words look like a key: then
|
|
15
|
+
* nothing of the change is committed, and the next turn is told which file and why. The server
|
|
16
|
+
* refuses the same (ADR 0220), as the backstop.
|
|
17
|
+
*
|
|
18
|
+
* The token never reaches git's command line or a file: it goes through `GIT_CONFIG_*` in git's
|
|
19
|
+
* environment, beside the agent in `chips-actor`, so an agent's own `git push` carries neither.
|
|
20
|
+
* That keeps the token out of the copy, not out of the agent's reach: the agent can write the
|
|
21
|
+
* copy's `.git/config`, which git reads on every run. So the bridge fetches and sends by the
|
|
22
|
+
* project's URL, never what `origin` names, turns `core.fsmonitor` off, and leaves an agent's copy
|
|
23
|
+
* alone while its config holds anything git and the bridge didn't put there, such as a rewritten
|
|
24
|
+
* URL, a proxy or a program for git to run (SEC-52). git reads none of the machine's config and
|
|
25
|
+
* runs none of its hooks, so a person's own git setup (a signing key, a hook, `pull.rebase`) changes
|
|
26
|
+
* nothing here.
|
|
27
|
+
*
|
|
28
|
+
* **A person's folder** is the same copy with `person` set (ADR 0224), as the Mac app's Your folder
|
|
29
|
+
* keeps it: the folder itself is the copy, the token a project token and the commits theirs. It is
|
|
30
|
+
* never committed to behind their back: `look` brings it up to `main` only while nothing in it is
|
|
31
|
+
* theirs, and `push` sends what they changed when they say Send.
|
|
32
|
+
*/
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { isSecretsFile, namesInWords, PROJECT_ACTOR_HEADER, PROJECT_BRANCH, PROJECT_GIT_VERSION, PROJECT_LEAVE_OUT, projectPath, secretIn, } from "@chipschat/protocol";
|
|
38
|
+
/** A file over this is not read for keys: keys live in config files and scripts. */
|
|
39
|
+
const READ_UP_TO = 4 * 1024 * 1024;
|
|
40
|
+
/** The most of a clash the card shows; the editor has the rest. */
|
|
41
|
+
const MARKED_LINES = 14;
|
|
42
|
+
/** How many times a push is replayed on top of others' before it gives up. */
|
|
43
|
+
const TRIES = 4;
|
|
44
|
+
const MARKS = /^(<<<<<<<|>>>>>>>) /m;
|
|
45
|
+
const REJECTED = /\[rejected\]|fetch first|non-fast-forward/;
|
|
46
|
+
/**
|
|
47
|
+
* What `git init` and the bridge write in a copy's config (SEC-52), and what an agent's own git
|
|
48
|
+
* may harmlessly add: a branch's upstream (`git push -u`) and a name for its commits, which mean
|
|
49
|
+
* nothing to a bridge that fetches and sends by URL. git lists names lowercased.
|
|
50
|
+
*/
|
|
51
|
+
const OWN_CONFIG = /^(core\.(repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode|symlinks)|remote\.origin\.(url|fetch)|branch\..+\.(remote|merge)|user\.(name|email))$/;
|
|
52
|
+
const lines = (said) => said.split("\n").filter((line) => line !== "");
|
|
53
|
+
const unique = (names) => [...new Set(names)];
|
|
54
|
+
/** A commit's subject: the first line of the agent's words, as it said it. */
|
|
55
|
+
export function subjectOf(words) {
|
|
56
|
+
const first = words
|
|
57
|
+
.split("\n")
|
|
58
|
+
.find((line) => line.trim() !== "")
|
|
59
|
+
?.trim() ?? "";
|
|
60
|
+
if (first === "")
|
|
61
|
+
return "A change to the project";
|
|
62
|
+
return first.length <= 72 ? first : `${first.slice(0, 71)}…`;
|
|
63
|
+
}
|
|
64
|
+
/** What the server said, from git's `remote:` lines; git's own first line when it said none. */
|
|
65
|
+
function remoteWords(err) {
|
|
66
|
+
const said = lines(err)
|
|
67
|
+
.filter((line) => line.startsWith("remote: "))
|
|
68
|
+
.map((line) => line.slice("remote: ".length).trim())
|
|
69
|
+
.filter((line) => line !== "" && !/^error: hook declined/.test(line));
|
|
70
|
+
return said.length > 0 ? said.join(" ") : (lines(err)[0] ?? "git said nothing");
|
|
71
|
+
}
|
|
72
|
+
/** `[2, 50]` from `git version 2.50.1`; nothing from words that aren't one. */
|
|
73
|
+
function versionIn(said) {
|
|
74
|
+
const found = /(\d+)\.(\d+)/.exec(said);
|
|
75
|
+
return found === null ? undefined : [Number(found[1]), Number(found[2])];
|
|
76
|
+
}
|
|
77
|
+
/** What `push` did, in a few words for the bridge's log. */
|
|
78
|
+
export function pushedInWords(pushed) {
|
|
79
|
+
switch (pushed.state) {
|
|
80
|
+
case "sent": {
|
|
81
|
+
const files = `${pushed.files.length} file${pushed.files.length === 1 ? "" : "s"}`;
|
|
82
|
+
const over = pushed.over.length > 0 ? `, over ${namesInWords(pushed.over)}'s` : "";
|
|
83
|
+
return `sent ${pushed.commit.slice(0, 7)} (${files}${over})`;
|
|
84
|
+
}
|
|
85
|
+
case "nothing":
|
|
86
|
+
return "nothing to send";
|
|
87
|
+
case "clash":
|
|
88
|
+
return `held: ${namesInWords(pushed.files)} clashed with ${namesInWords(pushed.with) || "another change"}`;
|
|
89
|
+
case "keys":
|
|
90
|
+
return `held: ${namesInWords(pushed.keys.map((key) => `${key.path} (${key.what})`))}`;
|
|
91
|
+
case "refused":
|
|
92
|
+
return `refused: ${pushed.why}`;
|
|
93
|
+
case "failed":
|
|
94
|
+
return `not sent: ${pushed.why}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** A sentence's last words with one full stop after them, however they came. */
|
|
98
|
+
const stopped = (words) => `${words.trim().replace(/[.\s]+$/, "")}.`;
|
|
99
|
+
/**
|
|
100
|
+
* What a reply says after its words when its change didn't reach the room's project (AP-6, ADR
|
|
101
|
+
* 0222's addendum): a line in brackets, as a file that didn't attach has (`sendAnswer`), so that
|
|
102
|
+
* "it's in the project" is never the last word on a change that isn't. Nothing when it was sent,
|
|
103
|
+
* or when nothing changed.
|
|
104
|
+
*/
|
|
105
|
+
export function heldNote(pushed, name) {
|
|
106
|
+
switch (pushed.state) {
|
|
107
|
+
case "sent":
|
|
108
|
+
case "nothing":
|
|
109
|
+
return undefined;
|
|
110
|
+
case "clash": {
|
|
111
|
+
const whose = pushed.with.length === 0 ? "another change" : `${namesInWords(pushed.with)}'s change`;
|
|
112
|
+
return `(Not in the space's project yet: ${namesInWords(pushed.files)} clashed with ${whose}. ${name} is asked to settle it next turn.)`;
|
|
113
|
+
}
|
|
114
|
+
case "keys": {
|
|
115
|
+
const [first] = pushed.keys;
|
|
116
|
+
const held = pushed.keys.length === 1 && first !== undefined
|
|
117
|
+
? `${first.path} looks like it holds ${first.what.replace(/, by its name$/, "")}`
|
|
118
|
+
: `${namesInWords(pushed.keys.map((key) => key.path))} look like they hold keys`;
|
|
119
|
+
return `(Not in the space's project: ${held}, so nothing was sent.)`;
|
|
120
|
+
}
|
|
121
|
+
case "refused":
|
|
122
|
+
return `(Not in the space's project: it was turned back: ${stopped(pushed.why)})`;
|
|
123
|
+
case "failed":
|
|
124
|
+
return `(Not in the space's project: it couldn't be sent: ${stopped(pushed.why)})`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Whether this machine has a git new enough for a room's project, or why not. */
|
|
128
|
+
export async function checkGit(git = "git",
|
|
129
|
+
// biome-ignore lint/style/noProcessEnv: git is found where the harness finds its tools.
|
|
130
|
+
path = process.env["PATH"] ?? "/usr/bin:/bin") {
|
|
131
|
+
const [major, minor] = versionIn(PROJECT_GIT_VERSION) ?? [0, 0];
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
execFile(git, ["--version"], { env: { PATH: path } }, (error, stdout) => {
|
|
134
|
+
if (error !== null)
|
|
135
|
+
return resolve({ ok: false, why: "git was not found on this machine" });
|
|
136
|
+
const mine = versionIn(stdout);
|
|
137
|
+
const enough = mine !== undefined && (mine[0] > major || (mine[0] === major && mine[1] >= minor));
|
|
138
|
+
resolve(enough
|
|
139
|
+
? { ok: true, version: mine.join(".") }
|
|
140
|
+
: { ok: false, why: `${stdout.trim()} is older than ${PROJECT_GIT_VERSION}` });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export class Project {
|
|
145
|
+
options;
|
|
146
|
+
/** The agent's copy of the project. */
|
|
147
|
+
copy;
|
|
148
|
+
remote;
|
|
149
|
+
constructor(options) {
|
|
150
|
+
this.options = options;
|
|
151
|
+
this.copy = options.person === true ? options.folder : join(options.folder, "room");
|
|
152
|
+
this.remote = `${options.url.replace(/\/$/, "").replace(/^ws/, "http")}${projectPath(options.spaceId)}`;
|
|
153
|
+
}
|
|
154
|
+
/** Before a turn: the copy made or brought up to date, and what the turn is to be told of it. */
|
|
155
|
+
async pull() {
|
|
156
|
+
const made = await this.ready();
|
|
157
|
+
const foreign = await this.foreignConfig();
|
|
158
|
+
if (foreign !== undefined)
|
|
159
|
+
return this.facts({ trouble: foreign });
|
|
160
|
+
const known = await this.head("refs/remotes/origin/main");
|
|
161
|
+
const marked = await this.marked();
|
|
162
|
+
if (marked.length > 0) {
|
|
163
|
+
// A clash not yet settled: nothing moves until it is, and the turn is told again.
|
|
164
|
+
return this.facts({ clashes: marked, clashedWith: await this.mergingWith() });
|
|
165
|
+
}
|
|
166
|
+
const keys = await this.commit("Work left from a turn that didn't finish");
|
|
167
|
+
if (keys.length > 0) {
|
|
168
|
+
return this.facts({ keys, trouble: "a file in it looks like it holds a key." });
|
|
169
|
+
}
|
|
170
|
+
const fetched = await this.fetch();
|
|
171
|
+
if (!fetched.ok) {
|
|
172
|
+
const why = remoteWords(fetched.err);
|
|
173
|
+
this.options.log?.(`project: ${this.options.name} couldn't fetch ${this.remote}: ${why}`);
|
|
174
|
+
return this.facts({ trouble: why });
|
|
175
|
+
}
|
|
176
|
+
const remote = await this.head("refs/remotes/origin/main");
|
|
177
|
+
if (remote === "")
|
|
178
|
+
return this.facts({});
|
|
179
|
+
const brought = await this.bringUp(remote);
|
|
180
|
+
// What others changed since the agent last looked: since where the room's `main` was then, or
|
|
181
|
+
// all of it when the agent's last turn found the project empty. Nothing for a copy made now.
|
|
182
|
+
const since = known !== "" ? known : made ? undefined : await this.emptyTree();
|
|
183
|
+
if (since === undefined || since === remote)
|
|
184
|
+
return this.facts(brought);
|
|
185
|
+
const changed = lines((await this.git(["diff", "--name-only", since, remote])).out);
|
|
186
|
+
const by = await this.authors(known !== "" ? `${known}..${remote}` : remote);
|
|
187
|
+
return this.facts({ changed, by, ...brought });
|
|
188
|
+
}
|
|
189
|
+
/** After a turn: what the agent changed, committed under its name and sent to the room. */
|
|
190
|
+
async push(input) {
|
|
191
|
+
if (!existsSync(join(this.copy, ".git")))
|
|
192
|
+
return { state: "nothing" };
|
|
193
|
+
const foreign = await this.foreignConfig();
|
|
194
|
+
if (foreign !== undefined)
|
|
195
|
+
return { state: "failed", why: foreign };
|
|
196
|
+
const marked = await this.marked();
|
|
197
|
+
if (marked.length > 0) {
|
|
198
|
+
return { state: "clash", files: marked, with: await this.mergingWith() };
|
|
199
|
+
}
|
|
200
|
+
const keys = await this.commit(subjectOf(input.words));
|
|
201
|
+
if (keys.length > 0)
|
|
202
|
+
return { state: "keys", keys };
|
|
203
|
+
if ((await this.head("HEAD")) === "")
|
|
204
|
+
return { state: "nothing" };
|
|
205
|
+
let over = [];
|
|
206
|
+
for (let attempt = 0; attempt < TRIES; attempt += 1) {
|
|
207
|
+
const tried = await this.send(over);
|
|
208
|
+
if ("state" in tried)
|
|
209
|
+
return tried;
|
|
210
|
+
over = tried.over;
|
|
211
|
+
}
|
|
212
|
+
return { state: "failed", why: "others kept sending first." };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A person's folder made, or brought up to the room's `main` while nothing in it is theirs: no
|
|
216
|
+
* file changed or added, nothing waiting to be sent, no clash (ADR 0224). Once something is
|
|
217
|
+
* theirs, nothing in the folder changes; what others changed since is told instead.
|
|
218
|
+
*/
|
|
219
|
+
async look() {
|
|
220
|
+
await this.ready();
|
|
221
|
+
const fetched = await this.fetch();
|
|
222
|
+
const trouble = fetched.ok ? undefined : remoteWords(fetched.err);
|
|
223
|
+
const remote = await this.head("refs/remotes/origin/main");
|
|
224
|
+
await this.settledAsTheirs(remote);
|
|
225
|
+
const brought = await this.bringUntouched(remote);
|
|
226
|
+
return { ...(await this.standing(remote)), ...brought, trouble };
|
|
227
|
+
}
|
|
228
|
+
/** The folder moved on to the room's `main` while nothing in it is theirs: what came in, whose. */
|
|
229
|
+
async bringUntouched(remote) {
|
|
230
|
+
const mine = await this.head("HEAD");
|
|
231
|
+
const none = { came: [], cameBy: [] };
|
|
232
|
+
if (remote === "" || mine === remote || !(await this.untouched(mine, remote)))
|
|
233
|
+
return none;
|
|
234
|
+
const moved = mine === ""
|
|
235
|
+
? await this.git(["checkout", "--quiet", "-B", PROJECT_BRANCH, remote])
|
|
236
|
+
: await this.git(["merge", "--quiet", "--ff-only", remote]);
|
|
237
|
+
if (!moved.ok)
|
|
238
|
+
return none;
|
|
239
|
+
const from = mine === "" ? await this.emptyTree() : mine;
|
|
240
|
+
return {
|
|
241
|
+
came: lines((await this.git(["diff", "--name-only", from, remote])).out),
|
|
242
|
+
cameBy: await this.authors(mine === "" ? remote : `${mine}..${remote}`),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A clash the person settled by taking the room's side whole: nothing of theirs is left to send,
|
|
247
|
+
* so the folder goes back to the room's `main` and is kept up to date again. Every tracked file
|
|
248
|
+
* is already the room's, and a file git doesn't track is never touched.
|
|
249
|
+
*/
|
|
250
|
+
async settledAsTheirs(remote) {
|
|
251
|
+
if (remote === "" || !existsSync(join(this.copy, ".git", "MERGE_HEAD")))
|
|
252
|
+
return;
|
|
253
|
+
if ((await this.marked()).length > 0)
|
|
254
|
+
return;
|
|
255
|
+
if (!(await this.git(["diff", "--quiet", remote])).ok)
|
|
256
|
+
return;
|
|
257
|
+
await this.git(["reset", "--quiet", "--hard", remote]);
|
|
258
|
+
}
|
|
259
|
+
/** Whether nothing in the folder is the person's own: no change, no unsent commit, no clash. */
|
|
260
|
+
async untouched(mine, remote) {
|
|
261
|
+
if (existsSync(join(this.copy, ".git", "MERGE_HEAD")))
|
|
262
|
+
return false;
|
|
263
|
+
const status = await this.git(["status", "--porcelain", "--untracked-files=all"]);
|
|
264
|
+
if (!status.ok || status.out.trim() !== "")
|
|
265
|
+
return false;
|
|
266
|
+
if (mine === "")
|
|
267
|
+
return true;
|
|
268
|
+
return (await this.git(["merge-base", "--is-ancestor", mine, remote])).ok;
|
|
269
|
+
}
|
|
270
|
+
/** What the person changed, a clash, and what others changed that the folder hasn't. */
|
|
271
|
+
async standing(remote) {
|
|
272
|
+
const mine = await this.head("HEAD");
|
|
273
|
+
const base = await this.baseOf(mine, remote);
|
|
274
|
+
const counted = lines((await this.git(["diff", "--numstat", "--no-renames", "-z", base])).out.replace(/\0/g, "\n")).map((line) => {
|
|
275
|
+
const [added = "0", removed = "0", ...path] = line.split("\t");
|
|
276
|
+
return { path: path.join("\t"), added: Number(added) || 0, removed: Number(removed) || 0 };
|
|
277
|
+
});
|
|
278
|
+
const added = lines((await this.git(["ls-files", "--others", "--exclude-standard", "-z"])).out.replace(/\0/g, "\n"));
|
|
279
|
+
const fresh = await Promise.all(added.map(async (path) => ({ path, added: await this.lineCount(path), removed: 0 })));
|
|
280
|
+
const changed = [...counted, ...fresh]
|
|
281
|
+
.filter((change) => change.path !== "")
|
|
282
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
283
|
+
const clashes = await this.marked();
|
|
284
|
+
const clashedWith = await this.mergingWith();
|
|
285
|
+
const first = clashes[0];
|
|
286
|
+
const marked = first === undefined ? undefined : await this.markedLines(first);
|
|
287
|
+
const since = remote === "" || remote === base
|
|
288
|
+
? []
|
|
289
|
+
: lines((await this.git(["diff", "--name-only", base, remote])).out);
|
|
290
|
+
const by = since.length === 0 ? [] : await this.authors(mine === "" ? remote : `${base}..${remote}`);
|
|
291
|
+
return { changed, clashes, clashedWith, marked, since, by };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* What the person's changes are measured from: where the folder and the room's `main` parted.
|
|
295
|
+
* In a clash the room's side is already in the folder, so it is `main` itself: what differs from
|
|
296
|
+
* it is what Send would change.
|
|
297
|
+
*/
|
|
298
|
+
async baseOf(mine, remote) {
|
|
299
|
+
if (mine === "")
|
|
300
|
+
return this.emptyTree();
|
|
301
|
+
if (remote === "")
|
|
302
|
+
return mine;
|
|
303
|
+
if (existsSync(join(this.copy, ".git", "MERGE_HEAD")))
|
|
304
|
+
return remote;
|
|
305
|
+
return (await this.git(["merge-base", mine, remote])).out.trim() || mine;
|
|
306
|
+
}
|
|
307
|
+
/** How many lines a file the person added has: the lines it adds. None for a picture. */
|
|
308
|
+
async lineCount(path) {
|
|
309
|
+
try {
|
|
310
|
+
const at = join(this.copy, path);
|
|
311
|
+
if ((await stat(at)).size > READ_UP_TO)
|
|
312
|
+
return 0;
|
|
313
|
+
const words = await readFile(at, "utf8");
|
|
314
|
+
if (words.includes("\0"))
|
|
315
|
+
return 0;
|
|
316
|
+
return words === "" ? 0 : words.split("\n").length - (words.endsWith("\n") ? 1 : 0);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** The first clash in a file, from the line before its first mark to the line after its last. */
|
|
323
|
+
async markedLines(path) {
|
|
324
|
+
try {
|
|
325
|
+
const all = (await readFile(join(this.copy, path), "utf8")).split("\n");
|
|
326
|
+
const start = all.findIndex((line) => line.startsWith("<<<<<<< "));
|
|
327
|
+
const end = all.findIndex((line, at) => at > start && line.startsWith(">>>>>>> "));
|
|
328
|
+
if (start < 0 || end < 0)
|
|
329
|
+
return undefined;
|
|
330
|
+
const shown = all.slice(Math.max(0, start - 1), end + 2);
|
|
331
|
+
return shown.length <= MARKED_LINES
|
|
332
|
+
? shown.join("\n")
|
|
333
|
+
: [...shown.slice(0, MARKED_LINES - 1), "…"].join("\n");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* One try at sending: sent, nothing to send, or turned back; or, when somebody sent first, the
|
|
341
|
+
* agent's commits replayed on top of theirs, for the next try, unless they clashed.
|
|
342
|
+
*/
|
|
343
|
+
async send(earlier) {
|
|
344
|
+
const fetched = await this.fetch();
|
|
345
|
+
if (!fetched.ok)
|
|
346
|
+
return { state: "failed", why: remoteWords(fetched.err) };
|
|
347
|
+
const remote = await this.head("refs/remotes/origin/main");
|
|
348
|
+
const ahead = await this.git([
|
|
349
|
+
"rev-list",
|
|
350
|
+
"--count",
|
|
351
|
+
remote === "" ? "HEAD" : `${remote}..HEAD`,
|
|
352
|
+
]);
|
|
353
|
+
if (ahead.out.trim() === "0")
|
|
354
|
+
return { state: "nothing" };
|
|
355
|
+
const sent = await this.git([
|
|
356
|
+
"push",
|
|
357
|
+
"--quiet",
|
|
358
|
+
"--porcelain",
|
|
359
|
+
this.remote,
|
|
360
|
+
`HEAD:refs/heads/${PROJECT_BRANCH}`,
|
|
361
|
+
]);
|
|
362
|
+
if (sent.ok) {
|
|
363
|
+
const commit = await this.head("HEAD");
|
|
364
|
+
// Sent by URL, git leaves `origin/main` where it was: the next turn counts from this commit.
|
|
365
|
+
await this.git(["update-ref", "refs/remotes/origin/main", commit]);
|
|
366
|
+
const from = remote === "" ? await this.emptyTree() : remote;
|
|
367
|
+
const files = lines((await this.git(["diff", "--name-only", from, commit])).out);
|
|
368
|
+
return { state: "sent", commit, files, over: earlier };
|
|
369
|
+
}
|
|
370
|
+
const said = `${sent.out}\n${sent.err}`;
|
|
371
|
+
if (!REJECTED.test(said))
|
|
372
|
+
return { state: "refused", why: remoteWords(said) };
|
|
373
|
+
// Somebody sent first: their commits fetched, and the agent's replayed on top.
|
|
374
|
+
await this.fetch();
|
|
375
|
+
const theirs = await this.head("refs/remotes/origin/main");
|
|
376
|
+
const over = unique([...earlier, ...(await this.authors(`HEAD..${theirs}`))]);
|
|
377
|
+
const replayed = await this.replay(theirs);
|
|
378
|
+
if (replayed.trouble !== undefined)
|
|
379
|
+
return { state: "failed", why: replayed.trouble };
|
|
380
|
+
if (replayed.clashes === undefined)
|
|
381
|
+
return { over };
|
|
382
|
+
return { state: "clash", files: replayed.clashes, with: replayed.clashedWith ?? over };
|
|
383
|
+
}
|
|
384
|
+
/** The room's branches, from the project's own URL rather than whatever `origin` names (SEC-52). */
|
|
385
|
+
fetch() {
|
|
386
|
+
return this.git(["fetch", "--quiet", this.remote, "+refs/heads/*:refs/remotes/origin/*"]);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Why an agent's copy is left alone: its config holds a setting git and the bridge didn't write,
|
|
390
|
+
* or can't be read (SEC-52). Nothing for a person's folder, whose config is theirs.
|
|
391
|
+
*/
|
|
392
|
+
async foreignConfig() {
|
|
393
|
+
if (this.options.person === true)
|
|
394
|
+
return undefined;
|
|
395
|
+
const listed = await this.git(["config", "--local", "--list", "--name-only"]);
|
|
396
|
+
if (!listed.ok)
|
|
397
|
+
return remoteWords(listed.err);
|
|
398
|
+
const foreign = unique(lines(listed.out).filter((key) => !OWN_CONFIG.test(key)));
|
|
399
|
+
if (foreign.length === 0)
|
|
400
|
+
return undefined;
|
|
401
|
+
return `the copy's .git/config has settings Chips didn't put there (${namesInWords(foreign)}), so Chips won't fetch or send until they're taken out.`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The copy, made on first use, pointed at the room's project, its leave-out list written: whether
|
|
405
|
+
* it was made now.
|
|
406
|
+
*/
|
|
407
|
+
async ready() {
|
|
408
|
+
if (existsSync(join(this.copy, ".git"))) {
|
|
409
|
+
const pointed = (await this.git(["remote", "get-url", "origin"])).out.trim();
|
|
410
|
+
if (pointed === this.remote)
|
|
411
|
+
return false;
|
|
412
|
+
if (this.options.person === true) {
|
|
413
|
+
// A person's folder is found by its room (ADR 0224): the server moved, not the room.
|
|
414
|
+
await this.git(["remote", "set-url", "origin", this.remote]);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
// The agent moved rooms (ADR 0087): its old copy is kept beside, named for that room.
|
|
418
|
+
const was = /\/v1\/spaces\/([^/]+)\/project\.git$/.exec(pointed)?.[1] ?? "before";
|
|
419
|
+
await rename(this.copy, join(this.options.folder, `room-${was}`));
|
|
420
|
+
}
|
|
421
|
+
await mkdir(this.copy, { recursive: true });
|
|
422
|
+
await this.git(["init", "--quiet", `--initial-branch=${PROJECT_BRANCH}`]);
|
|
423
|
+
await this.git(["remote", "add", "origin", this.remote]);
|
|
424
|
+
await mkdir(join(this.copy, ".git", "info"), { recursive: true });
|
|
425
|
+
await writeFile(join(this.copy, ".git", "info", "exclude"), `${PROJECT_LEAVE_OUT.join("\n")}\n`);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
/** The copy at the room's `main`, with the agent's own unsent commits replayed on top. */
|
|
429
|
+
async bringUp(remote) {
|
|
430
|
+
const mine = await this.head("HEAD");
|
|
431
|
+
if (mine === "") {
|
|
432
|
+
const checked = await this.git(["checkout", "--quiet", "-B", PROJECT_BRANCH, remote]);
|
|
433
|
+
return checked.ok ? {} : { trouble: remoteWords(checked.err) };
|
|
434
|
+
}
|
|
435
|
+
const replayed = await this.replay(remote);
|
|
436
|
+
if (replayed.trouble !== undefined)
|
|
437
|
+
return { trouble: replayed.trouble };
|
|
438
|
+
return replayed.clashes === undefined
|
|
439
|
+
? {}
|
|
440
|
+
: { clashes: replayed.clashes, clashedWith: replayed.clashedWith ?? [] };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The agent's commits replayed on top of `onto`. Where they overlap, the replay is undone and the
|
|
444
|
+
* two merged instead, which leaves both sides marked in the files, labelled for whose they are.
|
|
445
|
+
* Two copies that both began in an empty project share no commit, and the merge is still made.
|
|
446
|
+
*/
|
|
447
|
+
async replay(onto) {
|
|
448
|
+
const rebased = await this.git(["rebase", "--quiet", onto]);
|
|
449
|
+
if (rebased.ok)
|
|
450
|
+
return {};
|
|
451
|
+
await this.git(["rebase", "--abort"]);
|
|
452
|
+
const theirs = await this.authors(`HEAD..${onto}`);
|
|
453
|
+
const merged = await this.git([
|
|
454
|
+
"merge",
|
|
455
|
+
"--quiet",
|
|
456
|
+
"--no-edit",
|
|
457
|
+
"--no-ff",
|
|
458
|
+
"--allow-unrelated-histories",
|
|
459
|
+
onto,
|
|
460
|
+
]);
|
|
461
|
+
const clashes = lines((await this.git(["diff", "--name-only", "--diff-filter=U"])).out);
|
|
462
|
+
if (clashes.length === 0)
|
|
463
|
+
return merged.ok ? {} : { trouble: remoteWords(merged.err) };
|
|
464
|
+
const label = theirs.length === 1 ? (theirs[0] ?? "the space") : "the space";
|
|
465
|
+
for (const path of clashes)
|
|
466
|
+
await this.label(path, onto, label);
|
|
467
|
+
return { clashes, clashedWith: theirs };
|
|
468
|
+
}
|
|
469
|
+
/** git's labels for a clash's two sides, `HEAD` and a commit, as a person reads them. */
|
|
470
|
+
async label(path, onto, theirs) {
|
|
471
|
+
const at = join(this.copy, path);
|
|
472
|
+
try {
|
|
473
|
+
const words = await readFile(at, "utf8");
|
|
474
|
+
const relabelled = words
|
|
475
|
+
.replace(/^<<<<<<< HEAD$/gm, "<<<<<<< yours")
|
|
476
|
+
.replace(new RegExp(`^>>>>>>> ${onto}$`, "gm"), `>>>>>>> ${theirs}`);
|
|
477
|
+
if (relabelled !== words)
|
|
478
|
+
await writeFile(at, relabelled);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// A file deleted on one side has no marks to label.
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Files git still holds as clashing that still carry marks. */
|
|
485
|
+
async marked() {
|
|
486
|
+
if (!existsSync(join(this.copy, ".git", "MERGE_HEAD")))
|
|
487
|
+
return [];
|
|
488
|
+
const clashing = lines((await this.git(["diff", "--name-only", "--diff-filter=U"])).out);
|
|
489
|
+
const marked = [];
|
|
490
|
+
for (const path of clashing) {
|
|
491
|
+
try {
|
|
492
|
+
if (MARKS.test(await readFile(join(this.copy, path), "utf8")))
|
|
493
|
+
marked.push(path);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Gone from the copy: the agent settled it by deleting it.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return marked;
|
|
500
|
+
}
|
|
501
|
+
/** Whose change a clash in progress is with, by name. */
|
|
502
|
+
async mergingWith() {
|
|
503
|
+
if (!existsSync(join(this.copy, ".git", "MERGE_HEAD")))
|
|
504
|
+
return [];
|
|
505
|
+
return this.authors("HEAD..MERGE_HEAD");
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Everything in the copy committed as the agent, a clash it settled included; nothing when
|
|
509
|
+
* nothing changed. A file that looks like it holds a key stops the commit: the keys, by file.
|
|
510
|
+
*/
|
|
511
|
+
async commit(subject) {
|
|
512
|
+
if (!existsSync(join(this.copy, ".git")))
|
|
513
|
+
return [];
|
|
514
|
+
await this.git(["add", "--all"]);
|
|
515
|
+
const staged = lines((await this.git(["diff", "--cached", "--name-only", "--diff-filter=AMC"])).out);
|
|
516
|
+
const keys = await this.keysIn(staged);
|
|
517
|
+
if (keys.length > 0) {
|
|
518
|
+
await this.git(["reset", "--quiet"]);
|
|
519
|
+
return keys;
|
|
520
|
+
}
|
|
521
|
+
const merging = existsSync(join(this.copy, ".git", "MERGE_HEAD"));
|
|
522
|
+
const nothing = (await this.git(["diff", "--cached", "--quiet"])).ok;
|
|
523
|
+
if (nothing && !merging)
|
|
524
|
+
return [];
|
|
525
|
+
// A settled clash too goes under the agent's words, not git's "Merge commit '…'".
|
|
526
|
+
const made = await this.git(["commit", "--quiet", "-m", subject]);
|
|
527
|
+
if (!made.ok)
|
|
528
|
+
this.options.log?.(`project: ${this.options.name} couldn't commit: ${made.err}`);
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
/** Which of these files hold what looks like a key, and whose. */
|
|
532
|
+
async keysIn(paths) {
|
|
533
|
+
const keys = [];
|
|
534
|
+
for (const path of paths) {
|
|
535
|
+
if (isSecretsFile(path)) {
|
|
536
|
+
keys.push({ path, what: "secrets, by its name" });
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const at = join(this.copy, path);
|
|
540
|
+
try {
|
|
541
|
+
if ((await stat(at)).size > READ_UP_TO)
|
|
542
|
+
continue;
|
|
543
|
+
const what = secretIn(await readFile(at, "latin1"));
|
|
544
|
+
if (what !== undefined)
|
|
545
|
+
keys.push({ path, what });
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// Gone again before it could be read: nothing to send.
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return keys;
|
|
552
|
+
}
|
|
553
|
+
async facts(found) {
|
|
554
|
+
const tracked = lines((await this.git(["ls-files"])).out);
|
|
555
|
+
return {
|
|
556
|
+
folder: this.copy,
|
|
557
|
+
files: tracked.length,
|
|
558
|
+
changed: found.changed ?? [],
|
|
559
|
+
by: (found.by ?? []).filter((name) => name !== this.options.name),
|
|
560
|
+
clashes: found.clashes ?? [],
|
|
561
|
+
clashedWith: found.clashedWith ?? [],
|
|
562
|
+
keys: found.keys ?? [],
|
|
563
|
+
trouble: found.trouble,
|
|
564
|
+
markdown: tracked.some((path) => /\.(md|markdown)$/i.test(path)),
|
|
565
|
+
pages: tracked.some((path) => /\.html?$/i.test(path)),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Who made the commits in a range, by name, the agent itself left out. */
|
|
569
|
+
async authors(range) {
|
|
570
|
+
const names = lines((await this.git(["log", "--no-merges", "--format=%an", range])).out);
|
|
571
|
+
return unique(names.filter((name) => name !== this.options.name));
|
|
572
|
+
}
|
|
573
|
+
async head(ref) {
|
|
574
|
+
return (await this.git(["rev-parse", "--quiet", "--verify", `${ref}^{commit}`])).out.trim();
|
|
575
|
+
}
|
|
576
|
+
async emptyTree() {
|
|
577
|
+
return (await this.git(["hash-object", "-t", "tree", "/dev/null"])).out.trim();
|
|
578
|
+
}
|
|
579
|
+
git(args) {
|
|
580
|
+
const { options } = this;
|
|
581
|
+
const person = options.person === true;
|
|
582
|
+
const email = `${options.agentId}@${person ? "people" : "agents"}.chips.invalid`;
|
|
583
|
+
const pairs = [
|
|
584
|
+
["http.extraHeader", `Authorization: Bearer ${options.token}`],
|
|
585
|
+
// A person's project token says who they are; an agent's harness token needs the agent.
|
|
586
|
+
...(person
|
|
587
|
+
? []
|
|
588
|
+
: [["http.extraHeader", `${PROJECT_ACTOR_HEADER}: ${options.agentId}`]]),
|
|
589
|
+
["core.hooksPath", "/dev/null"],
|
|
590
|
+
// A program git would run on most commands, with the token in its environment (SEC-52).
|
|
591
|
+
// Empty is off in every git from 2.31; before 2.36, "false" would be run as the program.
|
|
592
|
+
["core.fsmonitor", ""],
|
|
593
|
+
["commit.gpgSign", "false"],
|
|
594
|
+
["merge.conflictStyle", "merge"],
|
|
595
|
+
["core.quotePath", "false"],
|
|
596
|
+
];
|
|
597
|
+
const env = {
|
|
598
|
+
// biome-ignore lint/style/noProcessEnv: git is found where the harness finds its tools.
|
|
599
|
+
PATH: options.path ?? process.env["PATH"] ?? "/usr/bin:/bin",
|
|
600
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
601
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
602
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
603
|
+
GIT_AUTHOR_NAME: options.name,
|
|
604
|
+
GIT_AUTHOR_EMAIL: email,
|
|
605
|
+
GIT_COMMITTER_NAME: options.name,
|
|
606
|
+
GIT_COMMITTER_EMAIL: email,
|
|
607
|
+
GIT_EDITOR: "true",
|
|
608
|
+
GIT_CONFIG_COUNT: String(pairs.length),
|
|
609
|
+
};
|
|
610
|
+
pairs.forEach(([key, value], at) => {
|
|
611
|
+
env[`GIT_CONFIG_KEY_${at}`] = key;
|
|
612
|
+
env[`GIT_CONFIG_VALUE_${at}`] = value;
|
|
613
|
+
});
|
|
614
|
+
return new Promise((resolve) => {
|
|
615
|
+
execFile(options.git ?? "git", [...args], { cwd: this.copy, env, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({ ok: error === null, out: stdout, err: stderr }));
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
//# sourceMappingURL=project.js.map
|