-
Notifications
You must be signed in to change notification settings - Fork 173
Expand file tree
/
Copy pathshell-changes.ts
More file actions
380 lines (337 loc) · 14.6 KB
/
Copy pathshell-changes.ts
File metadata and controls
380 lines (337 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
// Gentle Shell changes: the working tree against HEAD, new files included.
// Git is the source of truth; this module turns raw `git diff --numstat` and
// `git status --porcelain -z` output into a model and renders the widget.
export const CHANGE_STATUS = {
MODIFIED: "modified",
ADDED: "added",
DELETED: "deleted",
RENAMED: "renamed",
UNTRACKED: "untracked",
} as const;
export type ChangeStatus = (typeof CHANGE_STATUS)[keyof typeof CHANGE_STATUS];
export interface ChangedFile {
path: string;
added: number;
deleted: number;
status: ChangeStatus;
countsUnavailable?: string;
diffRevision?: string;
}
export interface ChangesModel {
files: ChangedFile[];
added: number;
deleted: number;
notice?: string;
}
export interface ChangesSnapshot {
numstat: string;
porcelain: string;
}
export interface ChangesTheme {
fg(color: string, text: string): string;
}
interface NumstatEntry {
path: string;
added: number;
deleted: number;
}
export const CHANGES_COMMAND = "/gentle:changes";
const WIDGET_GLYPH = "✎";
const STATUS_BY_CODE: Record<string, ChangeStatus> = {
A: CHANGE_STATUS.ADDED,
D: CHANGE_STATUS.DELETED,
R: CHANGE_STATUS.RENAMED,
C: CHANGE_STATUS.ADDED,
"?": CHANGE_STATUS.UNTRACKED,
};
const RENAME_BRACES = /\{([^{}]*) => ([^{}]*)\}/g;
const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
const RENAME_ARROW = " => ";
export function emptyChanges(): ChangesModel {
return { files: [], added: 0, deleted: 0 };
}
function renamedPath(path: string): string {
if (HAS_RENAME_BRACES.test(path)) return path.replace(RENAME_BRACES, "$2").replace(/\/\//g, "/");
const arrow = path.indexOf(RENAME_ARROW);
return arrow === -1 ? path : path.slice(arrow + RENAME_ARROW.length);
}
function count(value: string): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function parseNumstat(text: string): NumstatEntry[] {
const entries: NumstatEntry[] = [];
for (const line of text.split("\n")) {
const [added, deleted, ...rest] = line.split("\t");
if (added === undefined || deleted === undefined || rest.length === 0) continue;
entries.push({ path: renamedPath(rest.join("\t")), added: count(added), deleted: count(deleted) });
}
return entries;
}
export function parsePorcelain(text: string): Map<string, ChangeStatus> {
const statuses = new Map<string, ChangeStatus>();
const records = text.split("\0").filter((record) => record.length > 0);
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
const code = record.slice(0, 2);
const path = record.slice(3);
// A trailing "/" is Git's own marker for an embedded repository (a
// nested .git directory, e.g. an inner repo inside an outer one) or an
// excluded directory -- never an individual file. Even with
// --untracked-files=all, Git reports these as one directory-shaped line
// instead of expanding into files, so keeping it would show a phantom
// changed "file" on whatever ancestor repository contains the nested one.
if (!path.endsWith("/")) statuses.set(path, STATUS_BY_CODE[code[0]] ?? STATUS_BY_CODE[code[1]] ?? CHANGE_STATUS.MODIFIED);
if (code[0] === "R" || code[0] === "C") index += 1;
}
return statuses;
}
export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
const files: ChangedFile[] = [];
for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
const entry = counts.get(path);
files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
}
return files;
}
export function changesModel(changed: ChangedFile[]): ChangesModel {
const files = [...changed].sort((a, b) => a.path.localeCompare(b.path));
return {
files,
added: files.reduce((total, file) => total + file.added, 0),
deleted: files.reduce((total, file) => total + file.deleted, 0),
};
}
export function changesSummary(model: ChangesModel): string {
const noun = model.files.length === 1 ? "file" : "files";
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? " · capture limit reached" : ""}`;
}
// One line: summary, the files joined by dots, and the command pushed to
// the right edge. The file list goes first when the terminal is narrow.
export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
if (model.files.length === 0) return [];
const noun = model.files.length === 1 ? "file" : "files";
const dot = theme.fg("muted", "·");
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? ` ${dot} ${theme.fg("warning", "capture limit reached")}` : ""}`;
const hint = theme.fg("dim", CHANGES_COMMAND);
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
const left = `${head} ${dot} ${list}`;
const gap = width - visibleWidth(left) - visibleWidth(hint);
if (gap >= 2) return [`${left}${" ".repeat(gap)}${hint}`];
const headGap = width - visibleWidth(head) - visibleWidth(hint);
if (headGap >= 2) return [`${head}${" ".repeat(headGap)}${hint}`];
return [truncateToWidth(head, width, "…")];
}
export interface GitResult {
stdout: string;
code: number;
}
export type GitRunner = (args: string[]) => Promise<GitResult>;
export type LineCounter = (path: string) => Promise<number>;
export interface WorktreeChanges {
root: string;
branch?: string;
model: ChangesModel;
}
// -z avoids Git's quoting of paths containing whitespace or newlines.
export function parseWorktrees(text: string): Array<{ root: string; branch?: string }> {
return text.split("\0\0").flatMap((record) => {
const fields = record.split("\0");
const root = fields.find((field) => field.startsWith("worktree "))?.slice(9);
if (!root || fields.some((field) => field === "bare" || field.startsWith("prunable"))) return [];
const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
return [branch ? { root, branch } : { root }];
});
}
// A root the view shows as-is when its own HEAD could not be determined at
// all -- distinct from "detached", which means git answered and there really
// is no branch. Exported so callers and tests share one literal.
export const UNKNOWN_BRANCH = "unknown";
// Resolve a foreign clone's own HEAD directly instead of assuming "detached":
// a real branch name, "no commits yet" for an unborn branch (a branch ref
// that exists but has no commit), or undefined for a genuine detached HEAD
// (the caller falls back to "detached" in that case). A GitRunner that
// cannot even ask -- it rejects, e.g. because the root was removed or git
// itself is unavailable -- rejects here too, on purpose: that is a real
// failure to distinguish from git successfully reporting "no branch", and
// callers must not fold the two into the same undefined result.
export async function foreignRootBranch(git: GitRunner): Promise<string | undefined> {
const symbolic = await git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
const branch = symbolic.code === 0 ? symbolic.stdout.trim() : "";
if (!branch) return undefined;
const verified = await git(["rev-parse", "--verify", "-q", "HEAD"]);
return verified.code === 0 ? branch : "no commits yet";
}
// The session-evidence tracker behind /gentle:changes records roots, never
// branches, so the overlay would label every tree "detached". This resolves
// each root's own HEAD state once (branch, "no commits yet", undefined for a
// real detached HEAD, or UNKNOWN_BRANCH when the root could not even be
// asked), decorates the trees from the cache, and tells the caller when a
// fresh answer arrived so the view can repaint. Git is touched only while the
// overlay is open, never at startup or on evidence refresh.
export class RootBranchLabels {
private readonly labels = new Map<string, string | undefined>();
private readonly pending = new Map<string, Promise<void>>();
private readonly gitForRoot: (root: string) => GitRunner;
private readonly onChange: () => void;
constructor(gitForRoot: (root: string) => GitRunner, onChange: () => void = () => {}) {
this.gitForRoot = gitForRoot;
this.onChange = onChange;
}
decorate(trees: readonly WorktreeChanges[]): WorktreeChanges[] {
return trees.map((tree) => {
if (tree.branch !== undefined) return tree;
if (!this.labels.has(tree.root)) this.resolve(tree.root);
const branch = this.labels.get(tree.root);
return branch === undefined ? tree : { ...tree, branch };
});
}
/** Every in-flight resolution has finished. */
async settled(): Promise<void> {
while (this.pending.size) await Promise.all(this.pending.values());
}
private resolve(root: string): void {
if (this.pending.has(root)) return;
// A rejection here means the root could not even be asked -- distinct
// from foreignRootBranch resolving to undefined, which means git
// answered and there really is no branch (a real detached HEAD).
const task = foreignRootBranch(this.gitForRoot(root))
.then(
(branch) => branch,
() => UNKNOWN_BRANCH,
)
.then((branch) => {
this.labels.set(root, branch);
this.pending.delete(root);
this.onChange();
});
this.pending.set(root, task);
}
}
export class WorktreeChangesTracker {
worktrees: WorktreeChanges[] = [];
private inFlight: Promise<ChangesModel> | undefined;
private readonly discover: GitRunner;
private readonly gitForRoot: (root: string) => GitRunner;
private readonly linesForRoot: (root: string) => LineCounter;
private readonly registeredRoots: () => readonly string[];
constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => []) {
this.discover = discover;
this.gitForRoot = gitForRoot;
this.linesForRoot = linesForRoot;
this.registeredRoots = registeredRoots;
}
get model(): ChangesModel {
return changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
...file,
path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
}))));
}
async start(): Promise<void> {
await this.refresh();
}
async refresh(): Promise<ChangesModel> {
// A scan can take longer than the polling interval in a large clone.
// Share it rather than extending it indefinitely with queued polls.
if (this.inFlight) return this.inFlight;
this.inFlight = this.capture();
try {
return await this.inFlight;
} finally {
this.inFlight = undefined;
}
}
private async capture(): Promise<ChangesModel> {
const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
const trees: WorktreeChanges[] = [];
const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
// Discovery labels registered roots; it never grants visibility to siblings.
const roots = new Set(this.registeredRoots());
for (const root of roots) {
const known = metadata.get(root);
try {
const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root));
await tracker.start();
if (!tracker.model.files.length) continue;
// A root missing from this discovery scan belongs to a different Git
// clone entirely (e.g. an inner repository nested inside an outer
// one) -- worktree list can never describe a foreign clone, so ask
// it directly instead of defaulting to "detached". A root the scan
// DID describe is trusted as-is: its own "detached" marker (or lack
// of a branch) is already accurate for that clone. The direct ask
// can fail on its own (root gone, git unavailable) without the
// change scan above failing -- that must label the root
// UNKNOWN_BRANCH, not drop real, already-captured changes.
const branch = known ? known.branch : await foreignRootBranch(this.gitForRoot(root)).catch(() => UNKNOWN_BRANCH);
trees.push({ root, ...(branch ? { branch } : {}), model: tracker.model });
} catch {
// Linked roots can disappear between discovery and status.
}
}
const latest = new Set(this.registeredRoots());
// A root admitted during status must not wait for a later poll (which may
// be disabled). Ordinary overlapping polls still share exactly one scan.
if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
this.worktrees = trees;
return this.model;
}
}
const noLines: LineCounter = async () => 0;
const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
// Tracks working-tree changes. Concurrent refreshes coalesce: one git
// round-trip runs at a time and a refresh requested meanwhile triggers
// exactly one more.
export class ChangesTracker {
private readonly git: GitRunner;
private readonly countLines: LineCounter;
private current: ChangesModel = emptyChanges();
private available = false;
private inFlight: Promise<ChangesModel> | undefined;
private queued = false;
constructor(git: GitRunner, countLines: LineCounter = noLines) {
this.git = git;
this.countLines = countLines;
}
get model(): ChangesModel {
return this.current;
}
async start(): Promise<void> {
const files = await this.capture();
this.available = files !== undefined;
this.current = files ? changesModel(files) : emptyChanges();
}
async refresh(): Promise<ChangesModel> {
if (!this.available) return this.current;
if (this.inFlight) {
this.queued = true;
return this.inFlight;
}
this.inFlight = this.runRefresh();
try {
return await this.inFlight;
} finally {
this.inFlight = undefined;
}
}
private async runRefresh(): Promise<ChangesModel> {
do {
this.queued = false;
const files = await this.capture();
if (files) this.current = changesModel(files);
} while (this.queued);
return this.current;
}
private async capture(): Promise<ChangedFile[] | undefined> {
const [numstat, porcelain] = await Promise.all([this.git(NUMSTAT_ARGS), this.git(PORCELAIN_ARGS)]);
if (porcelain.code !== 0) return undefined;
const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
// Untracked files never appear in numstat; count their lines directly.
for (const file of files) {
if (file.status === CHANGE_STATUS.UNTRACKED) file.added = await this.countLines(file.path).catch(() => 0);
}
return files;
}
}