@young1lin/dsh-ui-gitworkbench 0.1.3 → 0.1.5
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/AGENTS.md +1 -1
- package/CHANGELOG.md +48 -0
- package/CHANGELOG_EN.md +48 -0
- package/README.md +36 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2126 -304
- package/lib/discard-ops.js +158 -0
- package/lib/fs-remove.js +73 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +191 -5
- package/lib/log-filter.js +71 -0
- package/lib/shortlog.js +40 -0
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +523 -6
- package/src/client/GitWorkbenchPanel.tsx +1030 -26
- package/src/client/active-file.ts +83 -0
- package/src/client/calendar.ts +99 -0
- package/src/client/commit-filter.ts +49 -0
- package/src/client/dir-tree.ts +112 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-filter.ts +66 -0
- package/src/client/index.ts +52 -5
- package/src/client/locales.ts +94 -0
- package/src/client/log-filter-query.ts +189 -0
- package/src/client/path-select.ts +131 -0
- package/src/discard-ops.ts +197 -0
- package/src/fs-remove.ts +76 -0
- package/src/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +191 -4
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "discard this file" resolves to, as a plan the host can carry out.
|
|
3
|
+
*
|
|
4
|
+
* This is the confirmation design `git-ops.ts` says a destructive button owes
|
|
5
|
+
* before it may exist. Three rules hold here, and each one closes a way this
|
|
6
|
+
* feature could quietly destroy the wrong thing:
|
|
7
|
+
*
|
|
8
|
+
* - **The plan is derived from git's OWN report of the file, never from what
|
|
9
|
+
* the client said it was.** A client that mislabels a tracked file as
|
|
10
|
+
* untracked would otherwise turn a restore into a delete. `planFor` takes
|
|
11
|
+
* a porcelain XY pair that the host read itself.
|
|
12
|
+
* - **No plan may contain a destructive spelling.** Not `clean`, not
|
|
13
|
+
* `reset --hard`, not `checkout -f`, no `--force`. `git restore` scoped to
|
|
14
|
+
* one pathspec after `--` is the whole vocabulary; a stray `clean` would
|
|
15
|
+
* take out every untracked file in the tree instead of the one named.
|
|
16
|
+
* `tests/discard-ops.test.ts` scans every plan this module can produce.
|
|
17
|
+
* - **A path that is deleted from disk is checked far harder than a
|
|
18
|
+
* pathspec.** git refuses to leave the repository; `fs.rm` does not, so
|
|
19
|
+
* `isSafeRelativePath` rejects absolutes, drive letters, UNC prefixes and
|
|
20
|
+
* any `..` segment before a delete step can name a file.
|
|
21
|
+
*
|
|
22
|
+
* IDEA parity decides the semantics: Rollback there takes a file back to its
|
|
23
|
+
* committed state in one gesture and does not ask whether the change was
|
|
24
|
+
* staged, so neither does this. A file that was never committed goes away; a
|
|
25
|
+
* file that was deleted comes back.
|
|
26
|
+
*
|
|
27
|
+
* Pure throughout — no spawning, no fs. `src/index.ts` executes the steps.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/discard-ops
|
|
30
|
+
*/
|
|
31
|
+
import { isSafePathArg, parseStatusLine } from './git-ops.js';
|
|
32
|
+
/**
|
|
33
|
+
* Whether a path is safe to hand a filesystem delete.
|
|
34
|
+
*
|
|
35
|
+
* Stricter than {@link isSafePathArg}, which only has to keep git from reading
|
|
36
|
+
* a path as an option: git will not step outside the repository whatever it is
|
|
37
|
+
* given, so a pathspec needs no traversal check. A delete has no such backstop.
|
|
38
|
+
* Rejected: absolute paths (POSIX and Windows), UNC prefixes, drive letters,
|
|
39
|
+
* NUL bytes, and any `..` segment — including one buried mid-path, which is
|
|
40
|
+
* how traversal is usually spelled.
|
|
41
|
+
* @param path - repo-relative path from a plan step.
|
|
42
|
+
*/
|
|
43
|
+
export function isSafeRelativePath(path) {
|
|
44
|
+
if (!isSafePathArg(path))
|
|
45
|
+
return false;
|
|
46
|
+
if (path.includes('\0'))
|
|
47
|
+
return false;
|
|
48
|
+
// Windows accepts both separators, so normalise before splitting or
|
|
49
|
+
// `a\..\..\b` walks out through a check that only knew about `/`.
|
|
50
|
+
const unified = path.replace(/\\/g, '/');
|
|
51
|
+
if (unified.startsWith('/'))
|
|
52
|
+
return false;
|
|
53
|
+
if (/^[A-Za-z]:/.test(unified))
|
|
54
|
+
return false;
|
|
55
|
+
if (unified.startsWith('//'))
|
|
56
|
+
return false;
|
|
57
|
+
return !unified.split('/').includes('..');
|
|
58
|
+
}
|
|
59
|
+
/** Take one file back to HEAD in both the index and the working tree. */
|
|
60
|
+
function restoreBoth(path) {
|
|
61
|
+
return { kind: 'git', argv: ['restore', '--source=HEAD', '--staged', '--worktree', '--', path] };
|
|
62
|
+
}
|
|
63
|
+
/** Drop a file's index entry, leaving the working tree untouched. */
|
|
64
|
+
function unstage(path) {
|
|
65
|
+
return { kind: 'git', argv: ['restore', '--staged', '--', path] };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The plan for one file, from git's own porcelain line.
|
|
69
|
+
*
|
|
70
|
+
* @param xy - the two porcelain status columns for this path, e.g. ` M`, `??`,
|
|
71
|
+
* `R `. Read by the host from `git status --porcelain`, never
|
|
72
|
+
* supplied by the client.
|
|
73
|
+
* @param path - repo-relative path, as git printed it.
|
|
74
|
+
* @param previousPath - for a rename, the path HEAD still knows the file by.
|
|
75
|
+
* @returns the ordered plan, or null when the file has nothing to discard.
|
|
76
|
+
* @throws if a path is not safe to pass on.
|
|
77
|
+
*/
|
|
78
|
+
export function planFor(xy, path, previousPath) {
|
|
79
|
+
if (!isSafePathArg(path))
|
|
80
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
81
|
+
const index = xy[0] ?? ' ';
|
|
82
|
+
const worktree = xy[1] ?? ' ';
|
|
83
|
+
// Untracked and ignored: git has no copy, so the only way back is the
|
|
84
|
+
// filesystem's, and there is none.
|
|
85
|
+
if (xy === '??' || xy === '!!') {
|
|
86
|
+
if (!isSafeRelativePath(path))
|
|
87
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`);
|
|
88
|
+
return { steps: [{ kind: 'delete', path }], effect: 'delete', irreversible: true, path };
|
|
89
|
+
}
|
|
90
|
+
// A rename: HEAD holds `previousPath`, the index holds `path`. Bring the old
|
|
91
|
+
// one back first, then retire the new one — doing it the other way round
|
|
92
|
+
// would leave the tree with neither name for as long as the second step
|
|
93
|
+
// takes, which a reader watching a file tree would see.
|
|
94
|
+
if (index === 'R' || index === 'C') {
|
|
95
|
+
if (previousPath === undefined || !isSafePathArg(previousPath)) {
|
|
96
|
+
throw new Error(`rename without a usable previous path: ${JSON.stringify(path)}`);
|
|
97
|
+
}
|
|
98
|
+
if (!isSafeRelativePath(path))
|
|
99
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`);
|
|
100
|
+
return {
|
|
101
|
+
steps: [restoreBoth(previousPath), unstage(path), { kind: 'delete', path }],
|
|
102
|
+
effect: 'unrename',
|
|
103
|
+
irreversible: true,
|
|
104
|
+
path,
|
|
105
|
+
previousPath,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Added to the index but absent from HEAD: rolling back means the file was
|
|
109
|
+
// never committed, so it leaves. Unstage first — otherwise the index would
|
|
110
|
+
// still carry an entry for a path that no longer exists on disk.
|
|
111
|
+
if (index === 'A') {
|
|
112
|
+
if (!isSafeRelativePath(path))
|
|
113
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`);
|
|
114
|
+
return {
|
|
115
|
+
steps: [unstage(path), { kind: 'delete', path }],
|
|
116
|
+
effect: 'delete',
|
|
117
|
+
irreversible: true,
|
|
118
|
+
path,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Deleted, either side. HEAD still has the content, so this is recovery:
|
|
122
|
+
// nothing is lost and nothing needs confirming.
|
|
123
|
+
if (index === 'D' || worktree === 'D') {
|
|
124
|
+
return { steps: [restoreBoth(path)], effect: 'recover', irreversible: false, path };
|
|
125
|
+
}
|
|
126
|
+
// Everything else that git reported as changed — modified, type-changed,
|
|
127
|
+
// staged, unstaged, or both — goes back to HEAD wholesale. Content only the
|
|
128
|
+
// working tree ever held has no object behind it.
|
|
129
|
+
if (index !== ' ' || worktree !== ' ') {
|
|
130
|
+
return { steps: [restoreBoth(path)], effect: 'restore', irreversible: true, path };
|
|
131
|
+
}
|
|
132
|
+
// Clean: git reported the path with nothing to say about it.
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Find the file in a status report and plan for it.
|
|
137
|
+
*
|
|
138
|
+
* The report is the WHOLE tree's, not one narrowed by a pathspec: git detects
|
|
139
|
+
* a rename by pairing a deletion with an addition, and a pathspec that admits
|
|
140
|
+
* only one of the pair turns `R old -> new` into an unrelated `D` and `??`.
|
|
141
|
+
* The plan for those two is delete-and-lose where the truth is un-rename.
|
|
142
|
+
*
|
|
143
|
+
* @param stdout - `git status --porcelain=v1` over the whole worktree.
|
|
144
|
+
* @param path - the file the reader asked about, as the drawer lists it.
|
|
145
|
+
* @returns the plan, or null when git does not report that path as changed —
|
|
146
|
+
* which is also what a stale tree looks like, and is not an error.
|
|
147
|
+
*/
|
|
148
|
+
export function planFromStatus(stdout, path) {
|
|
149
|
+
if (!isSafePathArg(path))
|
|
150
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
151
|
+
for (const line of stdout.split('\n')) {
|
|
152
|
+
const parsed = parseStatusLine(line);
|
|
153
|
+
if (parsed === null || parsed.path !== path)
|
|
154
|
+
continue;
|
|
155
|
+
return planFor(parsed.xy, parsed.path, parsed.renamed ? parsed.previousPath : undefined);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
package/lib/fs-remove.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one filesystem delete in this plugin, and the checks it carries.
|
|
3
|
+
*
|
|
4
|
+
* `discard-ops.ts` plans a delete when git has no copy of a file to restore
|
|
5
|
+
* from — untracked, or added-but-never-committed. git will not carry that out:
|
|
6
|
+
* `git clean` refuses paths it cannot index, which on Windows includes every
|
|
7
|
+
* reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
|
|
8
|
+
* any extension). So the removal goes through the filesystem, where git's own
|
|
9
|
+
* refusal to leave the repository does not apply — hence the checks here
|
|
10
|
+
* rather than a bare `rm`.
|
|
11
|
+
*
|
|
12
|
+
* Lives outside `index.ts` so vitest can load it: the class there needs the
|
|
13
|
+
* dsh runtime, and the property worth testing is "what does this delete, and
|
|
14
|
+
* what does it refuse" — a question about paths and the disk, not about RPC.
|
|
15
|
+
*
|
|
16
|
+
* @module @young1lin/dsh-ui-gitworkbench/fs-remove
|
|
17
|
+
*/
|
|
18
|
+
import { rm } from 'node:fs/promises';
|
|
19
|
+
import { resolve, sep } from 'node:path';
|
|
20
|
+
import { isSafeRelativePath } from './discard-ops.js';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a repo-relative path against the worktree root, refusing to leave it.
|
|
23
|
+
*
|
|
24
|
+
* The second lock rather than the only one: {@link isSafeRelativePath} already
|
|
25
|
+
* rejected traversal spellings when the plan was made. This re-checks the
|
|
26
|
+
* RESOLVED path, which is the form the filesystem acts on, so a path that
|
|
27
|
+
* survives the first check by being spelled unusually still has to land inside
|
|
28
|
+
* the root to be acted on.
|
|
29
|
+
*
|
|
30
|
+
* @param root - the worktree directory, absolute.
|
|
31
|
+
* @param relative - repo-relative path from a plan step.
|
|
32
|
+
* @returns the absolute path to act on.
|
|
33
|
+
* @throws if the path is not a safe relative path, resolves outside the root,
|
|
34
|
+
* or IS the root.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveInside(root, relative) {
|
|
37
|
+
if (!isSafeRelativePath(relative)) {
|
|
38
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`);
|
|
39
|
+
}
|
|
40
|
+
const base = resolve(root);
|
|
41
|
+
const target = resolve(base, relative);
|
|
42
|
+
if (target === base)
|
|
43
|
+
throw new Error('refusing to delete the worktree root');
|
|
44
|
+
if (!target.startsWith(base + sep)) {
|
|
45
|
+
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove one entry from the worktree, having proven it is inside it.
|
|
51
|
+
*
|
|
52
|
+
* `recursive` is not a widening of the blast radius: `resolveInside` has
|
|
53
|
+
* already pinned the target to one path git named, and git names a DIRECTORY
|
|
54
|
+
* whenever it will not look inside one — an untracked nested repository is
|
|
55
|
+
* reported as `sub/`, with no per-file lines even under
|
|
56
|
+
* `--untracked-files=all`. Without `recursive` that row is the only one in the
|
|
57
|
+
* drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
|
|
58
|
+
* to the person who clicked it.
|
|
59
|
+
*
|
|
60
|
+
* `force` makes an absent entry a success: the reader asked for it to be gone,
|
|
61
|
+
* and it is.
|
|
62
|
+
*
|
|
63
|
+
* A symlinked directory inside the worktree could still point outward; that is
|
|
64
|
+
* a repository someone already has write access to, and resolving link targets
|
|
65
|
+
* per segment on every delete would cost a stat per segment for a case git
|
|
66
|
+
* itself does not defend against.
|
|
67
|
+
*
|
|
68
|
+
* @param root - the worktree directory, absolute.
|
|
69
|
+
* @param relative - repo-relative path from a plan step.
|
|
70
|
+
*/
|
|
71
|
+
export async function removePathInside(root, relative) {
|
|
72
|
+
await rm(resolveInside(root, relative), { recursive: true, force: true });
|
|
73
|
+
}
|
package/lib/git-log.js
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
* cannot carry `undefined`.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* Pretty format: RS, hash, when, subject, parents, refs,
|
|
12
|
+
* Pretty format: RS, hash, when, subject, parents, refs, author, committer,
|
|
13
|
+
* ISO date, body.
|
|
13
14
|
*
|
|
14
15
|
* `body` stays last because it is the only field that may contain newlines;
|
|
15
|
-
* anything after it would have to survive them. Parents (`%p`)
|
|
16
|
-
* are single-line by construction.
|
|
16
|
+
* anything after it would have to survive them. Parents (`%p`), refs (`%D`),
|
|
17
|
+
* names (`%an`, `%cn`) and the ISO date (`%cI`) are single-line by construction.
|
|
17
18
|
*/
|
|
18
|
-
export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%b';
|
|
19
|
+
export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%an%x1f%cn%x1f%cI%x1f%b';
|
|
19
20
|
/**
|
|
20
21
|
* Split `%D` into plain ref names.
|
|
21
22
|
*
|
|
@@ -63,9 +64,12 @@ export function parseLog(stdout) {
|
|
|
63
64
|
const subject = (parts[2] ?? '').replace(/\n+$/g, '');
|
|
64
65
|
const parents = (parts[3] ?? '').trim().split(/\s+/).filter(part => part.length > 0);
|
|
65
66
|
const refs = parseRefs(parts[4] ?? '');
|
|
66
|
-
const
|
|
67
|
+
const authorName = parts[5] ?? '';
|
|
68
|
+
const committerName = parts[6] ?? '';
|
|
69
|
+
const dateIso = parts[7] ?? '';
|
|
70
|
+
const body = (parts[8] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '');
|
|
67
71
|
if (hash.length > 0)
|
|
68
|
-
out.push({ hash, subject, when, body, parents, refs });
|
|
72
|
+
out.push({ hash, subject, when, body, authorName, committerName, dateIso, parents, refs });
|
|
69
73
|
}
|
|
70
74
|
return out;
|
|
71
75
|
}
|
package/lib/git-ops.js
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
* of that. A file may legitimately be named `-f`, and passed positionally
|
|
14
14
|
* it becomes an option instead of a path.
|
|
15
15
|
* - Nothing here builds a destructive command. There is no `--force`, no
|
|
16
|
-
* `reset --hard`, no `clean`.
|
|
17
|
-
*
|
|
16
|
+
* `reset --hard`, no `clean`. The one action that does lose work — rolling
|
|
17
|
+
* one file back — lives in `discard-ops.ts` instead, behind the
|
|
18
|
+
* confirmation design this rule demanded: git's own reading of the file
|
|
19
|
+
* rather than the client's claim, `git restore` scoped to a single
|
|
20
|
+
* pathspec as the whole vocabulary, and a dialog that names the
|
|
21
|
+
* consequence. It is deliberately not a button adjacent to Push.
|
|
18
22
|
*
|
|
19
23
|
* @module
|
|
20
24
|
*/
|
|
@@ -254,19 +258,36 @@ export function parseNumstat(stdout) {
|
|
|
254
258
|
}
|
|
255
259
|
return out;
|
|
256
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Split one `git status --porcelain=v1` line.
|
|
263
|
+
*
|
|
264
|
+
* Exported because more than the file list needs it: `discard-ops` plans from
|
|
265
|
+
* the RAW XY pair, which {@link parseStatus} folds away into a
|
|
266
|
+
* {@link GitFileStatus}. Sharing this keeps the quoting and `old -> new`
|
|
267
|
+
* handling in one place — a second implementation of it is how a path with a
|
|
268
|
+
* non-ASCII name ends up being acted on unescaped.
|
|
269
|
+
* @param line - one output line, branch header and blanks included.
|
|
270
|
+
* @returns the split, or null for a line that names no file.
|
|
271
|
+
*/
|
|
272
|
+
export function parseStatusLine(line) {
|
|
273
|
+
if (line.length === 0 || line.startsWith('##'))
|
|
274
|
+
return null;
|
|
275
|
+
if (line.length < 3)
|
|
276
|
+
return null;
|
|
277
|
+
const { path, previousPath, renamed } = parsePath(line.slice(3));
|
|
278
|
+
if (path.length === 0)
|
|
279
|
+
return null;
|
|
280
|
+
return { xy: line.slice(0, 2), path, previousPath, renamed };
|
|
281
|
+
}
|
|
257
282
|
/** Parse porcelain lines into a MUTABLE file list — untracked entries get their
|
|
258
283
|
* counts filled in by the synthesis pass afterwards. */
|
|
259
284
|
export function parseStatus(stdout, numstat) {
|
|
260
285
|
const files = [];
|
|
261
286
|
for (const line of stdout.split('\n')) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (line.length < 3)
|
|
265
|
-
continue;
|
|
266
|
-
const xy = line.slice(0, 2);
|
|
267
|
-
const { path, previousPath, renamed } = parsePath(line.slice(3));
|
|
268
|
-
if (path.length === 0)
|
|
287
|
+
const parsed = parseStatusLine(line);
|
|
288
|
+
if (parsed === null)
|
|
269
289
|
continue;
|
|
290
|
+
const { xy, path, previousPath, renamed } = parsed;
|
|
270
291
|
const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false };
|
|
271
292
|
const { staged, unstaged } = stageStateOf(xy);
|
|
272
293
|
const base = {
|
package/lib/index.js
CHANGED
|
@@ -83,8 +83,12 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
83
83
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
84
84
|
import { saveJsonAtomic } from './atomic-json.js';
|
|
85
85
|
import { CommitPayloadCache, cacheKey } from './commit-cache.js';
|
|
86
|
-
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
86
|
+
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
87
|
+
import { planFromStatus, } from './discard-ops.js';
|
|
88
|
+
import { removePathInside } from './fs-remove.js';
|
|
87
89
|
import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
90
|
+
import { emptyLogFilter, logFilterArgs } from './log-filter.js';
|
|
91
|
+
import { parseShortlog } from './shortlog.js';
|
|
88
92
|
import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
|
|
89
93
|
import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
90
94
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
@@ -104,6 +108,12 @@ const HISTORY_COMMITS = 20;
|
|
|
104
108
|
const HISTORY_PAGE = 30;
|
|
105
109
|
/** Upper bound on a caller-supplied page size. */
|
|
106
110
|
const HISTORY_PAGE_MAX = 200;
|
|
111
|
+
/** Author roster cap: the busiest 500 — enough for any real project's people,
|
|
112
|
+
* and a list a popup can scroll without choking. */
|
|
113
|
+
const SHORTLOG_CAP = 500;
|
|
114
|
+
/** Path list cap for the picker: a monorepo can outrun any popup; past this
|
|
115
|
+
* the tree is cut and the truncation reported, never silent. */
|
|
116
|
+
const TREE_PATH_CAP = 50_000;
|
|
107
117
|
/**
|
|
108
118
|
* Most branch names sent to the browser. `worktreeStatus` is polled, so an
|
|
109
119
|
* unbounded list would repeat on the wire every few seconds; the picker reports
|
|
@@ -136,6 +146,8 @@ let GitWorkbenchService = (() => {
|
|
|
136
146
|
let _fileDiff_decorators;
|
|
137
147
|
let _commitStats_decorators;
|
|
138
148
|
let _commits_decorators;
|
|
149
|
+
let _authors_decorators;
|
|
150
|
+
let _repoTree_decorators;
|
|
139
151
|
let _compareRefs_decorators;
|
|
140
152
|
let _sessionWorktree_decorators;
|
|
141
153
|
let _worktreeEnter_decorators;
|
|
@@ -146,6 +158,8 @@ let GitWorkbenchService = (() => {
|
|
|
146
158
|
let _syncStatus_decorators;
|
|
147
159
|
let _stage_decorators;
|
|
148
160
|
let _unstage_decorators;
|
|
161
|
+
let _discardPlan_decorators;
|
|
162
|
+
let _discardFile_decorators;
|
|
149
163
|
let _commit_decorators;
|
|
150
164
|
let _fetch_decorators;
|
|
151
165
|
let _pull_decorators;
|
|
@@ -157,6 +171,8 @@ let GitWorkbenchService = (() => {
|
|
|
157
171
|
_fileDiff_decorators = [Remote('fileDiff')];
|
|
158
172
|
_commitStats_decorators = [Remote('commitStats')];
|
|
159
173
|
_commits_decorators = [Remote('commits')];
|
|
174
|
+
_authors_decorators = [Remote('authors')];
|
|
175
|
+
_repoTree_decorators = [Remote('repoTree')];
|
|
160
176
|
_compareRefs_decorators = [Remote('compareRefs')];
|
|
161
177
|
_sessionWorktree_decorators = [Remote('sessionWorktree')];
|
|
162
178
|
_worktreeEnter_decorators = [Remote('worktreeEnter')];
|
|
@@ -167,6 +183,8 @@ let GitWorkbenchService = (() => {
|
|
|
167
183
|
_syncStatus_decorators = [Remote('syncStatus')];
|
|
168
184
|
_stage_decorators = [Remote('stage')];
|
|
169
185
|
_unstage_decorators = [Remote('unstage')];
|
|
186
|
+
_discardPlan_decorators = [Remote('discardPlan')];
|
|
187
|
+
_discardFile_decorators = [Remote('discardFile')];
|
|
170
188
|
_commit_decorators = [Remote('commit')];
|
|
171
189
|
_fetch_decorators = [Remote('fetch')];
|
|
172
190
|
_pull_decorators = [Remote('pull')];
|
|
@@ -175,6 +193,8 @@ let GitWorkbenchService = (() => {
|
|
|
175
193
|
__esDecorate(this, null, _fileDiff_decorators, { kind: "method", name: "fileDiff", static: false, private: false, access: { has: obj => "fileDiff" in obj, get: obj => obj.fileDiff }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
176
194
|
__esDecorate(this, null, _commitStats_decorators, { kind: "method", name: "commitStats", static: false, private: false, access: { has: obj => "commitStats" in obj, get: obj => obj.commitStats }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
177
195
|
__esDecorate(this, null, _commits_decorators, { kind: "method", name: "commits", static: false, private: false, access: { has: obj => "commits" in obj, get: obj => obj.commits }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
196
|
+
__esDecorate(this, null, _authors_decorators, { kind: "method", name: "authors", static: false, private: false, access: { has: obj => "authors" in obj, get: obj => obj.authors }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
197
|
+
__esDecorate(this, null, _repoTree_decorators, { kind: "method", name: "repoTree", static: false, private: false, access: { has: obj => "repoTree" in obj, get: obj => obj.repoTree }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
178
198
|
__esDecorate(this, null, _compareRefs_decorators, { kind: "method", name: "compareRefs", static: false, private: false, access: { has: obj => "compareRefs" in obj, get: obj => obj.compareRefs }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
179
199
|
__esDecorate(this, null, _sessionWorktree_decorators, { kind: "method", name: "sessionWorktree", static: false, private: false, access: { has: obj => "sessionWorktree" in obj, get: obj => obj.sessionWorktree }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
180
200
|
__esDecorate(this, null, _worktreeEnter_decorators, { kind: "method", name: "worktreeEnter", static: false, private: false, access: { has: obj => "worktreeEnter" in obj, get: obj => obj.worktreeEnter }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -185,6 +205,8 @@ let GitWorkbenchService = (() => {
|
|
|
185
205
|
__esDecorate(this, null, _syncStatus_decorators, { kind: "method", name: "syncStatus", static: false, private: false, access: { has: obj => "syncStatus" in obj, get: obj => obj.syncStatus }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
186
206
|
__esDecorate(this, null, _stage_decorators, { kind: "method", name: "stage", static: false, private: false, access: { has: obj => "stage" in obj, get: obj => obj.stage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
187
207
|
__esDecorate(this, null, _unstage_decorators, { kind: "method", name: "unstage", static: false, private: false, access: { has: obj => "unstage" in obj, get: obj => obj.unstage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
208
|
+
__esDecorate(this, null, _discardPlan_decorators, { kind: "method", name: "discardPlan", static: false, private: false, access: { has: obj => "discardPlan" in obj, get: obj => obj.discardPlan }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
209
|
+
__esDecorate(this, null, _discardFile_decorators, { kind: "method", name: "discardFile", static: false, private: false, access: { has: obj => "discardFile" in obj, get: obj => obj.discardFile }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
188
210
|
__esDecorate(this, null, _commit_decorators, { kind: "method", name: "commit", static: false, private: false, access: { has: obj => "commit" in obj, get: obj => obj.commit }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
189
211
|
__esDecorate(this, null, _fetch_decorators, { kind: "method", name: "fetch", static: false, private: false, access: { has: obj => "fetch" in obj, get: obj => obj.fetch }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
190
212
|
__esDecorate(this, null, _pull_decorators, { kind: "method", name: "pull", static: false, private: false, access: { has: obj => "pull" in obj, get: obj => obj.pull }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -534,16 +556,23 @@ let GitWorkbenchService = (() => {
|
|
|
534
556
|
* @param ref - ref to walk; empty means the worktree's own HEAD.
|
|
535
557
|
* @param skip - commits to skip, counting back from the ref.
|
|
536
558
|
* @param limit - page size; out-of-range values fall back to the default page.
|
|
559
|
+
* @param filter - history filter compiled into git log arguments (IDEA-style
|
|
560
|
+
* pushdown: matching runs over ALL history, not the loaded pages). Absent
|
|
561
|
+
* from older clients — treated as "no filter".
|
|
537
562
|
* @param signal - abort signal.
|
|
538
563
|
* @returns the page, and whether the log continues past it.
|
|
539
564
|
*/
|
|
540
|
-
async commits(worktreePath, ref, skip, limit, signal) {
|
|
565
|
+
async commits(worktreePath, ref, skip, limit, filter, signal) {
|
|
541
566
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
|
|
542
|
-
|
|
543
|
-
|
|
567
|
+
// '--all' is the ALL-BRANCHES sentinel (a ref cannot begin with a dash, so
|
|
568
|
+
// it collides with nothing): "who did what" must not require knowing which
|
|
569
|
+
// branch holds it — IDEA's All branches, same idea.
|
|
570
|
+
const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD');
|
|
571
|
+
if (target !== '--all' && !isRefName(target))
|
|
544
572
|
return { commits: [], hasMore: false };
|
|
545
573
|
const from = Number.isInteger(skip) && skip >= 0 ? skip : 0;
|
|
546
574
|
const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE;
|
|
575
|
+
const effective = filter ?? emptyLogFilter();
|
|
547
576
|
// Reading one row beyond the page answers "is there more" without a second
|
|
548
577
|
// traversal of the log.
|
|
549
578
|
//
|
|
@@ -553,10 +582,59 @@ let GitWorkbenchService = (() => {
|
|
|
553
582
|
// a dozen unrelated rows, and closes far from where it started. Topological
|
|
554
583
|
// order keeps a branch's commits contiguous — it is what `git log --graph`
|
|
555
584
|
// turns on for itself, for the same reason.
|
|
556
|
-
|
|
585
|
+
//
|
|
586
|
+
// Filter args go LAST: their segment ends with `--` + pathspecs, and
|
|
587
|
+
// nothing after that separator may be parsed as a flag.
|
|
588
|
+
const log = await this.git(cwd, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
|
|
589
|
+
// A bad filter (unparsable regex, invalid date) dies here, and an empty
|
|
590
|
+
// page is indistinguishable from "no match" unless the failure speaks —
|
|
591
|
+
// §6.13: the exit code + stderr tail is the only honest answer.
|
|
592
|
+
if (log.exitCode !== 0) {
|
|
593
|
+
const detail = log.stderr.length > 0 ? `: ${log.stderr.slice(-160)}` : '';
|
|
594
|
+
return { commits: [], hasMore: false, error: `git log failed (exit ${log.exitCode})${detail}` };
|
|
595
|
+
}
|
|
557
596
|
const page = parseLog(log.stdout);
|
|
558
597
|
return { commits: page.slice(0, size), hasMore: page.length > size };
|
|
559
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Every author with commits reachable from a ref, busiest first — the
|
|
601
|
+
* history filter popup's user picker.
|
|
602
|
+
*
|
|
603
|
+
* The roster walks the SAME ref the history list walks (`git shortlog -sne
|
|
604
|
+
* <ref>`), not `--all`: a picker entry is a promise that ticking it yields
|
|
605
|
+
* commits in the list below. `--all` once listed authors whose commits live
|
|
606
|
+
* only on other refs — visible in the menu, invisible to every search.
|
|
607
|
+
* @param worktreePath - worktree whose object store resolves the ref; empty falls back to the host cwd.
|
|
608
|
+
* @param ref - ref whose history the roster counts; empty means HEAD.
|
|
609
|
+
* @param signal - abort signal.
|
|
610
|
+
*/
|
|
611
|
+
async authors(worktreePath, ref, signal) {
|
|
612
|
+
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
|
|
613
|
+
// Same '--all' sentinel as `commits`: when the list walks every ref, the
|
|
614
|
+
// roster counts every ref — the picker and the list stay one claim.
|
|
615
|
+
const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD');
|
|
616
|
+
if (target !== '--all' && !isRefName(target))
|
|
617
|
+
return { authors: [], truncated: false };
|
|
618
|
+
const res = await this.git(cwd, ['shortlog', '-sne', target], signal);
|
|
619
|
+
return parseShortlog(res.stdout, SHORTLOG_CAP);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
623
|
+
* a directory tree client-side.
|
|
624
|
+
*
|
|
625
|
+
* `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
|
|
626
|
+
* would render non-ASCII names as quoted octal escapes under
|
|
627
|
+
* `core.quotepath` and hand the picker garbage.
|
|
628
|
+
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
629
|
+
* @param signal - abort signal.
|
|
630
|
+
*/
|
|
631
|
+
async repoTree(worktreePath, signal) {
|
|
632
|
+
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
|
|
633
|
+
const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal);
|
|
634
|
+
const all = res.stdout.split('\0').filter(path => path.length > 0);
|
|
635
|
+
const truncated = all.length > TREE_PATH_CAP;
|
|
636
|
+
return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated };
|
|
637
|
+
}
|
|
560
638
|
/**
|
|
561
639
|
* Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
|
|
562
640
|
*
|
|
@@ -876,6 +954,114 @@ let GitWorkbenchService = (() => {
|
|
|
876
954
|
async unstage(worktreePath, paths, signal) {
|
|
877
955
|
return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal);
|
|
878
956
|
}
|
|
957
|
+
/**
|
|
958
|
+
* What discarding this file WOULD do, without doing it.
|
|
959
|
+
*
|
|
960
|
+
* The confirmation has to state the real consequence, and only git knows it:
|
|
961
|
+
* the drawer's own file list is a poll old, and the difference between "this
|
|
962
|
+
* goes back to its committed content" and "this file leaves the disk and
|
|
963
|
+
* cannot come back" is exactly the difference the reader is being asked
|
|
964
|
+
* about. So the dialog is built from this, read fresh, rather than from the
|
|
965
|
+
* row that was clicked.
|
|
966
|
+
* @param worktreePath - directory to run in.
|
|
967
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
968
|
+
* @param signal - abort signal.
|
|
969
|
+
* @returns the effect and whether it is irreversible; `effect` is absent when
|
|
970
|
+
* git reports nothing to discard for that path.
|
|
971
|
+
*/
|
|
972
|
+
async discardPlan(worktreePath, path, signal) {
|
|
973
|
+
let plan;
|
|
974
|
+
try {
|
|
975
|
+
plan = await this.planDiscard(worktreePath, path, signal);
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
979
|
+
}
|
|
980
|
+
if (plan === null)
|
|
981
|
+
return {};
|
|
982
|
+
// JSON-safe: an absent previousPath is an omitted key, never `undefined`.
|
|
983
|
+
return plan.previousPath !== undefined
|
|
984
|
+
? { effect: plan.effect, irreversible: plan.irreversible, previousPath: plan.previousPath }
|
|
985
|
+
: { effect: plan.effect, irreversible: plan.irreversible };
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Take one file back to its committed state — IntelliJ's Rollback.
|
|
989
|
+
*
|
|
990
|
+
* One path per call, never a list. Discarding is the only thing the drawer
|
|
991
|
+
* does that cannot be undone, and a batch entry point is the shape that
|
|
992
|
+
* turns one mistaken click into a lost afternoon; a caller that wants two
|
|
993
|
+
* files asks twice, and gets asked twice.
|
|
994
|
+
*
|
|
995
|
+
* The plan is re-derived here from a fresh `git status`, so a client that
|
|
996
|
+
* mislabels a tracked file as untracked cannot talk the host into deleting
|
|
997
|
+
* it. `expectedEffect` is what the reader was shown and agreed to: if the
|
|
998
|
+
* file changed underneath the dialog — staged, edited, reverted by someone
|
|
999
|
+
* else — the freshly derived effect no longer matches and nothing is done.
|
|
1000
|
+
* @param worktreePath - directory to run in.
|
|
1001
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
1002
|
+
* @param expectedEffect - the effect the confirmation stated; blank skips
|
|
1003
|
+
* the agreement check, which only the reversible
|
|
1004
|
+
* `recover` path takes (it shows no dialog).
|
|
1005
|
+
* @param signal - abort signal.
|
|
1006
|
+
* @returns the operation result, with the effect actually carried out.
|
|
1007
|
+
*/
|
|
1008
|
+
async discardFile(worktreePath, path, expectedEffect, signal) {
|
|
1009
|
+
let plan;
|
|
1010
|
+
try {
|
|
1011
|
+
plan = await this.planDiscard(worktreePath, path, signal);
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
1015
|
+
}
|
|
1016
|
+
// Nothing to discard is not a failure: the row was stale, and the tree the
|
|
1017
|
+
// client refreshes onto will simply no longer carry it.
|
|
1018
|
+
if (plan === null)
|
|
1019
|
+
return { ok: true };
|
|
1020
|
+
if (typeof expectedEffect === 'string' && expectedEffect.length > 0 && expectedEffect !== plan.effect) {
|
|
1021
|
+
return {
|
|
1022
|
+
ok: false,
|
|
1023
|
+
failure: 'unknown',
|
|
1024
|
+
error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const cwd = this.cwdOf(worktreePath);
|
|
1028
|
+
for (const step of plan.steps) {
|
|
1029
|
+
if (step.kind === 'git') {
|
|
1030
|
+
const result = await this.git(cwd, step.argv, signal);
|
|
1031
|
+
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
|
|
1032
|
+
if (failure !== null) {
|
|
1033
|
+
return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) };
|
|
1034
|
+
}
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
await removePathInside(cwd, step.path);
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return { ok: true, effect: plan.effect };
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Read the worktree's status and plan for one path.
|
|
1048
|
+
*
|
|
1049
|
+
* The status is the WHOLE tree's, deliberately: git pairs a deletion with an
|
|
1050
|
+
* addition to see a rename, and a pathspec that admits only one of the pair
|
|
1051
|
+
* reports `D` plus `??` instead — which plans as "restore one, DELETE the
|
|
1052
|
+
* other" where the truth is "undo the rename".
|
|
1053
|
+
*/
|
|
1054
|
+
async planDiscard(worktreePath, path, signal) {
|
|
1055
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
1056
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
1057
|
+
}
|
|
1058
|
+
const cwd = this.cwdOf(worktreePath);
|
|
1059
|
+
const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
|
|
1060
|
+
if (status.exitCode !== 0) {
|
|
1061
|
+
throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed');
|
|
1062
|
+
}
|
|
1063
|
+
return planFromStatus(status.stdout, path);
|
|
1064
|
+
}
|
|
879
1065
|
/**
|
|
880
1066
|
* Commit what is in the index.
|
|
881
1067
|
* @param worktreePath - directory to run in.
|