@neosh/git 0.1.0

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.
Files changed (3) hide show
  1. package/main.ts +1114 -0
  2. package/package.json +21 -0
  3. package/plugin.toml +15 -0
package/main.ts ADDED
@@ -0,0 +1,1114 @@
1
+ /**
2
+ * Git actions: pick a branch, start one named after what you are about to do, commit with a
3
+ * written message.
4
+ *
5
+ * **Every prompt here is a setting.** The defaults below are what you get out of the box; each one
6
+ * has a `.prompt` option that replaces it wholesale and an `.instructions` option that appends to
7
+ * it. That split matters — appending is what you want nine times out of ten ("always prefix with
8
+ * the ticket number"), and replacing is the escape hatch for when it is not.
9
+ *
10
+ * ```toml
11
+ * [options]
12
+ * "git.branch.instructions" = "Start with the Jira key when the message mentions one."
13
+ * "git.branch.model" = "anthropic/claude-haiku-4-5-20251001" # empty uses the model you talk to
14
+ * "git.branch.auto" = false # keep the scratch name
15
+ * ```
16
+ *
17
+ * **A worktree names itself once you have said what it is for.** `git.worktree.new.auto` creates a
18
+ * branch called `brisk-otter`, because a name chosen before the work is a decision made at the
19
+ * worst possible moment. The first message sent in it is that decision, arriving on its own, so
20
+ * the branch is renamed from it — `fix/composer-paste-truncation` — and never touched again.
21
+ *
22
+ * Nothing in this file is privileged. It is an ordinary plugin over `neosh.git` and `neosh.gen`,
23
+ * and replacing it with your own is `plugins.disabled = ["git"]` plus a plugin directory.
24
+ */
25
+
26
+ import { byteLength, sessionScope } from "@neosh/api";
27
+ import type {
28
+ CommitInfo,
29
+ ModelSelection,
30
+ Neosh,
31
+ PluginContext,
32
+ RepoStatus,
33
+ SessionId,
34
+ WorktreeInfo,
35
+ } from "@neosh/api";
36
+ import {
37
+ confirm,
38
+ confirmDestructive,
39
+ defineHighlights,
40
+ picker,
41
+ prompt,
42
+ statusPrefix,
43
+ } from "@neosh/api/ui";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Default prompts
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const BRANCH_PROMPT = `You generate git branch names.
50
+ Return a JSON object with exactly one key: branch.
51
+
52
+ Rules:
53
+ - Start with the type of work, as a prefix ending in a slash. Use exactly one of:
54
+ feature/ for something new, fix/ for a defect, refactor/ for a change that keeps behaviour,
55
+ chore/ for maintenance, docs/ for documentation, test/ for tests, perf/ for a speed-up.
56
+ - Choose the type from what the work is, not from the words used to ask for it: "the sidebar
57
+ flickers" is fix/, "add a sidebar" is feature/.
58
+ - After the prefix, 2 to 6 words describing the work, lowercase, hyphen separated.
59
+ - Prefer the noun the change is about over the verb used to request it.
60
+ - No ticket keys, no punctuation, no trailing slash, nothing after the words.
61
+
62
+ Examples:
63
+ - "the composer eats the last character when you paste" -> fix/composer-paste-truncation
64
+ - "let people pin a project to the top" -> feature/pin-project
65
+ - "bump the deno version" -> chore/bump-deno`;
66
+
67
+ const COMMIT_PROMPT = `You write git commit messages.
68
+ Return a JSON object with keys: subject, body.
69
+
70
+ Rules:
71
+ - subject is imperative, at most 72 characters, with no trailing period.
72
+ - body is either an empty string or short bullet points explaining why, not what.
73
+ - Describe the primary user-visible or developer-visible change.
74
+ - Do not mention the diff format, the number of files, or that you are an assistant.`;
75
+
76
+ export async function activate({ neosh, subscriptions }: PluginContext) {
77
+ await defineHighlights(neosh);
78
+
79
+ for (const spec of [
80
+ {
81
+ name: "git.branch.prompt",
82
+ description:
83
+ "Replaces the branch-naming prompt entirely. Empty uses the built-in one. It must ask for JSON with a `branch` key.",
84
+ },
85
+ {
86
+ name: "git.branch.instructions",
87
+ description: "Appended to the branch-naming prompt. The usual way to add a house rule.",
88
+ },
89
+ {
90
+ name: "git.commit.prompt",
91
+ description:
92
+ "Replaces the commit-message prompt entirely. Empty uses the built-in one. It must ask for JSON with `subject` and `body` keys.",
93
+ },
94
+ {
95
+ name: "git.commit.instructions",
96
+ description: "Appended to the commit-message prompt. Put your convention here.",
97
+ },
98
+ {
99
+ name: "git.branch.prefix",
100
+ description:
101
+ 'Prepended to a generated branch name that has no prefix of its own, e.g. "feature/". \
102
+ Applied after slugging, so it survives verbatim — and skipped when the model already chose a \
103
+ type, which the built-in prompt asks it to, so setting this does not produce "feature/fix/thing".',
104
+ },
105
+ {
106
+ name: "git.branch.model",
107
+ description:
108
+ "Model for naming branches, as `instance/model`. Empty falls back to `gen.model`, and \
109
+ then to the model the conversation is using — so out of the box a branch is named by whatever you \
110
+ are already talking to.",
111
+ },
112
+ ]) {
113
+ await neosh.opt.declare({
114
+ name: spec.name,
115
+ type: { type: "str" },
116
+ default: "",
117
+ description: spec.description,
118
+ });
119
+ }
120
+
121
+ await neosh.opt.declare({
122
+ name: "git.branch.auto",
123
+ type: { type: "bool" },
124
+ default: true,
125
+ description:
126
+ "Name an auto-created worktree's branch from the first message sent in it. Off leaves the \
127
+ two-word scratch name it was created with.",
128
+ });
129
+ await neosh.opt.declare({
130
+ name: "git.commit.confirm",
131
+ type: { type: "bool" },
132
+ default: true,
133
+ description: "Show the written commit message for approval before committing.",
134
+ });
135
+ await neosh.opt.declare({
136
+ name: "git.diff.max_bytes",
137
+ type: { type: "int", min: 1000, max: 400000 },
138
+ default: 40000,
139
+ description:
140
+ "Largest patch sent to the model when writing a commit message. A large refactor is truncated rather than rejected.",
141
+ });
142
+
143
+ const cmds: Array<[string, (args: string[]) => Promise<void>, string]> = [
144
+ ["git.status", () => showStatus(neosh), "Show working tree status"],
145
+ ["git.branch.switch", () => switchBranch(neosh), "Switch to another branch"],
146
+ ["git.branch.new", () => newBranch(neosh), "Start a branch named after what you describe"],
147
+ ["git.commit", () => commit(neosh), "Stage everything and commit with a written message"],
148
+ ["git.diff", () => showDiff(neosh), "Show what changed"],
149
+ ["git.worktree.list", () => pickWorktree(neosh), "Open a conversation in another worktree"],
150
+ [
151
+ "git.worktree.new",
152
+ // Empty is absent. A caller filling a later slot has to pass something for the earlier ones,
153
+ // and `""` meaning "a branch called nothing" turns "ask me" into a silent no-op.
154
+ (args: string[]) =>
155
+ newWorktree(neosh, { branch: arg(args, 0), path: arg(args, 1), cwd: arg(args, 2) }),
156
+ "Create a worktree and start working in it — `git.worktree.new <branch> [path] [cwd]`",
157
+ ],
158
+ [
159
+ "git.worktree.new.auto",
160
+ (args: string[]) => newWorktree(neosh, { cwd: arg(args, 0), auto: true }),
161
+ "A worktree on a branch named for you, with nothing to answer — `git.worktree.new.auto [cwd]`",
162
+ ],
163
+ [
164
+ "git.worktree.new.inside",
165
+ (args: string[]) => newWorktree(neosh, { cwd: arg(args, 0), auto: true, inside: true }),
166
+ "A worktree kept inside the repository, on a branch named for you — `git.worktree.new.inside [cwd]`",
167
+ ],
168
+ [
169
+ "git.pull",
170
+ (args: string[]) => pull(neosh, arg(args, 0)),
171
+ "Pull from the remote — `git.pull [cwd]`",
172
+ ],
173
+ [
174
+ "git.worktree.remove",
175
+ // With a path, that worktree; without one, a picker. The path form is what a panel row
176
+ // needs — a picker opened from the row you were already on asks you to point twice.
177
+ (args: string[]) => removeWorktree(neosh, { path: arg(args, 0), cwd: arg(args, 1) }),
178
+ "Remove a worktree — `git.worktree.remove [path] [cwd]`",
179
+ ],
180
+ // The sidebar hands a row over as `(kind, cwd, …)`, which is not the shape the commands above
181
+ // take from the palette. Two small verbs translate rather than every command growing a second
182
+ // calling convention.
183
+ [
184
+ "git.sidebar.pull",
185
+ (args: string[]) => pull(neosh, arg(args, 1)),
186
+ "Pull the repository of the sidebar row under the cursor",
187
+ ],
188
+ [
189
+ "git.sidebar.worktree.remove",
190
+ (args: string[]) => removeWorktree(neosh, { path: arg(args, 1), cwd: arg(args, 1) }),
191
+ "Remove the worktree of the sidebar row under the cursor",
192
+ ],
193
+ ];
194
+ for (const [name, fn, desc] of cmds) {
195
+ await neosh.cmd.register(name, fn, { desc });
196
+ }
197
+
198
+ // Verbs on the sidebar's rows. Contributed rather than baked into the panel, because that is
199
+ // what the contribution point is for — a sidebar that is not ours picks these up unchanged, and
200
+ // `plugins.disabled = ["git"]` takes the keys away with the plugin.
201
+ await neosh.ext.contribute("sidebar.action", "pull", {
202
+ key: "p",
203
+ label: "pull",
204
+ command: "git.sidebar.pull",
205
+ on: "any",
206
+ });
207
+ await neosh.ext.contribute("sidebar.action", "worktree-remove", {
208
+ key: "d",
209
+ label: "remove worktree",
210
+ command: "git.sidebar.worktree.remove",
211
+ on: "project",
212
+ });
213
+
214
+ await neosh.keymap.set("chat", "<C-g>", "git.status", { desc: "Git status" });
215
+ await neosh.keymap.set("chat", "<C-d>", "git.diff", { desc: "Show what changed" });
216
+
217
+ // What changed, on the project's own row. A decoration rather than a section of our own: the
218
+ // row already exists, the sidebar already knows how to draw a mark on it, and the whole point of
219
+ // the point is that this plugin never imports the panel. Keyed by the project's directory, and
220
+ // withdrawn when the tree is clean — a `0` on every row is a column of noise.
221
+ const decorate = async () => {
222
+ const projects = await neosh.vars
223
+ .get<unknown>({ scope: "global" }, "sidebar.projects")
224
+ .catch(() => null);
225
+ const cwds = Array.isArray(projects)
226
+ ? projects.filter((p): p is string => typeof p === "string")
227
+ : [];
228
+ for (const cwd of cwds) {
229
+ const status = await neosh.git.status({ cwd }).catch(() => null);
230
+ const dirty = status?.changes.length ?? 0;
231
+ if (dirty === 0) {
232
+ await neosh.ext.remove("sidebar.decoration", `dirty:${cwd}`).catch(() => {});
233
+ continue;
234
+ }
235
+ await neosh.ext.contribute("sidebar.decoration", `dirty:${cwd}`, {
236
+ target: { project: cwd },
237
+ badge: { text: `${dirty === 1 ? "●" : `●${dirty}`}`, hl: "Git.Modified" },
238
+ }).catch(() => {});
239
+ }
240
+ };
241
+ // Listeners before the first read: the sidebar writes `sidebar.projects` from its first draw,
242
+ // which can land while this plugin is still awaiting its own `git status`, and a change that
243
+ // arrives before the listener exists is a change nobody hears.
244
+ subscriptions.push(neosh.agent.onTurnEnd(() => void decorate()));
245
+ subscriptions.push(neosh.session.onChange(() => void decorate()));
246
+ subscriptions.push(
247
+ neosh.vars.onChange((e) => {
248
+ if (e.scope.scope === "global" && e.key === "sidebar.projects") void decorate();
249
+ }),
250
+ );
251
+ // And once the workspace is up: the project list is somebody else's, so it is read when they
252
+ // have had their say rather than when we have had ours.
253
+ neosh.event.on("neosh.ready", () => void decorate());
254
+ await decorate();
255
+
256
+ // Which branch you are on belongs beside the model: both answer "where is what I type going".
257
+ const footer = async () => {
258
+ const status = await neosh.git.status().catch(() => null);
259
+ if (!status) {
260
+ await neosh.status.clear("branch");
261
+ return;
262
+ }
263
+ const head = status.repo.detached
264
+ ? `detached ${status.repo.head ?? ""}`.trim()
265
+ : (status.repo.branch ?? "no branch");
266
+ const dirty = status.changes.length;
267
+ await neosh.status.set("branch", {
268
+ text: `${head}${dirty ? ` ●${dirty}` : ""}`,
269
+ hl: dirty ? "Git.Modified" : "Git.Branch",
270
+ priority: 20,
271
+ });
272
+ };
273
+ refreshFooter = () => void footer();
274
+ await footer();
275
+ subscriptions.push(neosh.agent.onTurnEnd(() => void footer()));
276
+ subscriptions.push(neosh.session.onChange(() => void footer()));
277
+ // The first thing asked in a scratch worktree is what its branch should have been called.
278
+ subscriptions.push(
279
+ neosh.agent.onTurnStart((e) => void nameScratchBranch(neosh, e.session as SessionId)),
280
+ );
281
+ // The working tree changes whenever anything writes a file, including the agent.
282
+ subscriptions.push(neosh.timer.every(5000, () => void footer()));
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Commands
287
+ // ---------------------------------------------------------------------------
288
+
289
+ async function showStatus(neosh: Neosh): Promise<void> {
290
+ const status = await repoStatus(neosh);
291
+ if (!status) return;
292
+
293
+ const lines = [describeHead(status)];
294
+ if (status.changes.length === 0) {
295
+ lines.push("", "clean");
296
+ } else {
297
+ lines.push("");
298
+ for (const c of status.changes) {
299
+ const rename = c.from ? ` ← ${c.from}` : "";
300
+ lines.push(`${statusPrefix(c)} ${c.path}${rename}`);
301
+ }
302
+ }
303
+
304
+ const buf = await neosh.buf.create({ name: "[git status]", scratch: true });
305
+ await neosh.buf.setLines(buf, 0, -1, lines);
306
+ await neosh.float.open(buf, {
307
+ anchor: { kind: "screen" },
308
+ width: { kind: "fixed", n: 76 },
309
+ height: { kind: "fixed", n: Math.min(24, lines.length) },
310
+ border: "rounded",
311
+ closeOnBlur: true,
312
+ });
313
+ }
314
+
315
+ async function switchBranch(neosh: Neosh): Promise<void> {
316
+ const branches = await neosh.git.branches().catch((e) => {
317
+ neosh.notify(String(e), "error");
318
+ return [];
319
+ });
320
+ if (branches.length === 0) return;
321
+
322
+ const chosen = await picker(
323
+ neosh,
324
+ branches.map((b) => ({
325
+ label: b.name,
326
+ detail: [b.is_head ? "current" : "", tracking(b.ahead, b.behind), b.subject ?? ""]
327
+ .filter(Boolean)
328
+ .join(" · "),
329
+ value: b.name,
330
+ })),
331
+ { title: "Switch branch", width: 76 },
332
+ );
333
+ if (!chosen) return;
334
+
335
+ try {
336
+ await neosh.git.checkout(chosen);
337
+ refreshFooter();
338
+ } catch (e) {
339
+ // Almost always uncommitted changes that would be overwritten. git's own message says which
340
+ // files, and is more useful than anything this plugin could invent.
341
+ neosh.notify(String(e), "error");
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Redraw the branch segment, now.
347
+ *
348
+ * Set by `activate`. Anything that moves `HEAD` calls it instead of announcing the move in the
349
+ * corner: the branch already has a place on screen, and a toast saying what the footer says is the
350
+ * same fact printed twice. Without this the footer is up to five seconds stale — which is why the
351
+ * toast was there, and is the thing to fix rather than to caption.
352
+ */
353
+ let refreshFooter: () => void = () => {};
354
+
355
+ async function newBranch(neosh: Neosh): Promise<void> {
356
+ const description = await prompt(neosh, "What are you about to work on?", { width: 76 });
357
+ if (!description || !description.trim()) return;
358
+
359
+ // Progress rather than a message: it is a state that stops being true the moment the model
360
+ // answers, and pushed onto the stack it sat *above* the row that superseded it.
361
+ neosh.progress("git.name", "naming the branch…");
362
+ let unique: string;
363
+ try {
364
+ unique = await nameBranch(neosh, description);
365
+ } catch (e) {
366
+ neosh.notify(`could not name the branch: ${e}`, "error");
367
+ return;
368
+ } finally {
369
+ neosh.done("git.name");
370
+ }
371
+
372
+ const edited = await prompt(neosh, "Branch name", { initial: unique, width: 76 });
373
+ if (!edited || !edited.trim()) return;
374
+
375
+ try {
376
+ await neosh.git.createBranch(edited.trim());
377
+ refreshFooter();
378
+ } catch (e) {
379
+ neosh.notify(String(e), "error");
380
+ }
381
+ }
382
+
383
+ /**
384
+ * A refname for `description`: generated, slugged, prefixed and not already taken.
385
+ *
386
+ * Every step of it happens here rather than in the host so that the name a caller shows for
387
+ * approval is exactly the name that will exist. "Why is my branch called that" is a question best
388
+ * answered before the branch does.
389
+ *
390
+ * Throws rather than returning a fallback. There is no useful default branch name, and the two
391
+ * callers want opposite things when this fails — one tells you, the other says nothing — which is
392
+ * a decision for them and not for this.
393
+ */
394
+ async function nameBranch(neosh: Neosh, description: string, cwd?: string): Promise<string> {
395
+ const answer = await neosh.gen.json<{ branch?: string }>(
396
+ `${await promptFor(neosh, "branch", BRANCH_PROMPT)}\n\nUser message:\n${description}`,
397
+ await branchModel(neosh),
398
+ );
399
+ if (typeof answer.branch !== "string" || answer.branch.trim() === "") {
400
+ throw new Error("the model returned no branch name");
401
+ }
402
+ const name = slug(answer.branch);
403
+ const prefix = (await neosh.opt.get<string>("git.branch.prefix")) ?? "";
404
+ // Skipped when the model already chose a type. The built-in prompt asks for `fix/`, `feature/`
405
+ // and the rest, so a `git.branch.prefix` applied unconditionally on top of it would read
406
+ // `feature/fix/composer-paste` — a prefix nobody meant and a type that is now wrong. Somebody
407
+ // who wants the prefix *always* has the prompt: it is a setting for the same reason.
408
+ const full = name.includes("/") ? name : `${prefix}${name}`;
409
+
410
+ const taken = new Set(
411
+ (await neosh.git.branches({ includeRemote: true, ...(cwd ? { cwd } : {}) }).catch(() => []))
412
+ .flatMap((b) => [b.name, b.name.replace(/^[^/]+\//, "")]),
413
+ );
414
+ return dedupe(full, taken);
415
+ }
416
+
417
+ /**
418
+ * Which model names a branch, if anyone said.
419
+ *
420
+ * Three rungs and this is only the first: `git.branch.model`, then `gen.model`, then the model the
421
+ * conversation is using — and the last two are the host's, which is why an unset option returns
422
+ * nothing at all rather than a guess. Out of the box that means your own model names the branch,
423
+ * which is the right default for a workspace where the cheap model is not configured and the only
424
+ * alternative would be failing.
425
+ */
426
+ async function branchModel(
427
+ neosh: Neosh,
428
+ ): Promise<{ selection: ModelSelection } | undefined> {
429
+ const spec = ((await neosh.opt.get<string>("git.branch.model")) ?? "").trim();
430
+ const cut = spec.indexOf("/");
431
+ if (cut <= 0 || cut === spec.length - 1) return undefined;
432
+ return {
433
+ selection: {
434
+ instance: spec.slice(0, cut) as ModelSelection["instance"],
435
+ model: spec.slice(cut + 1) as ModelSelection["model"],
436
+ },
437
+ };
438
+ }
439
+
440
+ async function commit(neosh: Neosh): Promise<void> {
441
+ const status = await repoStatus(neosh);
442
+ if (!status) return;
443
+ if (status.changes.length === 0) {
444
+ neosh.notify("nothing to commit");
445
+ return;
446
+ }
447
+
448
+ const conflicted = status.changes.filter(
449
+ (c) => c.staged === "conflicted" || c.unstaged === "conflicted",
450
+ );
451
+ if (conflicted.length > 0) {
452
+ // Committing a conflict marker is a mistake that survives into history.
453
+ neosh.notify(`resolve ${conflicted.length} conflicted file(s) first`, "error");
454
+ return;
455
+ }
456
+
457
+ const anythingStaged = status.changes.some((c) => c.staged);
458
+ if (!anythingStaged) {
459
+ if (!(await confirm(neosh, "Nothing is staged. Stage everything?"))) return;
460
+ await neosh.git.stage();
461
+ }
462
+
463
+ const limit = (await neosh.opt.get<number>("git.diff.max_bytes")) ?? 40000;
464
+ const [patch, stat] = await Promise.all([
465
+ neosh.git.diff({ kind: "staged" }),
466
+ neosh.git.diff({ kind: "staged" }, { stat: true }),
467
+ ]);
468
+ if (patch.trim() === "") {
469
+ neosh.notify("nothing staged to commit");
470
+ return;
471
+ }
472
+
473
+ neosh.progress("git.commit", "writing the commit message…");
474
+ let message: string;
475
+ try {
476
+ const answer = await neosh.gen.json<{ subject?: string; body?: string }>(
477
+ [
478
+ await promptFor(neosh, "commit", COMMIT_PROMPT),
479
+ "",
480
+ `Branch: ${status.repo.branch ?? "(detached)"}`,
481
+ "",
482
+ "Staged files:",
483
+ stat.trim(),
484
+ "",
485
+ "Staged patch:",
486
+ truncate(patch, limit),
487
+ ].join("\n"),
488
+ );
489
+ const subject = (answer.subject ?? "").trim();
490
+ if (subject === "") throw new Error("the model returned no subject");
491
+ const body = (answer.body ?? "").trim();
492
+ message = body ? `${subject}\n\n${body}` : subject;
493
+ } catch (e) {
494
+ neosh.notify(`could not write a commit message: ${e}`, "error");
495
+ return;
496
+ } finally {
497
+ // Before the confirmation prompt, not after the commit: what the row claims is that a model is
498
+ // writing, and it stops being that as soon as one has.
499
+ neosh.done("git.commit");
500
+ }
501
+
502
+ if (await neosh.opt.get<boolean>("git.commit.confirm")) {
503
+ const first = message.split("\n")[0] ?? message;
504
+ const edited = await prompt(neosh, "Commit message", { initial: first, width: 76 });
505
+ if (edited === null) return;
506
+ const rest = message.split("\n").slice(1).join("\n");
507
+ message = rest.trim() ? `${edited}\n${rest}` : edited;
508
+ }
509
+
510
+ try {
511
+ const made: CommitInfo = await neosh.git.commit(message);
512
+ neosh.notify(`committed ${made.short}: ${made.subject}`);
513
+ } catch (e) {
514
+ neosh.notify(String(e), "error");
515
+ }
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Helpers
520
+ // ---------------------------------------------------------------------------
521
+
522
+ /**
523
+ * Resolve the prompt for one generation kind.
524
+ *
525
+ * `<kind>.prompt` replaces the default; `<kind>.instructions` is appended to whichever is in use —
526
+ * including a replaced one, so a user with their own prompt can still layer a per-project rule on
527
+ * top of it from `.neosh/config.toml`.
528
+ */
529
+ async function promptFor(neosh: Neosh, kind: "branch" | "commit", fallback: string): Promise<string> {
530
+ const override = ((await neosh.opt.get<string>(`git.${kind}.prompt`)) ?? "").trim();
531
+ const extra = ((await neosh.opt.get<string>(`git.${kind}.instructions`)) ?? "").trim();
532
+ const base = override || fallback;
533
+ return extra ? `${base}\n\nAdditional instructions:\n${extra}` : base;
534
+ }
535
+
536
+ async function repoStatus(neosh: Neosh): Promise<RepoStatus | null> {
537
+ try {
538
+ return await neosh.git.status();
539
+ } catch (e) {
540
+ neosh.notify(String(e), "warn");
541
+ return null;
542
+ }
543
+ }
544
+
545
+ function describeHead(status: RepoStatus): string {
546
+ const head = status.repo.detached
547
+ ? `detached at ${status.repo.head ?? "?"}`
548
+ : (status.repo.branch ?? "no branch");
549
+ const track = tracking(status.repo.ahead, status.repo.behind);
550
+ return track ? `${head} ${track}` : head;
551
+ }
552
+
553
+ function tracking(ahead: number, behind: number): string {
554
+ const bits: string[] = [];
555
+ if (ahead) bits.push(`↑${ahead}`);
556
+ if (behind) bits.push(`↓${behind}`);
557
+ return bits.join(" ");
558
+ }
559
+
560
+ /**
561
+ * Make a model's answer into something `git switch -c` accepts.
562
+ *
563
+ * The host slugs too, but doing it here means the name the user is shown for approval is the name
564
+ * the branch will actually have.
565
+ */
566
+ export function slug(raw: string): string {
567
+ // Dots survive, because `feat/v1.2` is a name someone means. That makes the rules below live
568
+ // rather than decorative: git rejects `..`, a component starting or ending with `.`, and a
569
+ // component ending in `.lock`.
570
+ const mapped = Array.from(raw.toLowerCase())
571
+ .map((ch) => (/[a-z0-9/_.]/.test(ch) ? ch : "-"))
572
+ .join("");
573
+ const parts = mapped
574
+ .split("/")
575
+ .map((p) =>
576
+ p
577
+ .replace(/-+/g, "-")
578
+ .replace(/\.{2,}/g, ".")
579
+ // Trim *before* stripping `.lock`, or trailing punctuation from the model
580
+ // ("login.lock!!" -> "login.lock-") hides the suffix from the pattern. Then trim again,
581
+ // because stripping can expose a new trailing dot.
582
+ .replace(/^[-.]+|[-.]+$/g, "")
583
+ .replace(/(\.lock)+$/, "")
584
+ .replace(/[-.]+$/, ""),
585
+ )
586
+ .filter((p) => p !== "");
587
+ return parts.join("/") || "work";
588
+ }
589
+
590
+ export function dedupe(name: string, taken: Set<string>): string {
591
+ if (!taken.has(name)) return name;
592
+ for (let n = 2; n < 1000; n++) {
593
+ const candidate = `${name}-${n}`;
594
+ if (!taken.has(candidate)) return candidate;
595
+ }
596
+ return name;
597
+ }
598
+
599
+ /** Keep the *end* of a patch: the last hunks are the ones a truncated diff most needs. */
600
+ function truncate(patch: string, limit: number): string {
601
+ if (patch.length <= limit) return patch;
602
+ return `[earlier hunks truncated]\n\n${patch.slice(-limit)}`;
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Diffs
607
+ // ---------------------------------------------------------------------------
608
+
609
+ /**
610
+ * What changed, one file at a time.
611
+ *
612
+ * Whole-repository diffs are unreadable in a terminal pane and the interesting question is almost
613
+ * always "what happened to *this* file". So: pick a file, read its patch. Staged and unstaged are
614
+ * shown together because that is how you actually look at a working tree — the split matters when
615
+ * you commit, not when you read.
616
+ */
617
+ async function showDiff(neosh: Neosh): Promise<void> {
618
+ const status = await repoStatus(neosh);
619
+ if (!status) return;
620
+ if (status.changes.length === 0) {
621
+ neosh.notify("nothing has changed");
622
+ return;
623
+ }
624
+
625
+ const chosen = await picker(
626
+ neosh,
627
+ status.changes.map((c) => ({
628
+ label: c.path,
629
+ detail: [c.staged ? `staged ${c.staged}` : "", c.unstaged ?? ""].filter(Boolean).join(" · "),
630
+ value: c,
631
+ })),
632
+ { title: "Changed files", width: 78 },
633
+ );
634
+ if (!chosen) return;
635
+
636
+ // Both halves, labelled, so a file that is staged *and* edited again shows what each contains
637
+ // rather than silently picking one.
638
+ const parts: string[] = [];
639
+ for (const [label, target] of [
640
+ ["staged", { kind: "staged" as const }],
641
+ ["unstaged", { kind: "unstaged" as const }],
642
+ ]) {
643
+ const patch = await neosh.git.diff(target as never).catch(() => "");
644
+ const forFile = filterToFile(patch, chosen.path);
645
+ if (forFile.trim()) {
646
+ parts.push(`── ${label} ──`, ...forFile.split("\n"));
647
+ }
648
+ }
649
+ if (parts.length === 0) {
650
+ // Untracked files have no diff; showing "nothing" would look like a bug.
651
+ parts.push(
652
+ chosen.unstaged === "untracked"
653
+ ? `${chosen.path} is untracked — there is nothing to diff against yet.`
654
+ : `no textual diff for ${chosen.path}`,
655
+ );
656
+ }
657
+
658
+ const buf = await neosh.buf.create({ name: `[diff] ${chosen.path}`, scratch: true });
659
+ await neosh.buf.setLines(buf, 0, -1, parts);
660
+ const ns = await neosh.ns.create("neosh.git.diff");
661
+ for (let i = 0; i < parts.length; i++) {
662
+ const hl = diffHl(parts[i] ?? "");
663
+ if (hl) {
664
+ await neosh.ns.mark(ns, buf, i, 0, { hlGroup: hl, endCol: byteLength(parts[i] ?? "") });
665
+ }
666
+ }
667
+
668
+ const win = await neosh.float.open(buf, {
669
+ anchor: { kind: "screen" },
670
+ width: { kind: "max", n: 100 },
671
+ height: { kind: "max", n: 30 },
672
+ border: "rounded",
673
+ title: ` ${chosen.path} `,
674
+ closeOnBlur: true,
675
+ focusable: true,
676
+ });
677
+ await neosh.focus.push(win);
678
+ }
679
+
680
+ /**
681
+ * Keep only the hunk belonging to one file.
682
+ *
683
+ * `git diff -- <path>` would be a second subprocess and would need the path escaped; the patch is
684
+ * already in hand, and its `diff --git` markers are unambiguous.
685
+ */
686
+ function filterToFile(patch: string, path: string): string {
687
+ const out: string[] = [];
688
+ let inFile = false;
689
+ for (const line of patch.split("\n")) {
690
+ if (line.startsWith("diff --git ")) {
691
+ inFile = line.includes(` b/${path}`) || line.endsWith(`/${path}`);
692
+ if (inFile) out.push(line);
693
+ continue;
694
+ }
695
+ if (inFile) out.push(line);
696
+ }
697
+ return out.join("\n");
698
+ }
699
+
700
+ function diffHl(line: string): string | undefined {
701
+ if (line.startsWith("diff --git ") || line.startsWith("── ")) return "Diff.Header";
702
+ if (line.startsWith("@@")) return "Diff.Hunk";
703
+ // `+++`/`---` are file headers, not content, and colouring them as additions and deletions is
704
+ // the single most common thing terminal diff viewers get wrong.
705
+ if (line.startsWith("+++") || line.startsWith("---")) return "Diff.Header";
706
+ if (line.startsWith("+")) return "Diff.Add";
707
+ if (line.startsWith("-")) return "Diff.Delete";
708
+ return undefined;
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Worktrees
713
+ // ---------------------------------------------------------------------------
714
+
715
+ /**
716
+ * Switch to another worktree by opening a conversation in it.
717
+ *
718
+ * There is no "current directory" to change — a conversation carries its own, and `neosh.git`
719
+ * answers about whichever one is active. That is what makes a worktree a place you can *be* rather
720
+ * than a directory you happen to have created: the sidebar groups by it, the footer shows its
721
+ * branch, and the agent's tools run in it.
722
+ */
723
+ async function pickWorktree(neosh: Neosh): Promise<void> {
724
+ const trees = await neosh.git.worktrees().catch((e) => {
725
+ neosh.notify(String(e), "warn");
726
+ return [];
727
+ });
728
+ if (trees.length === 0) return;
729
+ if (trees.length === 1) {
730
+ neosh.notify("this repository has one worktree — `git.worktree.new` makes another");
731
+ return;
732
+ }
733
+
734
+ const chosen = await picker(
735
+ neosh,
736
+ trees.map((t) => ({
737
+ label: t.branch ?? t.head ?? t.path,
738
+ detail: [t.is_current ? "current" : "", t.is_main ? "main" : "", t.locked ? "locked" : "", t.path]
739
+ .filter(Boolean)
740
+ .join(" · "),
741
+ keywords: t.path,
742
+ value: t,
743
+ })),
744
+ { title: "Worktrees", width: 78 },
745
+ );
746
+ if (!chosen) return;
747
+
748
+ // An existing conversation in that tree is almost always the one you meant; a new one otherwise.
749
+ const existing = (await neosh.session.list()).find((s) => s.cwd === chosen.path);
750
+ if (existing) {
751
+ await neosh.session.switch(existing.id);
752
+ neosh.notify(`in ${chosen.branch ?? chosen.path}`);
753
+ return;
754
+ }
755
+ await neosh.session.create({ cwd: chosen.path });
756
+ neosh.notify(`new conversation in ${chosen.branch ?? chosen.path}`);
757
+ }
758
+
759
+ /** One positional argument, with blank treated as not given. */
760
+ function arg(args: string[], at: number): string | undefined {
761
+ const v = args[at]?.trim();
762
+ return v ? v : undefined;
763
+ }
764
+
765
+ /** What a worktree is being made of, and what to call it. */
766
+ interface WorktreeSpec {
767
+ /** The branch. Asked for when absent — unless `auto`, which invents one. */
768
+ branch?: string;
769
+ /** Where it lands. Follows from `worktree.root` when absent. */
770
+ path?: string;
771
+ /** Which repository. The conversation's own when absent. */
772
+ cwd?: string;
773
+ /** Name it rather than ask. See {@link scratchName}. */
774
+ auto?: boolean;
775
+ /**
776
+ * Inside the repository, whatever `worktree.root` says.
777
+ *
778
+ * The per-call form of a relative root: the picker offers "in this project" as a *choice*, and a
779
+ * choice that only works after editing config.toml is a row that lies to everyone who has not.
780
+ * A relative `worktree.root` still names the directory; absent one, `.worktrees` is the word
781
+ * every tool that does this has already agreed on.
782
+ */
783
+ inside?: boolean;
784
+ }
785
+
786
+ /**
787
+ * Make a worktree and start working in it.
788
+ *
789
+ * **One question at most, and `auto` makes it none.** It used to ask twice — the branch, then the
790
+ * path, prefilled with the answer it had already worked out. That second prompt was the whole point
791
+ * of `worktree.root` being a setting, asked again: somebody who has configured where their
792
+ * worktrees go has said where their worktrees go. The path is still reachable —
793
+ * `git.worktree.new <branch> <path>` takes it — but from the palette or a key, the location follows
794
+ * from the configuration and the message says where it landed.
795
+ *
796
+ * `auto` drops the last question too. Naming a branch before you know what the work is is a
797
+ * decision you are not yet equipped to make, and every one of those is a reason not to start —
798
+ * which is the opposite of what a key for "somewhere clean to try this" is for. So the branch is
799
+ * real and its name is two words from a list, and the decision is deferred rather than skipped:
800
+ * the first message sent in the tree is what it should have been called, so that is what it gets
801
+ * called. See [`nameScratchBranch`].
802
+ *
803
+ * Landing in it is the point. Creating a directory you then have to go and find is not a feature,
804
+ * it is a chore with extra steps.
805
+ */
806
+ async function newWorktree(neosh: Neosh, spec: WorktreeSpec = {}): Promise<void> {
807
+ const root = await repoRoot(neosh, spec.cwd);
808
+ if (root === null) return;
809
+
810
+ // Remote names count. `origin/fix-thing` with no local branch means `git worktree add` should
811
+ // check the existing one out rather than fail trying to create it, and for a generated name it
812
+ // means one that is free here but taken upstream is not offered.
813
+ const taken = new Set(
814
+ (await neosh.git.branches({ includeRemote: true, cwd: spec.cwd }).catch(() => []))
815
+ .flatMap((b) => [b.name, b.name.replace(/^[^/]+\//, "")]),
816
+ );
817
+
818
+ const asked = spec.branch ??
819
+ (spec.auto
820
+ ? scratchName(taken)
821
+ : await prompt(neosh, "Branch for the new worktree", { width: 70 }));
822
+ if (!asked || !asked.trim()) return;
823
+ const name = slug(asked);
824
+ const where = (spec.path ?? (await worktreePath(neosh, root, name, spec.inside))).trim();
825
+ if (where === "") return;
826
+
827
+ const create = !taken.has(name);
828
+
829
+ try {
830
+ await neosh.git.addWorktree(where, name, { create, cwd: spec.cwd });
831
+ } catch (e) {
832
+ neosh.notify(String(e), "error");
833
+ return;
834
+ }
835
+ const session = await neosh.session.create({ cwd: where });
836
+ // Only a name *nobody chose* is one this plugin may replace later. A branch somebody typed is
837
+ // theirs, and `git.worktree.new feat/thing` must not be quietly renamed the moment a message is
838
+ // sent — so the mark goes on here, where the difference is still known, rather than being
839
+ // guessed at afterwards from the shape of the name.
840
+ if (spec.auto && create) {
841
+ await neosh.vars.set(sessionScope(session.id), VAR_SCRATCH, name)
842
+ .catch((e: unknown) => neosh.log.info(`could not mark ${name} as scratch: ${e}`));
843
+ }
844
+ neosh.notify(`${create ? "branched" : "checked out"} ${name} in ${where}`);
845
+ }
846
+
847
+ /**
848
+ * The branch this conversation's worktree was created on, while that is still a name nobody chose.
849
+ *
850
+ * A var rather than a pattern match on the name. t3code recognises its own temporary branches by
851
+ * their shape — `t3code/<8 hex>` — which works until somebody's real branch happens to look like
852
+ * one, and which cannot tell `brisk-otter` that we generated from `brisk-otter` that you typed.
853
+ * Writing it down at the one moment the difference is known costs a key and removes the guess.
854
+ *
855
+ * Session-scoped, so it is deleted with the conversation, and removed the moment the branch is
856
+ * named — after which this worktree is an ordinary worktree and nothing here will touch it again.
857
+ */
858
+ const VAR_SCRATCH = "git.branch.scratch";
859
+
860
+ /**
861
+ * Name the branch of a scratch worktree after the first thing asked in it.
862
+ *
863
+ * The alternative is asking before the work starts, and a branch named before you know what the
864
+ * work is is a decision made at the worst possible moment — which is the whole reason
865
+ * `git.worktree.new.auto` exists and gives you `brisk-otter` instead. This is the other half:
866
+ * `brisk-otter` is a fine thing to *start* on and a poor thing to find in `git branch` next week.
867
+ *
868
+ * On turn start rather than turn end, because the panel is drawn all through a turn and the row
869
+ * you are watching should say what you asked for, not what a word list picked. `git branch -m` is
870
+ * one ref write, so doing it under a running agent changes nothing about the files it is editing.
871
+ *
872
+ * **One attempt, ever.** The mark is removed before the model is asked, so a cheap model that
873
+ * hiccups costs one request rather than one per message for the life of the conversation — the
874
+ * rule the titles plugin arrived at the same way. Too *early* is not a failure and does not spend
875
+ * it: a turn with nothing to read yet leaves the mark alone and waits for the next one.
876
+ */
877
+ async function nameScratchBranch(neosh: Neosh, session: SessionId): Promise<void> {
878
+ if (!((await neosh.opt.get<boolean>("git.branch.auto")) ?? true)) return;
879
+ const scope = sessionScope(session);
880
+ const scratch = await neosh.vars.get<string>(scope, VAR_SCRATCH).catch(() => null);
881
+ if (!scratch) return;
882
+
883
+ const info = (await neosh.session.list().catch(() => [])).find((x) => x.id === session);
884
+ // Somebody renamed it themselves, or moved off it. Either way the name is theirs now.
885
+ if (!info || info.branch !== scratch) {
886
+ await neosh.vars.remove(scope, VAR_SCRATCH).catch(() => {});
887
+ return;
888
+ }
889
+
890
+ const messages = await neosh.session.messages(session).catch(() => []);
891
+ const asked = messages
892
+ .filter((m) => m.role === "user")
893
+ .flatMap((m) => m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])))
894
+ .join("\n\n")
895
+ .trim()
896
+ .slice(0, 4000);
897
+ if (asked === "") return;
898
+
899
+ await neosh.vars.remove(scope, VAR_SCRATCH).catch(() => {});
900
+ try {
901
+ const named = await nameBranch(neosh, asked, info.cwd);
902
+ if (named === scratch) return;
903
+ await neosh.git.renameBranch(scratch, named, { cwd: info.cwd });
904
+ neosh.notify(`branch is ${named}`);
905
+ } catch (e) {
906
+ // Not worth interrupting for: the worktree works, the branch has a name, and a popup every
907
+ // time a model hiccups would cost more than the name it failed to improve.
908
+ neosh.log.info(`could not name ${scratch}: ${e}`);
909
+ }
910
+ }
911
+
912
+ /**
913
+ * The original checkout of the repository at `cwd`.
914
+ *
915
+ * Not `git status`, which is a subprocess that stats every file in the tree to answer a question
916
+ * about the *repository* — and not `status.repo.root`, which in a linked worktree is that
917
+ * worktree. A new worktree of `feat-thing` belongs beside the others under the repository's name,
918
+ * not under `feat-thing`'s, and `is_main` is what says which one that is.
919
+ */
920
+ async function repoRoot(neosh: Neosh, cwd?: string): Promise<string | null> {
921
+ const trees = await neosh.git.worktrees(cwd ? { cwd } : undefined).catch((e: unknown) => {
922
+ neosh.notify(String(e), "warn");
923
+ return [] as WorktreeInfo[];
924
+ });
925
+ return trees.find((t) => t.is_main)?.path ?? trees[0]?.path ?? null;
926
+ }
927
+
928
+ /**
929
+ * Two words that are not a branch yet.
930
+ *
931
+ * An adjective and a noun, because a name you can say out loud is a name you can find again in a
932
+ * list of eight of them — `brisk-otter` is a thing you remember starting, `wt-3` is not, and a
933
+ * timestamp is neither. Both lists are short, concrete and unambiguous when spoken.
934
+ *
935
+ * Collisions are checked rather than hoped away: with a few dozen worktrees the birthday problem
936
+ * is real, and the caller has the branch list in its hand already. The counter suffix is the floor
937
+ * — it never loops forever, and `brisk-otter-2` is still a name.
938
+ */
939
+ function scratchName(taken: ReadonlySet<string>): string {
940
+ const pick = <T>(xs: readonly T[]): T => xs[Math.floor(Math.random() * xs.length)]!;
941
+ for (let i = 0; i < 50; i++) {
942
+ const name = `${pick(SCRATCH_ADJECTIVES)}-${pick(SCRATCH_NOUNS)}`;
943
+ if (!taken.has(name)) return name;
944
+ }
945
+ const base = `${pick(SCRATCH_ADJECTIVES)}-${pick(SCRATCH_NOUNS)}`;
946
+ for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
947
+ }
948
+
949
+ const SCRATCH_ADJECTIVES = [
950
+ "amber", "brisk", "calm", "clever", "coral", "crisp", "dapper", "eager", "fleet", "gentle",
951
+ "glad", "golden", "hardy", "keen", "lively", "lucid", "mellow", "merry", "nimble", "noble",
952
+ "placid", "plucky", "quiet", "rapid", "ruby", "sage", "sleek", "solar", "spry", "stout",
953
+ "sunny", "swift", "tidy", "vivid", "warm", "wily", "witty", "zesty",
954
+ ] as const;
955
+
956
+ const SCRATCH_NOUNS = [
957
+ "alder", "anchor", "badger", "beacon", "birch", "brook", "cedar", "comet", "coral", "crane",
958
+ "delta", "ember", "falcon", "fern", "finch", "garnet", "harbor", "heron", "ibis", "juniper",
959
+ "kestrel", "lantern", "lark", "linden", "marlin", "meadow", "mesa", "nimbus", "otter", "pike",
960
+ "quartz", "raven", "reef", "ridge", "sable", "sparrow", "summit", "thistle", "tundra", "vale",
961
+ "walrus", "willow", "yarrow", "zephyr",
962
+ ] as const;
963
+
964
+ /**
965
+ * Where a worktree for `branch` goes.
966
+ *
967
+ * `<root>/<repo>/<branch>` under `worktree.root`, which is `~/.nsh` unless configured — a
968
+ * directory of neosh's own rather than a sibling of the repository, because a worktree is not part
969
+ * of the project you are working on and littering its parent with `foo-worktrees/` is how people
970
+ * end up with checkouts they cannot account for. The repository name is a level of its own so two
971
+ * projects with a `main` branch do not collide.
972
+ *
973
+ * A *relative* root is inside the repository — `worktree.root = ".worktrees"` puts every tree at
974
+ * `<repo>/.worktrees/<branch>` — and there the repository's name is a level of noise rather than a
975
+ * disambiguator: nothing but this repository's worktrees can land in its own directory. The host
976
+ * keeps an in-tree checkout out of `git status` by writing the directory into the repository's
977
+ * `.gitignore`, so choosing this layout does not mean reading past an untracked directory forever
978
+ * — in this clone or anyone else's.
979
+ *
980
+ * An empty `worktree.root` restores the sibling layout, for anyone who wants their trees next to
981
+ * the thing they are trees of.
982
+ *
983
+ * Slashes in a branch become dashes: `feat/thing` is one directory, not two, because the directory
984
+ * is a name and not a path.
985
+ */
986
+ async function worktreePath(
987
+ neosh: Neosh,
988
+ repoRoot: string,
989
+ branch: string,
990
+ inside = false,
991
+ ): Promise<string> {
992
+ const leaf = branch.replace(/\//g, "-");
993
+ const repoName = repoRoot.split("/").filter(Boolean).pop() ?? "repo";
994
+ const configured = ((await neosh.opt.get<string>("worktree.root")) ?? "").trim();
995
+ // Asked to stay inside regardless of what is configured. A relative root still names the
996
+ // directory; anything else falls back to the conventional one.
997
+ if (inside) return `${repoRoot}/${insideDir(configured)}/${leaf}`;
998
+ if (configured === "") return `${parentOf(repoRoot)}/${repoName}-worktrees/${leaf}`;
999
+ if (configured.startsWith("/")) return `${configured}/${repoName}/${leaf}`;
1000
+ return `${repoRoot}/${configured}/${leaf}`;
1001
+ }
1002
+
1003
+ /** The in-repository directory worktrees go in: a relative `worktree.root`, else `.worktrees`. */
1004
+ function insideDir(configured: string): string {
1005
+ const c = configured.trim();
1006
+ return c !== "" && !c.startsWith("/") ? c.replace(/\/+$/, "") : ".worktrees";
1007
+ }
1008
+
1009
+ /**
1010
+ * `git pull`, saying what happened.
1011
+ *
1012
+ * The summary is git's own — "Already up to date." and a fast-forward range are different answers,
1013
+ * and a command that swallows both teaches you to run it twice to be sure.
1014
+ */
1015
+ async function pull(neosh: Neosh, cwd?: string): Promise<void> {
1016
+ neosh.progress("git.pull", "pulling…");
1017
+ try {
1018
+ const summary = await neosh.git.pull(cwd ? { cwd } : undefined);
1019
+ neosh.notify(summary);
1020
+ } catch (e) {
1021
+ // Diverged branches, no remote, auth — git's message names it, and inventing a friendlier one
1022
+ // here would mean guessing which of those it was.
1023
+ neosh.notify(String(e), "error");
1024
+ } finally {
1025
+ neosh.done("git.pull");
1026
+ }
1027
+ }
1028
+
1029
+ /**
1030
+ * Remove a worktree, with or without being told which.
1031
+ *
1032
+ * `path` given is the sidebar's flow — `d` on the row *is* the pointing — and a picker opened from
1033
+ * a row you were already on asks you to point twice. Without one it is the palette's flow, and the
1034
+ * picker is the pointing. Either way the removal runs from the main checkout: `git worktree
1035
+ * remove` refuses to saw off the branch it is sitting on, and the conversation this command runs
1036
+ * in may be sitting in exactly the tree being removed.
1037
+ */
1038
+ async function removeWorktree(
1039
+ neosh: Neosh,
1040
+ spec: { path?: string; cwd?: string } = {},
1041
+ ): Promise<void> {
1042
+ const all = await neosh.git.worktrees(spec.cwd ? { cwd: spec.cwd } : undefined).catch(() => []);
1043
+ const main = all.find((t) => t.is_main)?.path;
1044
+ const removable = all.filter((t) => !t.is_main && !t.is_current);
1045
+
1046
+ let chosen: WorktreeInfo | undefined;
1047
+ if (spec.path) {
1048
+ const named = all.find((t) => t.path === spec.path);
1049
+ if (!named) {
1050
+ neosh.notify(`no worktree at ${spec.path}`, "warn");
1051
+ return;
1052
+ }
1053
+ if (named.is_main) {
1054
+ neosh.notify("this is the repository itself — `d` removes a worktree row", "warn");
1055
+ return;
1056
+ }
1057
+ if (named.is_current) {
1058
+ neosh.notify("you are in this worktree — switch to another conversation first", "warn");
1059
+ return;
1060
+ }
1061
+ chosen = named;
1062
+ } else {
1063
+ if (removable.length === 0) {
1064
+ neosh.notify("nothing to remove — the main worktree and the one you are in are off limits");
1065
+ return;
1066
+ }
1067
+ chosen = await picker(
1068
+ neosh,
1069
+ removable.map((t) => ({ label: t.branch ?? t.path, detail: t.path, value: t })),
1070
+ { title: "Remove worktree", width: 78 },
1071
+ ) ?? undefined;
1072
+ }
1073
+ if (!chosen) return;
1074
+ // The conversations that go with it. They are deleted below, and a dialog that does not mention
1075
+ // them is a dialog you agreed to something else in: removing a worktree is a git operation, and
1076
+ // losing what you talked about in it is not.
1077
+ const doomed = (await neosh.session.list().catch(() => []))
1078
+ .filter((s) => s.cwd === chosen.path && !s.is_active).length;
1079
+ // A worktree is a directory with your work in it. The same gate as deleting a conversation, and
1080
+ // the same reason: `git worktree remove` does not put it back.
1081
+ if (!(await confirmDestructive(neosh, `Remove the worktree at ${chosen.path}?`, {
1082
+ yes: "Remove",
1083
+ no: "Keep",
1084
+ detail: [
1085
+ chosen.branch ? `The branch ${chosen.branch} stays; the checkout goes.` : "The checkout goes.",
1086
+ ...(doomed > 0
1087
+ ? [`${doomed} ${doomed === 1 ? "conversation" : "conversations"} in it will be deleted too.`]
1088
+ : []),
1089
+ ],
1090
+ }))) {
1091
+ return;
1092
+ }
1093
+
1094
+ try {
1095
+ await neosh.git.removeWorktree(chosen.path, main ? { cwd: main } : undefined);
1096
+ } catch (e) {
1097
+ // git refuses when the tree has changes, which is the right answer and a better message than
1098
+ // anything this plugin could invent.
1099
+ neosh.notify(String(e), "error");
1100
+ return;
1101
+ }
1102
+ // Conversations in a directory that no longer exists are dead weight in the sidebar.
1103
+ for (const s of await neosh.session.list()) {
1104
+ if (s.cwd === chosen.path && !s.is_active) {
1105
+ await neosh.session.close(s.id).catch(() => {});
1106
+ }
1107
+ }
1108
+ neosh.notify(`removed ${chosen.path}`);
1109
+ }
1110
+
1111
+ function parentOf(path: string): string {
1112
+ const at = path.replace(/\/+$/, "").lastIndexOf("/");
1113
+ return at <= 0 ? "/" : path.slice(0, at);
1114
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@neosh/git",
3
+ "version": "0.1.0",
4
+ "description": "Branch, commit and status, with model-written names and messages you can re-prompt.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "neosh",
9
+ "neosh-plugin"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/neoswarm/neosh.git",
14
+ "directory": "plugins/builtin/git"
15
+ },
16
+ "files": [
17
+ "*.ts",
18
+ "plugin.toml",
19
+ "!._*"
20
+ ]
21
+ }
package/plugin.toml ADDED
@@ -0,0 +1,15 @@
1
+ name = "git"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "Branch, commit and status, with model-written names and messages you can re-prompt."
5
+
6
+ # Creates branches, stages and commits. Declared so `plugin.toml` answers "what can this
7
+ # do to my repository" without reading the source.
8
+ permissions = ["vcs_write"]
9
+
10
+ # Puts a dirty-count badge on the sidebar's project rows and verbs on its rows. Soft ordering:
11
+ # the sidebar is not needed for anything else here, so this is `after` rather than `requires`.
12
+ after = ["sidebar"]
13
+
14
+ [provides]
15
+ vars = ["git.branch.scratch"]