@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.
- package/main.ts +1114 -0
- package/package.json +21 -0
- 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"]
|