@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,76 @@
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
+
19
+ import { rm } from 'node:fs/promises'
20
+ import { resolve, sep } from 'node:path'
21
+
22
+ import { isSafeRelativePath } from './discard-ops.js'
23
+
24
+ /**
25
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
26
+ *
27
+ * The second lock rather than the only one: {@link isSafeRelativePath} already
28
+ * rejected traversal spellings when the plan was made. This re-checks the
29
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
30
+ * survives the first check by being spelled unusually still has to land inside
31
+ * the root to be acted on.
32
+ *
33
+ * @param root - the worktree directory, absolute.
34
+ * @param relative - repo-relative path from a plan step.
35
+ * @returns the absolute path to act on.
36
+ * @throws if the path is not a safe relative path, resolves outside the root,
37
+ * or IS the root.
38
+ */
39
+ export function resolveInside(root: string, relative: string): string {
40
+ if (!isSafeRelativePath(relative)) {
41
+ throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`)
42
+ }
43
+ const base = resolve(root)
44
+ const target = resolve(base, relative)
45
+ if (target === base) throw new Error('refusing to delete the worktree root')
46
+ if (!target.startsWith(base + sep)) {
47
+ throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
48
+ }
49
+ return target
50
+ }
51
+
52
+ /**
53
+ * Remove one entry from the worktree, having proven it is inside it.
54
+ *
55
+ * `recursive` is not a widening of the blast radius: `resolveInside` has
56
+ * already pinned the target to one path git named, and git names a DIRECTORY
57
+ * whenever it will not look inside one — an untracked nested repository is
58
+ * reported as `sub/`, with no per-file lines even under
59
+ * `--untracked-files=all`. Without `recursive` that row is the only one in the
60
+ * drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
61
+ * to the person who clicked it.
62
+ *
63
+ * `force` makes an absent entry a success: the reader asked for it to be gone,
64
+ * and it is.
65
+ *
66
+ * A symlinked directory inside the worktree could still point outward; that is
67
+ * a repository someone already has write access to, and resolving link targets
68
+ * per segment on every delete would cost a stat per segment for a case git
69
+ * itself does not defend against.
70
+ *
71
+ * @param root - the worktree directory, absolute.
72
+ * @param relative - repo-relative path from a plan step.
73
+ */
74
+ export async function removePathInside(root: string, relative: string): Promise<void> {
75
+ await rm(resolveInside(root, relative), { recursive: true, force: true })
76
+ }
package/src/git-log.ts CHANGED
@@ -14,6 +14,20 @@ export interface GitCommit {
14
14
  readonly subject: string
15
15
  readonly when: string
16
16
  readonly body: string
17
+ /**
18
+ * Author name (`%an`) — who wrote the change. The history filter matches it.
19
+ */
20
+ readonly authorName: string
21
+ /**
22
+ * Committer name (`%cn`) — who applied it. Equals the author in most repos;
23
+ * differs on rebases, cherry-picks and patches applied by a maintainer.
24
+ */
25
+ readonly committerName: string
26
+ /**
27
+ * Committer date as strict ISO 8601 (`%cI`) — the exact moment, same clock
28
+ * `when` summarizes. Rendered client-side in the viewer's own timezone.
29
+ */
30
+ readonly dateIso: string
17
31
  /**
18
32
  * Abbreviated parent hashes, in git's order — first parent first. This is the
19
33
  * DAG: the commit graph is drawn from nothing else. Empty for a root commit.
@@ -27,13 +41,14 @@ export interface GitCommit {
27
41
  }
28
42
 
29
43
  /**
30
- * Pretty format: RS, hash, when, subject, parents, refs, body.
44
+ * Pretty format: RS, hash, when, subject, parents, refs, author, committer,
45
+ * ISO date, body.
31
46
  *
32
47
  * `body` stays last because it is the only field that may contain newlines;
33
- * anything after it would have to survive them. Parents (`%p`) and refs (`%D`)
34
- * are single-line by construction.
48
+ * anything after it would have to survive them. Parents (`%p`), refs (`%D`),
49
+ * names (`%an`, `%cn`) and the ISO date (`%cI`) are single-line by construction.
35
50
  */
36
- export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%b'
51
+ export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%an%x1f%cn%x1f%cI%x1f%b'
37
52
 
38
53
  /**
39
54
  * Split `%D` into plain ref names.
@@ -76,8 +91,11 @@ export function parseLog(stdout: string): GitCommit[] {
76
91
  const subject = (parts[2] ?? '').replace(/\n+$/g, '')
77
92
  const parents = (parts[3] ?? '').trim().split(/\s+/).filter(part => part.length > 0)
78
93
  const refs = parseRefs(parts[4] ?? '')
79
- const body = (parts[5] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '')
80
- if (hash.length > 0) out.push({ hash, subject, when, body, parents, refs })
94
+ const authorName = parts[5] ?? ''
95
+ const committerName = parts[6] ?? ''
96
+ const dateIso = parts[7] ?? ''
97
+ const body = (parts[8] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '')
98
+ if (hash.length > 0) out.push({ hash, subject, when, body, authorName, committerName, dateIso, parents, refs })
81
99
  }
82
100
  return out
83
101
  }
package/src/git-ops.ts 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
  */
@@ -341,16 +345,44 @@ export function parseNumstat(stdout: string): Map<string, NumstatEntry> {
341
345
  return out
342
346
  }
343
347
 
348
+ /** One porcelain line, split into the parts a caller can act on. */
349
+ export interface StatusLine {
350
+ /** The two status columns, e.g. ` M`, `??`, `R `. */
351
+ readonly xy: string
352
+ /** The path git reports the file under NOW — the rename target, if renamed. */
353
+ readonly path: string
354
+ /** For a rename, the path HEAD still knows the file by; '' otherwise. */
355
+ readonly previousPath: string
356
+ readonly renamed: boolean
357
+ }
358
+
359
+ /**
360
+ * Split one `git status --porcelain=v1` line.
361
+ *
362
+ * Exported because more than the file list needs it: `discard-ops` plans from
363
+ * the RAW XY pair, which {@link parseStatus} folds away into a
364
+ * {@link GitFileStatus}. Sharing this keeps the quoting and `old -> new`
365
+ * handling in one place — a second implementation of it is how a path with a
366
+ * non-ASCII name ends up being acted on unescaped.
367
+ * @param line - one output line, branch header and blanks included.
368
+ * @returns the split, or null for a line that names no file.
369
+ */
370
+ export function parseStatusLine(line: string): StatusLine | null {
371
+ if (line.length === 0 || line.startsWith('##')) return null
372
+ if (line.length < 3) return null
373
+ const { path, previousPath, renamed } = parsePath(line.slice(3))
374
+ if (path.length === 0) return null
375
+ return { xy: line.slice(0, 2), path, previousPath, renamed }
376
+ }
377
+
344
378
  /** Parse porcelain lines into a MUTABLE file list — untracked entries get their
345
379
  * counts filled in by the synthesis pass afterwards. */
346
380
  export function parseStatus(stdout: string, numstat: Map<string, NumstatEntry>): MutableGitFile[] {
347
381
  const files: MutableGitFile[] = []
348
382
  for (const line of stdout.split('\n')) {
349
- if (line.length === 0 || line.startsWith('##')) continue
350
- if (line.length < 3) continue
351
- const xy = line.slice(0, 2)
352
- const { path, previousPath, renamed } = parsePath(line.slice(3))
353
- if (path.length === 0) continue
383
+ const parsed = parseStatusLine(line)
384
+ if (parsed === null) continue
385
+ const { xy, path, previousPath, renamed } = parsed
354
386
  const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false }
355
387
  const { staged, unstaged } = stageStateOf(xy)
356
388
  const base: MutableGitFile = {
package/src/index.ts CHANGED
@@ -59,7 +59,14 @@ import {
59
59
  type GitFile, type GitFileStatus, type MutableGitFile,
60
60
  type OpFailure, type PullMode, type Tracking,
61
61
  } from './git-ops.js'
62
+ import {
63
+ planFromStatus,
64
+ type DiscardEffect, type DiscardPlan,
65
+ } from './discard-ops.js'
66
+ import { removePathInside } from './fs-remove.js'
62
67
  import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
68
+ import { emptyLogFilter, logFilterArgs, type LogFilter } from './log-filter.js'
69
+ import { parseShortlog, type AuthorEntry } from './shortlog.js'
63
70
  import {
64
71
  isBlankEntry, loadStyle, sanitizeEntry, stylePath,
65
72
  type StyleEntry, type StyleFile,
@@ -71,6 +78,7 @@ import {
71
78
 
72
79
  export type { WorktreeBinding, WorktreeOpResult }
73
80
  export type { GitFile, GitFileStatus }
81
+ export type { DiscardEffect }
74
82
  export type { StyleEntry }
75
83
 
76
84
  /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
@@ -90,6 +98,12 @@ const HISTORY_COMMITS = 20
90
98
  const HISTORY_PAGE = 30
91
99
  /** Upper bound on a caller-supplied page size. */
92
100
  const HISTORY_PAGE_MAX = 200
101
+ /** Author roster cap: the busiest 500 — enough for any real project's people,
102
+ * and a list a popup can scroll without choking. */
103
+ const SHORTLOG_CAP = 500
104
+ /** Path list cap for the picker: a monorepo can outrun any popup; past this
105
+ * the tree is cut and the truncation reported, never silent. */
106
+ const TREE_PATH_CAP = 50_000
93
107
  /**
94
108
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
95
109
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -524,16 +538,23 @@ export class GitWorkbenchService extends TypertRemoteService {
524
538
  * @param ref - ref to walk; empty means the worktree's own HEAD.
525
539
  * @param skip - commits to skip, counting back from the ref.
526
540
  * @param limit - page size; out-of-range values fall back to the default page.
541
+ * @param filter - history filter compiled into git log arguments (IDEA-style
542
+ * pushdown: matching runs over ALL history, not the loaded pages). Absent
543
+ * from older clients — treated as "no filter".
527
544
  * @param signal - abort signal.
528
545
  * @returns the page, and whether the log continues past it.
529
546
  */
530
547
  @Remote('commits')
531
- async commits(worktreePath: string, ref: string, skip: number, limit: number, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean }> {
548
+ async commits(worktreePath: string, ref: string, skip: number, limit: number, filter: LogFilter, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean; error?: string }> {
532
549
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
533
- const target = typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD'
534
- if (!isRefName(target)) return { commits: [], hasMore: false }
550
+ // '--all' is the ALL-BRANCHES sentinel (a ref cannot begin with a dash, so
551
+ // it collides with nothing): "who did what" must not require knowing which
552
+ // branch holds it — IDEA's All branches, same idea.
553
+ const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD')
554
+ if (target !== '--all' && !isRefName(target)) return { commits: [], hasMore: false }
535
555
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
536
556
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
557
+ const effective = filter ?? emptyLogFilter()
537
558
  // Reading one row beyond the page answers "is there more" without a second
538
559
  // traversal of the log.
539
560
  //
@@ -543,15 +564,67 @@ export class GitWorkbenchService extends TypertRemoteService {
543
564
  // a dozen unrelated rows, and closes far from where it started. Topological
544
565
  // order keeps a branch's commits contiguous — it is what `git log --graph`
545
566
  // turns on for itself, for the same reason.
567
+ //
568
+ // Filter args go LAST: their segment ends with `--` + pathspecs, and
569
+ // nothing after that separator may be parsed as a flag.
546
570
  const log = await this.git(
547
571
  cwd,
548
- ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`],
572
+ ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)],
549
573
  signal,
550
574
  )
575
+ // A bad filter (unparsable regex, invalid date) dies here, and an empty
576
+ // page is indistinguishable from "no match" unless the failure speaks —
577
+ // §6.13: the exit code + stderr tail is the only honest answer.
578
+ if (log.exitCode !== 0) {
579
+ const detail = log.stderr.length > 0 ? `: ${log.stderr.slice(-160)}` : ''
580
+ return { commits: [], hasMore: false, error: `git log failed (exit ${log.exitCode})${detail}` }
581
+ }
551
582
  const page = parseLog(log.stdout)
552
583
  return { commits: page.slice(0, size), hasMore: page.length > size }
553
584
  }
554
585
 
586
+ /**
587
+ * Every author with commits reachable from a ref, busiest first — the
588
+ * history filter popup's user picker.
589
+ *
590
+ * The roster walks the SAME ref the history list walks (`git shortlog -sne
591
+ * <ref>`), not `--all`: a picker entry is a promise that ticking it yields
592
+ * commits in the list below. `--all` once listed authors whose commits live
593
+ * only on other refs — visible in the menu, invisible to every search.
594
+ * @param worktreePath - worktree whose object store resolves the ref; empty falls back to the host cwd.
595
+ * @param ref - ref whose history the roster counts; empty means HEAD.
596
+ * @param signal - abort signal.
597
+ */
598
+ @Remote('authors')
599
+ async authors(worktreePath: string, ref: string, signal: AbortSignal): Promise<{ authors: AuthorEntry[]; truncated: boolean }> {
600
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
601
+ // Same '--all' sentinel as `commits`: when the list walks every ref, the
602
+ // roster counts every ref — the picker and the list stay one claim.
603
+ const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD')
604
+ if (target !== '--all' && !isRefName(target)) return { authors: [], truncated: false }
605
+ const res = await this.git(cwd, ['shortlog', '-sne', target], signal)
606
+ return parseShortlog(res.stdout, SHORTLOG_CAP)
607
+ }
608
+
609
+ /**
610
+ * Every file path on HEAD — the filter popup's path picker, aggregated into
611
+ * a directory tree client-side.
612
+ *
613
+ * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
614
+ * would render non-ASCII names as quoted octal escapes under
615
+ * `core.quotepath` and hand the picker garbage.
616
+ * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
617
+ * @param signal - abort signal.
618
+ */
619
+ @Remote('repoTree')
620
+ async repoTree(worktreePath: string, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean }> {
621
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
622
+ const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal)
623
+ const all = res.stdout.split('\0').filter(path => path.length > 0)
624
+ const truncated = all.length > TREE_PATH_CAP
625
+ return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated }
626
+ }
627
+
555
628
  /**
556
629
  * Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
557
630
  *
@@ -882,6 +955,120 @@ export class GitWorkbenchService extends TypertRemoteService {
882
955
  return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal)
883
956
  }
884
957
 
958
+ /**
959
+ * What discarding this file WOULD do, without doing it.
960
+ *
961
+ * The confirmation has to state the real consequence, and only git knows it:
962
+ * the drawer's own file list is a poll old, and the difference between "this
963
+ * goes back to its committed content" and "this file leaves the disk and
964
+ * cannot come back" is exactly the difference the reader is being asked
965
+ * about. So the dialog is built from this, read fresh, rather than from the
966
+ * row that was clicked.
967
+ * @param worktreePath - directory to run in.
968
+ * @param path - repository-relative path, as the drawer lists it.
969
+ * @param signal - abort signal.
970
+ * @returns the effect and whether it is irreversible; `effect` is absent when
971
+ * git reports nothing to discard for that path.
972
+ */
973
+ @Remote('discardPlan')
974
+ async discardPlan(worktreePath: string, path: string, signal: AbortSignal): Promise<{
975
+ effect?: DiscardEffect
976
+ irreversible?: boolean
977
+ previousPath?: string
978
+ error?: string
979
+ }> {
980
+ let plan: DiscardPlan | null
981
+ try {
982
+ plan = await this.planDiscard(worktreePath, path, signal)
983
+ } catch (error) {
984
+ return { error: error instanceof Error ? error.message : String(error) }
985
+ }
986
+ if (plan === null) return {}
987
+ // JSON-safe: an absent previousPath is an omitted key, never `undefined`.
988
+ return plan.previousPath !== undefined
989
+ ? { effect: plan.effect, irreversible: plan.irreversible, previousPath: plan.previousPath }
990
+ : { effect: plan.effect, irreversible: plan.irreversible }
991
+ }
992
+
993
+ /**
994
+ * Take one file back to its committed state — IntelliJ's Rollback.
995
+ *
996
+ * One path per call, never a list. Discarding is the only thing the drawer
997
+ * does that cannot be undone, and a batch entry point is the shape that
998
+ * turns one mistaken click into a lost afternoon; a caller that wants two
999
+ * files asks twice, and gets asked twice.
1000
+ *
1001
+ * The plan is re-derived here from a fresh `git status`, so a client that
1002
+ * mislabels a tracked file as untracked cannot talk the host into deleting
1003
+ * it. `expectedEffect` is what the reader was shown and agreed to: if the
1004
+ * file changed underneath the dialog — staged, edited, reverted by someone
1005
+ * else — the freshly derived effect no longer matches and nothing is done.
1006
+ * @param worktreePath - directory to run in.
1007
+ * @param path - repository-relative path, as the drawer lists it.
1008
+ * @param expectedEffect - the effect the confirmation stated; blank skips
1009
+ * the agreement check, which only the reversible
1010
+ * `recover` path takes (it shows no dialog).
1011
+ * @param signal - abort signal.
1012
+ * @returns the operation result, with the effect actually carried out.
1013
+ */
1014
+ @Remote('discardFile')
1015
+ async discardFile(worktreePath: string, path: string, expectedEffect: string | undefined, signal: AbortSignal): Promise<GitOpResult & { effect?: DiscardEffect }> {
1016
+ let plan: DiscardPlan | null
1017
+ try {
1018
+ plan = await this.planDiscard(worktreePath, path, signal)
1019
+ } catch (error) {
1020
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1021
+ }
1022
+ // Nothing to discard is not a failure: the row was stale, and the tree the
1023
+ // client refreshes onto will simply no longer carry it.
1024
+ if (plan === null) return { ok: true }
1025
+ if (typeof expectedEffect === 'string' && expectedEffect.length > 0 && expectedEffect !== plan.effect) {
1026
+ return {
1027
+ ok: false,
1028
+ failure: 'unknown',
1029
+ error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
1030
+ }
1031
+ }
1032
+
1033
+ const cwd = this.cwdOf(worktreePath)
1034
+ for (const step of plan.steps) {
1035
+ if (step.kind === 'git') {
1036
+ const result = await this.git(cwd, step.argv, signal)
1037
+ const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
1038
+ if (failure !== null) {
1039
+ return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
1040
+ }
1041
+ continue
1042
+ }
1043
+ try {
1044
+ await removePathInside(cwd, step.path)
1045
+ } catch (error) {
1046
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1047
+ }
1048
+ }
1049
+ return { ok: true, effect: plan.effect }
1050
+ }
1051
+
1052
+ /**
1053
+ * Read the worktree's status and plan for one path.
1054
+ *
1055
+ * The status is the WHOLE tree's, deliberately: git pairs a deletion with an
1056
+ * addition to see a rename, and a pathspec that admits only one of the pair
1057
+ * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1058
+ * other" where the truth is "undo the rename".
1059
+ */
1060
+ private async planDiscard(worktreePath: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
1061
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
1062
+ throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
1063
+ }
1064
+ const cwd = this.cwdOf(worktreePath)
1065
+ const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
1066
+ if (status.exitCode !== 0) {
1067
+ throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed')
1068
+ }
1069
+ return planFromStatus(status.stdout, path)
1070
+ }
1071
+
885
1072
  /**
886
1073
  * Commit what is in the index.
887
1074
  * @param worktreePath - directory to run in.
@@ -0,0 +1,87 @@
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
+
23
+ /** One history query, as the client sends it. JSON-safe: strings and booleans only. */
24
+ export interface LogFilter {
25
+ /** Author names, matched case-insensitively as literal substrings. Multiple = OR (git semantics, IDEA parity). */
26
+ readonly users: readonly string[]
27
+ /** Commit-message pattern. Empty string means no text criterion. */
28
+ readonly text: string
29
+ /** Interpret {@link text} as an ERE instead of a literal substring. */
30
+ readonly textRegex: boolean
31
+ /** Pathspecs, multiple = union (a commit touching ANY of them matches). */
32
+ readonly paths: readonly string[]
33
+ /** Inclusive lower bound, approxidate text. Empty means unbounded. */
34
+ readonly after: string
35
+ /** Exclusive upper bound, approxidate text. Empty means unbounded. */
36
+ readonly before: string
37
+ }
38
+
39
+ /** The filter that filters nothing — also the default when a client sends none. */
40
+ export function emptyLogFilter(): LogFilter {
41
+ return { users: [], text: '', textRegex: false, paths: [], after: '', before: '' }
42
+ }
43
+
44
+ /** POSIX ERE metacharacters, escaped so each matches itself. */
45
+ function escapeEre(literal: string): string {
46
+ return literal.replace(/[\\.[\]*+?(){}|^$-]/g, '\\$&')
47
+ }
48
+
49
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
50
+
51
+ /**
52
+ * Expand a bare calendar day into an explicit moment. Git's approxidate
53
+ * parses `--since=2026-08-18` against an implementation-defined timezone,
54
+ * which on Windows silently excludes that very day's commits; `T00:00:00`
55
+ * pins it to local midnight (a picker day means the whole day, so `before`
56
+ * gets the day's last second instead).
57
+ */
58
+ function expandDay(bound: string, endOfDay: boolean): string {
59
+ if (!DAY_RE.test(bound)) return bound
60
+ return endOfDay ? `${bound}T23:59:59` : `${bound}T00:00:00`
61
+ }
62
+
63
+ /**
64
+ * Compile a filter into the argument segment inserted after the log command's
65
+ * own flags. Pathspecs come last behind a bare `--`, so the CALLER must place
66
+ * this segment at the end of the argument list.
67
+ * @param filter - the query; blank entries are dropped, not turned into empty
68
+ * patterns (an empty `--author=` would match nothing).
69
+ */
70
+ export function logFilterArgs(filter: LogFilter): string[] {
71
+ const users = filter.users.map(user => user.trim()).filter(user => user.length > 0)
72
+ const paths = filter.paths.map(path => path.trim()).filter(path => path.length > 0)
73
+ const text = filter.text.trim()
74
+ const after = filter.after.trim()
75
+ const before = filter.before.trim()
76
+
77
+ const args: string[] = []
78
+ if (users.length > 0 || text.length > 0) {
79
+ args.push('-i', '-E')
80
+ for (const user of users) args.push(`--author=${escapeEre(user)}`)
81
+ if (text.length > 0) args.push(`--grep=${filter.textRegex ? text : escapeEre(text)}`)
82
+ }
83
+ if (after.length > 0) args.push(`--since=${expandDay(after, false)}`)
84
+ if (before.length > 0) args.push(`--until=${expandDay(before, true)}`)
85
+ if (paths.length > 0) args.push('--', ...paths)
86
+ return args
87
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Author roster from `git shortlog -sne`, for the filter popup's user picker.
3
+ *
4
+ * The REF the roster walks is the caller's choice (the host passes the same
5
+ * ref the history list walks, or `--all` in All-branches mode); shortlog
6
+ * already did the aggregation, and this module only parses its fixed shape
7
+ * (`<count> <name> <<email>>`), sorts by activity and applies the cap — with
8
+ * the cap VISIBLE, because a filter popup that silently lost the long tail of
9
+ * occasional committers would be quietly wrong about who exists.
10
+ *
11
+ * @module @young1lin/dsh-ui-gitworkbench/shortlog
12
+ */
13
+
14
+ /** One author as the picker shows them. JSON-safe; count is a plain number. */
15
+ export interface AuthorEntry {
16
+ readonly name: string
17
+ readonly email: string
18
+ /** Commits in the walked ref set — the picker's sort key. */
19
+ readonly count: number
20
+ }
21
+
22
+ /**
23
+ * Parse shortlog output into busiest-first authors, capped.
24
+ * @param stdout - `git shortlog -sne <ref>` output (the ref is the caller's choice).
25
+ * @param limit - how many authors to keep; the busier half survives.
26
+ * @returns the roster and whether it was cut short.
27
+ */
28
+ export function parseShortlog(stdout: string, limit: number): { authors: AuthorEntry[]; truncated: boolean } {
29
+ const authors: AuthorEntry[] = []
30
+ for (const line of stdout.split('\n')) {
31
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line)
32
+ if (match === null) continue
33
+ const count = Number(match[1])
34
+ const who = match[2]!
35
+ // The email is the LAST <...> on the line; a name may legally contain
36
+ // anything else.
37
+ const emailMatch = /<([^>]*)>\s*$/.exec(who)
38
+ if (emailMatch === null) continue
39
+ const name = who.slice(0, emailMatch.index).trim()
40
+ if (name.length === 0) continue
41
+ authors.push({ name, email: emailMatch[1]!, count })
42
+ }
43
+ authors.sort((a, b) => b.count - a.count)
44
+ const truncated = authors.length > limit
45
+ return { authors: truncated ? authors.slice(0, limit) : authors, truncated }
46
+ }