@indigoai-us/hq-cli 5.96.0 → 5.97.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/CHANGELOG.md +17 -0
- package/dist/commands/reindex.js +23 -0
- package/dist/utils/large-file-guard.d.ts +39 -0
- package/dist/utils/large-file-guard.js +249 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.97.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq reindex` now keeps files over GitHub's 100MB limit out of the HQ repo.
|
|
10
|
+
The HQ root is a git repo that `git add -A` sweeps wholesale after every
|
|
11
|
+
sync, and session logs and package stores routinely pass that limit — once
|
|
12
|
+
such a blob is committed, every later push is rejected with GH001
|
|
13
|
+
permanently, because the blob is in history. Oversized files now gain an
|
|
14
|
+
anchored `.gitignore` rule, and any already in the index are dropped with
|
|
15
|
+
`git rm --cached`, which leaves the working-tree file untouched. Every
|
|
16
|
+
affected path is named on stdout, since untracking mutates the user's index
|
|
17
|
+
and must never be silent. The scan runs through git rather than the
|
|
18
|
+
filesystem, so nested checkouts under `repos/` are excluded automatically,
|
|
19
|
+
and it is throttled to once an hour per root so the hook-driven reindex path
|
|
20
|
+
stays cheap. (#346)
|
|
21
|
+
|
|
5
22
|
## [5.96.0]
|
|
6
23
|
|
|
7
24
|
### Changed
|
package/dist/commands/reindex.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as yaml from 'js-yaml';
|
|
|
30
30
|
import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
31
31
|
import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
|
|
32
32
|
import { findHqRoot } from '../utils/manifest.js';
|
|
33
|
+
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
33
34
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
34
35
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
35
36
|
/** Resolve the same root the repair/check commands must operate on. */
|
|
@@ -205,6 +206,25 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
|
205
206
|
printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
|
|
211
|
+
*
|
|
212
|
+
* Untracking is a mutation of the user's index, so it is never silent: every
|
|
213
|
+
* affected path is named on stdout. Throttled internally to one scan per hour
|
|
214
|
+
* per root, so the hook-driven reindex path stays cheap.
|
|
215
|
+
*/
|
|
216
|
+
function reportLargeFileGuard(hqRoot) {
|
|
217
|
+
const { scanned, ignored, untracked } = guardLargeFiles(hqRoot);
|
|
218
|
+
if (!scanned)
|
|
219
|
+
return;
|
|
220
|
+
const removed = new Set(untracked);
|
|
221
|
+
for (const file of untracked) {
|
|
222
|
+
console.log(`reindex: ${file} exceeds GitHub's 100MB limit — removed from git tracking and ignored (the file itself is untouched)`);
|
|
223
|
+
}
|
|
224
|
+
for (const file of ignored.filter((f) => !removed.has(f))) {
|
|
225
|
+
console.log(`reindex: ${file} exceeds GitHub's 100MB limit — added to .gitignore`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
208
228
|
export function registerReindexCommand(program) {
|
|
209
229
|
program
|
|
210
230
|
.command('reindex')
|
|
@@ -239,6 +259,9 @@ export function registerReindexCommand(program) {
|
|
|
239
259
|
repairExtremeHookDrift(hqRoot, status === 0);
|
|
240
260
|
if (status === 0)
|
|
241
261
|
await trustHqRuntimeHooks(hqRoot);
|
|
262
|
+
// Runs even when reindex failed: a failed reindex does not make an
|
|
263
|
+
// oversized blob any less likely to wedge the next push.
|
|
264
|
+
reportLargeFileGuard(hqRoot);
|
|
242
265
|
process.exit(status);
|
|
243
266
|
});
|
|
244
267
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** GitHub rejects any single file above this size. */
|
|
2
|
+
export declare const DEFAULT_THRESHOLD_BYTES: number;
|
|
3
|
+
/** At most one scan per hour per root. */
|
|
4
|
+
export declare const SCAN_THROTTLE_MS: number;
|
|
5
|
+
export interface LargeFileGuardResult {
|
|
6
|
+
/** False when the root is not a git repo, or the scan was throttled. */
|
|
7
|
+
scanned: boolean;
|
|
8
|
+
/** Repo-relative paths newly added to `.gitignore`. */
|
|
9
|
+
ignored: string[];
|
|
10
|
+
/** Repo-relative paths removed from the index (still on disk). */
|
|
11
|
+
untracked: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface LargeFileGuardOptions {
|
|
14
|
+
/** Defaults to {@link DEFAULT_THRESHOLD_BYTES}. */
|
|
15
|
+
thresholdBytes?: number;
|
|
16
|
+
/** Injectable clock, in epoch milliseconds. */
|
|
17
|
+
now?: number;
|
|
18
|
+
/** Bypass the throttle. */
|
|
19
|
+
force?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render a repo-relative path as a literal `.gitignore` pattern.
|
|
23
|
+
*
|
|
24
|
+
* The leading slash anchors it to the repo root — without it a bare filename
|
|
25
|
+
* would also ignore same-named files in every subdirectory. Glob and
|
|
26
|
+
* character-class metacharacters are escaped so the rule matches the one real
|
|
27
|
+
* path and nothing else, and a trailing space is escaped because git strips
|
|
28
|
+
* unescaped ones.
|
|
29
|
+
*/
|
|
30
|
+
export declare function toGitignorePattern(relPath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Scan `hqRoot` for files above the size limit, ignore them, and drop any that
|
|
33
|
+
* are already in the index.
|
|
34
|
+
*
|
|
35
|
+
* Never throws: every failure path degrades to "did nothing", because a guard
|
|
36
|
+
* that can take `hq reindex` down is worse than the problem it prevents.
|
|
37
|
+
*/
|
|
38
|
+
export declare function guardLargeFiles(hqRoot: string, options?: LargeFileGuardOptions): LargeFileGuardResult;
|
|
39
|
+
//# sourceMappingURL=large-file-guard.d.ts.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Large-file guard — keep blobs over GitHub's hard limit out of the HQ repo.
|
|
3
|
+
*
|
|
4
|
+
* HQ's root is a git repo that `git add -A` sweeps wholesale (the desktop app's
|
|
5
|
+
* git-mirror does exactly that after every sync). Session logs and package
|
|
6
|
+
* stores routinely blow past GitHub's 100 MB per-file limit, and once such a
|
|
7
|
+
* blob is committed every subsequent push is rejected with GH001 — permanently,
|
|
8
|
+
* since the blob is in history. Recovering means rewriting history.
|
|
9
|
+
*
|
|
10
|
+
* This guard runs from `hq reindex` and closes the door before that happens:
|
|
11
|
+
* - oversized files gain a `.gitignore` rule, so `git add -A` skips them;
|
|
12
|
+
* - oversized files already in the index are dropped from it with
|
|
13
|
+
* `git rm --cached`, which leaves the working-tree file untouched.
|
|
14
|
+
*
|
|
15
|
+
* Design constraints that shaped this:
|
|
16
|
+
*
|
|
17
|
+
* - **Git-aware, not filesystem-aware.** A naive `find -size +100M` over an HQ
|
|
18
|
+
* root takes ~37s and surfaces files inside nested checkouts under `repos/`,
|
|
19
|
+
* which the outer repo does not own. Driving everything through git costs
|
|
20
|
+
* ~13s and gets nested-repo boundaries right for free.
|
|
21
|
+
* - **Index, not HEAD.** `git rm --cached` mutates the index and leaves HEAD
|
|
22
|
+
* alone until the next commit, so enumerating HEAD would re-report the same
|
|
23
|
+
* file on every run.
|
|
24
|
+
* - **Throttled.** `hq reindex` also fires from a PostToolUse hook shim on
|
|
25
|
+
* skill/worker/policy edits. A stamp file in the git dir bounds the scan to
|
|
26
|
+
* once an hour so a policy-editing session doesn't pay the cost repeatedly.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
/** GitHub rejects any single file above this size. */
|
|
32
|
+
export const DEFAULT_THRESHOLD_BYTES = 100 * 1024 * 1024;
|
|
33
|
+
/** At most one scan per hour per root. */
|
|
34
|
+
export const SCAN_THROTTLE_MS = 60 * 60 * 1000;
|
|
35
|
+
/** Lives in the git dir, so it is never committed, synced, or seen by status. */
|
|
36
|
+
const STAMP_BASENAME = "hq-large-file-scan.stamp";
|
|
37
|
+
const GITIGNORE_HEADER = "# hq reindex: files over GitHub's 100MB limit (added automatically)";
|
|
38
|
+
/**
|
|
39
|
+
* git output for a fully-populated HQ root runs to tens of megabytes
|
|
40
|
+
* (~450k tracked paths). The Node default of 1 MiB would truncate it.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_GIT_BUFFER = 512 * 1024 * 1024;
|
|
43
|
+
const SKIPPED = Object.freeze({
|
|
44
|
+
scanned: false,
|
|
45
|
+
ignored: [],
|
|
46
|
+
untracked: [],
|
|
47
|
+
});
|
|
48
|
+
function git(cwd, args, input) {
|
|
49
|
+
try {
|
|
50
|
+
const out = spawnSync("git", args, {
|
|
51
|
+
cwd,
|
|
52
|
+
input,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
maxBuffer: MAX_GIT_BUFFER,
|
|
55
|
+
});
|
|
56
|
+
if (out.error || out.status !== 0)
|
|
57
|
+
return { ok: false, stdout: "" };
|
|
58
|
+
return { ok: true, stdout: out.stdout ?? "" };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A missing cwd or an absent git binary must degrade to "guard did
|
|
62
|
+
// nothing", never take reindex down with it.
|
|
63
|
+
return { ok: false, stdout: "" };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Absolute path to the repo's git dir, or undefined when cwd is not a repo. */
|
|
67
|
+
function resolveGitDir(hqRoot) {
|
|
68
|
+
if (!fs.existsSync(hqRoot))
|
|
69
|
+
return undefined;
|
|
70
|
+
const out = git(hqRoot, ["rev-parse", "--git-dir"]);
|
|
71
|
+
if (!out.ok)
|
|
72
|
+
return undefined;
|
|
73
|
+
const raw = out.stdout.trim();
|
|
74
|
+
if (!raw)
|
|
75
|
+
return undefined;
|
|
76
|
+
return path.isAbsolute(raw) ? raw : path.resolve(hqRoot, raw);
|
|
77
|
+
}
|
|
78
|
+
function isThrottled(stampPath, now) {
|
|
79
|
+
try {
|
|
80
|
+
const last = Number(fs.readFileSync(stampPath, "utf8").trim());
|
|
81
|
+
if (!Number.isFinite(last))
|
|
82
|
+
return false;
|
|
83
|
+
return now - last < SCAN_THROTTLE_MS;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false; // no stamp yet, or unreadable — scan.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function writeStamp(stampPath, now) {
|
|
90
|
+
try {
|
|
91
|
+
fs.writeFileSync(stampPath, `${now}\n`);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Losing the stamp only costs an extra scan next time.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Split a NUL-delimited git record stream, dropping the trailing empty field. */
|
|
98
|
+
function splitNul(raw) {
|
|
99
|
+
return raw.split("\0").filter((entry) => entry.length > 0);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Oversized blobs in the *index*.
|
|
103
|
+
*
|
|
104
|
+
* `git ls-files -s` yields `<mode> <object> <stage>\t<path>`; the object ids are
|
|
105
|
+
* piped through `cat-file --batch-check` so sizes come from the object database
|
|
106
|
+
* rather than ~450k filesystem stats.
|
|
107
|
+
*/
|
|
108
|
+
function oversizedInIndex(hqRoot, threshold) {
|
|
109
|
+
const listed = git(hqRoot, ["ls-files", "-s", "-z"]);
|
|
110
|
+
if (!listed.ok)
|
|
111
|
+
return [];
|
|
112
|
+
const entries = [];
|
|
113
|
+
for (const record of splitNul(listed.stdout)) {
|
|
114
|
+
const tab = record.indexOf("\t");
|
|
115
|
+
if (tab === -1)
|
|
116
|
+
continue;
|
|
117
|
+
const fields = record.slice(0, tab).split(" ");
|
|
118
|
+
if (fields.length < 2)
|
|
119
|
+
continue;
|
|
120
|
+
entries.push({ oid: fields[1], file: record.slice(tab + 1) });
|
|
121
|
+
}
|
|
122
|
+
if (entries.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
const sizes = git(hqRoot, ["cat-file", "--batch-check=%(objectsize)"], `${entries.map((e) => e.oid).join("\n")}\n`);
|
|
125
|
+
if (!sizes.ok)
|
|
126
|
+
return [];
|
|
127
|
+
// One output line per input line, in order.
|
|
128
|
+
const lines = sizes.stdout.split("\n");
|
|
129
|
+
const oversized = [];
|
|
130
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
131
|
+
const size = Number(lines[i]);
|
|
132
|
+
if (Number.isFinite(size) && size > threshold)
|
|
133
|
+
oversized.push(entries[i].file);
|
|
134
|
+
}
|
|
135
|
+
return oversized;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Oversized files git would pick up on the next `add -A` — untracked or
|
|
139
|
+
* modified. `git status` already honours `.gitignore` and stops at nested
|
|
140
|
+
* repository boundaries, so neither needs handling here.
|
|
141
|
+
*/
|
|
142
|
+
function oversizedInWorktree(hqRoot, threshold) {
|
|
143
|
+
const status = git(hqRoot, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
144
|
+
if (!status.ok)
|
|
145
|
+
return [];
|
|
146
|
+
const records = splitNul(status.stdout);
|
|
147
|
+
const oversized = [];
|
|
148
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
149
|
+
const record = records[i];
|
|
150
|
+
if (record.length < 4)
|
|
151
|
+
continue;
|
|
152
|
+
const code = record.slice(0, 2);
|
|
153
|
+
const file = record.slice(3);
|
|
154
|
+
// Renames and copies emit the source path as a second record; consume it
|
|
155
|
+
// so it is not parsed as a status line of its own.
|
|
156
|
+
if (code.startsWith("R") || code.startsWith("C"))
|
|
157
|
+
i += 1;
|
|
158
|
+
if (code.includes("D"))
|
|
159
|
+
continue; // going away — nothing to guard
|
|
160
|
+
try {
|
|
161
|
+
const stat = fs.statSync(path.join(hqRoot, file));
|
|
162
|
+
if (stat.isFile() && stat.size > threshold)
|
|
163
|
+
oversized.push(file);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Vanished between status and stat; nothing to do.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return oversized;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Render a repo-relative path as a literal `.gitignore` pattern.
|
|
173
|
+
*
|
|
174
|
+
* The leading slash anchors it to the repo root — without it a bare filename
|
|
175
|
+
* would also ignore same-named files in every subdirectory. Glob and
|
|
176
|
+
* character-class metacharacters are escaped so the rule matches the one real
|
|
177
|
+
* path and nothing else, and a trailing space is escaped because git strips
|
|
178
|
+
* unescaped ones.
|
|
179
|
+
*/
|
|
180
|
+
export function toGitignorePattern(relPath) {
|
|
181
|
+
const escaped = relPath.replace(/([\\*?[\]!#])/g, "\\$1");
|
|
182
|
+
const anchored = `/${escaped}`;
|
|
183
|
+
return anchored.endsWith(" ") ? `${anchored.slice(0, -1)}\\ ` : anchored;
|
|
184
|
+
}
|
|
185
|
+
function appendGitignore(hqRoot, patterns) {
|
|
186
|
+
if (patterns.length === 0)
|
|
187
|
+
return true;
|
|
188
|
+
const file = path.join(hqRoot, ".gitignore");
|
|
189
|
+
let existing = "";
|
|
190
|
+
try {
|
|
191
|
+
existing = fs.readFileSync(file, "utf8");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// No .gitignore yet — it gets created below.
|
|
195
|
+
}
|
|
196
|
+
const present = new Set(existing.split("\n").map((line) => line.trim()));
|
|
197
|
+
const fresh = patterns.filter((p) => !present.has(p));
|
|
198
|
+
if (fresh.length === 0)
|
|
199
|
+
return true;
|
|
200
|
+
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
201
|
+
const header = existing.includes(GITIGNORE_HEADER) ? "" : `${GITIGNORE_HEADER}\n`;
|
|
202
|
+
try {
|
|
203
|
+
fs.appendFileSync(file, `${needsNewline ? "\n" : ""}${header}${fresh.join("\n")}\n`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Scan `hqRoot` for files above the size limit, ignore them, and drop any that
|
|
212
|
+
* are already in the index.
|
|
213
|
+
*
|
|
214
|
+
* Never throws: every failure path degrades to "did nothing", because a guard
|
|
215
|
+
* that can take `hq reindex` down is worse than the problem it prevents.
|
|
216
|
+
*/
|
|
217
|
+
export function guardLargeFiles(hqRoot, options = {}) {
|
|
218
|
+
const threshold = options.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
|
|
219
|
+
const now = options.now ?? Date.now();
|
|
220
|
+
const gitDir = resolveGitDir(hqRoot);
|
|
221
|
+
if (!gitDir)
|
|
222
|
+
return SKIPPED;
|
|
223
|
+
const stampPath = path.join(gitDir, STAMP_BASENAME);
|
|
224
|
+
if (!options.force && isThrottled(stampPath, now))
|
|
225
|
+
return SKIPPED;
|
|
226
|
+
writeStamp(stampPath, now);
|
|
227
|
+
const tracked = oversizedInIndex(hqRoot, threshold);
|
|
228
|
+
const trackedSet = new Set(tracked);
|
|
229
|
+
const worktree = oversizedInWorktree(hqRoot, threshold).filter((p) => !trackedSet.has(p));
|
|
230
|
+
const all = [...tracked, ...worktree];
|
|
231
|
+
if (all.length === 0)
|
|
232
|
+
return { scanned: true, ignored: [], untracked: [] };
|
|
233
|
+
// Ignore first, so a concurrent `git add -A` cannot re-stage what we are
|
|
234
|
+
// about to remove from the index.
|
|
235
|
+
const patterns = all.map(toGitignorePattern);
|
|
236
|
+
const wroteIgnore = appendGitignore(hqRoot, patterns);
|
|
237
|
+
const untracked = [];
|
|
238
|
+
for (const file of tracked) {
|
|
239
|
+
const removed = git(hqRoot, ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", file]);
|
|
240
|
+
if (removed.ok)
|
|
241
|
+
untracked.push(file);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
scanned: true,
|
|
245
|
+
ignored: wroteIgnore ? all : [],
|
|
246
|
+
untracked,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=large-file-guard.js.map
|