@cueloop/daemon 0.1.0-alpha.59 → 0.1.0-alpha.60
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/package.json +2 -2
- package/src/api.test.ts +14 -0
- package/src/api.ts +44 -3
- package/src/client.ts +12 -0
- package/src/diff-hot-reload.test.ts +11 -0
- package/src/diff-watcher.ts +6 -0
- package/src/herdr-pane.test.ts +7 -0
- package/src/herdr-pane.ts +11 -0
- package/src/herdr-split.test.ts +3 -0
- package/src/herdr-split.ts +4 -0
- package/src/herdr-tab-store.test.ts +1 -0
- package/src/herdr-tab-store.ts +2 -0
- package/src/main.ts +1 -0
- package/src/protocol.ts +7 -0
- package/src/retention.test.ts +8 -0
- package/src/retention.ts +16 -0
- package/src/review.test.ts +8 -0
- package/src/review.ts +26 -2
- package/src/server.test.ts +7 -0
- package/src/server.ts +32 -0
- package/src/share-blob.ts +4 -0
- package/src/single-instance.test.ts +10 -0
- package/src/store.ts +6 -0
- package/src/validate.test.ts +12 -0
- package/src/validate.ts +6 -1
- package/src/working-tree.ts +13 -0
package/src/server.ts
CHANGED
|
@@ -73,16 +73,20 @@ export class DaemonServer {
|
|
|
73
73
|
private acquireLock(): boolean {
|
|
74
74
|
if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
|
|
75
75
|
const path = lockPath(this.home);
|
|
76
|
+
|
|
76
77
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
77
78
|
try {
|
|
78
79
|
// "wx" fails when the file exists - the atomic part of the handshake
|
|
79
80
|
const fd = openSync(path, "wx");
|
|
81
|
+
|
|
80
82
|
writeFileSync(fd, String(process.pid));
|
|
81
83
|
this.lockFd = fd;
|
|
82
84
|
HELD_HOMES.add(this.home);
|
|
85
|
+
|
|
83
86
|
return true;
|
|
84
87
|
} catch {
|
|
85
88
|
let ownerPid = 0;
|
|
89
|
+
|
|
86
90
|
try {
|
|
87
91
|
ownerPid = Number(readFileSync(path, "utf8").trim());
|
|
88
92
|
} catch {
|
|
@@ -92,6 +96,7 @@ export class DaemonServer {
|
|
|
92
96
|
if (ownerPid && ownerPid !== process.pid) {
|
|
93
97
|
try {
|
|
94
98
|
process.kill(ownerPid, 0); // throws when the pid is gone
|
|
99
|
+
|
|
95
100
|
return false; // a live daemon owns this home
|
|
96
101
|
} catch {
|
|
97
102
|
// stale lock from a crashed daemon
|
|
@@ -106,6 +111,7 @@ export class DaemonServer {
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
}
|
|
114
|
+
|
|
109
115
|
return false;
|
|
110
116
|
}
|
|
111
117
|
|
|
@@ -129,6 +135,7 @@ export class DaemonServer {
|
|
|
129
135
|
start(): string | null {
|
|
130
136
|
if (!this.acquireLock()) return null;
|
|
131
137
|
const path = socketPath(this.home);
|
|
138
|
+
|
|
132
139
|
// safe now: holding the lock means no live daemon owns this home, so any
|
|
133
140
|
// socket file left behind is stale
|
|
134
141
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -146,6 +153,7 @@ export class DaemonServer {
|
|
|
146
153
|
subscribed: false,
|
|
147
154
|
role: "owner",
|
|
148
155
|
};
|
|
156
|
+
|
|
149
157
|
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
150
158
|
this.connections.add(connection);
|
|
151
159
|
this.scheduleIdleCheck();
|
|
@@ -170,6 +178,7 @@ export class DaemonServer {
|
|
|
170
178
|
chmodSync(path, 0o600);
|
|
171
179
|
writeFileSync(pidPath(this.home), String(process.pid));
|
|
172
180
|
this.scheduleIdleCheck();
|
|
181
|
+
|
|
173
182
|
return path;
|
|
174
183
|
}
|
|
175
184
|
|
|
@@ -185,6 +194,7 @@ export class DaemonServer {
|
|
|
185
194
|
|
|
186
195
|
private broadcast(event: DaemonEvent): void {
|
|
187
196
|
const frame = JSON.stringify(event) + "\n";
|
|
197
|
+
|
|
188
198
|
for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
|
|
189
199
|
this.scheduleIdleCheck();
|
|
190
200
|
}
|
|
@@ -205,6 +215,7 @@ export class DaemonServer {
|
|
|
205
215
|
|
|
206
216
|
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
207
217
|
let request: Request;
|
|
218
|
+
|
|
208
219
|
try {
|
|
209
220
|
request = JSON.parse(line) as Request;
|
|
210
221
|
} catch {
|
|
@@ -212,14 +223,17 @@ export class DaemonServer {
|
|
|
212
223
|
JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
|
|
213
224
|
"\n",
|
|
214
225
|
);
|
|
226
|
+
|
|
215
227
|
return;
|
|
216
228
|
}
|
|
217
229
|
try {
|
|
218
230
|
const result = await this.dispatch(connection, request);
|
|
231
|
+
|
|
219
232
|
connection.write(JSON.stringify({ id: request.id, result }) + "\n");
|
|
220
233
|
} catch (err) {
|
|
221
234
|
const code = err instanceof DaemonError ? err.code : "internal";
|
|
222
235
|
const message = err instanceof Error ? err.message : String(err);
|
|
236
|
+
|
|
223
237
|
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
224
238
|
}
|
|
225
239
|
}
|
|
@@ -228,6 +242,7 @@ export class DaemonServer {
|
|
|
228
242
|
"daemon.ping": () => ({ pid: process.pid }),
|
|
229
243
|
"daemon.hello": (connection, request) => {
|
|
230
244
|
connection.role = parseParams("daemon.hello", request.params).role;
|
|
245
|
+
|
|
231
246
|
return {};
|
|
232
247
|
},
|
|
233
248
|
"daemon.shutdown": () => {
|
|
@@ -235,14 +250,17 @@ export class DaemonServer {
|
|
|
235
250
|
this.stop();
|
|
236
251
|
this.onIdleExit();
|
|
237
252
|
}, 10);
|
|
253
|
+
|
|
238
254
|
return {};
|
|
239
255
|
},
|
|
240
256
|
"events.subscribe": (connection) => {
|
|
241
257
|
connection.subscribed = true;
|
|
258
|
+
|
|
242
259
|
return {};
|
|
243
260
|
},
|
|
244
261
|
"session.create": (_connection, request) => {
|
|
245
262
|
const params = parseParams("session.create", request.params);
|
|
263
|
+
|
|
246
264
|
return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
|
|
247
265
|
},
|
|
248
266
|
"session.get": (_connection, request) =>
|
|
@@ -251,38 +269,47 @@ export class DaemonServer {
|
|
|
251
269
|
this.core.sessionList(parseParams("session.list", request.params).filter),
|
|
252
270
|
"session.wait": (_connection, request) => {
|
|
253
271
|
const params = parseParams("session.wait", request.params);
|
|
272
|
+
|
|
254
273
|
return this.core.sessionWait(params.id, params.timeoutMs);
|
|
255
274
|
},
|
|
256
275
|
"session.annotate": (_connection, request) => {
|
|
257
276
|
const params = parseParams("session.annotate", request.params);
|
|
277
|
+
|
|
258
278
|
return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
|
|
259
279
|
},
|
|
260
280
|
"session.removeAnnotation": (_connection, request) => {
|
|
261
281
|
const params = parseParams("session.removeAnnotation", request.params);
|
|
282
|
+
|
|
262
283
|
return this.core.sessionRemoveAnnotation(params.id, params.annotationId);
|
|
263
284
|
},
|
|
264
285
|
"session.setWorkingCopy": (_connection, request) => {
|
|
265
286
|
const params = parseParams("session.setWorkingCopy", request.params);
|
|
287
|
+
|
|
266
288
|
return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
267
289
|
},
|
|
268
290
|
"session.setViewed": (_connection, request) => {
|
|
269
291
|
const params = parseParams("session.setViewed", request.params);
|
|
292
|
+
|
|
270
293
|
return this.core.sessionSetViewed(params.id, params.viewedPaths);
|
|
271
294
|
},
|
|
272
295
|
"session.refreshDiff": (_connection, request) => {
|
|
273
296
|
const params = parseParams("session.refreshDiff", request.params);
|
|
297
|
+
|
|
274
298
|
return this.core.sessionRefreshDiff(params.id);
|
|
275
299
|
},
|
|
276
300
|
"session.setShareId": (_connection, request) => {
|
|
277
301
|
const params = parseParams("session.setShareId", request.params);
|
|
302
|
+
|
|
278
303
|
return this.core.sessionSetShareId(params.id, params.shareId);
|
|
279
304
|
},
|
|
280
305
|
"session.delete": (_connection, request) => {
|
|
281
306
|
this.core.sessionDelete(parseParams("session.delete", request.params).id);
|
|
307
|
+
|
|
282
308
|
return {};
|
|
283
309
|
},
|
|
284
310
|
"session.mergeShared": (_connection, request) => {
|
|
285
311
|
const params = parseParams("session.mergeShared", request.params);
|
|
312
|
+
|
|
286
313
|
return this.core.sessionMergeShared(params.id, {
|
|
287
314
|
annotations: params.annotations,
|
|
288
315
|
participants: params.participants,
|
|
@@ -290,10 +317,12 @@ export class DaemonServer {
|
|
|
290
317
|
},
|
|
291
318
|
"session.resolve": (_connection, request) => {
|
|
292
319
|
const params = parseParams("session.resolve", request.params);
|
|
320
|
+
|
|
293
321
|
return this.core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
294
322
|
},
|
|
295
323
|
"session.submitRevision": (_connection, request) => {
|
|
296
324
|
const params = parseParams("session.submitRevision", request.params);
|
|
325
|
+
|
|
297
326
|
return this.core.sessionSubmitRevision(
|
|
298
327
|
params.id,
|
|
299
328
|
params.content,
|
|
@@ -304,7 +333,9 @@ export class DaemonServer {
|
|
|
304
333
|
this.core.herdrGetTab(parseParams("herdr.getTab", request.params).id),
|
|
305
334
|
"herdr.setTab": (_connection, request) => {
|
|
306
335
|
const params = parseParams("herdr.setTab", request.params);
|
|
336
|
+
|
|
307
337
|
this.core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
|
|
338
|
+
|
|
308
339
|
return {};
|
|
309
340
|
},
|
|
310
341
|
};
|
|
@@ -322,6 +353,7 @@ export class DaemonServer {
|
|
|
322
353
|
if (!Object.hasOwn(this.handlers, request.method)) {
|
|
323
354
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
324
355
|
}
|
|
356
|
+
|
|
325
357
|
return this.handlers[request.method](connection, request);
|
|
326
358
|
}
|
|
327
359
|
}
|
package/src/share-blob.ts
CHANGED
|
@@ -31,6 +31,7 @@ export function packSessionBlob(session: ReviewSession): Buffer {
|
|
|
31
31
|
/** Decompress + validate an uploaded blob, or throw a precise reason. */
|
|
32
32
|
export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
33
33
|
let json: string;
|
|
34
|
+
|
|
34
35
|
try {
|
|
35
36
|
json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
|
|
36
37
|
} catch (err) {
|
|
@@ -40,12 +41,15 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
43
|
let raw: unknown;
|
|
44
|
+
|
|
43
45
|
try {
|
|
44
46
|
raw = JSON.parse(json);
|
|
45
47
|
} catch {
|
|
46
48
|
throw new Error("blob is not valid JSON");
|
|
47
49
|
}
|
|
48
50
|
const parsed = validateSessionRecord(raw);
|
|
51
|
+
|
|
49
52
|
if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
|
|
53
|
+
|
|
50
54
|
return parsed.value as ReviewSession;
|
|
51
55
|
}
|
|
@@ -29,7 +29,9 @@ afterEach(() => {
|
|
|
29
29
|
|
|
30
30
|
function server(): DaemonServer {
|
|
31
31
|
const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
|
|
32
33
|
servers.push(daemonServer);
|
|
34
|
+
|
|
33
35
|
return daemonServer;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -38,6 +40,7 @@ describe("one daemon per home", () => {
|
|
|
38
40
|
// Arrange
|
|
39
41
|
const first = server();
|
|
40
42
|
const path = first.start();
|
|
43
|
+
|
|
41
44
|
expect(path).toBe(socketPath(home));
|
|
42
45
|
const second = server();
|
|
43
46
|
|
|
@@ -50,8 +53,10 @@ describe("one daemon per home", () => {
|
|
|
50
53
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
51
54
|
// Arrange
|
|
52
55
|
const first = server();
|
|
56
|
+
|
|
53
57
|
first.start();
|
|
54
58
|
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
59
|
+
|
|
55
60
|
server().start(); // refused
|
|
56
61
|
const client = await DaemonClient.connect({ home });
|
|
57
62
|
|
|
@@ -79,6 +84,7 @@ describe("one daemon per home", () => {
|
|
|
79
84
|
test("stopping releases the lock so a restart works", () => {
|
|
80
85
|
// Arrange
|
|
81
86
|
const first = server();
|
|
87
|
+
|
|
82
88
|
first.start();
|
|
83
89
|
|
|
84
90
|
// Act
|
|
@@ -87,6 +93,7 @@ describe("one daemon per home", () => {
|
|
|
87
93
|
// Assert
|
|
88
94
|
expect(existsSync(lockPath(home))).toBe(false);
|
|
89
95
|
const second = server();
|
|
96
|
+
|
|
90
97
|
expect(second.start()).toBe(socketPath(home));
|
|
91
98
|
});
|
|
92
99
|
|
|
@@ -103,14 +110,17 @@ describe("one daemon per home", () => {
|
|
|
103
110
|
try {
|
|
104
111
|
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
105
112
|
const unique = new Set(pids.map((ping) => ping.pid));
|
|
113
|
+
|
|
106
114
|
expect(unique.size).toBe(1);
|
|
107
115
|
// and state is shared: one client's session is visible to the others
|
|
108
116
|
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
117
|
+
|
|
109
118
|
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
110
119
|
} finally {
|
|
111
120
|
for (const c of clients) c.close();
|
|
112
121
|
// the autostarted daemon is not one of `servers` - shut it down
|
|
113
122
|
const admin = await DaemonClient.connect({ home });
|
|
123
|
+
|
|
114
124
|
await admin.shutdown();
|
|
115
125
|
admin.close();
|
|
116
126
|
}
|
package/src/store.ts
CHANGED
|
@@ -28,19 +28,23 @@ export class SessionStore {
|
|
|
28
28
|
/** Read-only scan of the state directory; called once on boot. */
|
|
29
29
|
recover(): RecoveryReport {
|
|
30
30
|
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
31
|
+
|
|
31
32
|
for (const file of readdirSync(this.dir)) {
|
|
32
33
|
if (!file.endsWith(".json")) continue;
|
|
33
34
|
try {
|
|
34
35
|
const raw = readFileSync(join(this.dir, file), "utf8");
|
|
35
36
|
const parsed = validateSessionRecord(JSON.parse(raw));
|
|
37
|
+
|
|
36
38
|
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
37
39
|
const session = parsed.value as ReviewSession;
|
|
40
|
+
|
|
38
41
|
this.sessions.set(session.id, session);
|
|
39
42
|
report.recovered.push(session.id);
|
|
40
43
|
} catch (err) {
|
|
41
44
|
report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
|
|
42
45
|
}
|
|
43
46
|
}
|
|
47
|
+
|
|
44
48
|
return report;
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -58,6 +62,7 @@ export class SessionStore {
|
|
|
58
62
|
this.sessions.set(session.id, session);
|
|
59
63
|
const path = join(this.dir, `${session.id}.json`);
|
|
60
64
|
const tempPath = path + ".tmp";
|
|
65
|
+
|
|
61
66
|
writeFileSync(tempPath, JSON.stringify(session, null, 2));
|
|
62
67
|
renameSync(tempPath, path);
|
|
63
68
|
}
|
|
@@ -65,6 +70,7 @@ export class SessionStore {
|
|
|
65
70
|
delete(id: string): boolean {
|
|
66
71
|
if (!this.sessions.delete(id)) return false;
|
|
67
72
|
rmSync(join(this.dir, `${id}.json`), { force: true });
|
|
73
|
+
|
|
68
74
|
return true;
|
|
69
75
|
}
|
|
70
76
|
}
|
package/src/validate.test.ts
CHANGED
|
@@ -61,6 +61,15 @@ describe("parseParams", () => {
|
|
|
61
61
|
}
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
test("accepts the reply artifact type", () => {
|
|
65
|
+
const params = parseParams("session.create", {
|
|
66
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
67
|
+
artifact: { type: "reply", content: "# R" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(params.artifact.type).toBe("reply");
|
|
71
|
+
});
|
|
72
|
+
|
|
64
73
|
test("rejects an unknown artifact type", () => {
|
|
65
74
|
expect(() =>
|
|
66
75
|
parseParams("session.create", {
|
|
@@ -236,6 +245,7 @@ describe("wire pins", () => {
|
|
|
236
245
|
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
237
246
|
// wire annotations arrive without createdAt; the daemon stamps it
|
|
238
247
|
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
248
|
+
|
|
239
249
|
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
240
250
|
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
241
251
|
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
@@ -243,11 +253,13 @@ describe("wire pins", () => {
|
|
|
243
253
|
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
244
254
|
// persisted annotations carry the stamped createdAt
|
|
245
255
|
const stored = SessionRecordSchema.entries.annotations.item;
|
|
256
|
+
|
|
246
257
|
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
247
258
|
});
|
|
248
259
|
|
|
249
260
|
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
250
261
|
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
262
|
+
|
|
251
263
|
try {
|
|
252
264
|
// Arrange
|
|
253
265
|
const params = parseParams("session.create", {
|
package/src/validate.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const DiffFileContentsSchema = v.object({
|
|
|
60
60
|
} satisfies EntriesOf<DiffFileContents>);
|
|
61
61
|
|
|
62
62
|
export const ArtifactSchema = v.object({
|
|
63
|
-
type: v.picklist(["plan", "diff", "prototype"]),
|
|
63
|
+
type: v.picklist(["plan", "diff", "prototype", "reply"]),
|
|
64
64
|
content: v.string(),
|
|
65
65
|
meta: v.optional(ArtifactMetaSchema, {}),
|
|
66
66
|
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
@@ -175,14 +175,17 @@ export function parseParams<M extends MethodName>(
|
|
|
175
175
|
params: unknown,
|
|
176
176
|
): v.InferOutput<(typeof Params)[M]> {
|
|
177
177
|
const result = v.safeParse(Params[method], params ?? {});
|
|
178
|
+
|
|
178
179
|
if (!result.success) {
|
|
179
180
|
const issue = result.issues[0]!;
|
|
180
181
|
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
182
|
+
|
|
181
183
|
throw new DaemonError(
|
|
182
184
|
"invalid_params",
|
|
183
185
|
`${method}: ${path ? path + " - " : ""}${issue.message}`,
|
|
184
186
|
);
|
|
185
187
|
}
|
|
188
|
+
|
|
186
189
|
return result.output;
|
|
187
190
|
}
|
|
188
191
|
|
|
@@ -221,8 +224,10 @@ export function validateSessionRecord(
|
|
|
221
224
|
raw: unknown,
|
|
222
225
|
): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
223
226
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
227
|
+
|
|
224
228
|
if (result.success) return { ok: true, value: result.output };
|
|
225
229
|
const issue = result.issues[0]!;
|
|
226
230
|
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
231
|
+
|
|
227
232
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
228
233
|
}
|
package/src/working-tree.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type { DiffFileContents } from "@cueloop/schema";
|
|
|
6
6
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
7
7
|
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
8
8
|
const stdout = await new Response(gitProcess.stdout).text();
|
|
9
|
+
|
|
9
10
|
if ((await gitProcess.exited) !== 0) return null;
|
|
11
|
+
|
|
10
12
|
return stdout.trim();
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -18,6 +20,7 @@ async function headContents(path: string, cwd: string): Promise<string> {
|
|
|
18
20
|
stderr: "ignore",
|
|
19
21
|
});
|
|
20
22
|
const stdout = await new Response(gitProcess.stdout).text();
|
|
23
|
+
|
|
21
24
|
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -35,6 +38,7 @@ function looksBinary(text: string): boolean {
|
|
|
35
38
|
for (let index = 0; index < text.length; index++) {
|
|
36
39
|
if (text.charCodeAt(index) === 0) return true;
|
|
37
40
|
}
|
|
41
|
+
|
|
38
42
|
return false;
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -51,8 +55,10 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
|
51
55
|
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
52
56
|
const files: DiffFileContents[] = [];
|
|
53
57
|
let cursor = 0;
|
|
58
|
+
|
|
54
59
|
while (cursor < tokens.length) {
|
|
55
60
|
const code = tokens[cursor++]![0];
|
|
61
|
+
|
|
56
62
|
if (code === "R" || code === "C") {
|
|
57
63
|
cursor += 2;
|
|
58
64
|
continue;
|
|
@@ -60,10 +66,13 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
|
60
66
|
const path = tokens[cursor++]!;
|
|
61
67
|
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
62
68
|
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
69
|
+
|
|
63
70
|
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
64
71
|
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
72
|
+
|
|
65
73
|
files.push({ path, oldContents, newContents, status });
|
|
66
74
|
}
|
|
75
|
+
|
|
67
76
|
return files;
|
|
68
77
|
}
|
|
69
78
|
|
|
@@ -79,6 +88,7 @@ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeD
|
|
|
79
88
|
// untracked files included by default
|
|
80
89
|
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
81
90
|
let untracked = "";
|
|
91
|
+
|
|
82
92
|
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
83
93
|
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
84
94
|
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
@@ -88,13 +98,16 @@ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeD
|
|
|
88
98
|
});
|
|
89
99
|
const stdout = await new Response(gitProcess.stdout).text();
|
|
90
100
|
const code = await gitProcess.exited;
|
|
101
|
+
|
|
91
102
|
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
92
103
|
untracked += stdout.trim() + "\n";
|
|
93
104
|
const newContents = await workingContents(file, cwd);
|
|
105
|
+
|
|
94
106
|
if (!looksBinary(newContents)) {
|
|
95
107
|
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
96
108
|
}
|
|
97
109
|
}
|
|
98
110
|
}
|
|
111
|
+
|
|
99
112
|
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
100
113
|
}
|