@young1lin/dsh-ui-gitworkbench 0.1.2 → 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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +45 -0
- package/CHANGELOG_EN.md +45 -0
- package/README.md +39 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2058 -305
- package/lib/discard-ops.js +158 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +215 -7
- package/lib/log-filter.js +71 -0
- package/lib/shortlog.js +40 -0
- package/package.json +6 -6
- package/src/client/GitWorkbenchPanel.module.css +523 -6
- package/src/client/GitWorkbenchPanel.tsx +1024 -26
- package/src/client/active-file.ts +83 -0
- package/src/client/calendar.ts +99 -0
- package/src/client/commit-filter.ts +49 -0
- package/src/client/dir-tree.ts +112 -0
- package/src/client/file-filter.ts +66 -0
- package/src/client/index.ts +44 -5
- package/src/client/locales.ts +94 -0
- package/src/client/log-filter-query.ts +189 -0
- package/src/client/path-select.ts +131 -0
- package/src/discard-ops.ts +197 -0
- package/src/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +215 -6
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
package/src/index.ts
CHANGED
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
43
43
|
*/
|
|
44
44
|
import { randomBytes } from 'node:crypto'
|
|
45
|
-
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'
|
|
45
|
+
import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
46
46
|
import { homedir } from 'node:os'
|
|
47
|
-
import { join } from 'node:path'
|
|
47
|
+
import { join, resolve, sep } from 'node:path'
|
|
48
48
|
import type { Readable } from 'node:stream'
|
|
49
49
|
import type { Context } from '@deepseek-ai/cordis'
|
|
50
50
|
import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
|
@@ -59,7 +59,13 @@ 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'
|
|
62
66
|
import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
|
|
67
|
+
import { emptyLogFilter, logFilterArgs, type LogFilter } from './log-filter.js'
|
|
68
|
+
import { parseShortlog, type AuthorEntry } from './shortlog.js'
|
|
63
69
|
import {
|
|
64
70
|
isBlankEntry, loadStyle, sanitizeEntry, stylePath,
|
|
65
71
|
type StyleEntry, type StyleFile,
|
|
@@ -71,6 +77,7 @@ import {
|
|
|
71
77
|
|
|
72
78
|
export type { WorktreeBinding, WorktreeOpResult }
|
|
73
79
|
export type { GitFile, GitFileStatus }
|
|
80
|
+
export type { DiscardEffect }
|
|
74
81
|
export type { StyleEntry }
|
|
75
82
|
|
|
76
83
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
@@ -90,6 +97,12 @@ const HISTORY_COMMITS = 20
|
|
|
90
97
|
const HISTORY_PAGE = 30
|
|
91
98
|
/** Upper bound on a caller-supplied page size. */
|
|
92
99
|
const HISTORY_PAGE_MAX = 200
|
|
100
|
+
/** Author roster cap: the busiest 500 — enough for any real project's people,
|
|
101
|
+
* and a list a popup can scroll without choking. */
|
|
102
|
+
const SHORTLOG_CAP = 500
|
|
103
|
+
/** Path list cap for the picker: a monorepo can outrun any popup; past this
|
|
104
|
+
* the tree is cut and the truncation reported, never silent. */
|
|
105
|
+
const TREE_PATH_CAP = 50_000
|
|
93
106
|
/**
|
|
94
107
|
* Most branch names sent to the browser. `worktreeStatus` is polled, so an
|
|
95
108
|
* unbounded list would repeat on the wire every few seconds; the picker reports
|
|
@@ -524,16 +537,23 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
524
537
|
* @param ref - ref to walk; empty means the worktree's own HEAD.
|
|
525
538
|
* @param skip - commits to skip, counting back from the ref.
|
|
526
539
|
* @param limit - page size; out-of-range values fall back to the default page.
|
|
540
|
+
* @param filter - history filter compiled into git log arguments (IDEA-style
|
|
541
|
+
* pushdown: matching runs over ALL history, not the loaded pages). Absent
|
|
542
|
+
* from older clients — treated as "no filter".
|
|
527
543
|
* @param signal - abort signal.
|
|
528
544
|
* @returns the page, and whether the log continues past it.
|
|
529
545
|
*/
|
|
530
546
|
@Remote('commits')
|
|
531
|
-
async commits(worktreePath: string, ref: string, skip: number, limit: number, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean }> {
|
|
547
|
+
async commits(worktreePath: string, ref: string, skip: number, limit: number, filter: LogFilter, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean; error?: string }> {
|
|
532
548
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
533
|
-
|
|
534
|
-
|
|
549
|
+
// '--all' is the ALL-BRANCHES sentinel (a ref cannot begin with a dash, so
|
|
550
|
+
// it collides with nothing): "who did what" must not require knowing which
|
|
551
|
+
// branch holds it — IDEA's All branches, same idea.
|
|
552
|
+
const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD')
|
|
553
|
+
if (target !== '--all' && !isRefName(target)) return { commits: [], hasMore: false }
|
|
535
554
|
const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
|
|
536
555
|
const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
|
|
556
|
+
const effective = filter ?? emptyLogFilter()
|
|
537
557
|
// Reading one row beyond the page answers "is there more" without a second
|
|
538
558
|
// traversal of the log.
|
|
539
559
|
//
|
|
@@ -543,15 +563,67 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
543
563
|
// a dozen unrelated rows, and closes far from where it started. Topological
|
|
544
564
|
// order keeps a branch's commits contiguous — it is what `git log --graph`
|
|
545
565
|
// turns on for itself, for the same reason.
|
|
566
|
+
//
|
|
567
|
+
// Filter args go LAST: their segment ends with `--` + pathspecs, and
|
|
568
|
+
// nothing after that separator may be parsed as a flag.
|
|
546
569
|
const log = await this.git(
|
|
547
570
|
cwd,
|
|
548
|
-
['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}
|
|
571
|
+
['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)],
|
|
549
572
|
signal,
|
|
550
573
|
)
|
|
574
|
+
// A bad filter (unparsable regex, invalid date) dies here, and an empty
|
|
575
|
+
// page is indistinguishable from "no match" unless the failure speaks —
|
|
576
|
+
// §6.13: the exit code + stderr tail is the only honest answer.
|
|
577
|
+
if (log.exitCode !== 0) {
|
|
578
|
+
const detail = log.stderr.length > 0 ? `: ${log.stderr.slice(-160)}` : ''
|
|
579
|
+
return { commits: [], hasMore: false, error: `git log failed (exit ${log.exitCode})${detail}` }
|
|
580
|
+
}
|
|
551
581
|
const page = parseLog(log.stdout)
|
|
552
582
|
return { commits: page.slice(0, size), hasMore: page.length > size }
|
|
553
583
|
}
|
|
554
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Every author with commits reachable from a ref, busiest first — the
|
|
587
|
+
* history filter popup's user picker.
|
|
588
|
+
*
|
|
589
|
+
* The roster walks the SAME ref the history list walks (`git shortlog -sne
|
|
590
|
+
* <ref>`), not `--all`: a picker entry is a promise that ticking it yields
|
|
591
|
+
* commits in the list below. `--all` once listed authors whose commits live
|
|
592
|
+
* only on other refs — visible in the menu, invisible to every search.
|
|
593
|
+
* @param worktreePath - worktree whose object store resolves the ref; empty falls back to the host cwd.
|
|
594
|
+
* @param ref - ref whose history the roster counts; empty means HEAD.
|
|
595
|
+
* @param signal - abort signal.
|
|
596
|
+
*/
|
|
597
|
+
@Remote('authors')
|
|
598
|
+
async authors(worktreePath: string, ref: string, signal: AbortSignal): Promise<{ authors: AuthorEntry[]; truncated: boolean }> {
|
|
599
|
+
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
600
|
+
// Same '--all' sentinel as `commits`: when the list walks every ref, the
|
|
601
|
+
// roster counts every ref — the picker and the list stay one claim.
|
|
602
|
+
const target = ref === '--all' ? '--all' : (typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD')
|
|
603
|
+
if (target !== '--all' && !isRefName(target)) return { authors: [], truncated: false }
|
|
604
|
+
const res = await this.git(cwd, ['shortlog', '-sne', target], signal)
|
|
605
|
+
return parseShortlog(res.stdout, SHORTLOG_CAP)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
610
|
+
* a directory tree client-side.
|
|
611
|
+
*
|
|
612
|
+
* `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
|
|
613
|
+
* would render non-ASCII names as quoted octal escapes under
|
|
614
|
+
* `core.quotepath` and hand the picker garbage.
|
|
615
|
+
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
616
|
+
* @param signal - abort signal.
|
|
617
|
+
*/
|
|
618
|
+
@Remote('repoTree')
|
|
619
|
+
async repoTree(worktreePath: string, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean }> {
|
|
620
|
+
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
621
|
+
const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal)
|
|
622
|
+
const all = res.stdout.split('\0').filter(path => path.length > 0)
|
|
623
|
+
const truncated = all.length > TREE_PATH_CAP
|
|
624
|
+
return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated }
|
|
625
|
+
}
|
|
626
|
+
|
|
555
627
|
/**
|
|
556
628
|
* Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
|
|
557
629
|
*
|
|
@@ -882,6 +954,143 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
882
954
|
return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal)
|
|
883
955
|
}
|
|
884
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
|
+
@Remote('discardPlan')
|
|
973
|
+
async discardPlan(worktreePath: string, path: string, signal: AbortSignal): Promise<{
|
|
974
|
+
effect?: DiscardEffect
|
|
975
|
+
irreversible?: boolean
|
|
976
|
+
previousPath?: string
|
|
977
|
+
error?: string
|
|
978
|
+
}> {
|
|
979
|
+
let plan: DiscardPlan | null
|
|
980
|
+
try {
|
|
981
|
+
plan = await this.planDiscard(worktreePath, path, signal)
|
|
982
|
+
} catch (error) {
|
|
983
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
984
|
+
}
|
|
985
|
+
if (plan === null) return {}
|
|
986
|
+
// JSON-safe: an absent previousPath is an omitted key, never `undefined`.
|
|
987
|
+
return plan.previousPath !== undefined
|
|
988
|
+
? { effect: plan.effect, irreversible: plan.irreversible, previousPath: plan.previousPath }
|
|
989
|
+
: { effect: plan.effect, irreversible: plan.irreversible }
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Take one file back to its committed state — IntelliJ's Rollback.
|
|
994
|
+
*
|
|
995
|
+
* One path per call, never a list. Discarding is the only thing the drawer
|
|
996
|
+
* does that cannot be undone, and a batch entry point is the shape that
|
|
997
|
+
* turns one mistaken click into a lost afternoon; a caller that wants two
|
|
998
|
+
* files asks twice, and gets asked twice.
|
|
999
|
+
*
|
|
1000
|
+
* The plan is re-derived here from a fresh `git status`, so a client that
|
|
1001
|
+
* mislabels a tracked file as untracked cannot talk the host into deleting
|
|
1002
|
+
* it. `expectedEffect` is what the reader was shown and agreed to: if the
|
|
1003
|
+
* file changed underneath the dialog — staged, edited, reverted by someone
|
|
1004
|
+
* else — the freshly derived effect no longer matches and nothing is done.
|
|
1005
|
+
* @param worktreePath - directory to run in.
|
|
1006
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
1007
|
+
* @param expectedEffect - the effect the confirmation stated; blank skips
|
|
1008
|
+
* the agreement check, which only the reversible
|
|
1009
|
+
* `recover` path takes (it shows no dialog).
|
|
1010
|
+
* @param signal - abort signal.
|
|
1011
|
+
* @returns the operation result, with the effect actually carried out.
|
|
1012
|
+
*/
|
|
1013
|
+
@Remote('discardFile')
|
|
1014
|
+
async discardFile(worktreePath: string, path: string, expectedEffect: string | undefined, signal: AbortSignal): Promise<GitOpResult & { effect?: DiscardEffect }> {
|
|
1015
|
+
let plan: DiscardPlan | null
|
|
1016
|
+
try {
|
|
1017
|
+
plan = await this.planDiscard(worktreePath, path, signal)
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
1020
|
+
}
|
|
1021
|
+
// Nothing to discard is not a failure: the row was stale, and the tree the
|
|
1022
|
+
// client refreshes onto will simply no longer carry it.
|
|
1023
|
+
if (plan === null) return { ok: true }
|
|
1024
|
+
if (typeof expectedEffect === 'string' && expectedEffect.length > 0 && expectedEffect !== plan.effect) {
|
|
1025
|
+
return {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
failure: 'unknown',
|
|
1028
|
+
error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const cwd = this.cwdOf(worktreePath)
|
|
1033
|
+
for (const step of plan.steps) {
|
|
1034
|
+
if (step.kind === 'git') {
|
|
1035
|
+
const result = await this.git(cwd, step.argv, signal)
|
|
1036
|
+
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
|
|
1037
|
+
if (failure !== null) {
|
|
1038
|
+
return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
|
|
1039
|
+
}
|
|
1040
|
+
continue
|
|
1041
|
+
}
|
|
1042
|
+
try {
|
|
1043
|
+
await this.removeInside(cwd, step.path)
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return { ok: true, effect: plan.effect }
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Read the worktree's status and plan for one path.
|
|
1053
|
+
*
|
|
1054
|
+
* The status is the WHOLE tree's, deliberately: git pairs a deletion with an
|
|
1055
|
+
* addition to see a rename, and a pathspec that admits only one of the pair
|
|
1056
|
+
* reports `D` plus `??` instead — which plans as "restore one, DELETE the
|
|
1057
|
+
* other" where the truth is "undo the rename".
|
|
1058
|
+
*/
|
|
1059
|
+
private async planDiscard(worktreePath: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
|
|
1060
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
1061
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
1062
|
+
}
|
|
1063
|
+
const cwd = this.cwdOf(worktreePath)
|
|
1064
|
+
const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
|
|
1065
|
+
if (status.exitCode !== 0) {
|
|
1066
|
+
throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed')
|
|
1067
|
+
}
|
|
1068
|
+
return planFromStatus(status.stdout, path)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Delete one file, having proven it is inside the worktree.
|
|
1073
|
+
*
|
|
1074
|
+
* `isSafeRelativePath` already rejected traversal in the plan, so this is the
|
|
1075
|
+
* second lock rather than the only one: it re-checks the RESOLVED path,
|
|
1076
|
+
* which is the form the filesystem actually acts on. `force` makes an absent
|
|
1077
|
+
* file a success — the reader asked for it to be gone, and it is.
|
|
1078
|
+
*
|
|
1079
|
+
* A symlinked directory inside the worktree could still point outward; that
|
|
1080
|
+
* is a repository someone already has write access to, and resolving link
|
|
1081
|
+
* targets on every delete would cost a stat per segment for a case git
|
|
1082
|
+
* itself does not defend against.
|
|
1083
|
+
*/
|
|
1084
|
+
private async removeInside(cwd: string, relative: string): Promise<void> {
|
|
1085
|
+
const root = resolve(cwd)
|
|
1086
|
+
const target = resolve(root, relative)
|
|
1087
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
1088
|
+
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
|
|
1089
|
+
}
|
|
1090
|
+
if (target === root) throw new Error('refusing to delete the worktree root')
|
|
1091
|
+
await rm(target, { force: true })
|
|
1092
|
+
}
|
|
1093
|
+
|
|
885
1094
|
/**
|
|
886
1095
|
* Commit what is in the index.
|
|
887
1096
|
* @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
|
+
}
|
package/src/shortlog.ts
ADDED
|
@@ -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
|
+
}
|