@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/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
- const target = typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD'
534
- if (!isRefName(target)) return { commits: [], hasMore: false }
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
+ }
@@ -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
+ }