@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.
@@ -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
+ }
@@ -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, body.
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`) and refs (`%D`)
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 body = (parts[5] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '');
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`. Losing committed work needs a confirmation
17
- * design of its own, not a button that happens to be adjacent to Push.
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
- if (line.length === 0 || line.startsWith('##'))
263
- continue;
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
- const target = typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD';
543
- if (!isRefName(target))
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
- const log = await this.git(cwd, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`], signal);
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.