@davesheffer/hunch 1.38.1 → 1.39.1
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/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace snapshot — THIS machine's worktrees and local branches, read from git with a
|
|
3
|
+
* fixed set of commands (docs/workspace-ledger.md). Deterministic, no LLM, no network
|
|
4
|
+
* unless `fetch` is explicitly requested.
|
|
5
|
+
*
|
|
6
|
+
* Every git invocation here uses execFileSync with a literal argv (never a shell), passes
|
|
7
|
+
* refs after `--end-of-options` / `--`, runs under `foreignRepoEnv` (so a hook's GIT_DIR
|
|
8
|
+
* cannot redirect a per-worktree query), and has a timeout. Paths come from
|
|
9
|
+
* `git worktree list` on this machine only — never from a stored record.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { foreignRepoEnv, gitCommonDir, mainWorktreeRoot, stableRepositoryName } from "./git.js";
|
|
15
|
+
import { extracted } from "../core/types.js";
|
|
16
|
+
import { CONTROL_CHARS, MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
|
|
17
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
18
|
+
const MAX_OUTPUT = 64 * 1024 * 1024;
|
|
19
|
+
/** How far back in the default branch a squash-merge is searched for. */
|
|
20
|
+
export const DEFAULT_SQUASH_SEARCH_COMMITS = 2000;
|
|
21
|
+
function env() {
|
|
22
|
+
// GIT_OPTIONAL_LOCKS=0: a read-only snapshot (it runs from hooks, in the background) must
|
|
23
|
+
// never take the index lock `git status` would otherwise grab to refresh stat data.
|
|
24
|
+
return { ...foreignRepoEnv(process.env), GIT_OPTIONAL_LOCKS: "0" };
|
|
25
|
+
}
|
|
26
|
+
function run(cwd, args, timeout = 10_000) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: MAX_OUTPUT, env: env(), timeout, stdio: ["ignore", "pipe", "ignore"] });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Exit status of a git predicate: 0 → true, 1 → false, anything else (git missing,
|
|
35
|
+
* timeout, fatal) → null. Never collapse "no" and "error" (the CommitRepairStatus rule). */
|
|
36
|
+
function predicate(cwd, args) {
|
|
37
|
+
const r = spawnSync("git", args, { cwd, env: env(), timeout: 10_000, stdio: "ignore" });
|
|
38
|
+
if (r.error || r.status === null)
|
|
39
|
+
return null;
|
|
40
|
+
if (r.status === 0)
|
|
41
|
+
return true;
|
|
42
|
+
return r.status === 1 ? false : null;
|
|
43
|
+
}
|
|
44
|
+
function sha(value) {
|
|
45
|
+
const v = value?.trim() ?? "";
|
|
46
|
+
return SHA.test(v) ? v : null;
|
|
47
|
+
}
|
|
48
|
+
function iso(value) {
|
|
49
|
+
const v = value?.trim() ?? "";
|
|
50
|
+
if (!v || !Number.isFinite(Date.parse(v)))
|
|
51
|
+
return null;
|
|
52
|
+
return new Date(v).toISOString();
|
|
53
|
+
}
|
|
54
|
+
/** origin/HEAD → origin/main|master → local main|master. Null when none resolves: every
|
|
55
|
+
* verdict is then `unknown`, never `unmerged`. */
|
|
56
|
+
function defaultBranch(root) {
|
|
57
|
+
const candidates = [];
|
|
58
|
+
const symbolic = run(root, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"])?.trim();
|
|
59
|
+
if (symbolic?.startsWith("refs/remotes/origin/")) {
|
|
60
|
+
const name = symbolic.slice("refs/remotes/origin/".length);
|
|
61
|
+
candidates.push({ name, ref: `origin/${name}`, full: symbolic });
|
|
62
|
+
}
|
|
63
|
+
for (const name of ["main", "master"]) {
|
|
64
|
+
candidates.push({ name, ref: `origin/${name}`, full: `refs/remotes/origin/${name}` });
|
|
65
|
+
}
|
|
66
|
+
for (const name of ["main", "master"])
|
|
67
|
+
candidates.push({ name, ref: name, full: `refs/heads/${name}` });
|
|
68
|
+
for (const c of candidates) {
|
|
69
|
+
if (!isSafeBranchName(c.name))
|
|
70
|
+
continue;
|
|
71
|
+
const head = sha(run(root, ["rev-parse", "--verify", "-q", "--end-of-options", `${c.full}^{commit}`]));
|
|
72
|
+
if (head)
|
|
73
|
+
return { name: c.name, ref: c.ref, head };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function listWorktrees(root) {
|
|
78
|
+
const out = run(root, ["worktree", "list", "--porcelain"]) ?? "";
|
|
79
|
+
const items = [];
|
|
80
|
+
let cur = null;
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (cur?.path && cur.head)
|
|
83
|
+
items.push({ path: cur.path, head: cur.head, branch: cur.branch ?? null, locked: !!cur.locked, prunable: !!cur.prunable, bare: !!cur.bare });
|
|
84
|
+
cur = null;
|
|
85
|
+
};
|
|
86
|
+
for (const line of out.split("\n")) {
|
|
87
|
+
if (!line.trim()) {
|
|
88
|
+
flush();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (line.startsWith("worktree ")) {
|
|
92
|
+
flush();
|
|
93
|
+
cur = { path: line.slice(9) };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!cur)
|
|
97
|
+
continue;
|
|
98
|
+
if (line.startsWith("HEAD "))
|
|
99
|
+
cur.head = sha(line.slice(5)) ?? undefined;
|
|
100
|
+
else if (line.startsWith("branch refs/heads/")) {
|
|
101
|
+
const b = line.slice("branch refs/heads/".length);
|
|
102
|
+
cur.branch = isSafeBranchName(b) ? b : null;
|
|
103
|
+
}
|
|
104
|
+
else if (line === "detached")
|
|
105
|
+
cur.branch = null;
|
|
106
|
+
else if (line === "locked" || line.startsWith("locked "))
|
|
107
|
+
cur.locked = true;
|
|
108
|
+
else if (line === "prunable" || line.startsWith("prunable "))
|
|
109
|
+
cur.prunable = true;
|
|
110
|
+
else if (line === "bare")
|
|
111
|
+
cur.bare = true;
|
|
112
|
+
}
|
|
113
|
+
flush();
|
|
114
|
+
return items.filter((w) => !w.bare);
|
|
115
|
+
}
|
|
116
|
+
/** `git status --porcelain` is non-empty → uncommitted or untracked work that
|
|
117
|
+
* `git worktree remove` would refuse to discard. null when the path is gone.
|
|
118
|
+
* `--untracked-files=all` is explicit: `status.showUntrackedFiles=no` in the user's config
|
|
119
|
+
* would otherwise hide an untracked source file and report the worktree clean. */
|
|
120
|
+
function isDirty(path) {
|
|
121
|
+
if (!existsSync(path))
|
|
122
|
+
return null;
|
|
123
|
+
const out = run(path, ["status", "--porcelain", "--untracked-files=all"]);
|
|
124
|
+
return out === null ? null : out.trim().length > 0;
|
|
125
|
+
}
|
|
126
|
+
/** Bound on the ignored paths NAMED in a prune plan; the total is always reported. */
|
|
127
|
+
export const MAX_IGNORED_SHOWN = 10;
|
|
128
|
+
/** Ignored files and directories in a worktree — what `git worktree remove` (without
|
|
129
|
+
* `--force`) deletes silently. Not a refusal (every Node worktree has node_modules/), but a
|
|
130
|
+
* prune plan and its confirmation name them. An ignored directory is one entry. Read live
|
|
131
|
+
* for this machine's plan only; never stored in a record. null when git cannot tell. */
|
|
132
|
+
export function ignoredPaths(path, maxShown = MAX_IGNORED_SHOWN) {
|
|
133
|
+
if (!existsSync(path))
|
|
134
|
+
return null;
|
|
135
|
+
// `--untracked-files=normal` is explicit: `--ignored=matching` refuses the `no` a user's
|
|
136
|
+
// status.showUntrackedFiles would otherwise supply.
|
|
137
|
+
const out = run(path, ["status", "--porcelain", "-z", "--ignored=matching", "--untracked-files=normal"]);
|
|
138
|
+
if (out === null)
|
|
139
|
+
return null;
|
|
140
|
+
const fields = out.split("\0");
|
|
141
|
+
const entries = [];
|
|
142
|
+
for (let i = 0; i < fields.length; i++) {
|
|
143
|
+
const field = fields[i];
|
|
144
|
+
if (field.startsWith("!! "))
|
|
145
|
+
entries.push(field.slice(3));
|
|
146
|
+
else if (/^(?:[RC].|.[RC]) /.test(field))
|
|
147
|
+
i++; // a rename/copy carries its source path as the next field
|
|
148
|
+
}
|
|
149
|
+
entries.sort();
|
|
150
|
+
return { shown: entries.slice(0, maxShown), total: entries.length };
|
|
151
|
+
}
|
|
152
|
+
function listBranches(root) {
|
|
153
|
+
const format = ["%(refname)", "%(objectname)", "%(upstream)", "%(upstream:track,nobracket)", "%(committerdate:iso-strict)", "%(worktreepath)"].join("%00");
|
|
154
|
+
const out = run(root, ["for-each-ref", `--format=${format}`, "refs/heads/"]) ?? "";
|
|
155
|
+
const items = [];
|
|
156
|
+
let skipped = 0;
|
|
157
|
+
for (const line of out.split("\n")) {
|
|
158
|
+
if (!line)
|
|
159
|
+
continue;
|
|
160
|
+
const [refname = "", objectname = "", upstream = "", track = "", date = "", worktreePath = ""] = line.split("\0");
|
|
161
|
+
if (!refname.startsWith("refs/heads/"))
|
|
162
|
+
continue;
|
|
163
|
+
const name = refname.slice("refs/heads/".length);
|
|
164
|
+
const head = sha(objectname);
|
|
165
|
+
if (!head)
|
|
166
|
+
continue;
|
|
167
|
+
// git will create `refs/heads/-x` through update-ref even though check-ref-format
|
|
168
|
+
// --branch refuses it; such a name is never recorded, and the omission is stated.
|
|
169
|
+
if (!isSafeBranchName(name)) {
|
|
170
|
+
skipped++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const up = upstream.startsWith("refs/remotes/") ? upstream.slice("refs/remotes/".length) : null;
|
|
174
|
+
items.push({ name, head, upstream: up, track, date: iso(date), worktreePath: worktreePath || null });
|
|
175
|
+
}
|
|
176
|
+
return { branches: items, skipped };
|
|
177
|
+
}
|
|
178
|
+
/** Newest first (by commit date, then name), so a truncated record keeps the branches
|
|
179
|
+
* someone is most likely to ask about. */
|
|
180
|
+
function newestFirst(items) {
|
|
181
|
+
const stamp = (x) => x.date ?? x.last_commit_at ?? "";
|
|
182
|
+
const key = (x) => x.name ?? x.path ?? "";
|
|
183
|
+
return [...items].sort((a, b) => stamp(b).localeCompare(stamp(a)) || key(a).localeCompare(key(b)));
|
|
184
|
+
}
|
|
185
|
+
function parseTrack(track) {
|
|
186
|
+
if (track.trim() === "gone")
|
|
187
|
+
return { gone: true, ahead: null, behind: null };
|
|
188
|
+
const ahead = /ahead (\d+)/.exec(track);
|
|
189
|
+
const behind = /behind (\d+)/.exec(track);
|
|
190
|
+
return { gone: false, ahead: ahead ? Number(ahead[1]) : 0, behind: behind ? Number(behind[1]) : 0 };
|
|
191
|
+
}
|
|
192
|
+
/** patch-id of the whole diff between two commits: what a squash-merge lands as one commit. */
|
|
193
|
+
function combinedPatchId(root, base, head) {
|
|
194
|
+
let diff;
|
|
195
|
+
try {
|
|
196
|
+
diff = execFileSync("git", ["diff", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", base, head, "--"], { cwd: root, env: env(), maxBuffer: MAX_OUTPUT, timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (!diff.byteLength)
|
|
202
|
+
return null;
|
|
203
|
+
const r = spawnSync("git", ["patch-id", "--stable"], { cwd: root, env: env(), input: diff, encoding: "utf8", maxBuffer: MAX_OUTPUT, timeout: 15_000 });
|
|
204
|
+
if (r.error || r.status !== 0)
|
|
205
|
+
return null;
|
|
206
|
+
const id = r.stdout.trim().split(/\s+/)[0] ?? "";
|
|
207
|
+
return SHA.test(id) ? id : null;
|
|
208
|
+
}
|
|
209
|
+
/** patch-id → commit for a range, via one `git log -p | git patch-id` pipeline. `ok: false`
|
|
210
|
+
* means the pipeline failed or overflowed — the caller says so instead of pretending the
|
|
211
|
+
* search happened. */
|
|
212
|
+
function patchIdsOf(root, range, limit, timeout) {
|
|
213
|
+
const map = new Map();
|
|
214
|
+
let log;
|
|
215
|
+
try {
|
|
216
|
+
log = execFileSync("git", ["log", "--format=%H", "-p", "--no-merges", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", ...(limit ? [`-n${limit}`] : []), ...range, "--"], { cwd: root, env: env(), maxBuffer: MAX_OUTPUT, timeout, stdio: ["ignore", "pipe", "ignore"] });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return { map, ok: false };
|
|
220
|
+
}
|
|
221
|
+
if (!log.byteLength)
|
|
222
|
+
return { map, ok: true };
|
|
223
|
+
const r = spawnSync("git", ["patch-id", "--stable"], { cwd: root, env: env(), input: log, encoding: "utf8", maxBuffer: MAX_OUTPUT, timeout });
|
|
224
|
+
if (r.error || r.status !== 0)
|
|
225
|
+
return { map, ok: false };
|
|
226
|
+
for (const line of r.stdout.split("\n")) {
|
|
227
|
+
const [patchId = "", commit = ""] = line.trim().split(/\s+/);
|
|
228
|
+
if (SHA.test(patchId) && SHA.test(commit) && !map.has(patchId))
|
|
229
|
+
map.set(patchId, commit);
|
|
230
|
+
}
|
|
231
|
+
return { map, ok: true };
|
|
232
|
+
}
|
|
233
|
+
/** The pull request a MERGE COMMIT names for this branch, from the local commit subject
|
|
234
|
+
* GitHub/GitLab write ("Merge pull request #N from owner/branch"). Bounded scan, JS-side
|
|
235
|
+
* matching (no branch name reaches git), never a forge request. */
|
|
236
|
+
function prFromMergeCommits(root, branch, range) {
|
|
237
|
+
const out = run(root, ["log", "--merges", "--format=%s", "-n500", range, "--"]) ?? "";
|
|
238
|
+
for (const subject of out.split("\n")) {
|
|
239
|
+
const m = /^Merge pull request #(\d{1,9}) from [^/\s]+\/(\S+)$/.exec(subject.trim());
|
|
240
|
+
if (m && m[2] === branch)
|
|
241
|
+
return Number(m[1]);
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
/** The pull request a SQUASH COMMIT names ("Title (#N)"). */
|
|
246
|
+
function prFromSquashCommit(root, commit) {
|
|
247
|
+
const subject = run(root, ["log", "-1", "--format=%s", "--end-of-options", commit, "--"])?.trim() ?? "";
|
|
248
|
+
const m = /\(#(\d{1,9})\)$/.exec(subject);
|
|
249
|
+
return m ? Number(m[1]) : undefined;
|
|
250
|
+
}
|
|
251
|
+
function withPr(verdict, pr) {
|
|
252
|
+
return pr === undefined ? verdict : { ...verdict, pr, evidence: [...verdict.evidence, `pull request #${pr} (from the local commit subject)`] };
|
|
253
|
+
}
|
|
254
|
+
/** The default branch's first-parent history: the commits made (or fast-forwarded) directly
|
|
255
|
+
* on it. null when git cannot list it. */
|
|
256
|
+
function firstParentsOf(root, head) {
|
|
257
|
+
const out = run(root, ["rev-list", "--first-parent", "--end-of-options", head, "--"], 60_000);
|
|
258
|
+
if (out === null)
|
|
259
|
+
return null;
|
|
260
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter((l) => SHA.test(l)));
|
|
261
|
+
}
|
|
262
|
+
function mergedVerdict(root, name, head, def, patchIds, firstParents, searched) {
|
|
263
|
+
if (!def)
|
|
264
|
+
return { status: "unknown", method: null, evidence: ["no default branch resolved (origin/HEAD, origin/main, origin/master, main, master)"] };
|
|
265
|
+
const ancestor = predicate(root, ["merge-base", "--is-ancestor", head, def.head]);
|
|
266
|
+
if (ancestor === null)
|
|
267
|
+
return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
|
|
268
|
+
if (ancestor) {
|
|
269
|
+
// A head ON the default branch's first-parent line holds no commits of its own: a branch
|
|
270
|
+
// created and never committed to (or fast-forwarded in). Ancestry would call it merged
|
|
271
|
+
// and prune would delete it with its worktree; it is labeled and kept instead.
|
|
272
|
+
const line = firstParents();
|
|
273
|
+
if (line === null)
|
|
274
|
+
return { status: "unknown", method: null, evidence: [`${head.slice(0, 12)} is an ancestor of ${def.ref}; its first-parent history could not be read`] };
|
|
275
|
+
if (line.has(head)) {
|
|
276
|
+
return { status: "no-commits", method: null, evidence: [`${head.slice(0, 12)} is on ${def.ref}@${def.head.slice(0, 12)} first-parent history: no commits of its own (or fast-forwarded)`] };
|
|
277
|
+
}
|
|
278
|
+
const verdict = { status: "merged", method: "ancestry", evidence: [`${head.slice(0, 12)} is an ancestor of ${def.ref}@${def.head.slice(0, 12)}`] };
|
|
279
|
+
return withPr(verdict, prFromMergeCommits(root, name, `${head}..${def.head}`));
|
|
280
|
+
}
|
|
281
|
+
const base = sha(run(root, ["merge-base", head, def.head]));
|
|
282
|
+
if (!base)
|
|
283
|
+
return { status: "unknown", method: null, evidence: [`no merge base with ${def.ref}`] };
|
|
284
|
+
const known = patchIds();
|
|
285
|
+
if (!known.ok) {
|
|
286
|
+
return { status: "unmerged", method: null, evidence: [`not an ancestor of ${def.ref}@${def.head.slice(0, 12)}; squash/rebase search unavailable (default-branch history too large or git failed)`] };
|
|
287
|
+
}
|
|
288
|
+
// Only default-branch commits AFTER the merge base can have landed this branch — the set
|
|
289
|
+
// `git cherry` compares against. A matching commit already behind the base is the branch's
|
|
290
|
+
// own history: a reland (revert of a revert) or a value flipped back matches the ORIGINAL
|
|
291
|
+
// commit and is not merged. The map keeps the newest commit per patch-id, so when that one
|
|
292
|
+
// is behind the base every older one is too. null → git failed (never "no").
|
|
293
|
+
const landedAfterBase = (commit) => {
|
|
294
|
+
const behind = predicate(root, ["merge-base", "--is-ancestor", commit, base]);
|
|
295
|
+
return behind === null ? null : !behind;
|
|
296
|
+
};
|
|
297
|
+
const combined = combinedPatchId(root, base, head);
|
|
298
|
+
if (combined) {
|
|
299
|
+
const commit = known.map.get(combined);
|
|
300
|
+
if (commit) {
|
|
301
|
+
const after = landedAfterBase(commit);
|
|
302
|
+
if (after === null)
|
|
303
|
+
return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
|
|
304
|
+
if (after) {
|
|
305
|
+
const verdict = { status: "merged", method: "squash", evidence: [`patch-id of ${base.slice(0, 12)}..${head.slice(0, 12)} equals ${def.ref} commit ${commit.slice(0, 12)}`] };
|
|
306
|
+
return withPr(verdict, prFromSquashCommit(root, commit));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Rebase / cherry-pick: every commit of the branch has a patch-equivalent commit in the
|
|
311
|
+
// default branch after the merge base. Uses the one-time map instead of `git cherry`, whose
|
|
312
|
+
// cost grows with the default branch's history for EVERY branch checked.
|
|
313
|
+
const own = patchIdsOf(root, [`${base}..${head}`], null, 30_000);
|
|
314
|
+
if (own.ok && own.map.size) {
|
|
315
|
+
let all = true;
|
|
316
|
+
for (const id of own.map.keys()) {
|
|
317
|
+
const commit = known.map.get(id);
|
|
318
|
+
const after = commit ? landedAfterBase(commit) : false;
|
|
319
|
+
if (after === null)
|
|
320
|
+
return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
|
|
321
|
+
if (!after) {
|
|
322
|
+
all = false;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (all)
|
|
327
|
+
return { status: "merged", method: "rebase", evidence: [`all ${own.map.size} commit(s) have a patch-equivalent commit in ${def.ref} after the merge base (last ${searched} searched)`] };
|
|
328
|
+
}
|
|
329
|
+
return { status: "unmerged", method: null, evidence: [`not in ${def.ref}@${def.head.slice(0, 12)}; squash/rebase searched last ${searched} commits`] };
|
|
330
|
+
}
|
|
331
|
+
function realpath(path) {
|
|
332
|
+
try {
|
|
333
|
+
return realpathSync(path);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return path;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function fetchedAt(root) {
|
|
340
|
+
const common = gitCommonDir(root);
|
|
341
|
+
if (!common)
|
|
342
|
+
return null;
|
|
343
|
+
try {
|
|
344
|
+
return statSync(join(common, "FETCH_HEAD")).mtime.toISOString();
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Snapshot this machine's workspace for the repository at `root`. Validated against the
|
|
351
|
+
* strict schema before it is returned, so the extractor can never emit a record the
|
|
352
|
+
* loader would refuse. */
|
|
353
|
+
export function snapshotWorkspace(root, opts) {
|
|
354
|
+
const now = opts.now ?? new Date();
|
|
355
|
+
const main = mainWorktreeRoot(root);
|
|
356
|
+
if (opts.fetch)
|
|
357
|
+
run(main, ["fetch", "--prune", "--quiet"], 120_000);
|
|
358
|
+
const def = defaultBranch(main);
|
|
359
|
+
const searched = opts.squashSearchCommits ?? DEFAULT_SQUASH_SEARCH_COMMITS;
|
|
360
|
+
let patchIds = null;
|
|
361
|
+
const lazyPatchIds = () => (patchIds ??= def ? patchIdsOf(main, [def.head], searched, 60_000) : { map: new Map(), ok: true });
|
|
362
|
+
let firstParents;
|
|
363
|
+
const lazyFirstParents = () => (firstParents === undefined ? (firstParents = def ? firstParentsOf(main, def.head) : null) : firstParents);
|
|
364
|
+
const notes = [];
|
|
365
|
+
const allWorktrees = listWorktrees(main).map((w) => ({ ...w, date: iso(run(main, ["log", "-1", "--format=%cI", "--end-of-options", w.head, "--"])) }));
|
|
366
|
+
const rawWorktrees = allWorktrees.length > MAX_WORKTREES ? newestFirst(allWorktrees).slice(0, MAX_WORKTREES) : allWorktrees;
|
|
367
|
+
if (rawWorktrees.length < allWorktrees.length)
|
|
368
|
+
notes.push(`truncated: ${allWorktrees.length - rawWorktrees.length} older worktree(s) omitted (record holds ${MAX_WORKTREES})`);
|
|
369
|
+
const mainReal = realpath(main);
|
|
370
|
+
const worktrees = rawWorktrees.map((w) => ({
|
|
371
|
+
id: worktreeId(w.path),
|
|
372
|
+
// A path with a control character (newline, ESC) is never recorded or printed; without a
|
|
373
|
+
// path `prune --apply` refuses the worktree instead of guessing.
|
|
374
|
+
path: opts.publish === "full" && !CONTROL_CHARS.test(w.path) ? w.path : null,
|
|
375
|
+
branch: w.branch,
|
|
376
|
+
head: w.head,
|
|
377
|
+
is_main: realpath(w.path) === mainReal,
|
|
378
|
+
dirty: w.prunable ? null : isDirty(w.path),
|
|
379
|
+
locked: w.locked,
|
|
380
|
+
prunable: w.prunable,
|
|
381
|
+
last_commit_at: w.date,
|
|
382
|
+
}));
|
|
383
|
+
const worktreeByPath = new Map(rawWorktrees.map((w) => [w.path, worktreeId(w.path)]));
|
|
384
|
+
const listed = listBranches(main);
|
|
385
|
+
if (listed.skipped)
|
|
386
|
+
notes.push(`skipped: ${listed.skipped} branch name(s) git would refuse as a branch argument`);
|
|
387
|
+
const maxBranches = Math.min(MAX_BRANCHES, Math.max(1, opts.maxBranches ?? MAX_BRANCHES));
|
|
388
|
+
const rawBranches = listed.branches.length > maxBranches ? newestFirst(listed.branches).slice(0, maxBranches) : listed.branches;
|
|
389
|
+
if (rawBranches.length < listed.branches.length)
|
|
390
|
+
notes.push(`truncated: ${listed.branches.length - rawBranches.length} older branch(es) omitted (record holds ${maxBranches})`);
|
|
391
|
+
const branches = rawBranches.map((b) => {
|
|
392
|
+
const track = b.upstream ? parseTrack(b.track) : { gone: false, ahead: null, behind: null };
|
|
393
|
+
return {
|
|
394
|
+
name: b.name,
|
|
395
|
+
head: b.head,
|
|
396
|
+
is_default: def?.name === b.name,
|
|
397
|
+
upstream: b.upstream,
|
|
398
|
+
upstream_gone: track.gone,
|
|
399
|
+
ahead: track.ahead,
|
|
400
|
+
behind: track.behind,
|
|
401
|
+
last_commit_at: b.date,
|
|
402
|
+
worktree: (b.worktreePath && worktreeByPath.get(b.worktreePath)) || null,
|
|
403
|
+
merged: def?.name === b.name
|
|
404
|
+
? { status: "unmerged", method: null, evidence: ["default branch"] }
|
|
405
|
+
: mergedVerdict(main, b.name, b.head, def, lazyPatchIds, lazyFirstParents, searched),
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
const record = {
|
|
409
|
+
schema: WORKSPACE_SCHEMA_VERSION,
|
|
410
|
+
id: workspaceId(opts.machine.id),
|
|
411
|
+
machine: { id: opts.machine.id, label: opts.machine.label, platform: process.platform },
|
|
412
|
+
repository: stableRepositoryName(main),
|
|
413
|
+
publish: opts.publish,
|
|
414
|
+
observed_at: now.toISOString(),
|
|
415
|
+
fetched_at: fetchedAt(main),
|
|
416
|
+
default_branch: def,
|
|
417
|
+
worktrees,
|
|
418
|
+
branches,
|
|
419
|
+
provenance: extracted(1, [
|
|
420
|
+
"git worktree list --porcelain", "git for-each-ref refs/heads/", "git status --porcelain --untracked-files=all",
|
|
421
|
+
"git merge-base --is-ancestor", "git rev-list --first-parent", "git diff | git patch-id --stable", "git log -p | git patch-id --stable",
|
|
422
|
+
...notes,
|
|
423
|
+
]),
|
|
424
|
+
};
|
|
425
|
+
return WorkspaceSchema.parse(record);
|
|
426
|
+
}
|
|
427
|
+
//# sourceMappingURL=workspaces.js.map
|
|
@@ -51,6 +51,7 @@ export function renderHunchSection(store, root) {
|
|
|
51
51
|
lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
|
|
52
52
|
lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
|
|
53
53
|
lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
|
|
54
|
+
lines.push("- `hunch_workspaces(view?)` — which worktrees and branches are open on which machine, what is merged and deletable (read-only; this machine live, others from memory). Call it instead of `git branch` / `git worktree list`; never delete on its say-so.");
|
|
54
55
|
lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
|
|
55
56
|
lines.push("- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.");
|
|
56
57
|
lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).");
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
export interface GitignoreResult {
|
|
2
2
|
path: string;
|
|
3
|
-
|
|
3
|
+
/** `updated` = an existing managed block was brought up to the current entry list. */
|
|
4
|
+
action: "created" | "appended" | "updated" | "unchanged";
|
|
5
|
+
/** Entries this call newly wrote into the managed block (empty when unchanged). */
|
|
6
|
+
added: string[];
|
|
4
7
|
}
|
|
5
8
|
/** Defense in depth for integration files written automatically after a clone.
|
|
6
9
|
* Refuse symlinks, directories/devices, and hard links; require the canonical
|
|
7
10
|
* target to be the expected top-level file inside the canonical repository root. */
|
|
8
11
|
export declare function assertSafeTopLevelConfigFile(root: string, name: string): string;
|
|
9
|
-
|
|
12
|
+
/** `upgradeExisting: false` only adds a missing block and never rewrites a present
|
|
13
|
+
* one — for `hunch index`, which also runs in CI and release gates where rewriting
|
|
14
|
+
* a tracked .gitignore would dirty the checkout. Setup and repair commands upgrade. */
|
|
15
|
+
export declare function ensureGitignore(root: string, opts?: {
|
|
16
|
+
upgradeExisting?: boolean;
|
|
17
|
+
}): GitignoreResult;
|
|
10
18
|
/** Ignore the engineering-memory tree so a private-migrated repo stays code-only.
|
|
11
19
|
* The kind subdirs the user's records live in (decisions/, bugs/, …) move to the
|
|
12
20
|
* private overlay; this stops git from re-publishing them. The `.hunch/` dir, its
|
|
@@ -14,3 +22,20 @@ export declare function ensureGitignore(root: string): GitignoreResult;
|
|
|
14
22
|
export declare function ignoreHunchMemory(root: string): GitignoreResult;
|
|
15
23
|
/** The .hunch memory subdirs un-published by a private migration (git pathspecs). */
|
|
16
24
|
export declare const HUNCH_MEMORY_DIRS: string[];
|
|
25
|
+
export interface ManagedGitignoreUpgrade {
|
|
26
|
+
/** The base runtime block, when this repository already has it. */
|
|
27
|
+
base: GitignoreResult | null;
|
|
28
|
+
/** The private-only memory block, when this repository was migrated to an overlay. */
|
|
29
|
+
memory: GitignoreResult | null;
|
|
30
|
+
/** Tracked memory files removed from the git INDEX (the files stay on disk). */
|
|
31
|
+
untracked: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Bring every EXISTING managed block up to the current entry lists without adding
|
|
34
|
+
* a block the repository never had (a repair path, not setup). A private-only
|
|
35
|
+
* block means the repository already declared its memory tree unpublished, so
|
|
36
|
+
* memory directories that a later release added to that block are also removed
|
|
37
|
+
* from the git index — never from disk — as `private --migrate` does for the whole
|
|
38
|
+
* list. */
|
|
39
|
+
export declare function upgradeManagedGitignore(root: string): ManagedGitignoreUpgrade;
|
|
40
|
+
/** Output lines describing what an upgrade changed; empty when nothing did. */
|
|
41
|
+
export declare function describeGitignoreUpgrade(upgrade: ManagedGitignoreUpgrade): string[];
|
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* index is ignored.
|
|
8
8
|
*
|
|
9
9
|
* Idempotent + merge-safe (con_8460b6770f): appends a single marked block and
|
|
10
|
-
* never rewrites the user's existing entries
|
|
10
|
+
* never rewrites the user's existing entries. Once the block exists, re-running
|
|
11
|
+
* brings ONLY the content between its markers up to the current entry list (so a
|
|
12
|
+
* block written by an older release gains entries added later) and is otherwise
|
|
13
|
+
* byte-identical; everything outside the markers is left untouched.
|
|
11
14
|
*/
|
|
12
15
|
import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
13
16
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
17
|
import { writeFileAtomic } from "../core/io.js";
|
|
18
|
+
import { gitTrackedPaths, gitUntrackCached, isGitRepoRoot } from "../extractors/git.js";
|
|
15
19
|
const MARK = "# >>> hunch (derived runtime index — regenerable from .hunch/*.json) >>>";
|
|
16
20
|
const END = "# <<< hunch <<<";
|
|
17
21
|
const ENTRIES = [
|
|
@@ -45,11 +49,16 @@ const ENTRIES = [
|
|
|
45
49
|
// re-touch the block above, and so the two concerns read clearly in the file.
|
|
46
50
|
const MEM_MARK = "# >>> hunch private-only (engineering memory kept in a private overlay; not published here) >>>";
|
|
47
51
|
const MEM_END = "# <<< hunch private-only <<<";
|
|
52
|
+
// Every ENTITY_KINDS directory (src/core/types.ts) must be listed here — guarded by
|
|
53
|
+
// test/gitignore.test.ts — plus the non-entity Constitution/ledger directories.
|
|
48
54
|
const MEM_ENTRIES = [
|
|
49
55
|
".hunch/decisions/",
|
|
50
56
|
".hunch/bugs/",
|
|
51
57
|
".hunch/constraints/",
|
|
52
58
|
".hunch/components/",
|
|
59
|
+
".hunch/resources/",
|
|
60
|
+
".hunch/conventions/",
|
|
61
|
+
".hunch/workspaces/",
|
|
53
62
|
".hunch/evidence/",
|
|
54
63
|
".hunch/corpora/",
|
|
55
64
|
".hunch/policies/",
|
|
@@ -107,33 +116,71 @@ export function assertSafeTopLevelConfigFile(root, name) {
|
|
|
107
116
|
}
|
|
108
117
|
return path;
|
|
109
118
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
|
|
119
|
+
const cleanLine = (line) => line.replace(/\r$/, "").trim();
|
|
120
|
+
/** Locate a managed block as whole lines: the marker line and the FIRST end-marker
|
|
121
|
+
* line after it. `null` when either is missing — a hand-damaged block is never
|
|
122
|
+
* guessed at (con_8460b6770f). Indices are into `text.split("\n")`, whose items
|
|
123
|
+
* keep a trailing `\r` in a CRLF file. */
|
|
124
|
+
function findBlock(lines, mark, end) {
|
|
125
|
+
const start = lines.findIndex((l) => cleanLine(l) === mark);
|
|
126
|
+
if (start < 0)
|
|
127
|
+
return null;
|
|
128
|
+
const stop = lines.findIndex((l, i) => i > start && cleanLine(l) === end);
|
|
129
|
+
if (stop < 0)
|
|
130
|
+
return null;
|
|
131
|
+
return { start, end: stop, inner: lines.slice(start + 1, stop).map(cleanLine).filter(Boolean) };
|
|
132
|
+
}
|
|
133
|
+
/** Rewrite ONLY the lines between an existing block's markers to the current entry
|
|
134
|
+
* list, keeping the file's line-ending style and every byte outside the markers.
|
|
135
|
+
* No write (byte-identical) when the block is already current. */
|
|
136
|
+
function upgradeBlock(root, path, cur, mark, entries, end) {
|
|
137
|
+
const lines = cur.split("\n");
|
|
138
|
+
const span = findBlock(lines, mark, end);
|
|
139
|
+
if (!span)
|
|
140
|
+
return { path, action: "unchanged", added: [] };
|
|
141
|
+
const cr = lines[span.start].endsWith("\r") ? "\r" : "";
|
|
142
|
+
const endCr = lines[span.end].endsWith("\r") ? "\r" : "";
|
|
143
|
+
const replacement = [mark + cr, ...entries.map((e) => e + cr), end + endCr];
|
|
144
|
+
const next = [...lines.slice(0, span.start), ...replacement, ...lines.slice(span.end + 1)].join("\n");
|
|
145
|
+
if (next === cur)
|
|
146
|
+
return { path, action: "unchanged", added: [] };
|
|
147
|
+
const had = new Set(span.inner);
|
|
148
|
+
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
149
|
+
writeFileAtomic(path, next);
|
|
150
|
+
return { path, action: "updated", added: entries.filter((e) => !had.has(e)) };
|
|
151
|
+
}
|
|
152
|
+
/** Idempotent + merge-safe write of one marked block (con_8460b6770f): never
|
|
153
|
+
* rewrites the user's entries outside the markers. A present block is upgraded in
|
|
154
|
+
* place to the current entry list; an absent one is appended unless the user's own
|
|
155
|
+
* lines already cover every entry. Re-running on a current file is a no-op. */
|
|
156
|
+
function appendBlock(root, mark, entries, end, upgrade = true) {
|
|
114
157
|
const path = assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
115
|
-
const block = [mark, ...entries, end].join("\n");
|
|
116
158
|
if (!existsSync(path)) {
|
|
117
159
|
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
118
|
-
writeFileAtomic(path,
|
|
119
|
-
return { path, action: "created" };
|
|
160
|
+
writeFileAtomic(path, [mark, ...entries, end].join("\n") + "\n");
|
|
161
|
+
return { path, action: "created", added: [...entries] };
|
|
120
162
|
}
|
|
121
163
|
const cur = readFileSync(path, "utf8");
|
|
122
|
-
if (cur.includes(mark))
|
|
123
|
-
return { path, action: "unchanged" };
|
|
164
|
+
if (cur.includes(mark)) { // already managed
|
|
165
|
+
return upgrade ? upgradeBlock(root, path, cur, mark, entries, end) : { path, action: "unchanged", added: [] };
|
|
166
|
+
}
|
|
124
167
|
// Already covered by the user's OWN entries (e.g. a hand-written, commented
|
|
125
168
|
// section listing the same patterns)? Don't append a redundant managed block —
|
|
126
169
|
// that would leave two copies of every ignore. Keep the .gitignore clean.
|
|
127
|
-
const lines = new Set(cur.split("\n").map(
|
|
170
|
+
const lines = new Set(cur.split("\n").map(cleanLine));
|
|
128
171
|
if (entries.every((e) => lines.has(e)))
|
|
129
|
-
return { path, action: "unchanged" };
|
|
130
|
-
const
|
|
172
|
+
return { path, action: "unchanged", added: [] };
|
|
173
|
+
const eol = cur.includes("\r\n") ? "\r\n" : "\n";
|
|
174
|
+
const gap = cur.endsWith("\n") || cur.length === 0 ? "" : eol;
|
|
131
175
|
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
132
|
-
writeFileAtomic(path, `${cur}${
|
|
133
|
-
return { path, action: "appended" };
|
|
176
|
+
writeFileAtomic(path, `${cur}${gap}${[mark, ...entries, end].join(eol)}${eol}`);
|
|
177
|
+
return { path, action: "appended", added: [...entries] };
|
|
134
178
|
}
|
|
135
|
-
|
|
136
|
-
|
|
179
|
+
/** `upgradeExisting: false` only adds a missing block and never rewrites a present
|
|
180
|
+
* one — for `hunch index`, which also runs in CI and release gates where rewriting
|
|
181
|
+
* a tracked .gitignore would dirty the checkout. Setup and repair commands upgrade. */
|
|
182
|
+
export function ensureGitignore(root, opts = {}) {
|
|
183
|
+
return appendBlock(root, MARK, ENTRIES, END, opts.upgradeExisting ?? true);
|
|
137
184
|
}
|
|
138
185
|
/** Ignore the engineering-memory tree so a private-migrated repo stays code-only.
|
|
139
186
|
* The kind subdirs the user's records live in (decisions/, bugs/, …) move to the
|
|
@@ -144,4 +191,43 @@ export function ignoreHunchMemory(root) {
|
|
|
144
191
|
}
|
|
145
192
|
/** The .hunch memory subdirs un-published by a private migration (git pathspecs). */
|
|
146
193
|
export const HUNCH_MEMORY_DIRS = MEM_ENTRIES.map((e) => e.replace(/\/$/, ""));
|
|
194
|
+
/** Bring every EXISTING managed block up to the current entry lists without adding
|
|
195
|
+
* a block the repository never had (a repair path, not setup). A private-only
|
|
196
|
+
* block means the repository already declared its memory tree unpublished, so
|
|
197
|
+
* memory directories that a later release added to that block are also removed
|
|
198
|
+
* from the git index — never from disk — as `private --migrate` does for the whole
|
|
199
|
+
* list. */
|
|
200
|
+
export function upgradeManagedGitignore(root) {
|
|
201
|
+
const result = { base: null, memory: null, untracked: [] };
|
|
202
|
+
const path = assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
203
|
+
if (!existsSync(path))
|
|
204
|
+
return result;
|
|
205
|
+
if (readFileSync(path, "utf8").includes(MARK))
|
|
206
|
+
result.base = ensureGitignore(root);
|
|
207
|
+
if (readFileSync(path, "utf8").includes(MEM_MARK)) {
|
|
208
|
+
result.memory = ignoreHunchMemory(root);
|
|
209
|
+
const dirs = result.memory.added.map((e) => e.replace(/\/$/, ""));
|
|
210
|
+
if (dirs.length && isGitRepoRoot(root)) {
|
|
211
|
+
result.untracked = gitTrackedPaths(root, dirs);
|
|
212
|
+
if (result.untracked.length)
|
|
213
|
+
gitUntrackCached(root, dirs);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
/** Output lines describing what an upgrade changed; empty when nothing did. */
|
|
219
|
+
export function describeGitignoreUpgrade(upgrade) {
|
|
220
|
+
const out = [];
|
|
221
|
+
if (upgrade.base?.action === "updated") {
|
|
222
|
+
out.push(`.gitignore: Hunch runtime block updated (added ${upgrade.base.added.join(", ") || "nothing; block normalized"})`);
|
|
223
|
+
}
|
|
224
|
+
if (upgrade.memory?.action === "updated") {
|
|
225
|
+
out.push(`.gitignore: private-only memory block updated (added ${upgrade.memory.added.join(", ") || "nothing; block normalized"})`);
|
|
226
|
+
}
|
|
227
|
+
if (upgrade.untracked.length) {
|
|
228
|
+
const shown = upgrade.untracked.slice(0, 5).join(", ") + (upgrade.untracked.length > 5 ? ", ..." : "");
|
|
229
|
+
out.push(`removed ${upgrade.untracked.length} newly ignored memory file(s) from the git index, kept on disk (commit the removal): ${shown}`);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
147
233
|
//# sourceMappingURL=gitignore.js.map
|