claude-code-runrate 0.2.4 → 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 +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- 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/liveness.js +18 -1
- package/src/migrate.js +155 -0
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
package/src/git-repo.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/git-repo.js — read a repository's IDENTITY out of .git, without git.
|
|
4
|
+
//
|
|
5
|
+
// THE BUILD FORK, RULED HERE. features/OUT-OF-SCOPE.md deferred two related
|
|
6
|
+
// questions to the build: "built-in .git reader vs. an external producer
|
|
7
|
+
// writing a pane blob", and "whether a `git` binary is ever invoked". Both are
|
|
8
|
+
// answered by a scenario the visionary already ratified —
|
|
9
|
+
// features/git-pane-safety.feature: "the pane holds no capability to run a
|
|
10
|
+
// command". That is not a preference the build gets to weigh; it is the
|
|
11
|
+
// contract, and test/sidecar-capabilities.test.js enforces it structurally by
|
|
12
|
+
// walking the sidecar's module graph against an allowlist of node:fs, node:path
|
|
13
|
+
// and node:os. A subprocess is therefore out, which settles the second question
|
|
14
|
+
// and decides the first: ccr reads .git itself.
|
|
15
|
+
//
|
|
16
|
+
// The producer alternative was the real other option, not a straw man — it
|
|
17
|
+
// reuses docs/PANE-CONTRACT.md and its hostile-renderer threat model, and costs
|
|
18
|
+
// no parsing at all. It loses on the reported problem: six tabs and nothing
|
|
19
|
+
// saying which repo each one is in, for a user who installed ccr and nothing
|
|
20
|
+
// else. A pane that only appears once a second tool is installed does not
|
|
21
|
+
// answer that. (The blob path is not wasted — it stays open for any producer,
|
|
22
|
+
// which is what the contract is for.)
|
|
23
|
+
//
|
|
24
|
+
// WHAT THIS COSTS, stated plainly so the next reader isn't surprised: identity
|
|
25
|
+
// is cheap (HEAD is one small text file), but the later sections are not.
|
|
26
|
+
// Working-tree status needs .git/index parsed, and history needs object reading
|
|
27
|
+
// including packfiles. That is design-tier work — features/design/ — and it is
|
|
28
|
+
// the price of the ratified safety property.
|
|
29
|
+
//
|
|
30
|
+
// EVERY READ HERE IS HOSTILE-INPUT SAFE, for the same reasons src/safe-read.js
|
|
31
|
+
// exists: a .git directory is on disk, anything running as the user can write
|
|
32
|
+
// it, and this code runs inside a synchronous 1 Hz draw loop. A fifo at
|
|
33
|
+
// .git/HEAD would freeze the sidebar forever. So reads go through
|
|
34
|
+
// readTextCapped (regular files only, size-capped, never blocking) and text
|
|
35
|
+
// that reaches a renderer is stripped of control bytes at THIS boundary — the
|
|
36
|
+
// renderer does no sanitizing of its own, the same choke-point split
|
|
37
|
+
// src/pane-blob.js and src/render/pane.js already use.
|
|
38
|
+
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const { readTextCapped } = require('./safe-read');
|
|
42
|
+
const { stripControl } = require('./sanitize');
|
|
43
|
+
|
|
44
|
+
// A path deep enough to need more than this is pathological, and an unbounded
|
|
45
|
+
// walk is a denial-of-display path: path.dirname eventually fixpoints at the
|
|
46
|
+
// root, but a crafted path with enough segments would spend the draw budget
|
|
47
|
+
// stat-ing on the way there.
|
|
48
|
+
const MAX_WALK = 64;
|
|
49
|
+
|
|
50
|
+
// HEAD is one line; a .git file is one line. Anything larger at those paths is
|
|
51
|
+
// not the thing we came for, and the cap is what makes a planted file cheap.
|
|
52
|
+
const SMALL_FILE_MAX = 4096;
|
|
53
|
+
|
|
54
|
+
// Display cap applied at the read boundary. A branch name has no length limit
|
|
55
|
+
// worth relying on, and layout wants a value it can budget around rather than a
|
|
56
|
+
// megabyte it must clamp later.
|
|
57
|
+
const NAME_MAX = 128;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the walk ended. `found` distinguishes the three outcomes that must not
|
|
61
|
+
* be collapsed: a repository was located; the walk reached the filesystem root
|
|
62
|
+
* without finding one; or it gave up at MAX_WALK, which is NOT evidence of
|
|
63
|
+
* absence and must never be reported as "not a git repository" — that would be
|
|
64
|
+
* a false statement about a perfectly healthy tree.
|
|
65
|
+
*
|
|
66
|
+
* @typedef {{ found: true, root: string, gitDir: string|null, bare?: boolean }
|
|
67
|
+
* | { found: false, exhausted: boolean }} RepoLocation
|
|
68
|
+
*
|
|
69
|
+
* `gitDir` is null when a `.git` FILE was found but did not name a readable git
|
|
70
|
+
* directory. Present-but-unreachable is not the same as absent, and the pane
|
|
71
|
+
* says different things about them.
|
|
72
|
+
*
|
|
73
|
+
* `bare` marks a repository that IS the directory rather than sitting in a
|
|
74
|
+
* `.git` beneath one.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Does this directory look like a git directory in its own right? This is git's
|
|
79
|
+
* own structural test (`is_git_directory`): HEAD, plus `objects` and `refs`.
|
|
80
|
+
*
|
|
81
|
+
* It is what finds a BARE repository — `git init --bare` produces no `.git` at
|
|
82
|
+
* all, so the walk above would pass straight over one and report "not a git
|
|
83
|
+
* repository" at a directory that is unambiguously a repository. It also
|
|
84
|
+
* catches a session sitting inside a `.git` directory, which real git treats
|
|
85
|
+
* the same way.
|
|
86
|
+
*
|
|
87
|
+
* Not a config read: `core.bare` is the declarative answer, but parsing config
|
|
88
|
+
* to decide whether a path is a repository at all inverts the order — you would
|
|
89
|
+
* have to already know it was one to trust the file.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} dir
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
function looksLikeGitDir(dir) {
|
|
95
|
+
try {
|
|
96
|
+
if (!fs.statSync(path.join(dir, 'HEAD')).isFile()) return false;
|
|
97
|
+
return fs.statSync(path.join(dir, 'objects')).isDirectory()
|
|
98
|
+
&& fs.statSync(path.join(dir, 'refs')).isDirectory();
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} RepoIdentity
|
|
106
|
+
* @property {'ok'|'not-a-repo'|'unreadable'} state
|
|
107
|
+
* @property {string|null} name Current repo, by working-tree directory name.
|
|
108
|
+
* @property {string|null} branch Checked-out branch, or null when detached.
|
|
109
|
+
* @property {boolean} detached HEAD names a commit rather than a branch.
|
|
110
|
+
* @property {boolean} bare The repository has no working tree.
|
|
111
|
+
* @property {string|null} root Working-tree root of the current repo.
|
|
112
|
+
* @property {string|null} launchName The repo ccr was launched in — set ONLY when
|
|
113
|
+
* it differs from the current one, because a tab that says the same name twice
|
|
114
|
+
* has told you nothing.
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk up from `startDir` looking for a `.git` entry, reporting whether one was
|
|
119
|
+
* found, and — when not — whether the walk actually reached the filesystem root
|
|
120
|
+
* or merely ran out of budget.
|
|
121
|
+
*
|
|
122
|
+
* `.git` is a DIRECTORY in an ordinary clone and a FILE in a linked worktree or
|
|
123
|
+
* a submodule, where it holds `gitdir: <path>`. Both are real repositories and
|
|
124
|
+
* the pane must name both; only the second needs a second hop.
|
|
125
|
+
*
|
|
126
|
+
* `statSync` follows symlinks deliberately, which readTextCapped does not: a
|
|
127
|
+
* symlinked `.git` is a legitimate layout, and following it costs nothing here
|
|
128
|
+
* because nothing downstream writes. The files we then READ are still held to
|
|
129
|
+
* the regular-file rule — a symlinked HEAD degrades to "unreadable" rather than
|
|
130
|
+
* being followed, which is the conservative half of the same trade.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} startDir
|
|
133
|
+
* @returns {RepoLocation}
|
|
134
|
+
*/
|
|
135
|
+
function discoverRepo(startDir) {
|
|
136
|
+
let dir;
|
|
137
|
+
try {
|
|
138
|
+
if (typeof startDir !== 'string' || !startDir) return { found: false, exhausted: false };
|
|
139
|
+
dir = path.resolve(startDir);
|
|
140
|
+
} catch { return { found: false, exhausted: false }; }
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < MAX_WALK; i += 1) {
|
|
143
|
+
const dotgit = path.join(dir, '.git');
|
|
144
|
+
let st = null;
|
|
145
|
+
try { st = fs.statSync(dotgit); } catch { st = null; }
|
|
146
|
+
if (st && st.isDirectory()) return { found: true, root: dir, gitDir: dotgit };
|
|
147
|
+
if (st && st.isFile()) {
|
|
148
|
+
const raw = readTextCapped(dotgit, SMALL_FILE_MAX);
|
|
149
|
+
const m = raw && /^gitdir:[ \t]*(.+)$/m.exec(raw);
|
|
150
|
+
// A `.git` file that exists is a repository marker whichever way its
|
|
151
|
+
// contents read, so an unparseable one reports the repo as unreachable
|
|
152
|
+
// rather than absent.
|
|
153
|
+
return { found: true, root: dir, gitDir: m ? path.resolve(dir, m[1].trim()) : null };
|
|
154
|
+
}
|
|
155
|
+
// No `.git` here — but this directory may BE the repository. Checked after
|
|
156
|
+
// the `.git` cases, never before: an ordinary clone has both a `.git` and,
|
|
157
|
+
// one level down, something that answers this test, and the working tree is
|
|
158
|
+
// the answer the pane wants.
|
|
159
|
+
if (looksLikeGitDir(dir)) return { found: true, root: dir, gitDir: dir, bare: true };
|
|
160
|
+
const parent = path.dirname(dir);
|
|
161
|
+
if (parent === dir) return { found: false, exhausted: false };
|
|
162
|
+
dir = parent;
|
|
163
|
+
}
|
|
164
|
+
// Ran out of walk before running out of path. Says nothing about whether a
|
|
165
|
+
// repository is up there, so the caller must not claim there isn't one.
|
|
166
|
+
return { found: false, exhausted: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read `.git/HEAD`. Returns null when it cannot be read or does not look like
|
|
171
|
+
* HEAD at all — the caller reports that as unreadable, never as a branch.
|
|
172
|
+
*
|
|
173
|
+
* Two shapes are valid: `ref: refs/heads/<name>` on a branch, and a bare object
|
|
174
|
+
* id when HEAD is detached. Both sha-1 (40 hex) and sha-256 (64 hex) ids are
|
|
175
|
+
* accepted; a repository in the newer hash format is still a repository, and
|
|
176
|
+
* refusing it would print "git data unavailable" at a perfectly healthy tree.
|
|
177
|
+
*
|
|
178
|
+
* @param {string|null} gitDir
|
|
179
|
+
* @returns {{ branch: string|null, detached: boolean }|null}
|
|
180
|
+
*/
|
|
181
|
+
function readHead(gitDir) {
|
|
182
|
+
if (!gitDir) return null;
|
|
183
|
+
const raw = readTextCapped(path.join(gitDir, 'HEAD'), SMALL_FILE_MAX);
|
|
184
|
+
if (raw == null) return null;
|
|
185
|
+
const line = raw.split('\n')[0].trim();
|
|
186
|
+
const ref = /^ref:[ \t]*(.+)$/.exec(line);
|
|
187
|
+
if (ref) {
|
|
188
|
+
const full = ref[1].trim();
|
|
189
|
+
if (!full) return null;
|
|
190
|
+
const short = full.startsWith('refs/heads/') ? full.slice('refs/heads/'.length) : full;
|
|
191
|
+
const name = display(short);
|
|
192
|
+
// A ref line whose name is entirely control bytes leaves nothing to print;
|
|
193
|
+
// "unreadable" is honest, an empty branch slot would not be.
|
|
194
|
+
return name ? { branch: name, detached: false } : null;
|
|
195
|
+
}
|
|
196
|
+
if (/^[0-9a-f]{40}$/i.test(line) || /^[0-9a-f]{64}$/i.test(line)) {
|
|
197
|
+
return { branch: null, detached: true };
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Strip control bytes and cap length — the one place repository-authored text
|
|
204
|
+
* becomes display text. Everything downstream may assume it is printable.
|
|
205
|
+
* @param {string} s
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function display(s) {
|
|
209
|
+
const clean = stripControl(String(s)).trim();
|
|
210
|
+
// By CODE POINT, not by UTF-16 unit. A plain .slice() at 128 can land in the
|
|
211
|
+
// middle of a surrogate pair and put a lone surrogate on the terminal — the
|
|
212
|
+
// exact hazard clampVisible's header documents. And a value cut without a
|
|
213
|
+
// mark reads as a complete one, which is the rule ellipsize exists to keep.
|
|
214
|
+
const cps = [...clean];
|
|
215
|
+
return cps.length <= NAME_MAX ? clean : cps.slice(0, NAME_MAX - 1).join('') + '…';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The pane's identity model: which repo the session is in, which branch, and
|
|
220
|
+
* (only when they differ) which repo the tab was launched in.
|
|
221
|
+
*
|
|
222
|
+
* WHY TWO DIRECTORIES. The launch repo is the tab's stable identity — it is
|
|
223
|
+
* what the terminal tab has been called all session. The current repo follows
|
|
224
|
+
* the session, because a session can `cd` somewhere else, and a pane that
|
|
225
|
+
* quietly keeps describing the old place is worse than one that admits the
|
|
226
|
+
* move. So both are read, and the launch name is shown only when it has
|
|
227
|
+
* something to add.
|
|
228
|
+
*
|
|
229
|
+
* THE LAUNCH REPO IS RESOLVED FIRST, AND SURVIVES EVERY OUTCOME. An earlier
|
|
230
|
+
* version returned "not a git repository" before it ever looked at the launch
|
|
231
|
+
* directory, which made the pane state a falsehood in the state EVERY TAB
|
|
232
|
+
* STARTS IN: between `ccr` launching and Claude's first status tick there is no
|
|
233
|
+
* session directory at all, so the pane announced that this was not a
|
|
234
|
+
* repository while the file naming the repository sat unread beside it. The tab
|
|
235
|
+
* always has an answer even when the session does not yet.
|
|
236
|
+
*
|
|
237
|
+
* Hence two rules here: an unknown session directory falls back to the launch
|
|
238
|
+
* directory (the tab is sitting in it), and a session that really is outside
|
|
239
|
+
* any repository still shows which repo the tab belongs to.
|
|
240
|
+
*
|
|
241
|
+
* @param {{ currentDir?: string|null, launchDir?: string|null }} dirs
|
|
242
|
+
* @returns {RepoIdentity}
|
|
243
|
+
*/
|
|
244
|
+
function readGitRepo(dirs) {
|
|
245
|
+
const launchDir = dirs && dirs.launchDir ? dirs.launchDir : null;
|
|
246
|
+
/** @type {RepoLocation} */
|
|
247
|
+
const launch = launchDir ? discoverRepo(launchDir) : { found: false, exhausted: false };
|
|
248
|
+
|
|
249
|
+
/** The launch repo's name, but only when it adds something to the current one. */
|
|
250
|
+
const launchNameFor = (/** @type {string|null} */ currentRoot) => {
|
|
251
|
+
if (!launch.found || launch.root === currentRoot) return null;
|
|
252
|
+
return display(path.basename(launch.root)) || null;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// No session directory yet → the tab is still where it was launched.
|
|
256
|
+
const currentDir = (dirs && dirs.currentDir) || launchDir;
|
|
257
|
+
/** @type {RepoLocation} */
|
|
258
|
+
const current = currentDir ? discoverRepo(currentDir) : { found: false, exhausted: false };
|
|
259
|
+
|
|
260
|
+
if (!current.found) {
|
|
261
|
+
// Giving up at MAX_WALK is not evidence of absence: a deep-but-healthy tree
|
|
262
|
+
// must degrade to "cannot read" rather than be declared repository-free.
|
|
263
|
+
return {
|
|
264
|
+
state: current.exhausted ? 'unreadable' : 'not-a-repo',
|
|
265
|
+
name: null, branch: null, detached: false, bare: false, root: null,
|
|
266
|
+
launchName: launchNameFor(null),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const bare = current.bare === true;
|
|
271
|
+
// A bare repo is conventionally `<project>.git`, and a session sitting inside
|
|
272
|
+
// an ordinary clone's `.git` would otherwise be named ".git" — neither of
|
|
273
|
+
// which answers "which repo is this tab". The directory holding it does.
|
|
274
|
+
const named = bare && path.basename(current.root) === '.git'
|
|
275
|
+
? path.dirname(current.root) : current.root;
|
|
276
|
+
const name = display(path.basename(named)) || null;
|
|
277
|
+
const head = readHead(current.gitDir);
|
|
278
|
+
if (!head) {
|
|
279
|
+
// The repo is located; only its HEAD is unreadable. Naming it is the pane's
|
|
280
|
+
// entire job, and the model has the answer — an earlier version computed the
|
|
281
|
+
// name here and threw it away.
|
|
282
|
+
return {
|
|
283
|
+
state: 'unreadable', name, branch: null, detached: false, bare,
|
|
284
|
+
root: current.root, launchName: launchNameFor(current.root),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
state: 'ok', name, branch: head.branch, detached: head.detached, bare,
|
|
290
|
+
root: current.root, launchName: launchNameFor(current.root),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { readGitRepo, discoverRepo, readHead, MAX_WALK, NAME_MAX };
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/git-working-tree.js — what is uncommitted, computed from the index, the
|
|
4
|
+
// HEAD tree and the working tree itself. The model behind the pane section
|
|
5
|
+
// features/git-working-tree.feature describes.
|
|
6
|
+
//
|
|
7
|
+
// THE FOUR MARKS, AND THEIR PRECEDENCE. Each path gets exactly one:
|
|
8
|
+
// ! conflicted — the index holds a stage-1/2/3 entry for it
|
|
9
|
+
// M modified — the working tree differs from the index
|
|
10
|
+
// + staged — the index differs from HEAD (and the tree matches the index)
|
|
11
|
+
// ? untracked — in the working tree, in no index entry, not ignored
|
|
12
|
+
// A staged-then-edited file shows M, not +: the mark describes the FRESHEST
|
|
13
|
+
// divergence, the one the user's editor is holding. Conflict outranks both
|
|
14
|
+
// because it blocks everything else until resolved.
|
|
15
|
+
//
|
|
16
|
+
// THE MODIFIED CHECK IS GIT'S OWN, including the racy rule: an index entry
|
|
17
|
+
// whose cached stat (size + mtime) matches the file is clean — unless the
|
|
18
|
+
// entry's mtime is not older than the index file itself, where a same-second
|
|
19
|
+
// edit could hide, so the content is hashed. Hashing uses the oid's own width
|
|
20
|
+
// (sha-1 or sha-256) over git's blob framing.
|
|
21
|
+
//
|
|
22
|
+
// NO CACHE, BY DECISION. Refresh cadence was explicitly deferred to the build
|
|
23
|
+
// (features/OUT-OF-SCOPE.md); the section is computed per render, only while
|
|
24
|
+
// the git view is the one on screen, and the stat-match rule keeps a quiet
|
|
25
|
+
// tree cheap (one lstat per index entry, no hashing). If a pathological repo
|
|
26
|
+
// ever makes a tick heavy, a cache is an additive change behind this API.
|
|
27
|
+
//
|
|
28
|
+
// EVERY UNTRUSTED READ IS BOUNDED (safe-read rules; the walk carries its own
|
|
29
|
+
// visit budget), and every parse failure degrades to { state: 'unavailable' }
|
|
30
|
+
// — the pane says "git data unavailable" rather than describing a tree it
|
|
31
|
+
// could not actually read.
|
|
32
|
+
|
|
33
|
+
const crypto = require('node:crypto');
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const { readIndex } = require('./git-index');
|
|
37
|
+
const { readHeadTree } = require('./git-objects');
|
|
38
|
+
const { parseIgnore, matchRules } = require('./git-ignore');
|
|
39
|
+
const { readTextCapped } = require('./safe-read');
|
|
40
|
+
const { stripControl } = require('./sanitize');
|
|
41
|
+
|
|
42
|
+
// The untracked walk's visit budget: dirents looked at, not files listed.
|
|
43
|
+
// Past it the walk stops and says so (`truncated`) instead of wedging the
|
|
44
|
+
// draw loop — the safety feature's rule applied to a whole directory tree.
|
|
45
|
+
const MAX_VISITED = 50_000;
|
|
46
|
+
|
|
47
|
+
// Hashing budget per compute: files whose stat is suspicious enough to need
|
|
48
|
+
// content hashed. A tree with more racy files than this degrades the excess
|
|
49
|
+
// to M (suspicion shown as suspicion) rather than reading gigabytes.
|
|
50
|
+
const MAX_HASHED = 500;
|
|
51
|
+
|
|
52
|
+
// Display cap for one path, applied at this boundary (the renderer trusts it).
|
|
53
|
+
const PATH_MAX = 512;
|
|
54
|
+
|
|
55
|
+
/** @typedef {'!'|'M'|'+'|'?'} ChangeMark */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} WorkingTree
|
|
59
|
+
* @property {'ok'|'unavailable'} state
|
|
60
|
+
* @property {boolean} rebase A rebase is part-way through.
|
|
61
|
+
* @property {Array<{ path: string, mark: ChangeMark }>} entries
|
|
62
|
+
* @property {boolean} truncated The untracked walk hit its budget.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/** @type {WorkingTree} */
|
|
66
|
+
const UNAVAILABLE = { state: 'unavailable', rebase: false, entries: [], truncated: false };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Hash a working-tree file the way git names blobs: `"blob <size>\0"` + bytes.
|
|
70
|
+
* A symlink's blob is its target string. Null when unreadable or over-cap.
|
|
71
|
+
* @param {string} file
|
|
72
|
+
* @param {fs.Stats} st lstat of `file`.
|
|
73
|
+
* @param {20|32} hashBytes
|
|
74
|
+
* @returns {string|null}
|
|
75
|
+
*/
|
|
76
|
+
function hashWorkFile(file, st, hashBytes) {
|
|
77
|
+
const algo = hashBytes === 32 ? 'sha256' : 'sha1';
|
|
78
|
+
/** @type {Buffer} */
|
|
79
|
+
let body;
|
|
80
|
+
if (st.isSymbolicLink()) {
|
|
81
|
+
try { body = Buffer.from(fs.readlinkSync(file), 'utf8'); } catch { return null; }
|
|
82
|
+
} else {
|
|
83
|
+
// Bounded read straight through the descriptor: no cap here would let one
|
|
84
|
+
// giant racy file spend the whole draw budget. Files past the object cap
|
|
85
|
+
// are legitimate content, but the pane only needs "same or different", and
|
|
86
|
+
// a file that big with a suspicious stat is different in every real case.
|
|
87
|
+
if (st.size > 64 * 1024 * 1024) return null;
|
|
88
|
+
let fd = -1;
|
|
89
|
+
try {
|
|
90
|
+
fd = fs.openSync(file, 'r');
|
|
91
|
+
const fst = fs.fstatSync(fd);
|
|
92
|
+
if (!fst.isFile()) return null;
|
|
93
|
+
body = Buffer.alloc(fst.size);
|
|
94
|
+
const read = fs.readSync(fd, body, 0, body.length, 0);
|
|
95
|
+
body = body.subarray(0, read);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
} finally {
|
|
99
|
+
if (fd !== -1) { try { fs.closeSync(fd); } catch { /* closed */ } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const h = crypto.createHash(algo);
|
|
103
|
+
h.update('blob ' + body.length + '\0');
|
|
104
|
+
h.update(body);
|
|
105
|
+
return h.digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute the working-tree section's model.
|
|
110
|
+
*
|
|
111
|
+
* `opts.maxVisited` exists for exactly one caller: the design-tier scenario
|
|
112
|
+
* that pins the walk-budget behavior (features/design/git-working-tree-rules
|
|
113
|
+
* .feature), which could otherwise only be proven with a 50,000-file fixture.
|
|
114
|
+
* Production callers pass nothing and get the real budget.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ root: string, gitDir: string }} repo
|
|
117
|
+
* @param {{ maxVisited?: number }} [opts]
|
|
118
|
+
* @returns {WorkingTree}
|
|
119
|
+
*/
|
|
120
|
+
function computeWorkingTree(repo, opts = {}) {
|
|
121
|
+
const { root, gitDir } = repo;
|
|
122
|
+
const maxVisited = opts.maxVisited || MAX_VISITED;
|
|
123
|
+
const idx = readIndex(gitDir);
|
|
124
|
+
if (idx === null) return UNAVAILABLE;
|
|
125
|
+
const headTree = readHeadTree(gitDir);
|
|
126
|
+
if (headTree === null) return UNAVAILABLE;
|
|
127
|
+
|
|
128
|
+
// A rebase leaves a state directory under .git for its whole run — both the
|
|
129
|
+
// interactive form (rebase-merge) and the apply form (rebase-apply).
|
|
130
|
+
let rebase = false;
|
|
131
|
+
for (const marker of ['rebase-merge', 'rebase-apply']) {
|
|
132
|
+
try { if (fs.lstatSync(path.join(gitDir, marker)).isDirectory()) rebase = true; } catch { /* absent */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {Set<string>} */
|
|
136
|
+
const conflicted = new Set();
|
|
137
|
+
/** @type {Map<string, import('./git-index').IndexEntry>} */
|
|
138
|
+
const stage0 = new Map();
|
|
139
|
+
for (const e of idx.entries) {
|
|
140
|
+
if (e.stage === 0) stage0.set(e.path, e);
|
|
141
|
+
else conflicted.add(e.path);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const indexMtimeSec = Math.floor(idx.mtimeMs / 1000);
|
|
145
|
+
/** @type {string[]} */
|
|
146
|
+
const modified = [];
|
|
147
|
+
/** @type {string[]} */
|
|
148
|
+
const staged = [];
|
|
149
|
+
let hashed = 0;
|
|
150
|
+
|
|
151
|
+
for (const [p, e] of stage0) {
|
|
152
|
+
if (conflicted.has(p)) continue;
|
|
153
|
+
const file = path.join(root, ...p.split('/'));
|
|
154
|
+
let st = null;
|
|
155
|
+
try { st = fs.lstatSync(file); } catch { st = null; }
|
|
156
|
+
|
|
157
|
+
let treeDiffers = false;
|
|
158
|
+
if (st === null || st.isDirectory()) {
|
|
159
|
+
// Gone from the working tree (or replaced by a directory): modified,
|
|
160
|
+
// unstaged — `git rm` would have removed the entry.
|
|
161
|
+
treeDiffers = true;
|
|
162
|
+
} else {
|
|
163
|
+
const isLink = (e.mode & 0o170000) === 0o120000;
|
|
164
|
+
if (isLink !== st.isSymbolicLink()) {
|
|
165
|
+
treeDiffers = true;
|
|
166
|
+
} else if (!isLink && process.platform !== 'win32'
|
|
167
|
+
&& ((e.mode & 0o100) !== 0) !== ((st.mode & 0o100) !== 0)) {
|
|
168
|
+
// The executable bit is content to git. Not consulted on Windows,
|
|
169
|
+
// where the filesystem has no such bit and git sets core.filemode off.
|
|
170
|
+
treeDiffers = true;
|
|
171
|
+
} else {
|
|
172
|
+
const statClean = st.size === e.size
|
|
173
|
+
&& Math.floor(st.mtimeMs / 1000) === e.mtimeSec
|
|
174
|
+
&& e.mtimeSec < indexMtimeSec; // the racy rule: same-second is suspect
|
|
175
|
+
if (!statClean) {
|
|
176
|
+
if (st.size !== e.size) {
|
|
177
|
+
treeDiffers = true;
|
|
178
|
+
} else if (hashed < MAX_HASHED) {
|
|
179
|
+
hashed += 1;
|
|
180
|
+
const oid = hashWorkFile(file, st, /** @type {20|32} */ (e.oid.length / 2));
|
|
181
|
+
treeDiffers = oid === null || oid !== e.oid;
|
|
182
|
+
} else {
|
|
183
|
+
treeDiffers = true; // over the hash budget: suspicion shown as M
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (treeDiffers) {
|
|
190
|
+
modified.push(p);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const committed = headTree.get(p);
|
|
194
|
+
if (!committed || committed.oid !== e.oid || committed.mode !== e.mode) staged.push(p);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Staged deletions: committed paths with no index entry at all. (A path
|
|
198
|
+
// whose entry is conflicted is already carrying the louder mark.)
|
|
199
|
+
for (const p of headTree.keys()) {
|
|
200
|
+
if (!stage0.has(p) && !conflicted.has(p)) staged.push(p);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Untracked: walk the tree the user can see ────────────────────────────
|
|
204
|
+
/** @type {string[]} */
|
|
205
|
+
const untracked = [];
|
|
206
|
+
let truncated = false;
|
|
207
|
+
/** Ignore-rule frames: repo-wide exclude first (weakest), then each
|
|
208
|
+
* directory's .gitignore on the way down. `base` is the POSIX-relative
|
|
209
|
+
* directory the frame's patterns are anchored to. */
|
|
210
|
+
const excludeRules = parseIgnore(readTextCapped(path.join(gitDir, 'info', 'exclude'), 256 * 1024));
|
|
211
|
+
/** @type {(rel: string, isDir: boolean, frames: Array<{ base: string, rules: import('./git-ignore').IgnoreRule[] }>) => boolean} */
|
|
212
|
+
const ignored = (rel, isDir, frames) => {
|
|
213
|
+
let verdict = matchRules(excludeRules, rel, isDir);
|
|
214
|
+
for (const f of frames) {
|
|
215
|
+
const sub = f.base === '' ? rel : rel.slice(f.base.length + 1);
|
|
216
|
+
const v = matchRules(f.rules, sub, isDir);
|
|
217
|
+
if (v !== null) verdict = v;
|
|
218
|
+
}
|
|
219
|
+
return verdict === true;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
let visited = 0;
|
|
223
|
+
/** @type {Array<{ dir: string, rel: string, frames: Array<{ base: string, rules: import('./git-ignore').IgnoreRule[] }> }>} */
|
|
224
|
+
const stack = [{ dir: root, rel: '', frames: [] }];
|
|
225
|
+
while (stack.length > 0) {
|
|
226
|
+
const top = /** @type {NonNullable<typeof stack[0]>} */ (stack.pop());
|
|
227
|
+
const gi = readTextCapped(path.join(top.dir, '.gitignore'), 256 * 1024);
|
|
228
|
+
const frames = gi === null ? top.frames : [...top.frames, { base: top.rel, rules: parseIgnore(gi) }];
|
|
229
|
+
/** @type {fs.Dirent[]} */
|
|
230
|
+
let dirents = [];
|
|
231
|
+
try { dirents = fs.readdirSync(top.dir, { withFileTypes: true }); } catch { continue; }
|
|
232
|
+
for (const d of dirents) {
|
|
233
|
+
visited += 1;
|
|
234
|
+
if (visited > maxVisited) { truncated = true; break; }
|
|
235
|
+
if (d.name === '.git') continue; // the repo itself, or a submodule's
|
|
236
|
+
const rel = top.rel === '' ? d.name : top.rel + '/' + d.name;
|
|
237
|
+
if (d.isDirectory()) {
|
|
238
|
+
if (ignored(rel, true, frames)) continue;
|
|
239
|
+
stack.push({ dir: path.join(top.dir, d.name), rel, frames });
|
|
240
|
+
} else if (d.isFile() || d.isSymbolicLink()) {
|
|
241
|
+
if (stage0.has(rel) || conflicted.has(rel)) continue;
|
|
242
|
+
if (ignored(rel, false, frames)) continue;
|
|
243
|
+
untracked.push(rel);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (truncated) break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Display form: control bytes stripped, capped, at this boundary only. */
|
|
250
|
+
const show = (/** @type {string} */ p) => {
|
|
251
|
+
const clean = stripControl(p);
|
|
252
|
+
const cps = [...clean];
|
|
253
|
+
return cps.length <= PATH_MAX ? clean : cps.slice(0, PATH_MAX - 1).join('') + '…';
|
|
254
|
+
};
|
|
255
|
+
const sort = (/** @type {string[]} */ a) => [...a].sort();
|
|
256
|
+
/** @type {WorkingTree['entries']} */
|
|
257
|
+
const entries = [
|
|
258
|
+
...sort([...conflicted]).map((p) => ({ path: show(p), mark: /** @type {ChangeMark} */ ('!') })),
|
|
259
|
+
...sort(staged).map((p) => ({ path: show(p), mark: /** @type {ChangeMark} */ ('+') })),
|
|
260
|
+
...sort(modified).map((p) => ({ path: show(p), mark: /** @type {ChangeMark} */ ('M') })),
|
|
261
|
+
...sort(untracked).map((p) => ({ path: show(p), mark: /** @type {ChangeMark} */ ('?') })),
|
|
262
|
+
];
|
|
263
|
+
return { state: 'ok', rebase, entries, truncated };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { computeWorkingTree, MAX_VISITED, PATH_MAX };
|