@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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query-box grammar for the history filter, and its chip model.
|
|
3
|
+
*
|
|
4
|
+
* The box and the funnel popup are two views of ONE LogFilter: typing
|
|
5
|
+
* `user:lia after:2 weeks ago` and ticking authors in the popup produce the
|
|
6
|
+
* same object, rendered back into the box by {@link serializeLogQuery} and
|
|
7
|
+
* into removable chips by {@link chipsFromFilter}. Everything is pure so the
|
|
8
|
+
* grammar round-trips under test: parse(serialize(f)) === f.
|
|
9
|
+
*
|
|
10
|
+
* Grammar (prefixes case-insensitive, IDEA-style):
|
|
11
|
+
* bare words → the text criterion (commit-message match)
|
|
12
|
+
* user:<name> → author; repeatable, multiple OR
|
|
13
|
+
* path:<spec> → pathspec; repeatable, union
|
|
14
|
+
* after:<date> → approxidate lower bound, single (last wins)
|
|
15
|
+
* before:<date> → approxidate upper bound, single
|
|
16
|
+
* "..." → quoted span: exact value after a prefix, or exact text
|
|
17
|
+
* unknown:prefix → an ordinary word (lenient, like IDEA)
|
|
18
|
+
*
|
|
19
|
+
* Only DATE values auto-extend over following words ("after:2 weeks ago") —
|
|
20
|
+
* approxidate is naturally multi-word. user/path take their inline value;
|
|
21
|
+
* names with spaces come from the popup or quotes.
|
|
22
|
+
*
|
|
23
|
+
* @module @young1lin/dsh-ui-gitworkbench/log-filter-query
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { LogFilter } from '../log-filter.ts'
|
|
27
|
+
|
|
28
|
+
/** The filter that filters nothing. */
|
|
29
|
+
export function emptyQueryFilter(): LogFilter {
|
|
30
|
+
return { users: [], text: '', textRegex: false, paths: [], after: '', before: '' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Criterion kinds a chip can represent. */
|
|
34
|
+
export type ChipKind = 'user' | 'path' | 'after' | 'before' | 'text'
|
|
35
|
+
|
|
36
|
+
/** One removable criterion, as shown under the filter box. */
|
|
37
|
+
export interface FilterChip {
|
|
38
|
+
readonly kind: ChipKind
|
|
39
|
+
readonly value: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PREFIXES: readonly ChipKind[] = ['user', 'path', 'after', 'before']
|
|
43
|
+
const PREFIX_RE = /^(user|path|after|before):(.*)$/i
|
|
44
|
+
|
|
45
|
+
interface Token {
|
|
46
|
+
readonly value: string
|
|
47
|
+
readonly quoted: boolean
|
|
48
|
+
readonly kind?: ChipKind
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tokenize(query: string): Token[] {
|
|
52
|
+
const tokens: Token[] = []
|
|
53
|
+
let i = 0
|
|
54
|
+
while (i < query.length) {
|
|
55
|
+
while (i < query.length && /\s/.test(query[i]!)) i += 1
|
|
56
|
+
if (i >= query.length) break
|
|
57
|
+
if (query[i] === '"') {
|
|
58
|
+
const end = query.indexOf('"', i + 1)
|
|
59
|
+
const value = end === -1 ? query.slice(i + 1) : query.slice(i + 1, end)
|
|
60
|
+
tokens.push({ value, quoted: true })
|
|
61
|
+
i = end === -1 ? query.length : end + 1
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
// `prefix:"value with spaces"` — the quote opens INSIDE the word, right
|
|
65
|
+
// after the colon, so the word scanner below must not eat it as text.
|
|
66
|
+
const prefixQuote = /^(user|path|after|before):"/i.exec(query.slice(i))
|
|
67
|
+
if (prefixQuote !== null) {
|
|
68
|
+
const kind = prefixQuote[1]!.toLowerCase() as ChipKind
|
|
69
|
+
const open = i + prefixQuote[0].length
|
|
70
|
+
const end = query.indexOf('"', open)
|
|
71
|
+
const value = end === -1 ? query.slice(open) : query.slice(open, end)
|
|
72
|
+
tokens.push({ value, quoted: true, kind })
|
|
73
|
+
i = end === -1 ? query.length : end + 1
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
const start = i
|
|
77
|
+
while (i < query.length && !/\s/.test(query[i]!)) i += 1
|
|
78
|
+
const word = query.slice(start, i)
|
|
79
|
+
const match = PREFIX_RE.exec(word)
|
|
80
|
+
tokens.push(match === null
|
|
81
|
+
? { value: word, quoted: false }
|
|
82
|
+
: { value: match[2]!, quoted: false, kind: match[1]!.toLowerCase() as ChipKind })
|
|
83
|
+
}
|
|
84
|
+
return tokens
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the box's text into a filter.
|
|
89
|
+
* @param query - raw box contents.
|
|
90
|
+
*/
|
|
91
|
+
export function parseLogQuery(query: string): LogFilter {
|
|
92
|
+
const tokens = tokenize(query)
|
|
93
|
+
const users: string[] = []
|
|
94
|
+
const paths: string[] = []
|
|
95
|
+
let text = ''
|
|
96
|
+
let after = ''
|
|
97
|
+
let before = ''
|
|
98
|
+
|
|
99
|
+
const textWords: string[] = []
|
|
100
|
+
let i = 0
|
|
101
|
+
while (i < tokens.length) {
|
|
102
|
+
const token = tokens[i]!
|
|
103
|
+
if (token.kind === undefined) {
|
|
104
|
+
textWords.push(token.value)
|
|
105
|
+
i += 1
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (token.kind === 'user' || token.kind === 'path') {
|
|
109
|
+
const list = token.kind === 'user' ? users : paths
|
|
110
|
+
if (token.value.length > 0 && !list.includes(token.value)) list.push(token.value)
|
|
111
|
+
i += 1
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
// Date bounds: approxidate is naturally multi-word, so an unquoted value
|
|
115
|
+
// swallows the bare words that follow, stopping at the next prefix token
|
|
116
|
+
// or a quoted span. A QUOTED base value is exact — no extension.
|
|
117
|
+
const parts: string[] = [token.value]
|
|
118
|
+
let j = token.quoted ? i : i + 1
|
|
119
|
+
while (j < tokens.length && tokens[j]!.kind === undefined && !tokens[j]!.quoted && tokens[j]!.value.length > 0) {
|
|
120
|
+
parts.push(tokens[j]!.value)
|
|
121
|
+
j += 1
|
|
122
|
+
}
|
|
123
|
+
const value = parts.join(' ').trim()
|
|
124
|
+
if (token.kind === 'after') after = value
|
|
125
|
+
else before = value
|
|
126
|
+
i = token.quoted ? i + 1 : j
|
|
127
|
+
}
|
|
128
|
+
text = textWords.join(' ').trim()
|
|
129
|
+
return { users, text, textRegex: false, paths, after, before }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Quote a serialized value iff it would not reparse as itself. */
|
|
133
|
+
function quote(value: string): string {
|
|
134
|
+
return /\s/.test(value) ? `"${value}"` : value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a filter back into the box's grammar. The text criterion goes last
|
|
139
|
+
* and is quoted when any of its words would parse as a prefix token.
|
|
140
|
+
* @param filter - the filter to render.
|
|
141
|
+
*/
|
|
142
|
+
export function serializeLogQuery(filter: LogFilter): string {
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
for (const user of filter.users) parts.push(`user:${quote(user)}`)
|
|
145
|
+
for (const path of filter.paths) parts.push(`path:${quote(path)}`)
|
|
146
|
+
if (filter.after.length > 0) parts.push(`after:${quote(filter.after)}`)
|
|
147
|
+
if (filter.before.length > 0) parts.push(`before:${quote(filter.before)}`)
|
|
148
|
+
if (filter.text.length > 0) {
|
|
149
|
+
const looksPrefixed = filter.text.split(/\s+/).some(word => PREFIX_RE.test(word))
|
|
150
|
+
parts.push(looksPrefixed ? `"${filter.text}"` : filter.text)
|
|
151
|
+
}
|
|
152
|
+
return parts.join(' ')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One chip per criterion, in grammar order: users, paths, bounds, text.
|
|
157
|
+
* @param filter - the filter to decompose.
|
|
158
|
+
*/
|
|
159
|
+
export function chipsFromFilter(filter: LogFilter): readonly FilterChip[] {
|
|
160
|
+
const chips: FilterChip[] = []
|
|
161
|
+
for (const user of filter.users) chips.push({ kind: 'user', value: user })
|
|
162
|
+
for (const path of filter.paths) chips.push({ kind: 'path', value: path })
|
|
163
|
+
if (filter.after.length > 0) chips.push({ kind: 'after', value: filter.after })
|
|
164
|
+
if (filter.before.length > 0) chips.push({ kind: 'before', value: filter.before })
|
|
165
|
+
if (filter.text.length > 0) chips.push({ kind: 'text', value: filter.text })
|
|
166
|
+
return chips
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The filter minus one chip. Immutable; dropping the last criterion yields
|
|
171
|
+
* the empty filter.
|
|
172
|
+
* @param filter - current filter.
|
|
173
|
+
* @param kind - the chip's criterion kind.
|
|
174
|
+
* @param value - the chip's value (which user, which path).
|
|
175
|
+
*/
|
|
176
|
+
export function removeChip(filter: LogFilter, kind: ChipKind, value: string): LogFilter {
|
|
177
|
+
switch (kind) {
|
|
178
|
+
case 'user':
|
|
179
|
+
return { ...filter, users: filter.users.filter(user => user !== value) }
|
|
180
|
+
case 'path':
|
|
181
|
+
return { ...filter, paths: filter.paths.filter(path => path !== value) }
|
|
182
|
+
case 'after':
|
|
183
|
+
return { ...filter, after: '' }
|
|
184
|
+
case 'before':
|
|
185
|
+
return { ...filter, before: '' }
|
|
186
|
+
case 'text':
|
|
187
|
+
return { ...filter, text: '', textRegex: false }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox-tree semantics for the path picker.
|
|
3
|
+
*
|
|
4
|
+
* Ticking a FOLDER means its whole subtree — so the selection is a set of
|
|
5
|
+
* pathspecs with an invariant: no member covers another. Ticking a folder
|
|
6
|
+
* absorbs the files already ticked inside it (the lone file's chip gives way
|
|
7
|
+
* to the folder's one); unticking one file under a checked folder cascades
|
|
8
|
+
* OUT — the folder is replaced by its other children, level by level. Rows
|
|
9
|
+
* then DERIVE their checkbox state (on / off / partial) from the set, which
|
|
10
|
+
* is why ticking a folder visibly checks everything under it.
|
|
11
|
+
*
|
|
12
|
+
* Pure throughout; the index is built from the same raw path list the host
|
|
13
|
+
* sent, so children order is the tree's alphabetical order.
|
|
14
|
+
*
|
|
15
|
+
* @module @young1lin/dsh-ui-gitworkbench/path-select
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Directory path → its direct children, full paths, files and dirs apart. */
|
|
19
|
+
export interface PathIndex {
|
|
20
|
+
readonly dirs: ReadonlyMap<string, readonly string[]>
|
|
21
|
+
readonly files: ReadonlyMap<string, readonly string[]>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Index a raw path list for child lookup.
|
|
26
|
+
* @param paths - repo-relative file paths, exactly as `repoTree` returned.
|
|
27
|
+
*/
|
|
28
|
+
export function buildIndex(paths: readonly string[]): PathIndex {
|
|
29
|
+
const dirs = new Map<string, string[]>()
|
|
30
|
+
const files = new Map<string, string[]>()
|
|
31
|
+
const noteDir = (dir: string): void => {
|
|
32
|
+
if (!dirs.has(dir)) dirs.set(dir, [])
|
|
33
|
+
}
|
|
34
|
+
noteDir('')
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
if (path.length === 0) continue
|
|
37
|
+
const parts = path.split('/')
|
|
38
|
+
let dir = ''
|
|
39
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
40
|
+
const childDir = dir === '' ? parts[i]! : `${dir}/${parts[i]!}`
|
|
41
|
+
noteDir(childDir)
|
|
42
|
+
const list = dirs.get(dir)!
|
|
43
|
+
if (!list.includes(childDir)) list.push(childDir)
|
|
44
|
+
dir = childDir
|
|
45
|
+
}
|
|
46
|
+
const list = files.get(dir) ?? []
|
|
47
|
+
list.push(path)
|
|
48
|
+
files.set(dir, list)
|
|
49
|
+
}
|
|
50
|
+
for (const list of dirs.values()) list.sort()
|
|
51
|
+
for (const list of files.values()) list.sort()
|
|
52
|
+
return { dirs, files }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Children of a directory, alphabetical by full path — the tree's order. */
|
|
56
|
+
function childrenOf(index: PathIndex, dir: string): readonly string[] {
|
|
57
|
+
return [...(index.dirs.get(dir) ?? []), ...(index.files.get(dir) ?? [])].sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is `p` selected — itself ticked, or inside a ticked directory?
|
|
62
|
+
* (Segment-boundary prefix: `src` does not cover `src2`.)
|
|
63
|
+
*/
|
|
64
|
+
export function isCovered(paths: readonly string[], p: string): boolean {
|
|
65
|
+
return paths.some(tick => tick === p || p.startsWith(`${tick}/`))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tick a path. No-op when an ancestor already covers it; absorbs every
|
|
70
|
+
* descendant it covers, keeping the set minimal — one folder chip, never the
|
|
71
|
+
* pile of files under it.
|
|
72
|
+
*/
|
|
73
|
+
export function addPath(paths: readonly string[], p: string): readonly string[] {
|
|
74
|
+
if (isCovered(paths, p)) return paths
|
|
75
|
+
const kept = paths.filter(tick => !(tick === p || tick.startsWith(`${p}/`)))
|
|
76
|
+
return [...kept, p]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Untick a path. Removing an exact tick drops it; removing a file COVERED by
|
|
81
|
+
* a ticked folder replaces that folder with its other children, level by
|
|
82
|
+
* level down to the file — the standard cascade-out.
|
|
83
|
+
*/
|
|
84
|
+
export function removePath(paths: readonly string[], p: string, index: PathIndex): readonly string[] {
|
|
85
|
+
const out: string[] = []
|
|
86
|
+
for (const tick of paths) {
|
|
87
|
+
if (tick !== p && !p.startsWith(`${tick}/`)) {
|
|
88
|
+
out.push(tick)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// This tick is p itself or an ancestor of it; replace it with the subtree
|
|
92
|
+
// minus p. Walk down the chain, adding each level's other children.
|
|
93
|
+
let dir = tick
|
|
94
|
+
while (dir !== p) {
|
|
95
|
+
// The child of `dir` on the way to p: the next path segment.
|
|
96
|
+
const rest = p.slice(dir.length + 1)
|
|
97
|
+
const nextName = dir === '' ? p.split('/')[0]! : rest.split('/')[0]!
|
|
98
|
+
const next = dir === '' ? nextName : `${dir}/${nextName}`
|
|
99
|
+
for (const child of childrenOf(index, dir)) {
|
|
100
|
+
if (child !== next) out.push(child)
|
|
101
|
+
}
|
|
102
|
+
dir = next
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every file under a directory (empty for a file path). */
|
|
109
|
+
function filesUnder(index: PathIndex, dir: string): readonly string[] {
|
|
110
|
+
const out: string[] = []
|
|
111
|
+
const stack = [dir]
|
|
112
|
+
while (stack.length > 0) {
|
|
113
|
+
const current = stack.pop()!
|
|
114
|
+
out.push(...(index.files.get(current) ?? []))
|
|
115
|
+
stack.push(...(index.dirs.get(current) ?? []))
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A row's checkbox state, derived: `on` when covered — by an ancestor tick OR
|
|
122
|
+
* by every file under it being covered individually; `partial` when a
|
|
123
|
+
* directory holds some but not all of its files; else `off`.
|
|
124
|
+
*/
|
|
125
|
+
export function checkedState(paths: readonly string[], p: string, index: PathIndex): 'on' | 'off' | 'partial' {
|
|
126
|
+
if (isCovered(paths, p)) return 'on'
|
|
127
|
+
const files = filesUnder(index, p)
|
|
128
|
+
if (files.length === 0) return 'off'
|
|
129
|
+
const covered = files.filter(file => isCovered(paths, file)).length
|
|
130
|
+
return covered === files.length ? 'on' : covered > 0 ? 'partial' : 'off'
|
|
131
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "discard this file" resolves to, as a plan the host can carry out.
|
|
3
|
+
*
|
|
4
|
+
* This is the confirmation design `git-ops.ts` says a destructive button owes
|
|
5
|
+
* before it may exist. Three rules hold here, and each one closes a way this
|
|
6
|
+
* feature could quietly destroy the wrong thing:
|
|
7
|
+
*
|
|
8
|
+
* - **The plan is derived from git's OWN report of the file, never from what
|
|
9
|
+
* the client said it was.** A client that mislabels a tracked file as
|
|
10
|
+
* untracked would otherwise turn a restore into a delete. `planFor` takes
|
|
11
|
+
* a porcelain XY pair that the host read itself.
|
|
12
|
+
* - **No plan may contain a destructive spelling.** Not `clean`, not
|
|
13
|
+
* `reset --hard`, not `checkout -f`, no `--force`. `git restore` scoped to
|
|
14
|
+
* one pathspec after `--` is the whole vocabulary; a stray `clean` would
|
|
15
|
+
* take out every untracked file in the tree instead of the one named.
|
|
16
|
+
* `tests/discard-ops.test.ts` scans every plan this module can produce.
|
|
17
|
+
* - **A path that is deleted from disk is checked far harder than a
|
|
18
|
+
* pathspec.** git refuses to leave the repository; `fs.rm` does not, so
|
|
19
|
+
* `isSafeRelativePath` rejects absolutes, drive letters, UNC prefixes and
|
|
20
|
+
* any `..` segment before a delete step can name a file.
|
|
21
|
+
*
|
|
22
|
+
* IDEA parity decides the semantics: Rollback there takes a file back to its
|
|
23
|
+
* committed state in one gesture and does not ask whether the change was
|
|
24
|
+
* staged, so neither does this. A file that was never committed goes away; a
|
|
25
|
+
* file that was deleted comes back.
|
|
26
|
+
*
|
|
27
|
+
* Pure throughout — no spawning, no fs. `src/index.ts` executes the steps.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/discard-ops
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { isSafePathArg, parseStatusLine } from './git-ops.js'
|
|
33
|
+
|
|
34
|
+
/** One thing the host does, in order. */
|
|
35
|
+
export type DiscardStep =
|
|
36
|
+
/** Run git with this argv. */
|
|
37
|
+
| { readonly kind: 'git'; readonly argv: readonly string[] }
|
|
38
|
+
/** Remove this repo-relative path from disk. Tolerates an absent file. */
|
|
39
|
+
| { readonly kind: 'delete'; readonly path: string }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the reader is about to lose, which is what the confirmation must say.
|
|
43
|
+
* The client maps these to copy; keeping them as data means the wording can be
|
|
44
|
+
* bilingual without this module knowing about locales.
|
|
45
|
+
*/
|
|
46
|
+
export type DiscardEffect =
|
|
47
|
+
/** Tracked file returns to its committed content. Local edits are gone. */
|
|
48
|
+
| 'restore'
|
|
49
|
+
/** File leaves the disk. git never had it, so nothing can bring it back. */
|
|
50
|
+
| 'delete'
|
|
51
|
+
/** A deleted file comes back. Nothing is lost — no confirmation needed. */
|
|
52
|
+
| 'recover'
|
|
53
|
+
/** A rename is undone: the old path returns, the new one goes. */
|
|
54
|
+
| 'unrename'
|
|
55
|
+
|
|
56
|
+
export interface DiscardPlan {
|
|
57
|
+
readonly steps: readonly DiscardStep[]
|
|
58
|
+
readonly effect: DiscardEffect
|
|
59
|
+
/**
|
|
60
|
+
* Whether carrying this out can lose work no git object holds. Drives
|
|
61
|
+
* whether the client confirms at all: recovering a deleted file is pure
|
|
62
|
+
* gain, and a dialog in front of it is noise that teaches people to click
|
|
63
|
+
* through dialogs.
|
|
64
|
+
*/
|
|
65
|
+
readonly irreversible: boolean
|
|
66
|
+
/** The path the reader named, for the confirmation copy. */
|
|
67
|
+
readonly path: string
|
|
68
|
+
/** For `unrename`, the path the file is going back to. */
|
|
69
|
+
readonly previousPath?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a path is safe to hand a filesystem delete.
|
|
74
|
+
*
|
|
75
|
+
* Stricter than {@link isSafePathArg}, which only has to keep git from reading
|
|
76
|
+
* a path as an option: git will not step outside the repository whatever it is
|
|
77
|
+
* given, so a pathspec needs no traversal check. A delete has no such backstop.
|
|
78
|
+
* Rejected: absolute paths (POSIX and Windows), UNC prefixes, drive letters,
|
|
79
|
+
* NUL bytes, and any `..` segment — including one buried mid-path, which is
|
|
80
|
+
* how traversal is usually spelled.
|
|
81
|
+
* @param path - repo-relative path from a plan step.
|
|
82
|
+
*/
|
|
83
|
+
export function isSafeRelativePath(path: string): boolean {
|
|
84
|
+
if (!isSafePathArg(path)) return false
|
|
85
|
+
if (path.includes('\0')) return false
|
|
86
|
+
// Windows accepts both separators, so normalise before splitting or
|
|
87
|
+
// `a\..\..\b` walks out through a check that only knew about `/`.
|
|
88
|
+
const unified = path.replace(/\\/g, '/')
|
|
89
|
+
if (unified.startsWith('/')) return false
|
|
90
|
+
if (/^[A-Za-z]:/.test(unified)) return false
|
|
91
|
+
if (unified.startsWith('//')) return false
|
|
92
|
+
return !unified.split('/').includes('..')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Take one file back to HEAD in both the index and the working tree. */
|
|
96
|
+
function restoreBoth(path: string): DiscardStep {
|
|
97
|
+
return { kind: 'git', argv: ['restore', '--source=HEAD', '--staged', '--worktree', '--', path] }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop a file's index entry, leaving the working tree untouched. */
|
|
101
|
+
function unstage(path: string): DiscardStep {
|
|
102
|
+
return { kind: 'git', argv: ['restore', '--staged', '--', path] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The plan for one file, from git's own porcelain line.
|
|
107
|
+
*
|
|
108
|
+
* @param xy - the two porcelain status columns for this path, e.g. ` M`, `??`,
|
|
109
|
+
* `R `. Read by the host from `git status --porcelain`, never
|
|
110
|
+
* supplied by the client.
|
|
111
|
+
* @param path - repo-relative path, as git printed it.
|
|
112
|
+
* @param previousPath - for a rename, the path HEAD still knows the file by.
|
|
113
|
+
* @returns the ordered plan, or null when the file has nothing to discard.
|
|
114
|
+
* @throws if a path is not safe to pass on.
|
|
115
|
+
*/
|
|
116
|
+
export function planFor(xy: string, path: string, previousPath?: string): DiscardPlan | null {
|
|
117
|
+
if (!isSafePathArg(path)) throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
118
|
+
const index = xy[0] ?? ' '
|
|
119
|
+
const worktree = xy[1] ?? ' '
|
|
120
|
+
|
|
121
|
+
// Untracked and ignored: git has no copy, so the only way back is the
|
|
122
|
+
// filesystem's, and there is none.
|
|
123
|
+
if (xy === '??' || xy === '!!') {
|
|
124
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
125
|
+
return { steps: [{ kind: 'delete', path }], effect: 'delete', irreversible: true, path }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A rename: HEAD holds `previousPath`, the index holds `path`. Bring the old
|
|
129
|
+
// one back first, then retire the new one — doing it the other way round
|
|
130
|
+
// would leave the tree with neither name for as long as the second step
|
|
131
|
+
// takes, which a reader watching a file tree would see.
|
|
132
|
+
if (index === 'R' || index === 'C') {
|
|
133
|
+
if (previousPath === undefined || !isSafePathArg(previousPath)) {
|
|
134
|
+
throw new Error(`rename without a usable previous path: ${JSON.stringify(path)}`)
|
|
135
|
+
}
|
|
136
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
137
|
+
return {
|
|
138
|
+
steps: [restoreBoth(previousPath), unstage(path), { kind: 'delete', path }],
|
|
139
|
+
effect: 'unrename',
|
|
140
|
+
irreversible: true,
|
|
141
|
+
path,
|
|
142
|
+
previousPath,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Added to the index but absent from HEAD: rolling back means the file was
|
|
147
|
+
// never committed, so it leaves. Unstage first — otherwise the index would
|
|
148
|
+
// still carry an entry for a path that no longer exists on disk.
|
|
149
|
+
if (index === 'A') {
|
|
150
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
151
|
+
return {
|
|
152
|
+
steps: [unstage(path), { kind: 'delete', path }],
|
|
153
|
+
effect: 'delete',
|
|
154
|
+
irreversible: true,
|
|
155
|
+
path,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Deleted, either side. HEAD still has the content, so this is recovery:
|
|
160
|
+
// nothing is lost and nothing needs confirming.
|
|
161
|
+
if (index === 'D' || worktree === 'D') {
|
|
162
|
+
return { steps: [restoreBoth(path)], effect: 'recover', irreversible: false, path }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Everything else that git reported as changed — modified, type-changed,
|
|
166
|
+
// staged, unstaged, or both — goes back to HEAD wholesale. Content only the
|
|
167
|
+
// working tree ever held has no object behind it.
|
|
168
|
+
if (index !== ' ' || worktree !== ' ') {
|
|
169
|
+
return { steps: [restoreBoth(path)], effect: 'restore', irreversible: true, path }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Clean: git reported the path with nothing to say about it.
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find the file in a status report and plan for it.
|
|
178
|
+
*
|
|
179
|
+
* The report is the WHOLE tree's, not one narrowed by a pathspec: git detects
|
|
180
|
+
* a rename by pairing a deletion with an addition, and a pathspec that admits
|
|
181
|
+
* only one of the pair turns `R old -> new` into an unrelated `D` and `??`.
|
|
182
|
+
* The plan for those two is delete-and-lose where the truth is un-rename.
|
|
183
|
+
*
|
|
184
|
+
* @param stdout - `git status --porcelain=v1` over the whole worktree.
|
|
185
|
+
* @param path - the file the reader asked about, as the drawer lists it.
|
|
186
|
+
* @returns the plan, or null when git does not report that path as changed —
|
|
187
|
+
* which is also what a stale tree looks like, and is not an error.
|
|
188
|
+
*/
|
|
189
|
+
export function planFromStatus(stdout: string, path: string): DiscardPlan | null {
|
|
190
|
+
if (!isSafePathArg(path)) throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
191
|
+
for (const line of stdout.split('\n')) {
|
|
192
|
+
const parsed = parseStatusLine(line)
|
|
193
|
+
if (parsed === null || parsed.path !== path) continue
|
|
194
|
+
return planFor(parsed.xy, parsed.path, parsed.renamed ? parsed.previousPath : undefined)
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
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 = {
|