@young1lin/dsh-ui-gitworkbench 0.1.3 → 0.1.4

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
+ }
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
@@ -76,15 +76,18 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
76
76
  * @module @young1lin/dsh-ui-gitworkbench
77
77
  */
78
78
  import { randomBytes } from 'node:crypto';
79
- import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
79
+ import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
80
80
  import { homedir } from 'node:os';
81
- import { join } from 'node:path';
81
+ import { join, resolve, sep } from 'node:path';
82
82
  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';
87
88
  import { LOG_FORMAT, parseLog } from './git-log.js';
89
+ import { emptyLogFilter, logFilterArgs } from './log-filter.js';
90
+ import { parseShortlog } from './shortlog.js';
88
91
  import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
89
92
  import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
90
93
  /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
@@ -104,6 +107,12 @@ const HISTORY_COMMITS = 20;
104
107
  const HISTORY_PAGE = 30;
105
108
  /** Upper bound on a caller-supplied page size. */
106
109
  const HISTORY_PAGE_MAX = 200;
110
+ /** Author roster cap: the busiest 500 — enough for any real project's people,
111
+ * and a list a popup can scroll without choking. */
112
+ const SHORTLOG_CAP = 500;
113
+ /** Path list cap for the picker: a monorepo can outrun any popup; past this
114
+ * the tree is cut and the truncation reported, never silent. */
115
+ const TREE_PATH_CAP = 50_000;
107
116
  /**
108
117
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
109
118
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -136,6 +145,8 @@ let GitWorkbenchService = (() => {
136
145
  let _fileDiff_decorators;
137
146
  let _commitStats_decorators;
138
147
  let _commits_decorators;
148
+ let _authors_decorators;
149
+ let _repoTree_decorators;
139
150
  let _compareRefs_decorators;
140
151
  let _sessionWorktree_decorators;
141
152
  let _worktreeEnter_decorators;
@@ -146,6 +157,8 @@ let GitWorkbenchService = (() => {
146
157
  let _syncStatus_decorators;
147
158
  let _stage_decorators;
148
159
  let _unstage_decorators;
160
+ let _discardPlan_decorators;
161
+ let _discardFile_decorators;
149
162
  let _commit_decorators;
150
163
  let _fetch_decorators;
151
164
  let _pull_decorators;
@@ -157,6 +170,8 @@ let GitWorkbenchService = (() => {
157
170
  _fileDiff_decorators = [Remote('fileDiff')];
158
171
  _commitStats_decorators = [Remote('commitStats')];
159
172
  _commits_decorators = [Remote('commits')];
173
+ _authors_decorators = [Remote('authors')];
174
+ _repoTree_decorators = [Remote('repoTree')];
160
175
  _compareRefs_decorators = [Remote('compareRefs')];
161
176
  _sessionWorktree_decorators = [Remote('sessionWorktree')];
162
177
  _worktreeEnter_decorators = [Remote('worktreeEnter')];
@@ -167,6 +182,8 @@ let GitWorkbenchService = (() => {
167
182
  _syncStatus_decorators = [Remote('syncStatus')];
168
183
  _stage_decorators = [Remote('stage')];
169
184
  _unstage_decorators = [Remote('unstage')];
185
+ _discardPlan_decorators = [Remote('discardPlan')];
186
+ _discardFile_decorators = [Remote('discardFile')];
170
187
  _commit_decorators = [Remote('commit')];
171
188
  _fetch_decorators = [Remote('fetch')];
172
189
  _pull_decorators = [Remote('pull')];
@@ -175,6 +192,8 @@ let GitWorkbenchService = (() => {
175
192
  __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
193
  __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
194
  __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);
195
+ __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);
196
+ __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
197
  __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
198
  __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
199
  __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 +204,8 @@ let GitWorkbenchService = (() => {
185
204
  __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
205
  __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
206
  __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);
207
+ __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);
208
+ __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
209
  __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
210
  __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
211
  __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 +555,23 @@ let GitWorkbenchService = (() => {
534
555
  * @param ref - ref to walk; empty means the worktree's own HEAD.
535
556
  * @param skip - commits to skip, counting back from the ref.
536
557
  * @param limit - page size; out-of-range values fall back to the default page.
558
+ * @param filter - history filter compiled into git log arguments (IDEA-style
559
+ * pushdown: matching runs over ALL history, not the loaded pages). Absent
560
+ * from older clients — treated as "no filter".
537
561
  * @param signal - abort signal.
538
562
  * @returns the page, and whether the log continues past it.
539
563
  */
540
- async commits(worktreePath, ref, skip, limit, signal) {
564
+ async commits(worktreePath, ref, skip, limit, filter, signal) {
541
565
  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))
566
+ // '--all' is the ALL-BRANCHES sentinel (a ref cannot begin with a dash, so
567
+ // it collides with nothing): "who did what" must not require knowing which
568
+ // branch holds it — IDEA's All branches, same idea.
569
+ const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD');
570
+ if (target !== '--all' && !isRefName(target))
544
571
  return { commits: [], hasMore: false };
545
572
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0;
546
573
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE;
574
+ const effective = filter ?? emptyLogFilter();
547
575
  // Reading one row beyond the page answers "is there more" without a second
548
576
  // traversal of the log.
549
577
  //
@@ -553,10 +581,59 @@ let GitWorkbenchService = (() => {
553
581
  // a dozen unrelated rows, and closes far from where it started. Topological
554
582
  // order keeps a branch's commits contiguous — it is what `git log --graph`
555
583
  // 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);
584
+ //
585
+ // Filter args go LAST: their segment ends with `--` + pathspecs, and
586
+ // nothing after that separator may be parsed as a flag.
587
+ const log = await this.git(cwd, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
588
+ // A bad filter (unparsable regex, invalid date) dies here, and an empty
589
+ // page is indistinguishable from "no match" unless the failure speaks —
590
+ // §6.13: the exit code + stderr tail is the only honest answer.
591
+ if (log.exitCode !== 0) {
592
+ const detail = log.stderr.length > 0 ? `: ${log.stderr.slice(-160)}` : '';
593
+ return { commits: [], hasMore: false, error: `git log failed (exit ${log.exitCode})${detail}` };
594
+ }
557
595
  const page = parseLog(log.stdout);
558
596
  return { commits: page.slice(0, size), hasMore: page.length > size };
559
597
  }
598
+ /**
599
+ * Every author with commits reachable from a ref, busiest first — the
600
+ * history filter popup's user picker.
601
+ *
602
+ * The roster walks the SAME ref the history list walks (`git shortlog -sne
603
+ * <ref>`), not `--all`: a picker entry is a promise that ticking it yields
604
+ * commits in the list below. `--all` once listed authors whose commits live
605
+ * only on other refs — visible in the menu, invisible to every search.
606
+ * @param worktreePath - worktree whose object store resolves the ref; empty falls back to the host cwd.
607
+ * @param ref - ref whose history the roster counts; empty means HEAD.
608
+ * @param signal - abort signal.
609
+ */
610
+ async authors(worktreePath, ref, signal) {
611
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
612
+ // Same '--all' sentinel as `commits`: when the list walks every ref, the
613
+ // roster counts every ref — the picker and the list stay one claim.
614
+ const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD');
615
+ if (target !== '--all' && !isRefName(target))
616
+ return { authors: [], truncated: false };
617
+ const res = await this.git(cwd, ['shortlog', '-sne', target], signal);
618
+ return parseShortlog(res.stdout, SHORTLOG_CAP);
619
+ }
620
+ /**
621
+ * Every file path on HEAD — the filter popup's path picker, aggregated into
622
+ * a directory tree client-side.
623
+ *
624
+ * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
625
+ * would render non-ASCII names as quoted octal escapes under
626
+ * `core.quotepath` and hand the picker garbage.
627
+ * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
628
+ * @param signal - abort signal.
629
+ */
630
+ async repoTree(worktreePath, signal) {
631
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
632
+ const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal);
633
+ const all = res.stdout.split('\0').filter(path => path.length > 0);
634
+ const truncated = all.length > TREE_PATH_CAP;
635
+ return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated };
636
+ }
560
637
  /**
561
638
  * Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
562
639
  *
@@ -876,6 +953,137 @@ let GitWorkbenchService = (() => {
876
953
  async unstage(worktreePath, paths, signal) {
877
954
  return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal);
878
955
  }
956
+ /**
957
+ * What discarding this file WOULD do, without doing it.
958
+ *
959
+ * The confirmation has to state the real consequence, and only git knows it:
960
+ * the drawer's own file list is a poll old, and the difference between "this
961
+ * goes back to its committed content" and "this file leaves the disk and
962
+ * cannot come back" is exactly the difference the reader is being asked
963
+ * about. So the dialog is built from this, read fresh, rather than from the
964
+ * row that was clicked.
965
+ * @param worktreePath - directory to run in.
966
+ * @param path - repository-relative path, as the drawer lists it.
967
+ * @param signal - abort signal.
968
+ * @returns the effect and whether it is irreversible; `effect` is absent when
969
+ * git reports nothing to discard for that path.
970
+ */
971
+ async discardPlan(worktreePath, path, signal) {
972
+ let plan;
973
+ try {
974
+ plan = await this.planDiscard(worktreePath, path, signal);
975
+ }
976
+ catch (error) {
977
+ return { error: error instanceof Error ? error.message : String(error) };
978
+ }
979
+ if (plan === null)
980
+ return {};
981
+ // JSON-safe: an absent previousPath is an omitted key, never `undefined`.
982
+ return plan.previousPath !== undefined
983
+ ? { effect: plan.effect, irreversible: plan.irreversible, previousPath: plan.previousPath }
984
+ : { effect: plan.effect, irreversible: plan.irreversible };
985
+ }
986
+ /**
987
+ * Take one file back to its committed state — IntelliJ's Rollback.
988
+ *
989
+ * One path per call, never a list. Discarding is the only thing the drawer
990
+ * does that cannot be undone, and a batch entry point is the shape that
991
+ * turns one mistaken click into a lost afternoon; a caller that wants two
992
+ * files asks twice, and gets asked twice.
993
+ *
994
+ * The plan is re-derived here from a fresh `git status`, so a client that
995
+ * mislabels a tracked file as untracked cannot talk the host into deleting
996
+ * it. `expectedEffect` is what the reader was shown and agreed to: if the
997
+ * file changed underneath the dialog — staged, edited, reverted by someone
998
+ * else — the freshly derived effect no longer matches and nothing is done.
999
+ * @param worktreePath - directory to run in.
1000
+ * @param path - repository-relative path, as the drawer lists it.
1001
+ * @param expectedEffect - the effect the confirmation stated; blank skips
1002
+ * the agreement check, which only the reversible
1003
+ * `recover` path takes (it shows no dialog).
1004
+ * @param signal - abort signal.
1005
+ * @returns the operation result, with the effect actually carried out.
1006
+ */
1007
+ async discardFile(worktreePath, path, expectedEffect, signal) {
1008
+ let plan;
1009
+ try {
1010
+ plan = await this.planDiscard(worktreePath, path, signal);
1011
+ }
1012
+ catch (error) {
1013
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
1014
+ }
1015
+ // Nothing to discard is not a failure: the row was stale, and the tree the
1016
+ // client refreshes onto will simply no longer carry it.
1017
+ if (plan === null)
1018
+ return { ok: true };
1019
+ if (typeof expectedEffect === 'string' && expectedEffect.length > 0 && expectedEffect !== plan.effect) {
1020
+ return {
1021
+ ok: false,
1022
+ failure: 'unknown',
1023
+ error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
1024
+ };
1025
+ }
1026
+ const cwd = this.cwdOf(worktreePath);
1027
+ for (const step of plan.steps) {
1028
+ if (step.kind === 'git') {
1029
+ const result = await this.git(cwd, step.argv, signal);
1030
+ const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
1031
+ if (failure !== null) {
1032
+ return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) };
1033
+ }
1034
+ continue;
1035
+ }
1036
+ try {
1037
+ await this.removeInside(cwd, step.path);
1038
+ }
1039
+ catch (error) {
1040
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
1041
+ }
1042
+ }
1043
+ return { ok: true, effect: plan.effect };
1044
+ }
1045
+ /**
1046
+ * Read the worktree's status and plan for one path.
1047
+ *
1048
+ * The status is the WHOLE tree's, deliberately: git pairs a deletion with an
1049
+ * addition to see a rename, and a pathspec that admits only one of the pair
1050
+ * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1051
+ * other" where the truth is "undo the rename".
1052
+ */
1053
+ async planDiscard(worktreePath, path, signal) {
1054
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
1055
+ throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
1056
+ }
1057
+ const cwd = this.cwdOf(worktreePath);
1058
+ const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
1059
+ if (status.exitCode !== 0) {
1060
+ throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed');
1061
+ }
1062
+ return planFromStatus(status.stdout, path);
1063
+ }
1064
+ /**
1065
+ * Delete one file, having proven it is inside the worktree.
1066
+ *
1067
+ * `isSafeRelativePath` already rejected traversal in the plan, so this is the
1068
+ * second lock rather than the only one: it re-checks the RESOLVED path,
1069
+ * which is the form the filesystem actually acts on. `force` makes an absent
1070
+ * file a success — the reader asked for it to be gone, and it is.
1071
+ *
1072
+ * A symlinked directory inside the worktree could still point outward; that
1073
+ * is a repository someone already has write access to, and resolving link
1074
+ * targets on every delete would cost a stat per segment for a case git
1075
+ * itself does not defend against.
1076
+ */
1077
+ async removeInside(cwd, relative) {
1078
+ const root = resolve(cwd);
1079
+ const target = resolve(root, relative);
1080
+ if (target !== root && !target.startsWith(root + sep)) {
1081
+ throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
1082
+ }
1083
+ if (target === root)
1084
+ throw new Error('refusing to delete the worktree root');
1085
+ await rm(target, { force: true });
1086
+ }
879
1087
  /**
880
1088
  * Commit what is in the index.
881
1089
  * @param worktreePath - directory to run in.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The history filter, compiled into `git log` arguments.
3
+ *
4
+ * This is the pushdown half of the IDEA-style filter: matching runs inside
5
+ * `git log` over ALL history, not client-side over the loaded pages — the
6
+ * loaded-pages blind spot is the reason this module exists. Everything here is
7
+ * pure so the argument matrix is testable without spawning git.
8
+ *
9
+ * DIALECT RULE: whenever any pattern is emitted, the flags are `-i -E` and
10
+ * every literal input is escaped for POSIX ERE. Mixing `--fixed-strings` with
11
+ * `-E` is not possible (one overrides the other for EVERY pattern on the
12
+ * command line), so a single dialect keeps users literal no matter what the
13
+ * text criterion's regex toggle says; regex text alone is passed raw.
14
+ *
15
+ * Dates pass through verbatim: approxidate ("2 weeks ago") is git's language,
16
+ * and reimplementing even a slice of it client-side is how the dual-semantics
17
+ * bug farm starts. An invalid date is git's error to raise, surfaced by the
18
+ * host's usual exit-code + stderr format.
19
+ *
20
+ * @module @young1lin/dsh-ui-gitworkbench/log-filter
21
+ */
22
+ /** The filter that filters nothing — also the default when a client sends none. */
23
+ export function emptyLogFilter() {
24
+ return { users: [], text: '', textRegex: false, paths: [], after: '', before: '' };
25
+ }
26
+ /** POSIX ERE metacharacters, escaped so each matches itself. */
27
+ function escapeEre(literal) {
28
+ return literal.replace(/[\\.[\]*+?(){}|^$-]/g, '\\$&');
29
+ }
30
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
31
+ /**
32
+ * Expand a bare calendar day into an explicit moment. Git's approxidate
33
+ * parses `--since=2026-08-18` against an implementation-defined timezone,
34
+ * which on Windows silently excludes that very day's commits; `T00:00:00`
35
+ * pins it to local midnight (a picker day means the whole day, so `before`
36
+ * gets the day's last second instead).
37
+ */
38
+ function expandDay(bound, endOfDay) {
39
+ if (!DAY_RE.test(bound))
40
+ return bound;
41
+ return endOfDay ? `${bound}T23:59:59` : `${bound}T00:00:00`;
42
+ }
43
+ /**
44
+ * Compile a filter into the argument segment inserted after the log command's
45
+ * own flags. Pathspecs come last behind a bare `--`, so the CALLER must place
46
+ * this segment at the end of the argument list.
47
+ * @param filter - the query; blank entries are dropped, not turned into empty
48
+ * patterns (an empty `--author=` would match nothing).
49
+ */
50
+ export function logFilterArgs(filter) {
51
+ const users = filter.users.map(user => user.trim()).filter(user => user.length > 0);
52
+ const paths = filter.paths.map(path => path.trim()).filter(path => path.length > 0);
53
+ const text = filter.text.trim();
54
+ const after = filter.after.trim();
55
+ const before = filter.before.trim();
56
+ const args = [];
57
+ if (users.length > 0 || text.length > 0) {
58
+ args.push('-i', '-E');
59
+ for (const user of users)
60
+ args.push(`--author=${escapeEre(user)}`);
61
+ if (text.length > 0)
62
+ args.push(`--grep=${filter.textRegex ? text : escapeEre(text)}`);
63
+ }
64
+ if (after.length > 0)
65
+ args.push(`--since=${expandDay(after, false)}`);
66
+ if (before.length > 0)
67
+ args.push(`--until=${expandDay(before, true)}`);
68
+ if (paths.length > 0)
69
+ args.push('--', ...paths);
70
+ return args;
71
+ }