@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +48 -0
- package/CHANGELOG_EN.md +48 -0
- package/README.md +36 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2126 -304
- package/lib/discard-ops.js +158 -0
- package/lib/fs-remove.js +73 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +191 -5
- package/lib/log-filter.js +71 -0
- package/lib/shortlog.js +40 -0
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +523 -6
- package/src/client/GitWorkbenchPanel.tsx +1030 -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/discard-flow.ts +82 -0
- package/src/client/file-filter.ts +66 -0
- package/src/client/index.ts +52 -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/fs-remove.ts +76 -0
- package/src/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +191 -4
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
package/src/fs-remove.ts
ADDED
|
@@ -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,
|
|
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`)
|
|
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
|
|
80
|
-
|
|
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`.
|
|
17
|
-
*
|
|
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
|
-
|
|
350
|
-
if (
|
|
351
|
-
const xy
|
|
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
|
-
|
|
534
|
-
|
|
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
|
+
}
|
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
|
+
}
|