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.
@@ -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 };