@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.
@@ -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, body.
44
+ * Pretty format: RS, hash, when, subject, parents, refs, author, committer,
45
+ * ISO date, body.
31
46
  *
32
47
  * `body` stays last because it is the only field that may contain newlines;
33
- * anything after it would have to survive them. Parents (`%p`) and refs (`%D`)
34
- * are single-line by construction.
48
+ * anything after it would have to survive them. Parents (`%p`), refs (`%D`),
49
+ * names (`%an`, `%cn`) and the ISO date (`%cI`) are single-line by construction.
35
50
  */
36
- export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%b'
51
+ export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%an%x1f%cn%x1f%cI%x1f%b'
37
52
 
38
53
  /**
39
54
  * Split `%D` into plain ref names.
@@ -76,8 +91,11 @@ export function parseLog(stdout: string): GitCommit[] {
76
91
  const subject = (parts[2] ?? '').replace(/\n+$/g, '')
77
92
  const parents = (parts[3] ?? '').trim().split(/\s+/).filter(part => part.length > 0)
78
93
  const refs = parseRefs(parts[4] ?? '')
79
- const body = (parts[5] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '')
80
- if (hash.length > 0) out.push({ hash, subject, when, body, parents, refs })
94
+ const authorName = parts[5] ?? ''
95
+ const committerName = parts[6] ?? ''
96
+ const dateIso = parts[7] ?? ''
97
+ const body = (parts[8] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '')
98
+ if (hash.length > 0) out.push({ hash, subject, when, body, authorName, committerName, dateIso, parents, refs })
81
99
  }
82
100
  return out
83
101
  }
package/src/git-ops.ts CHANGED
@@ -13,8 +13,12 @@
13
13
  * of that. A file may legitimately be named `-f`, and passed positionally
14
14
  * it becomes an option instead of a path.
15
15
  * - Nothing here builds a destructive command. There is no `--force`, no
16
- * `reset --hard`, no `clean`. Losing committed work needs a confirmation
17
- * design of its own, not a button that happens to be adjacent to Push.
16
+ * `reset --hard`, no `clean`. The one action that does lose work rolling
17
+ * one file back lives in `discard-ops.ts` instead, behind the
18
+ * confirmation design this rule demanded: git's own reading of the file
19
+ * rather than the client's claim, `git restore` scoped to a single
20
+ * pathspec as the whole vocabulary, and a dialog that names the
21
+ * consequence. It is deliberately not a button adjacent to Push.
18
22
  *
19
23
  * @module
20
24
  */
@@ -341,16 +345,44 @@ export function parseNumstat(stdout: string): Map<string, NumstatEntry> {
341
345
  return out
342
346
  }
343
347
 
348
+ /** One porcelain line, split into the parts a caller can act on. */
349
+ export interface StatusLine {
350
+ /** The two status columns, e.g. ` M`, `??`, `R `. */
351
+ readonly xy: string
352
+ /** The path git reports the file under NOW — the rename target, if renamed. */
353
+ readonly path: string
354
+ /** For a rename, the path HEAD still knows the file by; '' otherwise. */
355
+ readonly previousPath: string
356
+ readonly renamed: boolean
357
+ }
358
+
359
+ /**
360
+ * Split one `git status --porcelain=v1` line.
361
+ *
362
+ * Exported because more than the file list needs it: `discard-ops` plans from
363
+ * the RAW XY pair, which {@link parseStatus} folds away into a
364
+ * {@link GitFileStatus}. Sharing this keeps the quoting and `old -> new`
365
+ * handling in one place — a second implementation of it is how a path with a
366
+ * non-ASCII name ends up being acted on unescaped.
367
+ * @param line - one output line, branch header and blanks included.
368
+ * @returns the split, or null for a line that names no file.
369
+ */
370
+ export function parseStatusLine(line: string): StatusLine | null {
371
+ if (line.length === 0 || line.startsWith('##')) return null
372
+ if (line.length < 3) return null
373
+ const { path, previousPath, renamed } = parsePath(line.slice(3))
374
+ if (path.length === 0) return null
375
+ return { xy: line.slice(0, 2), path, previousPath, renamed }
376
+ }
377
+
344
378
  /** Parse porcelain lines into a MUTABLE file list — untracked entries get their
345
379
  * counts filled in by the synthesis pass afterwards. */
346
380
  export function parseStatus(stdout: string, numstat: Map<string, NumstatEntry>): MutableGitFile[] {
347
381
  const files: MutableGitFile[] = []
348
382
  for (const line of stdout.split('\n')) {
349
- if (line.length === 0 || line.startsWith('##')) continue
350
- if (line.length < 3) continue
351
- const xy = line.slice(0, 2)
352
- const { path, previousPath, renamed } = parsePath(line.slice(3))
353
- if (path.length === 0) continue
383
+ const parsed = parseStatusLine(line)
384
+ if (parsed === null) continue
385
+ const { xy, path, previousPath, renamed } = parsed
354
386
  const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false }
355
387
  const { staged, unstaged } = stageStateOf(xy)
356
388
  const base: MutableGitFile = {