@young1lin/dsh-ui-gitworkbench 0.1.0
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 +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded store for payloads addressed by a git object name.
|
|
3
|
+
*
|
|
4
|
+
* A commit hash names content that cannot change, so a hit stays valid for the
|
|
5
|
+
* life of the process and there is nothing to invalidate — capacity is the only
|
|
6
|
+
* reason an entry ever leaves. That is the whole distinction from working-tree
|
|
7
|
+
* data, which must never be cached because the next keystroke can change it.
|
|
8
|
+
*
|
|
9
|
+
* @module @young1lin/dsh-ui-gitworkbench/commit-cache
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Compose a cache key from its parts.
|
|
13
|
+
*
|
|
14
|
+
* The separator is a unit separator, which cannot occur in a filesystem path, a
|
|
15
|
+
* git object name, or a repository-relative filename. No two distinct part
|
|
16
|
+
* lists can therefore produce the same key.
|
|
17
|
+
* @param parts - key components, most general first.
|
|
18
|
+
* @returns the composed key.
|
|
19
|
+
*/
|
|
20
|
+
export function cacheKey(...parts) {
|
|
21
|
+
return parts.join('\x1f');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fixed-capacity map with least-recently-used eviction.
|
|
25
|
+
*
|
|
26
|
+
* A Map iterates in insertion order, so re-inserting an entry on every hit
|
|
27
|
+
* makes insertion order the recency order and the first key the least recently
|
|
28
|
+
* used one. No timestamps, no separate list.
|
|
29
|
+
*/
|
|
30
|
+
export class CommitPayloadCache {
|
|
31
|
+
capacity;
|
|
32
|
+
entries = new Map();
|
|
33
|
+
/** @param capacity - entries kept resident; the least recently used is dropped past it. */
|
|
34
|
+
constructor(capacity) {
|
|
35
|
+
this.capacity = capacity;
|
|
36
|
+
}
|
|
37
|
+
/** How many entries are resident. */
|
|
38
|
+
get size() {
|
|
39
|
+
return this.entries.size;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read an entry, marking it most recently used.
|
|
43
|
+
* @param key - cache key.
|
|
44
|
+
* @returns the stored payload, or undefined when the key is absent.
|
|
45
|
+
*/
|
|
46
|
+
get(key) {
|
|
47
|
+
const hit = this.entries.get(key);
|
|
48
|
+
if (hit === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
this.entries.delete(key);
|
|
51
|
+
this.entries.set(key, hit);
|
|
52
|
+
return hit;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Store an entry as most recently used, evicting past capacity.
|
|
56
|
+
* @param key - cache key.
|
|
57
|
+
* @param value - payload to store, replacing any existing entry for the key.
|
|
58
|
+
*/
|
|
59
|
+
set(key, value) {
|
|
60
|
+
this.entries.delete(key);
|
|
61
|
+
this.entries.set(key, value);
|
|
62
|
+
if (this.entries.size > this.capacity) {
|
|
63
|
+
const oldest = this.entries.keys().next();
|
|
64
|
+
if (!oldest.done)
|
|
65
|
+
this.entries.delete(oldest.value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/git-log.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit-log records from `git log` / `git show`.
|
|
3
|
+
*
|
|
4
|
+
* Subject (`%s`) is the first line; body (`%b`) is everything after the blank
|
|
5
|
+
* line. Records are delimited by ASCII RS (`%x1e`) so a body may contain
|
|
6
|
+
* newlines without breaking the parse. Fields inside a record are US (`%x1f`).
|
|
7
|
+
*
|
|
8
|
+
* `body` is always a string (empty when the commit has none). RPC payloads
|
|
9
|
+
* cannot carry `undefined`.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Pretty format: RS, hash, when, subject, parents, refs, body.
|
|
13
|
+
*
|
|
14
|
+
* `body` stays last because it is the only field that may contain newlines;
|
|
15
|
+
* anything after it would have to survive them. Parents (`%p`) and refs (`%D`)
|
|
16
|
+
* are single-line by construction.
|
|
17
|
+
*/
|
|
18
|
+
export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%b';
|
|
19
|
+
/**
|
|
20
|
+
* Split `%D` into plain ref names.
|
|
21
|
+
*
|
|
22
|
+
* git writes decorations as a comma-joined list where HEAD is an arrow pair
|
|
23
|
+
* (`HEAD -> main`) and tags carry a `tag: ` prefix. Both are rendered as the
|
|
24
|
+
* bare name; which kind of ref it is does not change what the row shows.
|
|
25
|
+
* @param decoration - the `%D` field, possibly empty.
|
|
26
|
+
*/
|
|
27
|
+
function parseRefs(decoration) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const raw of decoration.split(',')) {
|
|
30
|
+
let name = raw.trim();
|
|
31
|
+
if (name.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
// `HEAD -> main` names the branch HEAD is on; keep the branch.
|
|
34
|
+
const arrow = name.indexOf('->');
|
|
35
|
+
if (arrow !== -1)
|
|
36
|
+
name = name.slice(arrow + 2).trim();
|
|
37
|
+
if (name.startsWith('tag:'))
|
|
38
|
+
name = name.slice(4).trim();
|
|
39
|
+
// A remote's HEAD is a symbolic ref: it always points where that remote's
|
|
40
|
+
// default branch already points, so it is a second label for a commit that
|
|
41
|
+
// is guaranteed to carry the first. In a log row it is pure noise.
|
|
42
|
+
if (name === 'origin/HEAD' || name.endsWith('/HEAD'))
|
|
43
|
+
continue;
|
|
44
|
+
if (name.length > 0)
|
|
45
|
+
out.push(name);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a `LOG_FORMAT` stream into commits.
|
|
51
|
+
* @param stdout - git's stdout.
|
|
52
|
+
*/
|
|
53
|
+
export function parseLog(stdout) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const record of stdout.split('\x1e')) {
|
|
56
|
+
if (record.length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
const parts = record.split('\x1f');
|
|
59
|
+
if (parts.length < 3)
|
|
60
|
+
continue;
|
|
61
|
+
const hash = parts[0].trim();
|
|
62
|
+
const when = parts[1] ?? '';
|
|
63
|
+
const subject = (parts[2] ?? '').replace(/\n+$/g, '');
|
|
64
|
+
const parents = (parts[3] ?? '').trim().split(/\s+/).filter(part => part.length > 0);
|
|
65
|
+
const refs = parseRefs(parts[4] ?? '');
|
|
66
|
+
const body = (parts[5] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '');
|
|
67
|
+
if (hash.length > 0)
|
|
68
|
+
out.push({ hash, subject, when, body, parents, refs });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The text a "copy message" action puts on the clipboard: subject, then a
|
|
74
|
+
* blank line, then the body when there is one.
|
|
75
|
+
* @param commit - parsed commit.
|
|
76
|
+
*/
|
|
77
|
+
export function commitMessageText(commit) {
|
|
78
|
+
return commit.body.length > 0 ? `${commit.subject}\n\n${commit.body}` : commit.subject;
|
|
79
|
+
}
|
package/lib/git-ops.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument vectors and output readers for the drawer's WRITE operations —
|
|
3
|
+
* stage, unstage, commit, fetch, pull, push.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is a pure function over strings, kept apart from the RPC
|
|
6
|
+
* methods in `index.ts` so the interesting half can be tested without spawning
|
|
7
|
+
* git. What matters about `git push` is the argv it is handed and what the
|
|
8
|
+
* plugin concludes from the exit code; the spawn between them has no branches.
|
|
9
|
+
*
|
|
10
|
+
* Two rules hold throughout, because both failures are silent:
|
|
11
|
+
*
|
|
12
|
+
* - Every pathspec goes after `--`, and is checked for a leading dash on top
|
|
13
|
+
* of that. A file may legitimately be named `-f`, and passed positionally
|
|
14
|
+
* it becomes an option instead of a path.
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Environment that makes git FAIL on a credential prompt instead of waiting for
|
|
23
|
+
* one.
|
|
24
|
+
*
|
|
25
|
+
* The subprocess capability is spawned with `stdin: 'ignore'`, which does not
|
|
26
|
+
* make an interactive prompt an error — it makes it a prompt nobody can answer,
|
|
27
|
+
* and git waits. That wait is inside the host process, so a single push to a
|
|
28
|
+
* repository whose token expired would hang the plugin for every session until
|
|
29
|
+
* the 30s grace elapsed. Each variable below closes one prompt route: git's own
|
|
30
|
+
* terminal prompt, Git Credential Manager's GUI, and the two askpass helpers.
|
|
31
|
+
*/
|
|
32
|
+
export const NON_INTERACTIVE_ENV = {
|
|
33
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
34
|
+
GCM_INTERACTIVE: 'never',
|
|
35
|
+
GIT_ASKPASS: '',
|
|
36
|
+
SSH_ASKPASS: '',
|
|
37
|
+
};
|
|
38
|
+
/** Network operations wait on a remote, not on the disk. */
|
|
39
|
+
export const NETWORK_GRACE_MS = 120_000;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a string is safe to hand git as a pathspec.
|
|
42
|
+
* @param path - repository-relative path from the client.
|
|
43
|
+
* @returns false for an empty string or anything git would read as an option.
|
|
44
|
+
*/
|
|
45
|
+
export function isSafePathArg(path) {
|
|
46
|
+
return typeof path === 'string' && path.length > 0 && !path.startsWith('-');
|
|
47
|
+
}
|
|
48
|
+
function checkedPaths(paths) {
|
|
49
|
+
if (paths.length === 0)
|
|
50
|
+
throw new Error('no paths given');
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
if (!isSafePathArg(path))
|
|
53
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
54
|
+
}
|
|
55
|
+
return [...paths];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @param paths - repository-relative paths to stage.
|
|
59
|
+
* @returns argv for `git`, paths separated by `--`.
|
|
60
|
+
*/
|
|
61
|
+
export function stageArgv(paths) {
|
|
62
|
+
return ['add', '--', ...checkedPaths(paths)];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* @param paths - repository-relative paths to remove from the index.
|
|
66
|
+
* @returns argv for `git`. `restore --staged` leaves the working tree alone;
|
|
67
|
+
* `reset` would too, but `restore` cannot be confused with the
|
|
68
|
+
* destructive spellings of the same verb.
|
|
69
|
+
*/
|
|
70
|
+
export function unstageArgv(paths) {
|
|
71
|
+
return ['restore', '--staged', '--', ...checkedPaths(paths)];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* @param message - the commit message, used verbatim.
|
|
75
|
+
* @param amend - replace the previous commit instead of adding one.
|
|
76
|
+
* @returns argv for `git`. Never `-a`: the drawer has a staging area, and
|
|
77
|
+
* sweeping the whole worktree in would make that split a lie.
|
|
78
|
+
*/
|
|
79
|
+
export function commitArgv(message, amend) {
|
|
80
|
+
if (typeof message !== 'string' || message.trim().length === 0) {
|
|
81
|
+
throw new Error('a commit message is required');
|
|
82
|
+
}
|
|
83
|
+
// One argv element. No shell runs here, so quoting is not the hazard —
|
|
84
|
+
// splitting on whitespace would be, and a multi-line body is normal.
|
|
85
|
+
return amend ? ['commit', '--amend', '-m', message] : ['commit', '-m', message];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* @returns argv for `git`. `--prune` so a branch deleted on the remote stops
|
|
89
|
+
* being counted as something to pull.
|
|
90
|
+
*/
|
|
91
|
+
export function fetchArgv() {
|
|
92
|
+
return ['fetch', '--prune'];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* @param mode - how to integrate the upstream's commits.
|
|
96
|
+
* @returns argv for `git`. The mode is always explicit, never the user's
|
|
97
|
+
* `pull.rebase` config: the button says what it will do.
|
|
98
|
+
*/
|
|
99
|
+
export function pullArgv(mode) {
|
|
100
|
+
if (mode === 'rebase')
|
|
101
|
+
return ['pull', '--rebase'];
|
|
102
|
+
if (mode === 'merge')
|
|
103
|
+
return ['pull', '--no-rebase'];
|
|
104
|
+
return ['pull', '--ff-only'];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* @param branch - the current branch, needed only on its first push.
|
|
108
|
+
* @param hasUpstream - whether the branch already tracks a remote branch.
|
|
109
|
+
* @returns argv for `git`. With an upstream, bare `push` respects the user's
|
|
110
|
+
* own remote and refspec configuration; without one, the first push
|
|
111
|
+
* establishes `origin/<branch>`.
|
|
112
|
+
*/
|
|
113
|
+
export function pushArgv(branch, hasUpstream) {
|
|
114
|
+
if (hasUpstream)
|
|
115
|
+
return ['push'];
|
|
116
|
+
if (!isSafePathArg(branch))
|
|
117
|
+
throw new Error(`unsafe branch name: ${JSON.stringify(branch)}`);
|
|
118
|
+
return ['push', '--set-upstream', 'origin', branch];
|
|
119
|
+
}
|
|
120
|
+
const NO_TRACKING = { branch: '', upstream: null, ahead: 0, behind: 0, detached: false };
|
|
121
|
+
/**
|
|
122
|
+
* Read the `##` header of porcelain status output.
|
|
123
|
+
*
|
|
124
|
+
* The distinction that matters is "no upstream" versus "an upstream we are level
|
|
125
|
+
* with": the first means push must pass `--set-upstream`, and both otherwise
|
|
126
|
+
* look like zero ahead and zero behind.
|
|
127
|
+
* @param stdout - full `git status --porcelain=v1 --branch` output.
|
|
128
|
+
*/
|
|
129
|
+
export function parseTracking(stdout) {
|
|
130
|
+
const header = stdout.split('\n').find(line => line.startsWith('## '));
|
|
131
|
+
if (header === undefined)
|
|
132
|
+
return NO_TRACKING;
|
|
133
|
+
const body = header.slice(3);
|
|
134
|
+
if (body.startsWith('HEAD (no branch)'))
|
|
135
|
+
return { ...NO_TRACKING, detached: true };
|
|
136
|
+
// An unborn branch (fresh `git init`) reports "No commits yet on main" —
|
|
137
|
+
// with the same optional upstream and bracket suffixes as a born header.
|
|
138
|
+
// The sync bar wants the branch's NAME, not the English sentence around it.
|
|
139
|
+
const UNBORN_PREFIX = 'No commits yet on ';
|
|
140
|
+
const born = body.startsWith(UNBORN_PREFIX) ? body.slice(UNBORN_PREFIX.length) : body;
|
|
141
|
+
// Divergence rides in a trailing bracket; strip it before splitting the refs.
|
|
142
|
+
const bracket = born.indexOf(' [');
|
|
143
|
+
const refs = bracket === -1 ? born : born.slice(0, bracket);
|
|
144
|
+
const counts = bracket === -1 ? '' : born.slice(bracket);
|
|
145
|
+
// `...` is the separator. Split on the LAST occurrence, not the first: a
|
|
146
|
+
// branch may contain dots, and only the separator is three of them.
|
|
147
|
+
const at = refs.lastIndexOf('...');
|
|
148
|
+
const branch = at === -1 ? refs : refs.slice(0, at);
|
|
149
|
+
const upstream = at === -1 ? null : refs.slice(at + 3);
|
|
150
|
+
const ahead = /ahead (\d+)/.exec(counts);
|
|
151
|
+
const behind = /behind (\d+)/.exec(counts);
|
|
152
|
+
return {
|
|
153
|
+
branch,
|
|
154
|
+
upstream: upstream !== null && upstream.length > 0 ? upstream : null,
|
|
155
|
+
ahead: ahead ? Number.parseInt(ahead[1], 10) : 0,
|
|
156
|
+
behind: behind ? Number.parseInt(behind[1], 10) : 0,
|
|
157
|
+
detached: false,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Porcelain XY pairs that mean "unmerged", per git-status(1). */
|
|
161
|
+
const CONFLICT_XY = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
162
|
+
/**
|
|
163
|
+
* Split a porcelain status pair into index and worktree state.
|
|
164
|
+
*
|
|
165
|
+
* A conflicted file reports content in the index (`UU`), so reading the X
|
|
166
|
+
* column alone calls it staged — and the drawer would then offer to commit a
|
|
167
|
+
* file with conflict markers still in it. Conflicts are reported as unstaged
|
|
168
|
+
* work, which is what they are until somebody resolves them.
|
|
169
|
+
* @param xy - the two status columns, e.g. ` M`, `MM`, `??`.
|
|
170
|
+
*/
|
|
171
|
+
export function stageStateOf(xy) {
|
|
172
|
+
if (xy === '??' || xy === '!!')
|
|
173
|
+
return { staged: false, unstaged: true };
|
|
174
|
+
if (CONFLICT_XY.has(xy))
|
|
175
|
+
return { staged: false, unstaged: true };
|
|
176
|
+
const index = xy[0] ?? ' ';
|
|
177
|
+
const worktree = xy[1] ?? ' ';
|
|
178
|
+
return { staged: index !== ' ', unstaged: worktree !== ' ' };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Turn git's exit into a reason the UI can act on.
|
|
182
|
+
*
|
|
183
|
+
* Matching on message text is fragile in general, but the alternative is
|
|
184
|
+
* showing raw stderr and letting the user work out that "Updates were rejected"
|
|
185
|
+
* means "fetch first". Anything unrecognised becomes `unknown`, and the caller
|
|
186
|
+
* still carries the real stderr alongside — the classification adds a hint, it
|
|
187
|
+
* never replaces the evidence.
|
|
188
|
+
* @param exitCode - git's exit status.
|
|
189
|
+
* @param stderr - captured stderr.
|
|
190
|
+
* @param stdout - captured stdout; `nothing to commit` arrives here, not stderr.
|
|
191
|
+
* @returns null when the command succeeded.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyFailure(exitCode, stderr, stdout) {
|
|
194
|
+
if (exitCode === 0)
|
|
195
|
+
return null;
|
|
196
|
+
const text = `${stderr}\n${stdout}`.toLowerCase();
|
|
197
|
+
if (text.includes('nothing to commit')
|
|
198
|
+
|| text.includes('no changes added to commit')
|
|
199
|
+
|| text.includes('nothing added to commit'))
|
|
200
|
+
return 'nothing-to-commit';
|
|
201
|
+
if (text.includes('authentication failed')
|
|
202
|
+
|| text.includes('could not read username')
|
|
203
|
+
|| text.includes('could not read password')
|
|
204
|
+
|| text.includes('permission denied (publickey)')
|
|
205
|
+
|| text.includes('terminal prompts disabled'))
|
|
206
|
+
return 'auth';
|
|
207
|
+
// A network failure is worth its own class: "offline" and "bad remote URL"
|
|
208
|
+
// are fixable in different places, and neither is git's fault (TESTS.md D5).
|
|
209
|
+
if (text.includes('could not resolve host')
|
|
210
|
+
|| text.includes('network is unreachable')
|
|
211
|
+
|| text.includes('failed to connect')
|
|
212
|
+
|| text.includes('connection timed out'))
|
|
213
|
+
return 'network';
|
|
214
|
+
if (text.includes('no upstream configured')
|
|
215
|
+
|| text.includes('has no upstream branch'))
|
|
216
|
+
return 'no-upstream';
|
|
217
|
+
if (text.includes('conflict (')
|
|
218
|
+
|| text.includes('merge conflict')
|
|
219
|
+
|| text.includes('fix conflicts'))
|
|
220
|
+
return 'conflict';
|
|
221
|
+
if (text.includes('[rejected]')
|
|
222
|
+
|| text.includes('updates were rejected')
|
|
223
|
+
|| text.includes('not possible to fast-forward')
|
|
224
|
+
|| text.includes('need to specify how to reconcile divergent branches'))
|
|
225
|
+
return 'diverged';
|
|
226
|
+
if (text.includes('would be overwritten')
|
|
227
|
+
|| text.includes('local changes'))
|
|
228
|
+
return 'dirty';
|
|
229
|
+
return 'unknown';
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read `git diff --numstat` output into a path-keyed map.
|
|
233
|
+
*
|
|
234
|
+
* A `-` in either count column means git could not (or would not — a `-diff`
|
|
235
|
+
* gitattributes marker does it too) count the file: binary. Rename entries
|
|
236
|
+
* print `old => new` in the path column; the NEW path is what the porcelain
|
|
237
|
+
* status list also keys on, so that is the key kept here.
|
|
238
|
+
* @param stdout - full `--numstat` output.
|
|
239
|
+
*/
|
|
240
|
+
export function parseNumstat(stdout) {
|
|
241
|
+
const out = new Map();
|
|
242
|
+
for (const line of stdout.split('\n')) {
|
|
243
|
+
if (line.length === 0)
|
|
244
|
+
continue;
|
|
245
|
+
const parts = line.split('\t');
|
|
246
|
+
if (parts.length < 3)
|
|
247
|
+
continue;
|
|
248
|
+
const binary = parts[0] === '-' || parts[1] === '-';
|
|
249
|
+
const added = parts[0] === '-' ? 0 : Number.parseInt(parts[0], 10);
|
|
250
|
+
const deleted = parts[1] === '-' ? 0 : Number.parseInt(parts[1], 10);
|
|
251
|
+
const path = stripRenameTarget(parts.slice(2).join('\t'));
|
|
252
|
+
if (path.length > 0)
|
|
253
|
+
out.set(path, { added: Number.isFinite(added) ? added : 0, deleted: Number.isFinite(deleted) ? deleted : 0, binary });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
/** Parse porcelain lines into a MUTABLE file list — untracked entries get their
|
|
258
|
+
* counts filled in by the synthesis pass afterwards. */
|
|
259
|
+
export function parseStatus(stdout, numstat) {
|
|
260
|
+
const files = [];
|
|
261
|
+
for (const line of stdout.split('\n')) {
|
|
262
|
+
if (line.length === 0 || line.startsWith('##'))
|
|
263
|
+
continue;
|
|
264
|
+
if (line.length < 3)
|
|
265
|
+
continue;
|
|
266
|
+
const xy = line.slice(0, 2);
|
|
267
|
+
const { path, previousPath, renamed } = parsePath(line.slice(3));
|
|
268
|
+
if (path.length === 0)
|
|
269
|
+
continue;
|
|
270
|
+
const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false };
|
|
271
|
+
const { staged, unstaged } = stageStateOf(xy);
|
|
272
|
+
const base = {
|
|
273
|
+
path, status: statusFromXY(xy, renamed),
|
|
274
|
+
addedLines: counts.added, deletedLines: counts.deleted, binary: counts.binary,
|
|
275
|
+
staged, unstaged,
|
|
276
|
+
};
|
|
277
|
+
files.push(renamed && previousPath.length > 0 ? { ...base, previousPath } : base);
|
|
278
|
+
}
|
|
279
|
+
return files;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parse `git show --name-status --no-renames` into the mutable file list, taking
|
|
283
|
+
* line counts from the matching `--numstat` entry.
|
|
284
|
+
* @param stdout - name-status output (`<status>\t<path>` per line).
|
|
285
|
+
* @param numstat - per-path counts from {@link parseNumstat}.
|
|
286
|
+
* @returns one entry per file the commit touched.
|
|
287
|
+
*/
|
|
288
|
+
export function parseNameStatus(stdout, numstat) {
|
|
289
|
+
const files = [];
|
|
290
|
+
for (const line of stdout.split('\n')) {
|
|
291
|
+
if (line.length === 0)
|
|
292
|
+
continue;
|
|
293
|
+
const tab = line.indexOf('\t');
|
|
294
|
+
if (tab < 0)
|
|
295
|
+
continue;
|
|
296
|
+
const code = line.slice(0, tab);
|
|
297
|
+
const path = line.slice(tab + 1).trim();
|
|
298
|
+
if (path.length === 0)
|
|
299
|
+
continue;
|
|
300
|
+
const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false };
|
|
301
|
+
files.push({
|
|
302
|
+
path,
|
|
303
|
+
status: code.startsWith('A') ? 'added' : code.startsWith('D') ? 'deleted' : 'modified',
|
|
304
|
+
addedLines: counts.added,
|
|
305
|
+
deletedLines: counts.deleted,
|
|
306
|
+
binary: counts.binary,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return files;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Split a porcelain path field into the file's path and, for a rename, the
|
|
313
|
+
* path it moved from.
|
|
314
|
+
*
|
|
315
|
+
* With `core.quotepath=false` (this plugin sets it on every git call) CJK and
|
|
316
|
+
* accented paths arrive as raw UTF-8, unquoted. Git still quotes a path that
|
|
317
|
+
* contains control characters or a quote, using C escapes — which is exactly
|
|
318
|
+
* JSON's escape alphabet, so `JSON.parse` un-escapes it. Octal escapes from
|
|
319
|
+
* the default quotepath mode are NOT JSON and stay raw; that is why the
|
|
320
|
+
* config, not smarter unescaping, is the fix.
|
|
321
|
+
* @param rest - the porcelain line past the two status columns.
|
|
322
|
+
*/
|
|
323
|
+
function parsePath(rest) {
|
|
324
|
+
let value = rest;
|
|
325
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
326
|
+
value = value.slice(1, -1);
|
|
327
|
+
try {
|
|
328
|
+
value = JSON.parse(`"${value}"`);
|
|
329
|
+
}
|
|
330
|
+
catch { /* keep raw */ }
|
|
331
|
+
}
|
|
332
|
+
const arrow = value.indexOf(' -> ');
|
|
333
|
+
if (arrow >= 0)
|
|
334
|
+
return { path: value.slice(arrow + 4), previousPath: value.slice(0, arrow), renamed: true };
|
|
335
|
+
return { path: value, previousPath: '', renamed: false };
|
|
336
|
+
}
|
|
337
|
+
function stripRenameTarget(path) {
|
|
338
|
+
const arrow = path.indexOf(' -> ');
|
|
339
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
340
|
+
}
|
|
341
|
+
function statusFromXY(xy, renamed) {
|
|
342
|
+
if (xy === '??')
|
|
343
|
+
return 'untracked';
|
|
344
|
+
if (renamed || xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C')
|
|
345
|
+
return 'renamed';
|
|
346
|
+
if (xy[0] === 'A' || xy[1] === 'A')
|
|
347
|
+
return 'added';
|
|
348
|
+
if (xy[0] === 'D' || xy[1] === 'D')
|
|
349
|
+
return 'deleted';
|
|
350
|
+
return 'modified';
|
|
351
|
+
}
|
|
352
|
+
/** ASCII line feed. Safe to count in raw UTF-8 bytes: no multi-byte sequence
|
|
353
|
+
* can contain it, so a byte scan and a decoded scan agree exactly. */
|
|
354
|
+
const NEWLINE = 0x0a;
|
|
355
|
+
/**
|
|
356
|
+
* Count lines in a UTF-8 buffer without decoding it.
|
|
357
|
+
* @param bytes - file contents.
|
|
358
|
+
* @returns the line count, counting a final unterminated line.
|
|
359
|
+
*/
|
|
360
|
+
export function countBufferLines(bytes) {
|
|
361
|
+
if (bytes.length === 0)
|
|
362
|
+
return 0;
|
|
363
|
+
let lines = 0;
|
|
364
|
+
for (let at = bytes.indexOf(NEWLINE); at !== -1; at = bytes.indexOf(NEWLINE, at + 1))
|
|
365
|
+
lines += 1;
|
|
366
|
+
return bytes[bytes.length - 1] === NEWLINE ? lines : lines + 1;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Whether a raw file buffer smells binary: a NUL byte inside the sniff window.
|
|
370
|
+
*
|
|
371
|
+
* UTF-16 text is the trap this exists for — it is text, but every other byte
|
|
372
|
+
* is NUL, so an 8 KB prefix catches it without reading a 200 MB blob. A NUL
|
|
373
|
+
* PAST the window does not decide anything: a text file may legitimately
|
|
374
|
+
* contain one deep in its body (TESTS.md A9).
|
|
375
|
+
* @param bytes - the file's contents, however much of them is cheap to read.
|
|
376
|
+
* @param windowBytes - how many leading bytes may decide.
|
|
377
|
+
*/
|
|
378
|
+
export function isBinaryPrefix(bytes, windowBytes) {
|
|
379
|
+
return bytes.subarray(0, windowBytes).includes(0);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Clip a diff to a character cap, and SAY so when the clip happened — a
|
|
383
|
+
* silently shortened diff reads as a complete one (TESTS.md H1).
|
|
384
|
+
* @param text - the diff.
|
|
385
|
+
* @param cap - most characters to keep.
|
|
386
|
+
* @param marker - the truncation note appended when clipping.
|
|
387
|
+
*/
|
|
388
|
+
export function clipDiff(text, cap, marker) {
|
|
389
|
+
return text.length > cap ? `${text.slice(0, cap)}\n${marker}` : text;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Cap the branch list the picker shows, and REPORT the cut: `branchesTruncated`
|
|
393
|
+
* is what lets the picker say "showing the first 500" instead of quietly
|
|
394
|
+
* looking like the repository only has 500 branches (TESTS.md F5).
|
|
395
|
+
* @param names - branch names, newest-commit-first.
|
|
396
|
+
* @param cap - how many to send.
|
|
397
|
+
*/
|
|
398
|
+
export function capBranches(names, cap) {
|
|
399
|
+
return { branches: names.slice(0, cap), branchesTruncated: names.length > cap };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Whether stderr is the "no merge base" refusal `git diff A...B` gives for
|
|
403
|
+
* histories with no common ancestor — the cue to retry the comparison as a
|
|
404
|
+
* plain two-tip diff instead of failing outright (TESTS.md C3).
|
|
405
|
+
* @param stderr - stderr of the failed three-dot diff.
|
|
406
|
+
*/
|
|
407
|
+
export function isNoMergeBaseError(stderr) {
|
|
408
|
+
return stderr.includes('no merge base');
|
|
409
|
+
}
|