claude-code-runrate 0.3.0 → 0.4.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/README.md +86 -10
- package/bin/ccr.js +223 -26
- package/package.json +4 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +13 -2
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/migrate.js +155 -0
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +49 -1
- package/src/render/statusline.js +42 -4
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +145 -20
- package/src/state-dir.js +44 -1
package/src/doctor.js
CHANGED
|
@@ -91,8 +91,12 @@ function run(opts = {}) {
|
|
|
91
91
|
out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// newest captured snapshot across
|
|
95
|
-
//
|
|
94
|
+
// newest captured snapshot across the container. Instances live TWO levels
|
|
95
|
+
// down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) — the
|
|
96
|
+
// one-level scan alone would report "no status captured" while instances run
|
|
97
|
+
// fine (features/instance-lifecycle.feature: "doctor finds a live instance's
|
|
98
|
+
// captured status"). The root and one-level entries are still scanned so a
|
|
99
|
+
// pre-migration home keeps diagnosing.
|
|
96
100
|
const ccrDir = path.join(homedir, '.ccr');
|
|
97
101
|
const dirs = [ccrDir];
|
|
98
102
|
try {
|
|
@@ -101,6 +105,13 @@ function run(opts = {}) {
|
|
|
101
105
|
try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
|
|
102
106
|
}
|
|
103
107
|
} catch { /* none */ }
|
|
108
|
+
try {
|
|
109
|
+
const inst = path.join(ccrDir, 'instances');
|
|
110
|
+
for (const d of fs.readdirSync(inst)) {
|
|
111
|
+
const sub = path.join(inst, d);
|
|
112
|
+
try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
} catch { /* none */ }
|
|
104
115
|
let newest = null;
|
|
105
116
|
for (const d of dirs) {
|
|
106
117
|
try { const m = fs.statSync(path.join(d, 'last-status.json')).mtimeMs; if (!newest || m > newest.m) newest = { d, m }; } catch { /* none */ }
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/git-history.js — recent commits with their branch structure, the model
|
|
4
|
+
// behind the pane's graph section (features/git-commit-graph.feature).
|
|
5
|
+
//
|
|
6
|
+
// THE WALK STARTS AT EVERY LOCAL BRANCH, not just HEAD. The visionary chose a
|
|
7
|
+
// multi-lane graph to match IDE git-graph habits, and those graphs answer
|
|
8
|
+
// "what lines of work exist here", which a HEAD-ancestry walk cannot — a
|
|
9
|
+
// repository with three topic branches would draw one lane and call itself
|
|
10
|
+
// done. Tips are ordered newest-first; when there are more tips than lanes
|
|
11
|
+
// (laneBudget in the renderer), the newest keep their lanes and the rest are
|
|
12
|
+
// COUNTED, never silently dropped — the lane-overflow scenario exists because
|
|
13
|
+
// the visionary was warned a graph that dropped branches would be worse than
|
|
14
|
+
// the flat list it replaced.
|
|
15
|
+
//
|
|
16
|
+
// LANE ASSIGNMENT is the classic newest-first sweep: each lane holds the
|
|
17
|
+
// commit id it expects next; a commit takes the leftmost lane expecting it,
|
|
18
|
+
// closes every other lane that expected it (a fork, seen from below), and
|
|
19
|
+
// hands its first parent that lane — extra parents open lanes beside it (the
|
|
20
|
+
// merge's second line). This is a simplification of git log --graph's painter
|
|
21
|
+
// and its contract is exactly what the scenarios pin: lane COUNT, join at the
|
|
22
|
+
// merge, newest first.
|
|
23
|
+
//
|
|
24
|
+
// Commit metadata is display data from an untrusted repository: subjects are
|
|
25
|
+
// stripped at THIS boundary (the choke-point rule of src/git-repo.js), and
|
|
26
|
+
// every read is bounded — the walk has a hard commit cap, and object reads
|
|
27
|
+
// inherit src/git-objects.js's own caps.
|
|
28
|
+
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const { readObject, resolveHead, resolveRef } = require('./git-objects');
|
|
32
|
+
const { readTextCapped } = require('./safe-read');
|
|
33
|
+
const { stripControl } = require('./sanitize');
|
|
34
|
+
|
|
35
|
+
// More commits than a sidebar can show, fewer than a pathological repository
|
|
36
|
+
// could make us read. The renderer slices further by its row budget.
|
|
37
|
+
const MAX_COMMITS = 64;
|
|
38
|
+
|
|
39
|
+
// Branch tips considered, before the renderer's lane budget cuts further.
|
|
40
|
+
const MAX_TIPS = 128;
|
|
41
|
+
|
|
42
|
+
// A subject longer than this cannot survive any pane layout; cap at the read
|
|
43
|
+
// boundary so layout budgets around a value, not a megabyte.
|
|
44
|
+
const SUBJECT_MAX = 200;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} CommitRow
|
|
48
|
+
* @property {string} oid
|
|
49
|
+
* @property {string} shortHash 7 hex chars, git's default abbreviation floor.
|
|
50
|
+
* @property {string} subject First message line, control-stripped, capped.
|
|
51
|
+
* @property {number} when Committer time, seconds.
|
|
52
|
+
* @property {number} lane 0-based lane of this commit's node.
|
|
53
|
+
* @property {boolean[]} activeMask Which lanes are live on this row (the
|
|
54
|
+
* renderer's `│` columns).
|
|
55
|
+
* @property {number[]} joinLanes Lanes this merge's extra parents run in —
|
|
56
|
+
* the renderer's join glyph, the merge scenario's evidence.
|
|
57
|
+
* @property {boolean} closes Another lane also expected this commit (a
|
|
58
|
+
* fork seen from below) and was folded into this one.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} History
|
|
63
|
+
* @property {'ok'|'empty'|'unavailable'} state 'empty' = no commits anywhere.
|
|
64
|
+
* @property {CommitRow[]} rows Newest first.
|
|
65
|
+
* @property {number} laneCount Widest simultaneous lane use.
|
|
66
|
+
* @property {number} droppedBranches Tips beyond the lane budget, counted.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse the header of a commit object: tree, parents, committer time, subject.
|
|
71
|
+
* @param {Buffer} data
|
|
72
|
+
* @returns {{ parents: string[], when: number, subject: string }|null}
|
|
73
|
+
*/
|
|
74
|
+
function parseCommit(data) {
|
|
75
|
+
const text = data.toString('utf8');
|
|
76
|
+
const headerEnd = text.indexOf('\n\n');
|
|
77
|
+
const header = headerEnd === -1 ? text : text.slice(0, headerEnd);
|
|
78
|
+
if (!/^tree [0-9a-f]{40}|^tree [0-9a-f]{64}/m.test(header)) return null;
|
|
79
|
+
const parents = [...header.matchAll(/^parent ([0-9a-f]{40}|[0-9a-f]{64})$/gm)].map((m) => m[1]);
|
|
80
|
+
const committer = /^committer [^\n]* (\d{1,12}) [+-]\d{4}$/m.exec(header);
|
|
81
|
+
const when = committer ? Number(committer[1]) : 0;
|
|
82
|
+
const body = headerEnd === -1 ? '' : text.slice(headerEnd + 2);
|
|
83
|
+
const firstLine = body.split('\n')[0] || '';
|
|
84
|
+
const clean = stripControl(firstLine).trim();
|
|
85
|
+
const cps = [...clean];
|
|
86
|
+
const subject = cps.length <= SUBJECT_MAX ? clean : cps.slice(0, SUBJECT_MAX - 1).join('') + '…';
|
|
87
|
+
return { parents, when, subject };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every local branch tip: loose refs under refs/heads plus packed-refs
|
|
92
|
+
* entries, deduplicated, HEAD's target included even when detached.
|
|
93
|
+
* @param {string} gitDir
|
|
94
|
+
* @returns {string[]} Tip oids, unordered.
|
|
95
|
+
*/
|
|
96
|
+
function branchTips(gitDir) {
|
|
97
|
+
/** @type {Set<string>} */
|
|
98
|
+
const tips = new Set();
|
|
99
|
+
const headsDir = path.join(gitDir, 'refs', 'heads');
|
|
100
|
+
/** @type {Array<{ dir: string, ref: string }>} */
|
|
101
|
+
const stack = [{ dir: headsDir, ref: 'refs/heads' }];
|
|
102
|
+
let visited = 0;
|
|
103
|
+
while (stack.length > 0 && tips.size < MAX_TIPS) {
|
|
104
|
+
const top = /** @type {{ dir: string, ref: string }} */ (stack.pop());
|
|
105
|
+
/** @type {fs.Dirent[]} */
|
|
106
|
+
let dirents = [];
|
|
107
|
+
try { dirents = fs.readdirSync(top.dir, { withFileTypes: true }); } catch { continue; }
|
|
108
|
+
for (const d of dirents) {
|
|
109
|
+
visited += 1;
|
|
110
|
+
if (visited > MAX_TIPS * 4) break;
|
|
111
|
+
if (d.isDirectory()) stack.push({ dir: path.join(top.dir, d.name), ref: top.ref + '/' + d.name });
|
|
112
|
+
else if (d.isFile()) {
|
|
113
|
+
const oid = resolveRef(gitDir, top.ref + '/' + d.name);
|
|
114
|
+
if (oid) tips.add(oid);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const packed = readTextCapped(path.join(gitDir, 'packed-refs'), 4 * 1024 * 1024);
|
|
119
|
+
if (packed) {
|
|
120
|
+
for (const line of packed.split('\n')) {
|
|
121
|
+
if (tips.size >= MAX_TIPS) break;
|
|
122
|
+
const m = /^([0-9a-f]{40}|[0-9a-f]{64}) refs\/heads\/\S+$/.exec(line.trim());
|
|
123
|
+
// A loose ref shadows its packed entry; resolveRef already prefers it,
|
|
124
|
+
// and adding the packed oid too would resurrect a stale tip. Only tips
|
|
125
|
+
// whose ref has no loose file get taken from here — approximated by the
|
|
126
|
+
// Set: a shadowed packed oid that differs would add a phantom tip, so
|
|
127
|
+
// resolve the ref properly instead.
|
|
128
|
+
if (m) {
|
|
129
|
+
const ref = line.trim().slice(line.trim().indexOf(' ') + 1);
|
|
130
|
+
const oid = resolveRef(gitDir, ref);
|
|
131
|
+
if (oid) tips.add(oid);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const head = resolveHead(gitDir);
|
|
136
|
+
if (head) tips.add(head);
|
|
137
|
+
return [...tips];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read recent history: tips, walk, lanes.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} gitDir
|
|
144
|
+
* @param {{ maxLanes?: number, maxRows?: number }} [opts]
|
|
145
|
+
* @returns {History}
|
|
146
|
+
*/
|
|
147
|
+
function readHistory(gitDir, opts = {}) {
|
|
148
|
+
const maxLanes = opts.maxLanes && opts.maxLanes > 0 ? opts.maxLanes : 6;
|
|
149
|
+
const maxRows = opts.maxRows && opts.maxRows > 0 ? Math.min(opts.maxRows, MAX_COMMITS) : MAX_COMMITS;
|
|
150
|
+
|
|
151
|
+
const tips = branchTips(gitDir);
|
|
152
|
+
if (tips.length === 0) {
|
|
153
|
+
// No refs anywhere. An unborn HEAD (fresh init) is the EMPTY state the
|
|
154
|
+
// no-commits scenario names; an unreadable HEAD is not.
|
|
155
|
+
const raw = readTextCapped(path.join(gitDir, 'HEAD'), 4096);
|
|
156
|
+
const unborn = raw !== null && /^ref:[ \t]*refs\//.test(raw.split('\n')[0].trim());
|
|
157
|
+
return { state: unborn ? 'empty' : 'unavailable', rows: [], laneCount: 0, droppedBranches: 0 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Load every tip commit; tips that no longer resolve to a commit degrade the
|
|
161
|
+
// whole section (never guess at history).
|
|
162
|
+
/** @type {Map<string, { parents: string[], when: number, subject: string }>} */
|
|
163
|
+
const loaded = new Map();
|
|
164
|
+
const load = (/** @type {string} */ oid) => {
|
|
165
|
+
if (loaded.has(oid)) return loaded.get(oid) || null;
|
|
166
|
+
const obj = readObject(gitDir, oid);
|
|
167
|
+
if (obj === null || obj.type !== 'commit') return null;
|
|
168
|
+
const parsed = parseCommit(obj.data);
|
|
169
|
+
if (parsed === null) return null;
|
|
170
|
+
loaded.set(oid, parsed);
|
|
171
|
+
return parsed;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** @type {Array<{ oid: string, when: number }>} */
|
|
175
|
+
const tipList = [];
|
|
176
|
+
for (const oid of tips) {
|
|
177
|
+
const c = load(oid);
|
|
178
|
+
if (c === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches: 0 };
|
|
179
|
+
tipList.push({ oid, when: c.when });
|
|
180
|
+
}
|
|
181
|
+
tipList.sort((a, b) => b.when - a.when || (a.oid < b.oid ? -1 : 1));
|
|
182
|
+
const taken = tipList.slice(0, maxLanes);
|
|
183
|
+
// Tips sharing history with a taken tip still count as their own branch —
|
|
184
|
+
// the scenario counts BRANCHES, and each tip is one.
|
|
185
|
+
const droppedBranches = tipList.length - taken.length;
|
|
186
|
+
|
|
187
|
+
// Date-ordered walk from the taken tips: a max-heap by committer time,
|
|
188
|
+
// approximated with a sorted array (sizes here are tens, not thousands).
|
|
189
|
+
/** @type {Array<{ oid: string, when: number }>} */
|
|
190
|
+
const frontier = [...taken];
|
|
191
|
+
/** @type {Set<string>} */
|
|
192
|
+
const emitted = new Set();
|
|
193
|
+
/** Lanes: the commit id each lane expects next (null = closed). */
|
|
194
|
+
/** @type {Array<string|null>} */
|
|
195
|
+
const lanes = [];
|
|
196
|
+
/** @type {CommitRow[]} */
|
|
197
|
+
const rows = [];
|
|
198
|
+
let laneCount = 0;
|
|
199
|
+
|
|
200
|
+
while (frontier.length > 0 && rows.length < maxRows) {
|
|
201
|
+
frontier.sort((a, b) => b.when - a.when || (a.oid < b.oid ? -1 : 1));
|
|
202
|
+
const next = /** @type {{ oid: string, when: number }} */ (frontier.shift());
|
|
203
|
+
if (emitted.has(next.oid)) continue;
|
|
204
|
+
const c = load(next.oid);
|
|
205
|
+
if (c === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches };
|
|
206
|
+
emitted.add(next.oid);
|
|
207
|
+
|
|
208
|
+
// The leftmost lane expecting this commit; none → a new tip opens a lane.
|
|
209
|
+
let lane = lanes.findIndex((l) => l === next.oid);
|
|
210
|
+
let closes = false;
|
|
211
|
+
if (lane === -1) {
|
|
212
|
+
lane = lanes.findIndex((l) => l === null);
|
|
213
|
+
if (lane === -1) { lanes.push(null); lane = lanes.length - 1; }
|
|
214
|
+
} else {
|
|
215
|
+
// Every OTHER lane expecting it folds into this one — a fork, viewed
|
|
216
|
+
// from below.
|
|
217
|
+
for (let i = 0; i < lanes.length; i += 1) {
|
|
218
|
+
if (i !== lane && lanes[i] === next.oid) { lanes[i] = null; closes = true; }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const first = c.parents[0] || null;
|
|
222
|
+
lanes[lane] = first && !emitted.has(first) ? first : null;
|
|
223
|
+
/** @type {number[]} */
|
|
224
|
+
const joinLanes = [];
|
|
225
|
+
for (const p of c.parents.slice(1)) {
|
|
226
|
+
if (emitted.has(p)) continue;
|
|
227
|
+
const existing = lanes.indexOf(p);
|
|
228
|
+
if (existing !== -1) {
|
|
229
|
+
// The merge's other line already runs in a lane; the join points there.
|
|
230
|
+
joinLanes.push(existing);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
// Open the nearest free lane for it.
|
|
234
|
+
let free = lanes.findIndex((l) => l === null);
|
|
235
|
+
if (free === -1) {
|
|
236
|
+
if (lanes.length >= maxLanes) continue;
|
|
237
|
+
lanes.push(p);
|
|
238
|
+
free = lanes.length - 1;
|
|
239
|
+
} else {
|
|
240
|
+
lanes[free] = p;
|
|
241
|
+
}
|
|
242
|
+
joinLanes.push(free);
|
|
243
|
+
}
|
|
244
|
+
while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop();
|
|
245
|
+
const activeMask = lanes.map((l) => l !== null);
|
|
246
|
+
while (activeMask.length < lane + 1) activeMask.push(false);
|
|
247
|
+
activeMask[lane] = true; // the node's own column is always drawn
|
|
248
|
+
const active = activeMask.filter(Boolean).length;
|
|
249
|
+
laneCount = Math.max(laneCount, Math.max(active, lane + 1));
|
|
250
|
+
|
|
251
|
+
rows.push({
|
|
252
|
+
oid: next.oid,
|
|
253
|
+
shortHash: next.oid.slice(0, 7),
|
|
254
|
+
subject: c.subject,
|
|
255
|
+
when: c.when,
|
|
256
|
+
lane,
|
|
257
|
+
activeMask,
|
|
258
|
+
joinLanes,
|
|
259
|
+
closes,
|
|
260
|
+
});
|
|
261
|
+
for (const p of c.parents) {
|
|
262
|
+
if (!emitted.has(p)) {
|
|
263
|
+
const pc = load(p);
|
|
264
|
+
if (pc === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches };
|
|
265
|
+
frontier.push({ oid: p, when: pc.when });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { state: 'ok', rows, laneCount: Math.min(laneCount, maxLanes), droppedBranches };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { readHistory, branchTips, parseCommit, MAX_COMMITS };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/git-ignore.js — enough of gitignore matching for the untracked walk.
|
|
4
|
+
//
|
|
5
|
+
// The untracked list is the one part of the working-tree section that reads
|
|
6
|
+
// the WORLD rather than `.git`, and without ignore rules it would lead with
|
|
7
|
+
// node_modules — a thousand-line lie of omission about what the user actually
|
|
8
|
+
// created. So the walk honors the two per-repo sources: `.git/info/exclude`
|
|
9
|
+
// and every `.gitignore` on the path down.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IS DELIBERATELY OUT: the user's global excludesFile (a config lookup
|
|
12
|
+
// away, but its patterns describe the USER's machine, and the far-side oracle
|
|
13
|
+
// pins this reader against `git status` run with that config disabled), and
|
|
14
|
+
// the escape subtleties (`\#`, trailing backslash-space). Both are recorded in
|
|
15
|
+
// features/design/git-untracked-walk.feature rather than silently absent.
|
|
16
|
+
//
|
|
17
|
+
// Precedence is git's: within one file the LAST matching pattern wins; a
|
|
18
|
+
// deeper .gitignore beats a shallower one; exclude is the weakest. A directory
|
|
19
|
+
// that is ignored is never descended into, which also reproduces git's "cannot
|
|
20
|
+
// re-include below an excluded directory" rule for free.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} IgnoreRule
|
|
24
|
+
* @property {boolean} neg `!pattern` — re-includes.
|
|
25
|
+
* @property {boolean} dirOnly Trailing slash — matches directories only.
|
|
26
|
+
* @property {RegExp} re Compiled against the path RELATIVE TO the rule's base.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compile one gitignore pattern line, or null for blanks and comments.
|
|
31
|
+
* @param {string} line
|
|
32
|
+
* @returns {IgnoreRule|null}
|
|
33
|
+
*/
|
|
34
|
+
function compilePattern(line) {
|
|
35
|
+
let p = line.replace(/\r$/, '');
|
|
36
|
+
if (!p || p.startsWith('#')) return null;
|
|
37
|
+
let neg = false;
|
|
38
|
+
if (p.startsWith('!')) { neg = true; p = p.slice(1); }
|
|
39
|
+
p = p.replace(/(?<!\\)\s+$/, ''); // unescaped trailing spaces are trimmed
|
|
40
|
+
if (!p) return null;
|
|
41
|
+
let dirOnly = false;
|
|
42
|
+
if (p.endsWith('/')) { dirOnly = true; p = p.slice(0, -1); }
|
|
43
|
+
// A slash anywhere (now that a trailing one is gone) anchors the pattern to
|
|
44
|
+
// the rule's own directory; without one it matches at any depth.
|
|
45
|
+
const anchored = p.includes('/');
|
|
46
|
+
if (p.startsWith('/')) p = p.slice(1);
|
|
47
|
+
|
|
48
|
+
let re = '';
|
|
49
|
+
for (let i = 0; i < p.length; i += 1) {
|
|
50
|
+
const c = p[i];
|
|
51
|
+
if (c === '*') {
|
|
52
|
+
if (p[i + 1] === '*') {
|
|
53
|
+
// `**` spans directories: leading `**/` any prefix, trailing `/**`
|
|
54
|
+
// everything below, `a**b` collapses to any run.
|
|
55
|
+
i += 1;
|
|
56
|
+
if (p[i + 1] === '/') { i += 1; re += '(?:[^/]+/)*'; } else re += '.*';
|
|
57
|
+
} else re += '[^/]*';
|
|
58
|
+
} else if (c === '?') {
|
|
59
|
+
re += '[^/]';
|
|
60
|
+
} else if (c === '[') {
|
|
61
|
+
const close = p.indexOf(']', i + 2);
|
|
62
|
+
if (close === -1) { re += '\\['; continue; }
|
|
63
|
+
let cls = p.slice(i + 1, close);
|
|
64
|
+
if (cls.startsWith('!')) cls = '^' + cls.slice(1);
|
|
65
|
+
re += '[' + cls.replace(/\\/g, '\\\\') + ']';
|
|
66
|
+
i = close;
|
|
67
|
+
} else if (c === '\\' && i + 1 < p.length) {
|
|
68
|
+
i += 1;
|
|
69
|
+
re += p[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
70
|
+
} else {
|
|
71
|
+
re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const body = anchored ? re : '(?:[^/]+/)*' + re;
|
|
75
|
+
let compiled;
|
|
76
|
+
try {
|
|
77
|
+
compiled = new RegExp('^' + body + '$');
|
|
78
|
+
} catch {
|
|
79
|
+
return null; // a pattern this reader cannot compile ignores nothing
|
|
80
|
+
}
|
|
81
|
+
return { neg, dirOnly, re: compiled };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a whole ignore file's text into rules, in order.
|
|
86
|
+
* @param {string|null} text
|
|
87
|
+
* @returns {IgnoreRule[]}
|
|
88
|
+
*/
|
|
89
|
+
function parseIgnore(text) {
|
|
90
|
+
if (!text) return [];
|
|
91
|
+
/** @type {IgnoreRule[]} */
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const line of text.split('\n')) {
|
|
94
|
+
const rule = compilePattern(line);
|
|
95
|
+
if (rule) out.push(rule);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Is `rel` (POSIX path relative to the rules' base) ignored by these rules?
|
|
102
|
+
* Returns the last matching rule's verdict, or null when nothing matched.
|
|
103
|
+
* @param {IgnoreRule[]} rules
|
|
104
|
+
* @param {string} rel
|
|
105
|
+
* @param {boolean} isDir
|
|
106
|
+
* @returns {boolean|null}
|
|
107
|
+
*/
|
|
108
|
+
function matchRules(rules, rel, isDir) {
|
|
109
|
+
/** @type {boolean|null} */
|
|
110
|
+
let verdict = null;
|
|
111
|
+
for (const r of rules) {
|
|
112
|
+
if (r.dirOnly && !isDir) continue;
|
|
113
|
+
if (r.re.test(rel)) verdict = !r.neg;
|
|
114
|
+
}
|
|
115
|
+
return verdict;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { compilePattern, parseIgnore, matchRules };
|
package/src/git-index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/git-index.js — parse `.git/index`, the staging area, without git.
|
|
4
|
+
//
|
|
5
|
+
// Second stop of the build fork src/git-repo.js rules in its header: the pane
|
|
6
|
+
// reads `.git` itself, so the working-tree section needs the index in git's own
|
|
7
|
+
// on-disk format. The contract this parser is held to lives in
|
|
8
|
+
// features/design/git-index-format.feature — the design tier, because an index
|
|
9
|
+
// byte layout is an implementation criterion no visionary should be asked to
|
|
10
|
+
// review.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IS PARSED, AND WHAT IS DELIBERATELY NOT. Versions 2, 3 and 4 — the three
|
|
13
|
+
// git writes today — including the stage bits that mark conflicts and the stat
|
|
14
|
+
// cache the modified-check needs. Extensions (TREE, REUC, link…) are skipped
|
|
15
|
+
// whole: every one is an optimization cache over the entries themselves, and a
|
|
16
|
+
// reader that consults none of them can never be lied to by a stale one.
|
|
17
|
+
//
|
|
18
|
+
// EVERY FAILURE IS null, NEVER A GUESS. A truncated entry, an impossible count,
|
|
19
|
+
// an unknown version, an over-cap file — the caller degrades to "git data
|
|
20
|
+
// unavailable" (features/git-pane-safety.feature), which is honest where a
|
|
21
|
+
// partial listing would be a quiet lie about what is staged.
|
|
22
|
+
//
|
|
23
|
+
// Hostile-input rules are src/safe-read.js's: the index is read through
|
|
24
|
+
// readBytesCapped (regular file only, size-capped, never blocks), and paths are
|
|
25
|
+
// NOT display-sanitized here — the model layer does that at its own boundary,
|
|
26
|
+
// because these paths are also compared against real directory listings and a
|
|
27
|
+
// stripped path would fail to match the file it names.
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { readBytesCapped, readTextCapped } = require('./safe-read');
|
|
32
|
+
|
|
33
|
+
// A generous roof, not a target: the linux kernel's index is ~10 MB. Past this
|
|
34
|
+
// the pane degrades rather than spending the draw loop's budget parsing.
|
|
35
|
+
const INDEX_MAX_BYTES = 32 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// Entries are ~70 bytes plus a path, so this cap can only trip on a file whose
|
|
38
|
+
// header count lies about its body — the exact corruption it exists to stop.
|
|
39
|
+
const MAX_ENTRIES = 200_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One index entry. `stage` is 0 for an ordinary staged path and 1..3 for the
|
|
43
|
+
* three sides of a conflict (base, ours, theirs).
|
|
44
|
+
*
|
|
45
|
+
* @typedef {object} IndexEntry
|
|
46
|
+
* @property {string} path Repo-relative, POSIX separators, as git stores it.
|
|
47
|
+
* @property {number} mode File mode (e.g. 0o100644, 0o120000 symlink).
|
|
48
|
+
* @property {number} stage 0 normal, 1-3 conflict stages.
|
|
49
|
+
* @property {string} oid Object id, lowercase hex (40 or 64 chars).
|
|
50
|
+
* @property {number} size Cached worktree size at add time.
|
|
51
|
+
* @property {number} mtimeSec Cached worktree mtime (seconds).
|
|
52
|
+
* @property {number} mtimeNsec Cached worktree mtime (nanoseconds part).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} GitIndex
|
|
57
|
+
* @property {IndexEntry[]} entries In file order (git keeps them path-sorted).
|
|
58
|
+
* @property {number} version
|
|
59
|
+
* @property {number} mtimeMs The index file's own mtime — the racy-clean
|
|
60
|
+
* comparison needs it (see src/git-working-tree.js).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Does this repository use sha-256 object names? The index does not declare its
|
|
65
|
+
* hash width; the repository does, in config. A config that cannot be read
|
|
66
|
+
* means the default format, which is what the fallback answers.
|
|
67
|
+
* @param {string} gitDir
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function usesSha256(gitDir) {
|
|
71
|
+
const cfg = readTextCapped(path.join(gitDir, 'config'), 64 * 1024);
|
|
72
|
+
return cfg != null && /^\s*objectformat\s*=\s*sha256\s*$/im.test(cfg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse `.git/index`. Returns the entries, or null when the file cannot be
|
|
77
|
+
* trusted, or `{ entries: [] }` (empty, versioned 0) when it simply does not
|
|
78
|
+
* exist — a freshly-initialized repository has no index yet, and "nothing is
|
|
79
|
+
* staged" is the true statement about it.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} gitDir
|
|
82
|
+
* @returns {GitIndex|null}
|
|
83
|
+
*/
|
|
84
|
+
function readIndex(gitDir) {
|
|
85
|
+
const file = path.join(gitDir, 'index');
|
|
86
|
+
let st = null;
|
|
87
|
+
try { st = fs.lstatSync(file); } catch { st = null; }
|
|
88
|
+
if (st === null) return { entries: [], version: 0, mtimeMs: 0 };
|
|
89
|
+
|
|
90
|
+
const buf = readBytesCapped(file, INDEX_MAX_BYTES);
|
|
91
|
+
if (buf === null || buf.length < 12) return null;
|
|
92
|
+
if (buf.toString('latin1', 0, 4) !== 'DIRC') return null;
|
|
93
|
+
const version = buf.readUInt32BE(4);
|
|
94
|
+
if (version < 2 || version > 4) return null;
|
|
95
|
+
const count = buf.readUInt32BE(8);
|
|
96
|
+
if (count > MAX_ENTRIES) return null;
|
|
97
|
+
|
|
98
|
+
const hashBytes = usesSha256(gitDir) ? 32 : 20;
|
|
99
|
+
/** @type {IndexEntry[]} */
|
|
100
|
+
const entries = [];
|
|
101
|
+
let off = 12;
|
|
102
|
+
let prevPath = '';
|
|
103
|
+
for (let i = 0; i < count; i += 1) {
|
|
104
|
+
const start = off;
|
|
105
|
+
// Fixed part: ctime(8) mtime(8) dev(4) ino(4) mode(4) uid(4) gid(4)
|
|
106
|
+
// size(4) oid(hashBytes) flags(2).
|
|
107
|
+
if (off + 40 + hashBytes + 2 > buf.length) return null;
|
|
108
|
+
const mtimeSec = buf.readUInt32BE(off + 8);
|
|
109
|
+
const mtimeNsec = buf.readUInt32BE(off + 12);
|
|
110
|
+
const mode = buf.readUInt32BE(off + 24);
|
|
111
|
+
const size = buf.readUInt32BE(off + 36);
|
|
112
|
+
const oid = buf.toString('hex', off + 40, off + 40 + hashBytes);
|
|
113
|
+
const flags = buf.readUInt16BE(off + 40 + hashBytes);
|
|
114
|
+
off += 40 + hashBytes + 2;
|
|
115
|
+
const stage = (flags >> 12) & 0x3;
|
|
116
|
+
// Version 3+ may carry an extended-flags word, marked by bit 14.
|
|
117
|
+
if (flags & 0x4000) {
|
|
118
|
+
if (version < 3 || off + 2 > buf.length) return null;
|
|
119
|
+
off += 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @type {string} */
|
|
123
|
+
let p;
|
|
124
|
+
if (version === 4) {
|
|
125
|
+
// v4 compresses paths: a varint N ("strip N bytes from the previous
|
|
126
|
+
// path"), then the NUL-terminated suffix to append.
|
|
127
|
+
if (off >= buf.length) return null;
|
|
128
|
+
let b = buf[off]; off += 1;
|
|
129
|
+
let strip = b & 0x7f;
|
|
130
|
+
let hops = 0;
|
|
131
|
+
while (b & 0x80) {
|
|
132
|
+
// Git's offset varint: the accumulated value gains 1 BEFORE each
|
|
133
|
+
// 7-bit shift — decode_varint in git's own varint.c.
|
|
134
|
+
if (off >= buf.length || hops > 6) return null;
|
|
135
|
+
b = buf[off]; off += 1;
|
|
136
|
+
strip = ((strip + 1) << 7) + (b & 0x7f);
|
|
137
|
+
hops += 1;
|
|
138
|
+
}
|
|
139
|
+
const nul = buf.indexOf(0, off);
|
|
140
|
+
if (nul === -1) return null;
|
|
141
|
+
const suffix = buf.toString('utf8', off, nul);
|
|
142
|
+
off = nul + 1;
|
|
143
|
+
if (strip > prevPath.length) return null;
|
|
144
|
+
p = prevPath.slice(0, prevPath.length - strip) + suffix;
|
|
145
|
+
} else {
|
|
146
|
+
// v2/v3: NUL-terminated path, then the whole entry padded with NULs to a
|
|
147
|
+
// multiple of 8 bytes from the entry's start.
|
|
148
|
+
const nameLen = flags & 0x0fff;
|
|
149
|
+
const nul = nameLen < 0x0fff && off + nameLen <= buf.length && buf[off + nameLen] === 0
|
|
150
|
+
? off + nameLen
|
|
151
|
+
: buf.indexOf(0, off);
|
|
152
|
+
if (nul === -1) return null;
|
|
153
|
+
p = buf.toString('utf8', off, nul);
|
|
154
|
+
off = nul + 1;
|
|
155
|
+
const entryLen = off - start;
|
|
156
|
+
const padded = Math.ceil(entryLen / 8) * 8;
|
|
157
|
+
off = start + padded;
|
|
158
|
+
if (off > buf.length) return null;
|
|
159
|
+
}
|
|
160
|
+
if (!p) return null;
|
|
161
|
+
entries.push({ path: p, mode, stage, oid, size, mtimeSec, mtimeNsec });
|
|
162
|
+
prevPath = p;
|
|
163
|
+
}
|
|
164
|
+
return { entries, version, mtimeMs: st.mtimeMs };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { readIndex, usesSha256, INDEX_MAX_BYTES, MAX_ENTRIES };
|