@davesheffer/hunch 0.10.1 → 0.10.2

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.
@@ -1,16 +1,16 @@
1
1
  /** Deterministic git introspection for the extractor + learning loop.
2
2
  * No LLM here — just parsing what git already knows. */
3
3
  import { execFileSync } from "node:child_process";
4
- function git(args, cwd) {
4
+ function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
5
5
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
6
6
  return execFileSync("git", args, {
7
- cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
7
+ cwd, encoding: "utf8", maxBuffer,
8
8
  stdio: ["ignore", "pipe", "ignore"],
9
9
  }).trim();
10
10
  }
11
- function gitSafe(args, cwd) {
11
+ function gitSafe(args, cwd, maxBuffer) {
12
12
  try {
13
- return git(args, cwd);
13
+ return git(args, cwd, maxBuffer);
14
14
  }
15
15
  catch {
16
16
  return "";
@@ -100,6 +100,54 @@ export function lastCommitForFile(file, cwd) {
100
100
  export function lastChangeDate(file, cwd) {
101
101
  return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
102
102
  }
103
+ /** Batched per-file git metrics for indexing: churn (commits touching the file in
104
+ * the last `days`; pass 0 to skip) and the most-recent commit (`commit:<sha>`).
105
+ *
106
+ * Replaces the indexer's O(files) × 2 `git log` spawns — which dominate
107
+ * `hunch index` wall-time on a large repo, especially on Windows where process
108
+ * creation is costly — with ONE `git log` pass each. Only paths present in `want`
109
+ * are returned (every requested path gets an entry, defaulting to 0 / ""). */
110
+ export function fileGitMetrics(cwd, want, days = 90) {
111
+ const out = new Map();
112
+ for (const f of want)
113
+ out.set(f, { churn: 0, lastCommit: "" });
114
+ if (out.size === 0)
115
+ return out;
116
+ // churn — one windowed log; tally each wanted path's appearances (= commits).
117
+ if (days > 0) {
118
+ const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
119
+ if (raw) {
120
+ for (const line of raw.split("\n")) {
121
+ const e = line && out.get(line);
122
+ if (e)
123
+ e.churn++;
124
+ }
125
+ }
126
+ }
127
+ // last commit — one newest-first log; the FIRST time a path appears is its most
128
+ // recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
129
+ // 256MB buffer for the all-history name-only stream on large repos.
130
+ const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
131
+ if (raw) {
132
+ let remaining = out.size;
133
+ let sha = "";
134
+ for (const line of raw.split("\n")) {
135
+ if (line.charCodeAt(0) === 0) {
136
+ sha = line.slice(1);
137
+ continue;
138
+ }
139
+ if (!line)
140
+ continue;
141
+ const e = out.get(line);
142
+ if (e && !e.lastCommit && sha) {
143
+ e.lastCommit = `commit:${sha}`;
144
+ if (--remaining === 0)
145
+ break; // every wanted path resolved — stop scanning
146
+ }
147
+ }
148
+ }
149
+ return out;
150
+ }
103
151
  /** Files staged for commit (for `hunch check` pre-commit enforcement). */
104
152
  export function stagedFiles(cwd) {
105
153
  const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
@@ -12,7 +12,7 @@ import { join, relative, dirname, posix } from "node:path";
12
12
  import { parseSource, attributeCalls } from "./parse.js";
13
13
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
14
  import { extracted, inferred } from "../core/types.js";
15
- import { isGitRepo, trackedFiles, fileChurn, lastCommitForFile } from "./git.js";
15
+ import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
16
16
  const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
17
17
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
18
18
  export function indexRepo(store, root, opts = {}) {
@@ -25,7 +25,10 @@ export function indexRepo(store, root, opts = {}) {
25
25
  const fileStartByteId = new Map(); // file -> (symbol startByte -> id)
26
26
  const perFileCalls = [];
27
27
  const perFileImports = [];
28
- const churnCache = new Map();
28
+ // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
29
+ // total, instead of two per file — the dominant cost of indexing a large repo.
30
+ const rels = files.map((abs) => toPosix(relative(root, abs)));
31
+ const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
29
32
  let skipped = 0;
30
33
  for (const abs of files) {
31
34
  const rel = toPosix(relative(root, abs));
@@ -50,9 +53,9 @@ export function indexRepo(store, root, opts = {}) {
50
53
  skipped++;
51
54
  continue;
52
55
  }
53
- const churn = opts.churn !== false && useGit ? (churnCache.get(rel) ?? fileChurn(rel, root)) : 0;
54
- churnCache.set(rel, churn);
55
- const last = useGit ? lastCommitForFile(rel, root) : "";
56
+ const m = gitMeta?.get(rel);
57
+ const churn = m?.churn ?? 0;
58
+ const last = m?.lastCommit ?? "";
56
59
  const idsInFile = [];
57
60
  const startByteId = new Map();
58
61
  const idCounts = new Map(); // disambiguate same (file,name,kind)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "license": "MIT",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",