@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.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 +21 -0
- package/README.md +988 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/setup.ts +1151 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// Projects (git repos on this machine) and workspaces (git worktrees of them).
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { desc, eq } from "drizzle-orm";
|
|
10
|
+
import { killWorkspaceSessions } from "./attach.ts";
|
|
11
|
+
import type { Config } from "./config.ts";
|
|
12
|
+
import { type Db, newId, projects, windows, workspaces } from "./db.ts";
|
|
13
|
+
import { isDirectory } from "./fsx.ts";
|
|
14
|
+
import { describeError, log } from "./log.ts";
|
|
15
|
+
import { deleteProjectProfiles } from "./profiles.ts";
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/** Where worktrees are created, kept out of the repos themselves. */
|
|
20
|
+
const WORKTREE_DIR = ".vibe-worktrees";
|
|
21
|
+
|
|
22
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]{1,60}$/;
|
|
23
|
+
|
|
24
|
+
// Three short wordlists. ~64^3 combinations is plenty to avoid collisions in a
|
|
25
|
+
// list a person actually reads, and every word is unambiguous when spoken.
|
|
26
|
+
const ADJECTIVES = [
|
|
27
|
+
"quiet",
|
|
28
|
+
"brave",
|
|
29
|
+
"clever",
|
|
30
|
+
"bright",
|
|
31
|
+
"calm",
|
|
32
|
+
"eager",
|
|
33
|
+
"gentle",
|
|
34
|
+
"happy",
|
|
35
|
+
"keen",
|
|
36
|
+
"lucky",
|
|
37
|
+
"merry",
|
|
38
|
+
"nimble",
|
|
39
|
+
"polite",
|
|
40
|
+
"proud",
|
|
41
|
+
"rapid",
|
|
42
|
+
"sharp",
|
|
43
|
+
"shy",
|
|
44
|
+
"silly",
|
|
45
|
+
"smooth",
|
|
46
|
+
"solid",
|
|
47
|
+
"spry",
|
|
48
|
+
"steady",
|
|
49
|
+
"sunny",
|
|
50
|
+
"swift",
|
|
51
|
+
"tidy",
|
|
52
|
+
"warm",
|
|
53
|
+
"wise",
|
|
54
|
+
"witty",
|
|
55
|
+
"zesty",
|
|
56
|
+
"bold",
|
|
57
|
+
"crisp",
|
|
58
|
+
"daring",
|
|
59
|
+
];
|
|
60
|
+
const COLOURS = [
|
|
61
|
+
"amber",
|
|
62
|
+
"azure",
|
|
63
|
+
"cobalt",
|
|
64
|
+
"coral",
|
|
65
|
+
"cyan",
|
|
66
|
+
"emerald",
|
|
67
|
+
"garnet",
|
|
68
|
+
"indigo",
|
|
69
|
+
"ivory",
|
|
70
|
+
"jade",
|
|
71
|
+
"lilac",
|
|
72
|
+
"maroon",
|
|
73
|
+
"mint",
|
|
74
|
+
"olive",
|
|
75
|
+
"onyx",
|
|
76
|
+
"opal",
|
|
77
|
+
"pearl",
|
|
78
|
+
"plum",
|
|
79
|
+
"ruby",
|
|
80
|
+
"saffron",
|
|
81
|
+
"sage",
|
|
82
|
+
"scarlet",
|
|
83
|
+
"sienna",
|
|
84
|
+
"slate",
|
|
85
|
+
"teal",
|
|
86
|
+
"topaz",
|
|
87
|
+
"umber",
|
|
88
|
+
"violet",
|
|
89
|
+
"wheat",
|
|
90
|
+
"bronze",
|
|
91
|
+
"copper",
|
|
92
|
+
"silver",
|
|
93
|
+
];
|
|
94
|
+
const ANIMALS = [
|
|
95
|
+
"otter",
|
|
96
|
+
"falcon",
|
|
97
|
+
"lynx",
|
|
98
|
+
"heron",
|
|
99
|
+
"badger",
|
|
100
|
+
"marten",
|
|
101
|
+
"ibex",
|
|
102
|
+
"raven",
|
|
103
|
+
"panda",
|
|
104
|
+
"tapir",
|
|
105
|
+
"gecko",
|
|
106
|
+
"osprey",
|
|
107
|
+
"puffin",
|
|
108
|
+
"stoat",
|
|
109
|
+
"vole",
|
|
110
|
+
"wombat",
|
|
111
|
+
"yak",
|
|
112
|
+
"zebra",
|
|
113
|
+
"bison",
|
|
114
|
+
"crane",
|
|
115
|
+
"dingo",
|
|
116
|
+
"egret",
|
|
117
|
+
"ferret",
|
|
118
|
+
"gibbon",
|
|
119
|
+
"hare",
|
|
120
|
+
"jackal",
|
|
121
|
+
"koala",
|
|
122
|
+
"lemur",
|
|
123
|
+
"mongoose",
|
|
124
|
+
"newt",
|
|
125
|
+
"quail",
|
|
126
|
+
"shrew",
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const pick = <T>(list: T[]): T => list[Math.floor(Math.random() * list.length)];
|
|
130
|
+
|
|
131
|
+
export function threeWordName(): string {
|
|
132
|
+
return `${pick(ADJECTIVES)}-${pick(COLOURS)}-${pick(ANIMALS)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ceilings on the two things that touch the network, in milliseconds.
|
|
137
|
+
*
|
|
138
|
+
* They are what the server's request timeout is sized around, so raising one
|
|
139
|
+
* without the other is how a workspace comes into being on a request the
|
|
140
|
+
* browser was already told had failed. See REQUEST_IDLE_TIMEOUT_S in index.ts.
|
|
141
|
+
*/
|
|
142
|
+
const FETCH_TIMEOUT_MS = 120_000;
|
|
143
|
+
const ASK_TIMEOUT_MS = 30_000;
|
|
144
|
+
|
|
145
|
+
async function git(
|
|
146
|
+
cwd: string,
|
|
147
|
+
args: string[],
|
|
148
|
+
timeout = 60_000,
|
|
149
|
+
): Promise<string> {
|
|
150
|
+
const { stdout } = await run("git", ["-C", cwd, ...args], {
|
|
151
|
+
timeout,
|
|
152
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
153
|
+
env: {
|
|
154
|
+
...process.env,
|
|
155
|
+
// Nothing on this side can answer a credential prompt, so a remote that
|
|
156
|
+
// asks for one has to fail rather than sit there holding the request
|
|
157
|
+
// open. Only the prompt is disabled, not ssh: `GIT_SSH_COMMAND` would
|
|
158
|
+
// take precedence over a repo's own `core.sshCommand`, and a project
|
|
159
|
+
// cloned with a per-repo deploy key would stop being able to fetch at
|
|
160
|
+
// all. A key that wants a passphrase is bounded by the timeout instead.
|
|
161
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
return stdout.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The one line of a failed git command worth showing a person.
|
|
169
|
+
*
|
|
170
|
+
* The rest is scaffolding: "Command failed: git -C /home/lee/src/api fetch
|
|
171
|
+
* origin" names what we ran, which the reader already knows, while the line
|
|
172
|
+
* git put on stderr is the part they can act on. An expired token says to go
|
|
173
|
+
* and re-authenticate, and that is a different afternoon from being offline.
|
|
174
|
+
*/
|
|
175
|
+
function gitReason(err: unknown): string {
|
|
176
|
+
const failure = err as { stderr?: unknown; killed?: boolean };
|
|
177
|
+
const stderr = typeof failure?.stderr === "string" ? failure.stderr : "";
|
|
178
|
+
const lines = stderr
|
|
179
|
+
.split("\n")
|
|
180
|
+
.map((line) => line.trim())
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
const best =
|
|
183
|
+
lines.find(
|
|
184
|
+
(line) => line.startsWith("fatal:") || line.startsWith("error:"),
|
|
185
|
+
) ?? lines[0];
|
|
186
|
+
if (!best) {
|
|
187
|
+
// A timeout is a kill, and it leaves nothing on stderr to quote.
|
|
188
|
+
return failure?.killed ? "timed out" : describeError(err).split("\n")[0];
|
|
189
|
+
}
|
|
190
|
+
const reason = best.replace(/^(fatal|error):\s*/, "");
|
|
191
|
+
// Capped because git will happily quote a whole remote URL, and this ends up
|
|
192
|
+
// in a sidebar. The ellipsis is there so a cut does not read as a typo.
|
|
193
|
+
return reason.length > 160 ? `${reason.slice(0, 159)}…` : reason;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function hasOrigin(repo: string): Promise<boolean> {
|
|
197
|
+
const remotes = await git(repo, ["remote"]).catch(() => "");
|
|
198
|
+
return remotes.split("\n").some((r) => r.trim() === "origin");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function currentBranch(repo: string): Promise<string | null> {
|
|
202
|
+
try {
|
|
203
|
+
return await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Directories that never contain a project you meant to open. */
|
|
210
|
+
const SKIP = new Set([
|
|
211
|
+
"node_modules",
|
|
212
|
+
".git",
|
|
213
|
+
".cache",
|
|
214
|
+
".npm",
|
|
215
|
+
".bun",
|
|
216
|
+
".cargo",
|
|
217
|
+
".rustup",
|
|
218
|
+
".venv",
|
|
219
|
+
"venv",
|
|
220
|
+
"__pycache__",
|
|
221
|
+
"vendor",
|
|
222
|
+
"target",
|
|
223
|
+
"dist",
|
|
224
|
+
"build",
|
|
225
|
+
".next",
|
|
226
|
+
".nuxt",
|
|
227
|
+
"Library",
|
|
228
|
+
"Applications",
|
|
229
|
+
"System",
|
|
230
|
+
"snap",
|
|
231
|
+
"proc",
|
|
232
|
+
"sys",
|
|
233
|
+
"dev",
|
|
234
|
+
"run",
|
|
235
|
+
WORKTREE_DIR,
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
const MAX_DEPTH = 4;
|
|
239
|
+
/** Hard ceiling on directories visited per scan, so a scan cannot run away. */
|
|
240
|
+
const MAX_VISITS = 20_000;
|
|
241
|
+
/**
|
|
242
|
+
* The scan in flight, if there is one.
|
|
243
|
+
*
|
|
244
|
+
* This used to be a 2-second debounce whose every caller passed `force: true`,
|
|
245
|
+
* so it never once ran — while the thing it was written to prevent, two refresh
|
|
246
|
+
* clicks landing together, genuinely does start two full crawls of up to 20,000
|
|
247
|
+
* directories with a serial `git rev-parse` per repo, against the same tables.
|
|
248
|
+
* Sharing the in-flight promise is what the comment was reaching for: the
|
|
249
|
+
* second caller waits for the first's results instead of duplicating them.
|
|
250
|
+
*/
|
|
251
|
+
let inFlight: Promise<void> | null = null;
|
|
252
|
+
|
|
253
|
+
async function remoteUrl(repo: string): Promise<string | null> {
|
|
254
|
+
try {
|
|
255
|
+
return await git(repo, ["config", "--get", "remote.origin.url"]);
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Walks a root looking for git repositories.
|
|
263
|
+
*
|
|
264
|
+
* Bounded in three ways — depth, a skip list, and a global visit budget —
|
|
265
|
+
* because this runs on a poll and the alternative is a full filesystem crawl of
|
|
266
|
+
* someone's home directory every minute. Descent stops at a repository: the
|
|
267
|
+
* directories inside one are not separate projects.
|
|
268
|
+
*/
|
|
269
|
+
async function walkForRepos(
|
|
270
|
+
root: string,
|
|
271
|
+
found: Set<string>,
|
|
272
|
+
budget: { visits: number },
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const queue: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
|
275
|
+
|
|
276
|
+
for (;;) {
|
|
277
|
+
const next = queue.shift();
|
|
278
|
+
if (!next) break;
|
|
279
|
+
const { dir, depth } = next;
|
|
280
|
+
if (budget.visits >= MAX_VISITS) return;
|
|
281
|
+
budget.visits += 1;
|
|
282
|
+
|
|
283
|
+
if (await isDirectory(path.join(dir, ".git"))) {
|
|
284
|
+
found.add(dir);
|
|
285
|
+
continue; // a repo's subdirectories are not separate projects
|
|
286
|
+
}
|
|
287
|
+
if (depth >= MAX_DEPTH) continue;
|
|
288
|
+
|
|
289
|
+
let entries: Dirent[];
|
|
290
|
+
try {
|
|
291
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
292
|
+
} catch {
|
|
293
|
+
continue; // unreadable (permissions, races) — not worth reporting
|
|
294
|
+
}
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
297
|
+
if (SKIP.has(entry.name)) continue;
|
|
298
|
+
// Hidden directories are config, not code — except the roots themselves.
|
|
299
|
+
if (entry.name.startsWith(".")) continue;
|
|
300
|
+
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Finds every git repository on the host, not just the ones under the workspace
|
|
307
|
+
* root, so a repo cloned anywhere shows up in the project picker.
|
|
308
|
+
*/
|
|
309
|
+
export async function scanProjects(db: Db, config: Config): Promise<void> {
|
|
310
|
+
if (inFlight) return inFlight;
|
|
311
|
+
inFlight = runScan(db, config).finally(() => {
|
|
312
|
+
inFlight = null;
|
|
313
|
+
});
|
|
314
|
+
return inFlight;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function runScan(db: Db, config: Config): Promise<void> {
|
|
318
|
+
await mkdir(config.workspace, { recursive: true }).catch(() => {});
|
|
319
|
+
|
|
320
|
+
// Deduplicated because these overlap: the workspace root is usually inside
|
|
321
|
+
// home, and /root is home when running as root.
|
|
322
|
+
const roots = [
|
|
323
|
+
...new Set([config.workspace, os.homedir(), "/srv", "/opt", "/var/www"]),
|
|
324
|
+
];
|
|
325
|
+
const found = new Set<string>();
|
|
326
|
+
const budget = { visits: 0 };
|
|
327
|
+
|
|
328
|
+
for (const root of roots) {
|
|
329
|
+
if (!(await isDirectory(root))) continue;
|
|
330
|
+
await walkForRepos(root, found, budget);
|
|
331
|
+
}
|
|
332
|
+
if (budget.visits >= MAX_VISITS) {
|
|
333
|
+
log.warn(
|
|
334
|
+
`project scan hit its ${MAX_VISITS} directory budget — some repos may be missing`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const repo of found) {
|
|
339
|
+
// Our own worktrees are checkouts of a project, not projects themselves.
|
|
340
|
+
if (repo.includes(`${path.sep}${WORKTREE_DIR}${path.sep}`)) continue;
|
|
341
|
+
|
|
342
|
+
const name = path.basename(repo);
|
|
343
|
+
const branch = await currentBranch(repo);
|
|
344
|
+
const existing = db
|
|
345
|
+
.select()
|
|
346
|
+
.from(projects)
|
|
347
|
+
.where(eq(projects.path, repo))
|
|
348
|
+
.get();
|
|
349
|
+
if (existing) {
|
|
350
|
+
db.update(projects)
|
|
351
|
+
.set({ branch, name })
|
|
352
|
+
.where(eq(projects.id, existing.id))
|
|
353
|
+
.run();
|
|
354
|
+
} else {
|
|
355
|
+
const remote = await remoteUrl(repo);
|
|
356
|
+
db.insert(projects)
|
|
357
|
+
.values({
|
|
358
|
+
id: newId(),
|
|
359
|
+
name,
|
|
360
|
+
path: repo,
|
|
361
|
+
branch,
|
|
362
|
+
remote,
|
|
363
|
+
createdAt: Date.now(),
|
|
364
|
+
})
|
|
365
|
+
.run();
|
|
366
|
+
log.info(
|
|
367
|
+
`found project ${name} at ${repo}${remote ? ` (${remote})` : ""}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Forget projects whose directory is gone, and everything hanging off them.
|
|
373
|
+
for (const project of db.select().from(projects).all()) {
|
|
374
|
+
if (!(await isDirectory(project.path))) {
|
|
375
|
+
for (const ws of db
|
|
376
|
+
.select()
|
|
377
|
+
.from(workspaces)
|
|
378
|
+
.where(eq(workspaces.projectId, project.id))
|
|
379
|
+
.all()) {
|
|
380
|
+
await killWorkspaceSessions(db, config, ws.id);
|
|
381
|
+
db.delete(windows).where(eq(windows.workspaceId, ws.id)).run();
|
|
382
|
+
}
|
|
383
|
+
db.delete(workspaces).where(eq(workspaces.projectId, project.id)).run();
|
|
384
|
+
deleteProjectProfiles(db, project.id);
|
|
385
|
+
db.delete(projects).where(eq(projects.id, project.id)).run();
|
|
386
|
+
log.info(`project ${project.name} disappeared — removed`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function listProjects(db: Db) {
|
|
392
|
+
return db.select().from(projects).orderBy(projects.name).all();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function listWorkspaces(db: Db, projectId: string) {
|
|
396
|
+
return db
|
|
397
|
+
.select()
|
|
398
|
+
.from(workspaces)
|
|
399
|
+
.where(eq(workspaces.projectId, projectId))
|
|
400
|
+
.orderBy(desc(workspaces.lastOpenedAt))
|
|
401
|
+
.all();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function getWorkspace(db: Db, id: string) {
|
|
405
|
+
return db.select().from(workspaces).where(eq(workspaces.id, id)).get();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Makes the first `git push` out of a new worktree work on its own.
|
|
410
|
+
*
|
|
411
|
+
* A branch created with `worktree add -b` has no upstream, so pushing it stops
|
|
412
|
+
* with "the current branch has no upstream branch" and a command to copy. That
|
|
413
|
+
* is a papercut on every single workspace, which is most of them.
|
|
414
|
+
*
|
|
415
|
+
* The tempting fix — writing `branch.<name>.remote` and `branch.<name>.merge`
|
|
416
|
+
* at creation — does make push work, but it points the branch at a ref that
|
|
417
|
+
* does not exist yet, so until the first push `git status` reads
|
|
418
|
+
* `## name...origin/name [gone]`. "Gone" is what git says about an upstream
|
|
419
|
+
* that was deleted, and it is alarming for something that was simply never
|
|
420
|
+
* created. `push.autoSetupRemote` gets the same outcome by letting push set the
|
|
421
|
+
* upstream when it actually creates the branch, and status stays clean.
|
|
422
|
+
*
|
|
423
|
+
* Worktrees share their repository's config, so this is set once on the project
|
|
424
|
+
* and applies to every workspace cut from it. Two things keep that polite: it
|
|
425
|
+
* is skipped when there is no remote to track, and an explicit existing value
|
|
426
|
+
* is left alone rather than overwritten.
|
|
427
|
+
*/
|
|
428
|
+
async function enableAutoSetupRemote(
|
|
429
|
+
repo: string,
|
|
430
|
+
projectName: string,
|
|
431
|
+
): Promise<void> {
|
|
432
|
+
try {
|
|
433
|
+
if (!(await hasOrigin(repo))) return;
|
|
434
|
+
|
|
435
|
+
// `config --get` exits non-zero when unset, which is the "not configured"
|
|
436
|
+
// signal — a set value, including a deliberate false, means hands off.
|
|
437
|
+
const existing = await git(repo, [
|
|
438
|
+
"config",
|
|
439
|
+
"--local",
|
|
440
|
+
"--get",
|
|
441
|
+
"push.autoSetupRemote",
|
|
442
|
+
]).catch(() => "");
|
|
443
|
+
if (existing !== "") return;
|
|
444
|
+
|
|
445
|
+
await git(repo, ["config", "--local", "push.autoSetupRemote", "true"]);
|
|
446
|
+
log.info(
|
|
447
|
+
`set push.autoSetupRemote in ${projectName} so a new workspace can push without --set-upstream`,
|
|
448
|
+
);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
// Never fatal: a workspace that exists but needs `git push -u` once is a
|
|
451
|
+
// far better outcome than a workspace that failed to be created.
|
|
452
|
+
log.debug(
|
|
453
|
+
`could not set push.autoSetupRemote in ${repo}: ${describeError(err)}`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* origin's default branch, as a ref that exists locally — "origin/main".
|
|
460
|
+
*
|
|
461
|
+
* Read from `refs/remotes/origin/HEAD`, which is only as good as the last time
|
|
462
|
+
* something asked the remote — see `askOriginHead`, which is what keeps it
|
|
463
|
+
* current. What it names is verified rather than trusted, because a symbolic
|
|
464
|
+
* ref goes on naming a branch that has been deleted, and the two conventional
|
|
465
|
+
* names are the last resort for a clone that never had one.
|
|
466
|
+
*/
|
|
467
|
+
async function originHead(repo: string): Promise<string | null> {
|
|
468
|
+
// --verify exits non-zero for a ref that is not there, which is the question.
|
|
469
|
+
const exists = (ref: string) =>
|
|
470
|
+
git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).then(
|
|
471
|
+
() => true,
|
|
472
|
+
() => false,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const known = await git(repo, [
|
|
476
|
+
"symbolic-ref",
|
|
477
|
+
"--short",
|
|
478
|
+
"refs/remotes/origin/HEAD",
|
|
479
|
+
]).catch(() => "");
|
|
480
|
+
if (known && (await exists(known))) return known;
|
|
481
|
+
|
|
482
|
+
for (const guess of ["origin/main", "origin/master"]) {
|
|
483
|
+
if (await exists(guess)) return guess;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Asks origin which branch it calls default, and writes down the answer.
|
|
490
|
+
*
|
|
491
|
+
* A round trip on top of the fetch, and worth it: nothing about a fetch tells
|
|
492
|
+
* a clone that the default branch moved. `refs/remotes/origin/HEAD` is written
|
|
493
|
+
* once at clone time and never again, so a repo cloned when the default was
|
|
494
|
+
* `master` goes on reporting `origin/master` for as long as that branch exists
|
|
495
|
+
* — which after a `master` to `main` switch is forever, since the old branch is
|
|
496
|
+
* usually left behind rather than deleted. Verifying the ref cannot catch that:
|
|
497
|
+
* the branch it names is real, it is just not the default any more.
|
|
498
|
+
*
|
|
499
|
+
* Best effort. It fails when origin is unreachable, and then the last known
|
|
500
|
+
* answer is the one used.
|
|
501
|
+
*/
|
|
502
|
+
async function askOriginHead(repo: string): Promise<void> {
|
|
503
|
+
await git(
|
|
504
|
+
repo,
|
|
505
|
+
["remote", "set-head", "origin", "--auto"],
|
|
506
|
+
ASK_TIMEOUT_MS,
|
|
507
|
+
).catch((err: unknown) => {
|
|
508
|
+
log.debug(`could not ask origin for its default branch: ${gitReason(err)}`);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Where a new workspace's branch starts.
|
|
514
|
+
*
|
|
515
|
+
* Left alone, `worktree add -b` branches from the project checkout's HEAD —
|
|
516
|
+
* whatever that clone was last left sitting on, which after a while is a stale
|
|
517
|
+
* branch, a detached commit, or someone's abandoned merge. A workspace is
|
|
518
|
+
* meant to start from the current state of the project, so origin is fetched
|
|
519
|
+
* first and the branch is cut from origin's default branch.
|
|
520
|
+
*
|
|
521
|
+
* Every step degrades to the old behaviour instead of failing. No origin, an
|
|
522
|
+
* unreachable one, a default branch that cannot be worked out: all of them end
|
|
523
|
+
* at HEAD, because a workspace from a stale base is worth more than an error
|
|
524
|
+
* where a workspace should have been. But every degradation that a person
|
|
525
|
+
* would want to know about comes back as a warning for the sidebar, because
|
|
526
|
+
* "branched from origin/main" and "branched from something I could not check"
|
|
527
|
+
* are different claims and only one of them is safe to work on top of.
|
|
528
|
+
*
|
|
529
|
+
* What it deliberately does not do is prune. Pruning would make this the only
|
|
530
|
+
* thing in the app that rewrites refs in the project's own checkout, and a
|
|
531
|
+
* remote-tracking ref deleted from under a terminal open in that repo is a
|
|
532
|
+
* `git rebase origin/topic` failing somewhere nobody would connect to having
|
|
533
|
+
* clicked "+".
|
|
534
|
+
*/
|
|
535
|
+
async function baseForWorkspace(
|
|
536
|
+
repo: string,
|
|
537
|
+
projectName: string,
|
|
538
|
+
): Promise<{ ref: string; warning: string | null }> {
|
|
539
|
+
if (!(await hasOrigin(repo))) return { ref: "HEAD", warning: null };
|
|
540
|
+
|
|
541
|
+
let failure: string | null = null;
|
|
542
|
+
try {
|
|
543
|
+
await git(repo, ["fetch", "origin"], FETCH_TIMEOUT_MS);
|
|
544
|
+
await askOriginHead(repo);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
failure = gitReason(err);
|
|
547
|
+
log.warn(`could not fetch origin for ${projectName}: ${failure}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const ref = (await originHead(repo)) ?? "HEAD";
|
|
551
|
+
const where = ref === "HEAD" ? "the project checkout" : ref;
|
|
552
|
+
if (ref === "HEAD") {
|
|
553
|
+
log.warn(
|
|
554
|
+
`no default branch on origin for ${projectName} — branching from the checkout`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (failure) {
|
|
559
|
+
return {
|
|
560
|
+
ref,
|
|
561
|
+
warning: `Could not fetch origin (${failure}). This branch starts from ${where} as it was at the last fetch.`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
// Fetched fine and still no default branch: a single-branch clone, or one
|
|
565
|
+
// whose default is named something nobody guesses. Silence here would be the
|
|
566
|
+
// exact stale base this function exists to avoid, with nothing said about it.
|
|
567
|
+
if (ref === "HEAD") {
|
|
568
|
+
return {
|
|
569
|
+
ref,
|
|
570
|
+
warning:
|
|
571
|
+
"Could not work out origin's default branch. This branch starts from the project checkout, which may be behind.",
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return { ref, warning: null };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Creates a git worktree and records it as a workspace.
|
|
579
|
+
*
|
|
580
|
+
* The worktree gets its own branch named after the workspace, branched from a
|
|
581
|
+
* freshly fetched origin — see `baseForWorkspace`.
|
|
582
|
+
*/
|
|
583
|
+
export async function createWorkspace(
|
|
584
|
+
db: Db,
|
|
585
|
+
workspaceRoot: string,
|
|
586
|
+
projectId: string,
|
|
587
|
+
requested?: string,
|
|
588
|
+
): Promise<{
|
|
589
|
+
id: string;
|
|
590
|
+
name: string;
|
|
591
|
+
branch: string;
|
|
592
|
+
path: string;
|
|
593
|
+
base: string;
|
|
594
|
+
warning: string | null;
|
|
595
|
+
}> {
|
|
596
|
+
const project = db
|
|
597
|
+
.select()
|
|
598
|
+
.from(projects)
|
|
599
|
+
.where(eq(projects.id, projectId))
|
|
600
|
+
.get();
|
|
601
|
+
if (!project) throw new Error("unknown project");
|
|
602
|
+
|
|
603
|
+
const taken = new Set(listWorkspaces(db, projectId).map((w) => w.name));
|
|
604
|
+
let name = requested?.trim().toLowerCase() ?? "";
|
|
605
|
+
if (name) {
|
|
606
|
+
if (!NAME_PATTERN.test(name))
|
|
607
|
+
throw new Error("name must be lowercase letters, digits and dashes");
|
|
608
|
+
if (taken.has(name)) throw new Error(`workspace ${name} already exists`);
|
|
609
|
+
} else {
|
|
610
|
+
do {
|
|
611
|
+
name = threeWordName();
|
|
612
|
+
} while (taken.has(name));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const target = path.join(workspaceRoot, WORKTREE_DIR, project.name, name);
|
|
616
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
617
|
+
|
|
618
|
+
const base = await baseForWorkspace(project.path, project.name);
|
|
619
|
+
|
|
620
|
+
// -b creates the branch; git refuses if it already exists, which is the
|
|
621
|
+
// behaviour we want rather than silently reusing someone else's work.
|
|
622
|
+
//
|
|
623
|
+
// --no-track because branching from a remote-tracking ref otherwise makes
|
|
624
|
+
// origin/main this branch's upstream, and then `git push` stops with "the
|
|
625
|
+
// upstream branch of your current branch does not match the name of your
|
|
626
|
+
// current branch" and a command to copy — the same papercut, on the same
|
|
627
|
+
// first push, that `push.autoSetupRemote` below exists to remove. Leaving
|
|
628
|
+
// the branch without an upstream is what lets push set the right one.
|
|
629
|
+
await git(project.path, [
|
|
630
|
+
"worktree",
|
|
631
|
+
"add",
|
|
632
|
+
"--no-track",
|
|
633
|
+
"-b",
|
|
634
|
+
name,
|
|
635
|
+
target,
|
|
636
|
+
base.ref,
|
|
637
|
+
]);
|
|
638
|
+
await enableAutoSetupRemote(project.path, project.name);
|
|
639
|
+
|
|
640
|
+
const now = Date.now();
|
|
641
|
+
const id = newId();
|
|
642
|
+
db.insert(workspaces)
|
|
643
|
+
.values({
|
|
644
|
+
id,
|
|
645
|
+
projectId,
|
|
646
|
+
name,
|
|
647
|
+
branch: name,
|
|
648
|
+
path: target,
|
|
649
|
+
createdAt: now,
|
|
650
|
+
lastOpenedAt: now,
|
|
651
|
+
})
|
|
652
|
+
.run();
|
|
653
|
+
|
|
654
|
+
log.info(`created workspace ${project.name}/${name} from ${base.ref}`);
|
|
655
|
+
return {
|
|
656
|
+
id,
|
|
657
|
+
name,
|
|
658
|
+
branch: name,
|
|
659
|
+
path: target,
|
|
660
|
+
base: base.ref,
|
|
661
|
+
warning: base.warning,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export async function removeWorkspace(
|
|
666
|
+
db: Db,
|
|
667
|
+
config: Config,
|
|
668
|
+
id: string,
|
|
669
|
+
): Promise<void> {
|
|
670
|
+
const ws = getWorkspace(db, id);
|
|
671
|
+
if (!ws) throw new Error("unknown workspace");
|
|
672
|
+
const project = db
|
|
673
|
+
.select()
|
|
674
|
+
.from(projects)
|
|
675
|
+
.where(eq(projects.id, ws.projectId))
|
|
676
|
+
.get();
|
|
677
|
+
|
|
678
|
+
// Sessions die before the worktree does. The other order left every process
|
|
679
|
+
// the harness had spawned running in a directory that no longer existed,
|
|
680
|
+
// invisible to everything but ps (#38) — and a busy process in the worktree
|
|
681
|
+
// is also exactly what makes `worktree remove` fail.
|
|
682
|
+
await killWorkspaceSessions(db, config, id);
|
|
683
|
+
|
|
684
|
+
if (project) {
|
|
685
|
+
// --force because a worktree with uncommitted changes should still be
|
|
686
|
+
// removable from the UI; the branch is deliberately left behind so the work
|
|
687
|
+
// is recoverable with `git checkout`.
|
|
688
|
+
await git(project.path, ["worktree", "remove", "--force", ws.path]).catch(
|
|
689
|
+
(err: Error) => {
|
|
690
|
+
log.warn(`git worktree remove failed for ${ws.path}: ${err.message}`);
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
await rm(ws.path, { recursive: true, force: true }).catch(() => {});
|
|
695
|
+
|
|
696
|
+
db.delete(windows).where(eq(windows.workspaceId, id)).run();
|
|
697
|
+
db.delete(workspaces).where(eq(workspaces.id, id)).run();
|
|
698
|
+
log.info(`removed workspace ${ws.name} (branch ${ws.branch} kept)`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function touchWorkspace(db: Db, id: string): void {
|
|
702
|
+
db.update(workspaces)
|
|
703
|
+
.set({ lastOpenedAt: Date.now() })
|
|
704
|
+
.where(eq(workspaces.id, id))
|
|
705
|
+
.run();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Drops workspaces whose worktree directory has been deleted behind our back. */
|
|
709
|
+
export async function reconcileWorkspaces(
|
|
710
|
+
db: Db,
|
|
711
|
+
config: Config,
|
|
712
|
+
): Promise<void> {
|
|
713
|
+
for (const ws of db.select().from(workspaces).all()) {
|
|
714
|
+
if (!(await isDirectory(ws.path))) {
|
|
715
|
+
await killWorkspaceSessions(db, config, ws.id);
|
|
716
|
+
db.delete(windows).where(eq(windows.workspaceId, ws.id)).run();
|
|
717
|
+
db.delete(workspaces).where(eq(workspaces.id, ws.id)).run();
|
|
718
|
+
log.info(`workspace ${ws.name} no longer on disk — removed`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|