git-coco 0.41.2 → 0.43.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.
@@ -5,6 +5,8 @@ import yargs from 'yargs';
5
5
  import chalk from 'chalk';
6
6
  import * as fs from 'fs';
7
7
  import fs__default, { promises, existsSync, readFileSync, readdirSync } from 'fs';
8
+ import * as crypto from 'node:crypto';
9
+ import { createHash } from 'node:crypto';
8
10
  import * as ini from 'ini';
9
11
  import * as os from 'os';
10
12
  import os__default, { tmpdir } from 'os';
@@ -14,6 +16,9 @@ import Ajv from 'ajv';
14
16
  import ora from 'ora';
15
17
  import now from 'performance-now';
16
18
  import prettyMilliseconds from 'pretty-ms';
19
+ import * as fs$1 from 'node:fs';
20
+ import * as os$1 from 'node:os';
21
+ import * as path$1 from 'node:path';
17
22
  import { ChatAnthropic } from '@langchain/anthropic';
18
23
  import { ChatOllama } from '@langchain/ollama';
19
24
  import { ChatOpenAI } from '@langchain/openai';
@@ -38,10 +43,6 @@ import '@langchain/core/utils/async_caller';
38
43
  import { encoding_for_model } from 'tiktoken';
39
44
  import { spawn, exec, execFile } from 'child_process';
40
45
  import { spawnSync } from 'node:child_process';
41
- import * as fs$1 from 'node:fs';
42
- import * as os$1 from 'node:os';
43
- import * as path$1 from 'node:path';
44
- import * as crypto from 'node:crypto';
45
46
  import * as readline from 'readline';
46
47
  import readline__default from 'readline';
47
48
  import { promisify } from 'util';
@@ -53,7 +54,7 @@ import { pathToFileURL } from 'url';
53
54
  /**
54
55
  * Current build version from package.json
55
56
  */
56
- const BUILD_VERSION = "0.41.2";
57
+ const BUILD_VERSION = "0.43.0";
57
58
 
58
59
  const isInteractive = (config) => {
59
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -229,6 +230,19 @@ const SUMMARIZE_PROMPT = new PromptTemplate({
229
230
  inputVariables: inputVariables$4,
230
231
  template: template$4,
231
232
  });
233
+ /**
234
+ * Stable fingerprint of the active summarization template (#845, PR 5).
235
+ *
236
+ * The diff-summary cache keys include this hash so any prompt edit
237
+ * invalidates prior cache entries automatically — no manual bumps,
238
+ * no stale outputs that no longer reflect the current prompt's voice
239
+ * or rules. Only the template body matters; whitespace differences
240
+ * still re-key the cache, which is the safe default.
241
+ */
242
+ const SUMMARIZE_PROMPT_HASH = createHash('sha256')
243
+ .update(template$4)
244
+ .digest('hex')
245
+ .slice(0, 16);
232
246
 
233
247
  /**
234
248
  * Base class for all LangChain-related errors
@@ -412,10 +426,21 @@ function getDefaultServiceApiKey(config) {
412
426
  }
413
427
  const DEFAULT_OPENAI_LLM_SERVICE = {
414
428
  provider: 'openai',
415
- model: 'gpt-4o-mini',
429
+ // Bumped from `gpt-4o-mini` to `gpt-4.1-nano` (#854). Diff
430
+ // condensing is bounded summarization — the cheaper / faster
431
+ // tier is the right default for it; quality is on par for this
432
+ // class of task. Users who want the older 4o-mini can still
433
+ // override via service config.
434
+ model: 'gpt-4.1-nano',
416
435
  tokenLimit: 4096,
417
436
  temperature: 0.32,
418
- maxConcurrent: 12,
437
+ // Bumped 12 → 24 (#845, PR 3). The OpenAI fast tier comfortably
438
+ // handles ~30 concurrent on the per-key default rate limit; 24
439
+ // leaves headroom for retries while still doubling throughput.
440
+ // The summarize chain has a 429-aware backoff (`summarize`
441
+ // helper) so a temporary rate-limit hit no longer kills the
442
+ // whole pipeline.
443
+ maxConcurrent: 24,
419
444
  minTokensForSummary: 800,
420
445
  maxFileTokens: 2000,
421
446
  authentication: {
@@ -427,10 +452,20 @@ const DEFAULT_OPENAI_LLM_SERVICE = {
427
452
  };
428
453
  const DEFAULT_ANTHROPIC_LLM_SERVICE = {
429
454
  provider: 'anthropic',
430
- model: 'claude-3-5-sonnet-20240620',
455
+ // Bumped from `claude-3-5-sonnet-20240620` to
456
+ // `claude-haiku-4-5-20251001` (#854). The Sonnet 3.5 default
457
+ // was nearly two model generations stale; Haiku 4.5 is the
458
+ // current fast tier and the right fit for diff summarization.
459
+ // Users who want Sonnet for quality-sensitive runs can still
460
+ // override via service config (recommended: `claude-sonnet-4-6`).
461
+ model: 'claude-haiku-4-5-20251001',
431
462
  temperature: 0.32,
432
463
  tokenLimit: 4096,
433
- maxConcurrent: 12,
464
+ // Bumped 12 → 24 (#845, PR 3). Matches the OpenAI default;
465
+ // Anthropic's per-key concurrency on Haiku is generous enough
466
+ // that 24 stays under the rate ceiling for typical fast-model
467
+ // request shapes. Backoff in `summarize` handles spikes.
468
+ maxConcurrent: 24,
434
469
  minTokensForSummary: 800,
435
470
  maxFileTokens: 2000,
436
471
  authentication: {
@@ -1414,6 +1449,10 @@ const schema$1 = {
1414
1449
  "AnthropicModel": {
1415
1450
  "type": "string",
1416
1451
  "enum": [
1452
+ "claude-sonnet-4-6",
1453
+ "claude-haiku-4-5-20251001",
1454
+ "claude-haiku-4-5",
1455
+ "claude-opus-4-7",
1417
1456
  "claude-sonnet-4-0",
1418
1457
  "claude-3-7-sonnet-latest",
1419
1458
  "claude-3-5-haiku-latest",
@@ -1947,6 +1986,44 @@ function parseServiceConfig(service) {
1947
1986
  }
1948
1987
  }
1949
1988
 
1989
+ /**
1990
+ * Ensure the canonical default ignore lists are always present in
1991
+ * the resolved config (#851). User-provided `ignoredFiles` /
1992
+ * `ignoredExtensions` arrays from XDG / git / project / env config
1993
+ * sources used to *replace* the defaults wholesale via the shallow
1994
+ * spread in each loader, which silently dropped lockfile + node_modules
1995
+ * entries the moment a user provided their own list. The reported
1996
+ * symptom: `pnpm-lock.yaml` reaching the diff-condensing pipeline
1997
+ * after a user added `.coco.config.json` for unrelated overrides.
1998
+ *
1999
+ * Now: user values are *unioned* with the defaults. Order is preserved
2000
+ * (defaults first, then user-only additions in their original order).
2001
+ * Duplicates are de-duped. The defaults can no longer be opted out of —
2002
+ * the cost of accidentally summarizing a lockfile (minutes of LLM time
2003
+ * per commit) outweighs the niche case of intentionally excluding a
2004
+ * default lockfile pattern.
2005
+ */
2006
+ function unionPreservingOrder(base, extras) {
2007
+ if (!extras || extras.length === 0)
2008
+ return [...base];
2009
+ const seen = new Set(base);
2010
+ const merged = [...base];
2011
+ for (const value of extras) {
2012
+ if (!seen.has(value)) {
2013
+ seen.add(value);
2014
+ merged.push(value);
2015
+ }
2016
+ }
2017
+ return merged;
2018
+ }
2019
+ function mergeIgnoreLists(config) {
2020
+ return {
2021
+ ...config,
2022
+ ignoredFiles: unionPreservingOrder(DEFAULT_IGNORED_FILES$1, config.ignoredFiles),
2023
+ ignoredExtensions: unionPreservingOrder(DEFAULT_IGNORED_EXTENSIONS$1, config.ignoredExtensions),
2024
+ };
2025
+ }
2026
+
1950
2027
  /**
1951
2028
  * Tracked config sources populated during the last loadConfig call.
1952
2029
  * Useful for diagnostics (e.g. `coco doctor`).
@@ -1997,6 +2074,13 @@ function loadConfig(argv = {}) {
1997
2074
  config = envConfig;
1998
2075
  if (envActive)
1999
2076
  sources.push({ source: 'env' });
2077
+ // Re-apply the canonical default ignore lists after every loader has
2078
+ // had a chance to override (#851). Each loader replaces ignoredFiles
2079
+ // / ignoredExtensions wholesale via shallow spread, which used to
2080
+ // silently drop the lockfile + node_modules defaults the moment a
2081
+ // user provided their own list. The merge is a union — defaults first,
2082
+ // user-only entries appended.
2083
+ config = mergeIgnoreLists(config);
2000
2084
  _lastConfigSources = sources;
2001
2085
  return { ...config, ...argv };
2002
2086
  }
@@ -2171,6 +2255,232 @@ function commandExecutor(handler) {
2171
2255
  };
2172
2256
  }
2173
2257
 
2258
+ const command$8 = 'cache <subcommand>';
2259
+ const builder$8 = (yargs) => {
2260
+ return yargs
2261
+ .positional('subcommand', {
2262
+ describe: 'Cache action to run (clear, info)',
2263
+ type: 'string',
2264
+ choices: ['clear', 'info'],
2265
+ })
2266
+ .usage(getCommandUsageHeader(command$8));
2267
+ };
2268
+
2269
+ /**
2270
+ * Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a
2271
+ * re-run of `coco commit` after a small change, most files have
2272
+ * unchanged content and unchanged diffs — caching their summaries
2273
+ * by content hash means the second run skips the LLM entirely for
2274
+ * those files and only pays for what's actually different.
2275
+ *
2276
+ * Strict best-effort: read failures fall back to "no cache" (the
2277
+ * pipeline runs the LLM as before), and write failures are
2278
+ * swallowed silently. The cache is never load-bearing.
2279
+ *
2280
+ * Repos are keyed by a short hash of their absolute path. No PII
2281
+ * in the cache filename, and re-creating a repo at the same path
2282
+ * keeps the same cache.
2283
+ *
2284
+ * Cache key: `sha256(diff + ':' + model + ':' + promptHash)`.
2285
+ * - diff: the literal diff text being summarized
2286
+ * - model: switching models invalidates (different summaries)
2287
+ * - promptHash: editing the SUMMARIZE_PROMPT template invalidates
2288
+ *
2289
+ * Cap: 500 entries per repo. LRU eviction on overflow keeps the
2290
+ * cache file under ~500 KB on a typical repo (each entry is a
2291
+ * sha256 hash + 200-500-byte summary).
2292
+ */
2293
+ const CACHE_SCHEMA_VERSION$1 = 1;
2294
+ const CACHE_DIR_NAME$1 = 'diff-summaries';
2295
+ const CACHE_ENTRY_HARD_CAP = 500;
2296
+ function resolveCacheDir$4() {
2297
+ const xdg = process.env.XDG_CACHE_HOME;
2298
+ if (xdg && xdg.trim().length > 0) {
2299
+ return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
2300
+ }
2301
+ return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
2302
+ }
2303
+ function repoKey$3(repoPath) {
2304
+ // sha256 here is a non-security cache-key derivation — deterministic
2305
+ // short identifier for the cache filename so two repos at different
2306
+ // paths never collide. We truncate to 16 chars; collision-resistance
2307
+ // against an adversary is not required.
2308
+ return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
2309
+ }
2310
+ function getDiffSummaryCachePath(repoPath) {
2311
+ return path$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
2312
+ }
2313
+ /**
2314
+ * Build the cache key for a (diff, model, prompt) tuple. sha256
2315
+ * because we want a strong content-hash; the per-entry storage cost
2316
+ * is dominated by the summary text anyway.
2317
+ */
2318
+ function diffSummaryKey(diff, model, promptHash) {
2319
+ return crypto
2320
+ .createHash('sha256')
2321
+ .update(`${diff}\x1f${model}\x1f${promptHash}`)
2322
+ .digest('hex');
2323
+ }
2324
+ function readEnvelope(filePath) {
2325
+ try {
2326
+ const raw = fs$1.readFileSync(filePath, 'utf8');
2327
+ const parsed = JSON.parse(raw);
2328
+ if (parsed.version !== CACHE_SCHEMA_VERSION$1)
2329
+ return undefined;
2330
+ if (!parsed.entries || typeof parsed.entries !== 'object')
2331
+ return undefined;
2332
+ return parsed;
2333
+ }
2334
+ catch {
2335
+ return undefined;
2336
+ }
2337
+ }
2338
+ function readDiffSummary(repoPath, key) {
2339
+ const envelope = readEnvelope(getDiffSummaryCachePath(repoPath));
2340
+ if (!envelope)
2341
+ return undefined;
2342
+ const entry = envelope.entries[key];
2343
+ if (!entry)
2344
+ return undefined;
2345
+ return entry;
2346
+ }
2347
+ function writeDiffSummary(repoPath, key, entry) {
2348
+ const filePath = getDiffSummaryCachePath(repoPath);
2349
+ const existing = readEnvelope(filePath) || {
2350
+ version: CACHE_SCHEMA_VERSION$1,
2351
+ savedAt: new Date().toISOString(),
2352
+ entries: {},
2353
+ };
2354
+ existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() };
2355
+ existing.savedAt = new Date().toISOString();
2356
+ const evictedEntries = enforceHardCap(existing.entries);
2357
+ if (evictedEntries.length > 0) {
2358
+ for (const evicted of evictedEntries) {
2359
+ delete existing.entries[evicted];
2360
+ }
2361
+ }
2362
+ try {
2363
+ fs$1.mkdirSync(path$1.dirname(filePath), { recursive: true });
2364
+ fs$1.writeFileSync(filePath, JSON.stringify(existing));
2365
+ }
2366
+ catch {
2367
+ // Best-effort persistence; swallow.
2368
+ }
2369
+ }
2370
+ /**
2371
+ * Touch an existing entry's lastAccessedAt so LRU eviction prefers
2372
+ * dropping older / unused entries. Caller is expected to know the
2373
+ * entry exists (read returned a hit).
2374
+ */
2375
+ function touchDiffSummary(repoPath, key) {
2376
+ const filePath = getDiffSummaryCachePath(repoPath);
2377
+ const envelope = readEnvelope(filePath);
2378
+ if (!envelope || !envelope.entries[key])
2379
+ return;
2380
+ envelope.entries[key] = {
2381
+ ...envelope.entries[key],
2382
+ lastAccessedAt: new Date().toISOString(),
2383
+ };
2384
+ envelope.savedAt = new Date().toISOString();
2385
+ try {
2386
+ fs$1.writeFileSync(filePath, JSON.stringify(envelope));
2387
+ }
2388
+ catch {
2389
+ // Swallow.
2390
+ }
2391
+ }
2392
+ function enforceHardCap(entries) {
2393
+ const keys = Object.keys(entries);
2394
+ if (keys.length <= CACHE_ENTRY_HARD_CAP)
2395
+ return [];
2396
+ // Sort by lastAccessedAt ascending (oldest first), drop the
2397
+ // oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries.
2398
+ const sorted = keys
2399
+ .map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 }))
2400
+ .sort((a, b) => a.accessed - b.accessed);
2401
+ const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key);
2402
+ return toEvict;
2403
+ }
2404
+ /** Remove the entire cache file for the repo. Used by `coco cache:clear`. */
2405
+ function clearDiffSummaryCache(repoPath) {
2406
+ const filePath = getDiffSummaryCachePath(repoPath);
2407
+ if (!fs$1.existsSync(filePath)) {
2408
+ return { ok: true, removed: false };
2409
+ }
2410
+ try {
2411
+ fs$1.unlinkSync(filePath);
2412
+ return { ok: true, removed: true };
2413
+ }
2414
+ catch {
2415
+ return { ok: false, removed: false };
2416
+ }
2417
+ }
2418
+
2419
+ function readEnvelopeOrUndefined(filePath) {
2420
+ try {
2421
+ if (!fs$1.existsSync(filePath))
2422
+ return undefined;
2423
+ const raw = fs$1.readFileSync(filePath, 'utf8');
2424
+ return JSON.parse(raw);
2425
+ }
2426
+ catch {
2427
+ return undefined;
2428
+ }
2429
+ }
2430
+ function formatBytes(bytes) {
2431
+ if (bytes < 1024)
2432
+ return `${bytes} B`;
2433
+ if (bytes < 1024 * 1024)
2434
+ return `${(bytes / 1024).toFixed(1)} KB`;
2435
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
2436
+ }
2437
+ const handler$8 = async (argv, logger) => {
2438
+ const subcommand = argv.subcommand;
2439
+ const repoPath = process.cwd();
2440
+ const cachePath = getDiffSummaryCachePath(repoPath);
2441
+ if (subcommand === 'clear') {
2442
+ const result = clearDiffSummaryCache(repoPath);
2443
+ if (!result.ok) {
2444
+ logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
2445
+ process.exitCode = 1;
2446
+ return;
2447
+ }
2448
+ if (result.removed) {
2449
+ logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
2450
+ }
2451
+ else {
2452
+ logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`));
2453
+ }
2454
+ return;
2455
+ }
2456
+ if (subcommand === 'info') {
2457
+ const envelope = readEnvelopeOrUndefined(cachePath);
2458
+ if (!envelope) {
2459
+ logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`));
2460
+ return;
2461
+ }
2462
+ const stat = fs$1.statSync(cachePath);
2463
+ const entryCount = Object.keys(envelope.entries).length;
2464
+ const totalSummaryTokens = Object.values(envelope.entries).reduce((sum, entry) => sum + entry.tokens, 0);
2465
+ logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`);
2466
+ logger.log(` ${chalk.green('entries')} ${entryCount}`);
2467
+ logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`);
2468
+ logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`);
2469
+ logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`);
2470
+ return;
2471
+ }
2472
+ logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
2473
+ logger.log(chalk.dim('Use one of: clear, info'));
2474
+ process.exitCode = 1;
2475
+ };
2476
+
2477
+ var cache = {
2478
+ command: command$8,
2479
+ desc: 'Manage the diff-summary cache (clear, info)',
2480
+ builder: builder$8,
2481
+ handler: commandExecutor(handler$8),
2482
+ };
2483
+
2174
2484
  var util;
2175
2485
  (function (util) {
2176
2486
  util.assertEqual = (_) => { };
@@ -6343,6 +6653,7 @@ function resolveDynamicService(config, task) {
6343
6653
  };
6344
6654
  }
6345
6655
 
6656
+ const benchCalls = [];
6346
6657
  const telemetryByCommand = new Map();
6347
6658
  function estimatePromptTokens(tokenizer, renderedPrompt) {
6348
6659
  if (!tokenizer)
@@ -6354,10 +6665,28 @@ function estimatePromptTokens(tokenizer, renderedPrompt) {
6354
6665
  return undefined;
6355
6666
  }
6356
6667
  }
6668
+ function isBenchModeActive() {
6669
+ return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0');
6670
+ }
6671
+ function recordBenchCall(metadata) {
6672
+ if (!isBenchModeActive())
6673
+ return;
6674
+ benchCalls.push({
6675
+ task: metadata.task,
6676
+ command: metadata.command,
6677
+ provider: metadata.provider,
6678
+ model: metadata.model,
6679
+ promptTokens: metadata.promptTokens,
6680
+ elapsedMs: metadata.elapsedMs,
6681
+ inputDocuments: metadata.inputDocuments,
6682
+ inputChunks: metadata.inputChunks,
6683
+ });
6684
+ }
6357
6685
  function logLlmCall(logger, metadata) {
6358
6686
  if (!logger)
6359
6687
  return;
6360
6688
  recordLlmTelemetry(metadata);
6689
+ recordBenchCall(metadata);
6361
6690
  const fields = [
6362
6691
  `task=${metadata.task}`,
6363
6692
  metadata.command ? `command=${metadata.command}` : undefined,
@@ -7486,6 +7815,56 @@ function getPathFromFilePath(filePath) {
7486
7815
  return filePath.split('/').slice(0, -1).join('/');
7487
7816
  }
7488
7817
 
7818
+ /**
7819
+ * Adaptive backoff (#845, PR 3). Wraps the chain invocation so a
7820
+ * transient 429 (rate limit) or 5xx no longer kills the whole
7821
+ * pipeline — instead we wait briefly and retry up to N times
7822
+ * before surfacing the failure.
7823
+ *
7824
+ * Cap is intentionally short. Diff condensing fans out to many
7825
+ * concurrent calls; if rate limits hit hard, queueing requests
7826
+ * indefinitely just makes the user wait longer for a result the
7827
+ * pipeline ultimately handles via fewer concurrent passes anyway.
7828
+ * 3 retries with 1s/2s/4s waits trade ~7s of worst-case extra
7829
+ * latency for resilience to brief rate-limit blips.
7830
+ */
7831
+ const BACKOFF_RETRIES = 3;
7832
+ const BACKOFF_BASE_MS = 1000;
7833
+ const BACKOFF_CAP_MS = 5000;
7834
+ function isRetryableError(error) {
7835
+ if (!error || typeof error !== 'object')
7836
+ return false;
7837
+ const err = error;
7838
+ if (err.status === 429 || err.status === 503 || err.status === 502 || err.status === 504) {
7839
+ return true;
7840
+ }
7841
+ if (err.code === 429 || err.code === 'rate_limit_exceeded' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
7842
+ return true;
7843
+ }
7844
+ if (typeof err.message === 'string' && /(rate.?limit|429|too many requests|timeout|temporarily unavailable)/i.test(err.message)) {
7845
+ return true;
7846
+ }
7847
+ return false;
7848
+ }
7849
+ async function invokeWithBackoff(chain, input, logger) {
7850
+ let lastError;
7851
+ for (let attempt = 0; attempt <= BACKOFF_RETRIES; attempt++) {
7852
+ try {
7853
+ return await chain.invoke(input);
7854
+ }
7855
+ catch (error) {
7856
+ lastError = error;
7857
+ if (!isRetryableError(error) || attempt === BACKOFF_RETRIES) {
7858
+ throw error;
7859
+ }
7860
+ const wait = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, attempt));
7861
+ logger?.verbose(`[summarize] retryable error (attempt ${attempt + 1}/${BACKOFF_RETRIES}); backing off ${wait}ms`, { color: 'yellow' });
7862
+ await new Promise((resolve) => setTimeout(resolve, wait));
7863
+ }
7864
+ }
7865
+ // Unreachable — the loop either returns or rethrows above.
7866
+ throw lastError;
7867
+ }
7489
7868
  async function summarize(documents, { chain, textSplitter, options, logger, tokenizer, metadata }) {
7490
7869
  const { returnIntermediateSteps = false } = options || {};
7491
7870
  const docs = await textSplitter.splitDocuments(documents.map((doc) => new Document(doc)));
@@ -7493,10 +7872,10 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
7493
7872
  ? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
7494
7873
  : undefined;
7495
7874
  const startedAt = Date.now();
7496
- const res = await chain.invoke({
7875
+ const res = await invokeWithBackoff(chain, {
7497
7876
  input_documents: docs,
7498
7877
  returnIntermediateSteps,
7499
- });
7878
+ }, logger);
7500
7879
  const elapsedMs = Date.now() - startedAt;
7501
7880
  logLlmCall(logger, {
7502
7881
  task: 'summarize',
@@ -7511,10 +7890,175 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
7511
7890
  return res.text && res.text.trim();
7512
7891
  }
7513
7892
 
7893
+ /**
7894
+ * Inspect a unified-diff string and report its shape, or undefined
7895
+ * if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
7896
+ *
7897
+ * Detection rules (cheap on purpose — we're called per-file and the
7898
+ * goal is to skip work, not be exhaustive):
7899
+ *
7900
+ * - `Binary files ... differ` header → 'binary'
7901
+ * - `rename from`/`rename to` headers and no `+`/`-` content
7902
+ * lines → 'rename'
7903
+ * - All content lines are `+` (and at least one is) → 'addition'
7904
+ * - All content lines are `-` (and at least one is) → 'deletion'
7905
+ * - Otherwise → undefined (let the LLM handle it)
7906
+ */
7907
+ function detectTrivialDiffShape(diff) {
7908
+ if (!diff)
7909
+ return undefined;
7910
+ // Binary marker is unambiguous and short-circuits early.
7911
+ if (/^Binary files .+ and .+ differ$/m.test(diff)) {
7912
+ return 'binary';
7913
+ }
7914
+ // Pure rename: git emits `rename from` / `rename to` and no body.
7915
+ // We require BOTH markers AND no `+`/`-` content lines. Some
7916
+ // renames-with-edit show rename headers AND a hunk; those should
7917
+ // fall through to the LLM path.
7918
+ const hasRenameFrom = /^rename from /m.test(diff);
7919
+ const hasRenameTo = /^rename to /m.test(diff);
7920
+ if (hasRenameFrom && hasRenameTo) {
7921
+ const hasContentChange = diff
7922
+ .split('\n')
7923
+ .some((line) => isContentChangeLine(line));
7924
+ if (!hasContentChange) {
7925
+ return 'rename';
7926
+ }
7927
+ }
7928
+ // Walk the body once classifying content lines. We skip header
7929
+ // lines (diff --git, index, ---, +++, @@, etc.) and only inspect
7930
+ // the lines that represent actual change content.
7931
+ let plus = 0;
7932
+ let minus = 0;
7933
+ for (const line of diff.split('\n')) {
7934
+ if (isHeaderLine(line))
7935
+ continue;
7936
+ if (line.startsWith('+'))
7937
+ plus++;
7938
+ else if (line.startsWith('-'))
7939
+ minus++;
7940
+ // Context lines (' ' prefix) are ignored for shape classification:
7941
+ // a pure addition can still have surrounding context if a hunk
7942
+ // anchors at line 0, though `git diff` for a brand-new file
7943
+ // typically has none.
7944
+ }
7945
+ if (plus > 0 && minus === 0)
7946
+ return 'addition';
7947
+ if (minus > 0 && plus === 0)
7948
+ return 'deletion';
7949
+ return undefined;
7950
+ }
7951
+ /**
7952
+ * Build a deterministic summary string for a trivial diff. Returns
7953
+ * undefined when the shape can't be templated (caller should fall
7954
+ * back to the LLM path).
7955
+ */
7956
+ function summarizeTrivialDiff(fileDiff) {
7957
+ const shape = detectTrivialDiffShape(fileDiff.diff);
7958
+ if (!shape)
7959
+ return undefined;
7960
+ const lineCount = countContentLines(fileDiff.diff, shape);
7961
+ switch (shape) {
7962
+ case 'addition':
7963
+ return `Added \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7964
+ case 'deletion':
7965
+ return `Removed \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7966
+ case 'rename': {
7967
+ const oldPath = extractRenameOldPath(fileDiff.diff);
7968
+ return oldPath
7969
+ ? `Renamed \`${oldPath}\` → \`${fileDiff.file}\`.`
7970
+ : `Renamed file to \`${fileDiff.file}\`.`;
7971
+ }
7972
+ case 'binary':
7973
+ return `Updated binary file \`${fileDiff.file}\`.`;
7974
+ }
7975
+ }
7976
+ function isHeaderLine(line) {
7977
+ return (line.startsWith('diff --git') ||
7978
+ line.startsWith('index ') ||
7979
+ line.startsWith('--- ') ||
7980
+ line.startsWith('+++ ') ||
7981
+ line.startsWith('@@') ||
7982
+ line.startsWith('new file mode') ||
7983
+ line.startsWith('deleted file mode') ||
7984
+ line.startsWith('similarity index') ||
7985
+ line.startsWith('rename from ') ||
7986
+ line.startsWith('rename to ') ||
7987
+ line.startsWith('Binary files '));
7988
+ }
7989
+ function isContentChangeLine(line) {
7990
+ if (isHeaderLine(line))
7991
+ return false;
7992
+ return line.startsWith('+') || line.startsWith('-');
7993
+ }
7994
+ function countContentLines(diff, shape) {
7995
+ if (shape === 'binary' || shape === 'rename')
7996
+ return 0;
7997
+ const prefix = shape === 'addition' ? '+' : '-';
7998
+ let count = 0;
7999
+ for (const line of diff.split('\n')) {
8000
+ if (isHeaderLine(line))
8001
+ continue;
8002
+ if (line.startsWith(prefix))
8003
+ count++;
8004
+ }
8005
+ return count;
8006
+ }
8007
+ function extractRenameOldPath(diff) {
8008
+ const match = diff.match(/^rename from (.+)$/m);
8009
+ return match ? match[1].trim() : undefined;
8010
+ }
8011
+
8012
+ /**
8013
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8014
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8015
+ */
8016
+ function isCacheEnabled$1() {
8017
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8018
+ }
7514
8019
  /**
7515
8020
  * Summarize a single file diff that exceeds the token threshold.
8021
+ *
8022
+ * Trivial-shape short-circuit (#845, PR 2): pure additions / deletions
8023
+ * / renames / binary changes have no information content beyond the
8024
+ * diff's shape, so we templated-summarize them instead of paying for
8025
+ * an LLM call. On initial-commit fixtures (lots of pure adds) this
8026
+ * collapses the per-file summary phase entirely; the resulting tiny
8027
+ * synthetic summaries usually drop the directory token totals under
8028
+ * budget so wave consolidation skips too.
7516
8029
  */
7517
8030
  async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
8031
+ const trivialSummary = summarizeTrivialDiff(fileDiff);
8032
+ if (trivialSummary !== undefined) {
8033
+ logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
8034
+ return {
8035
+ ...fileDiff,
8036
+ diff: trivialSummary,
8037
+ tokenCount: tokenizer(trivialSummary),
8038
+ };
8039
+ }
8040
+ // Cache lookup (#845, PR 5). Keyed on the file's literal diff
8041
+ // content + the active model + the summarization prompt hash.
8042
+ // A hit returns the prior summary instantly; on iterative
8043
+ // `coco commit` re-runs after small edits, the unchanged files
8044
+ // never go to the LLM.
8045
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8046
+ const cacheRepo = process.cwd();
8047
+ const cacheKey = isCacheEnabled$1() && cacheModel
8048
+ ? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH)
8049
+ : undefined;
8050
+ if (cacheKey) {
8051
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8052
+ if (cached) {
8053
+ logger.verbose(` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8054
+ touchDiffSummary(cacheRepo, cacheKey);
8055
+ return {
8056
+ ...fileDiff,
8057
+ diff: cached.summary,
8058
+ tokenCount: cached.tokens,
8059
+ };
8060
+ }
8061
+ }
7518
8062
  try {
7519
8063
  const fileSummary = await summarize([
7520
8064
  {
@@ -7538,6 +8082,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7538
8082
  },
7539
8083
  });
7540
8084
  const newTokenCount = tokenizer(fileSummary);
8085
+ if (cacheKey && cacheModel) {
8086
+ writeDiffSummary(cacheRepo, cacheKey, {
8087
+ summary: fileSummary,
8088
+ model: cacheModel,
8089
+ tokens: newTokenCount,
8090
+ });
8091
+ }
7541
8092
  return {
7542
8093
  ...fileDiff,
7543
8094
  diff: fileSummary,
@@ -7551,16 +8102,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7551
8102
  }
7552
8103
  }
7553
8104
  /**
7554
- * Process files in waves to respect concurrency limits.
8105
+ * Continuous-queue scheduler (#845, PR 4). Mirrors the directory-
8106
+ * level scheduler in `summarizeDiffs.ts` and replaces the previous
8107
+ * fixed-wave Promise.all loop, which made the slowest call in
8108
+ * each wave block the next wave from starting. With realistic LLM
8109
+ * tail variance, that wave-locking adds dead time at every wave
8110
+ * boundary; continuous queue fills slots as in-flight calls
8111
+ * resolve, so the wall-clock tracks the slowest *call*, not the
8112
+ * sum of slowest-per-wave.
7555
8113
  */
7556
8114
  async function processInWaves$1(items, processor, maxConcurrent) {
7557
- const results = [];
7558
- for (let i = 0; i < items.length; i += maxConcurrent) {
7559
- const wave = items.slice(i, i + maxConcurrent);
7560
- const waveResults = await Promise.all(wave.map(processor));
7561
- results.push(...waveResults);
7562
- }
7563
- return results;
8115
+ const limit = createLimit$2(maxConcurrent);
8116
+ return Promise.all(items.map((item) => limit(() => processor(item))));
8117
+ }
8118
+ function createLimit$2(maxConcurrent) {
8119
+ const limit = Math.max(1, maxConcurrent);
8120
+ let active = 0;
8121
+ const queue = [];
8122
+ const runNext = () => {
8123
+ active--;
8124
+ const next = queue.shift();
8125
+ if (next)
8126
+ next();
8127
+ };
8128
+ return async (operation) => {
8129
+ if (active >= limit) {
8130
+ await new Promise((resolve) => queue.push(resolve));
8131
+ }
8132
+ active++;
8133
+ try {
8134
+ return await operation();
8135
+ }
8136
+ finally {
8137
+ runNext();
8138
+ }
8139
+ };
7564
8140
  }
7565
8141
  /**
7566
8142
  * Pre-summarize individual files that exceed the maxFileTokens threshold.
@@ -7625,6 +8201,13 @@ async function preprocessLargeFiles(rootNode, options) {
7625
8201
  return rebuildNode(rootNode);
7626
8202
  }
7627
8203
 
8204
+ /**
8205
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8206
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8207
+ */
8208
+ function isCacheEnabled() {
8209
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8210
+ }
7628
8211
  /**
7629
8212
  * Create groups from a given node info.
7630
8213
  * @param {DiffNode} node - The node info to start grouping.
@@ -7650,6 +8233,32 @@ function createDirectoryDiffs(node) {
7650
8233
  * Summarize a directory diff asynchronously.
7651
8234
  */
7652
8235
  async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer, logger, metadata }) {
8236
+ // Cache lookup (#845, PR 5). Joined per-file diffs become the
8237
+ // payload signature; if every file in the directory is unchanged
8238
+ // since the last run (and the model + prompt match), the prior
8239
+ // directory-level summary is reused instead of paying for another
8240
+ // map_reduce pass.
8241
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8242
+ const cacheRepo = process.cwd();
8243
+ const cachePayload = directory.diffs
8244
+ .map((diff) => `${diff.file}\x1e${diff.diff}`)
8245
+ .join('\x1d');
8246
+ const cacheKey = isCacheEnabled() && cacheModel
8247
+ ? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH)
8248
+ : undefined;
8249
+ if (cacheKey) {
8250
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8251
+ if (cached) {
8252
+ logger?.verbose?.(` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8253
+ touchDiffSummary(cacheRepo, cacheKey);
8254
+ return {
8255
+ diffs: directory.diffs,
8256
+ path: directory.path,
8257
+ summary: cached.summary,
8258
+ tokenCount: cached.tokens,
8259
+ };
8260
+ }
8261
+ }
7653
8262
  try {
7654
8263
  const directorySummary = await summarize(directory.diffs.map((diff) => ({
7655
8264
  pageContent: diff.diff,
@@ -7671,6 +8280,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
7671
8280
  },
7672
8281
  });
7673
8282
  const newTokenTotal = tokenizer(directorySummary);
8283
+ if (cacheKey && cacheModel) {
8284
+ writeDiffSummary(cacheRepo, cacheKey, {
8285
+ summary: directorySummary,
8286
+ model: cacheModel,
8287
+ tokens: newTokenTotal,
8288
+ });
8289
+ }
7674
8290
  return {
7675
8291
  diffs: directory.diffs,
7676
8292
  path: directory.path,
@@ -7705,66 +8321,99 @@ const defaultOutputCallback = (group) => {
7705
8321
  return output;
7706
8322
  };
7707
8323
  /**
7708
- * Process directory summarization in waves to respect concurrency limits
7709
- * while maintaining predictable behavior.
8324
+ * Continuous-queue scheduler for the directory summarization pass
8325
+ * (#845, PR 4). The previous wave-by-wave Promise.all forced the
8326
+ * scheduler to wait for the slowest call in a wave before starting
8327
+ * the next wave; on a fixture like `refactor` (20 directories, mixed
8328
+ * sizes) one big directory could pin the wave at ~its own latency
8329
+ * even though the other 19 calls finished long before.
8330
+ *
8331
+ * The continuous queue dispatches all eligible directories through
8332
+ * a `createLimit(maxConcurrent)` semaphore — same primitive
8333
+ * `collectDiffs` already uses. As soon as any in-flight summary
8334
+ * resolves, the next eligible directory takes its slot. Each
8335
+ * scheduled call also re-checks the budget at the moment it would
8336
+ * fire; if the budget is already met (because earlier completions
8337
+ * dropped the total under maxTokens), it returns the original
8338
+ * directory without an LLM call. So the work scales with what's
8339
+ * actually needed, not with the worst-case wave count.
8340
+ *
8341
+ * Order discipline is preserved: directories are sorted by token
8342
+ * count descending and dispatched in that order. The biggest
8343
+ * candidates land in the first batch of in-flight calls; as smaller
8344
+ * candidates reach the queue front, the budget is more likely to
8345
+ * already be met and they short-circuit.
7710
8346
  */
7711
8347
  async function summarizeInWaves(directories, options) {
7712
8348
  const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
7713
8349
  let totalTokenCount = initialTotal;
7714
8350
  const results = [...directories];
7715
- // Create sorted indices by token count (descending) for prioritized processing
7716
- const sortedIndices = directories
8351
+ // Pick eligible directories upfront, sorted big-first.
8352
+ const eligibleIndices = directories
7717
8353
  .map((d, i) => ({ index: i, tokens: d.tokenCount }))
7718
- .sort((a, b) => b.tokens - a.tokens);
7719
- let cursor = 0;
7720
- while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
7721
- // Select wave candidates: directories that exceed minTokensForSummary
7722
- const wave = [];
7723
- for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
7724
- const { index, tokens } = sortedIndices[i];
7725
- // Skip directories below the minimum threshold
7726
- if (tokens < minTokensForSummary) {
7727
- cursor = i + 1;
7728
- continue;
7729
- }
7730
- // Skip directories that have already been summarized
7731
- if (results[index].summary) {
7732
- cursor = i + 1;
7733
- continue;
7734
- }
7735
- wave.push(index);
7736
- cursor = i + 1;
7737
- }
7738
- // No more eligible candidates
7739
- if (wave.length === 0) {
7740
- break;
8354
+ .filter((entry) => entry.tokens >= minTokensForSummary && !results[entry.index].summary)
8355
+ .sort((a, b) => b.tokens - a.tokens)
8356
+ .map((entry) => entry.index);
8357
+ if (eligibleIndices.length === 0 || totalTokenCount <= maxTokens) {
8358
+ return { directories: results, totalTokenCount };
8359
+ }
8360
+ const limit = createLimit$1(maxConcurrent);
8361
+ logger.verbose(`\nProcessing ${eligibleIndices.length} directories with continuous queue (concurrency ${maxConcurrent})...`, { color: 'blue' });
8362
+ await Promise.all(eligibleIndices.map((idx) => limit(async () => {
8363
+ // Re-check the budget at dispatch time. Earlier completions
8364
+ // may have already dropped the total under the cap; in that
8365
+ // case skip the LLM call entirely.
8366
+ if (totalTokenCount <= maxTokens) {
8367
+ return;
7741
8368
  }
7742
- logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
7743
- // Process wave in parallel
7744
- const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer, logger, metadata })));
7745
- // Update results and recalculate total
7746
- waveResults.forEach((result, i) => {
7747
- const idx = wave[i];
7748
- const originalTokens = results[idx].tokenCount;
7749
- const newTokens = result.tokenCount;
7750
- const reduction = originalTokens - newTokens;
7751
- totalTokenCount -= reduction;
7752
- results[idx] = result;
7753
- logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
7754
- color: 'magenta',
7755
- });
8369
+ const result = await summarizeDirectoryDiff(results[idx], {
8370
+ chain,
8371
+ textSplitter,
8372
+ tokenizer,
8373
+ logger,
8374
+ metadata,
7756
8375
  });
7757
- logger.verbose(`Total token count: ${totalTokenCount}`, {
7758
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8376
+ const originalTokens = results[idx].tokenCount;
8377
+ const newTokens = result.tokenCount;
8378
+ totalTokenCount -= (originalTokens - newTokens);
8379
+ results[idx] = result;
8380
+ logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
8381
+ color: 'magenta',
7759
8382
  });
7760
- // Check if we're now under budget
7761
- if (totalTokenCount <= maxTokens) {
7762
- logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
7763
- break;
7764
- }
7765
- }
8383
+ })));
8384
+ logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
8385
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8386
+ });
7766
8387
  return { directories: results, totalTokenCount };
7767
8388
  }
8389
+ /**
8390
+ * Tiny semaphore mirroring `collectDiffs.createLimit` (kept private
8391
+ * here to avoid a cross-module import for one helper). Schedules at
8392
+ * most `maxConcurrent` operations concurrently; the rest queue FIFO.
8393
+ */
8394
+ function createLimit$1(maxConcurrent) {
8395
+ const limit = Math.max(1, maxConcurrent);
8396
+ let active = 0;
8397
+ const queue = [];
8398
+ const runNext = () => {
8399
+ active--;
8400
+ const next = queue.shift();
8401
+ if (next)
8402
+ next();
8403
+ };
8404
+ return async (operation) => {
8405
+ if (active >= limit) {
8406
+ await new Promise((resolve) => queue.push(resolve));
8407
+ }
8408
+ active++;
8409
+ try {
8410
+ return await operation();
8411
+ }
8412
+ finally {
8413
+ runNext();
8414
+ }
8415
+ };
8416
+ }
7768
8417
  /**
7769
8418
  * Summarize diffs using a three-phase approach:
7770
8419
  *
@@ -7778,7 +8427,16 @@ async function summarizeInWaves(directories, options) {
7778
8427
  * - Efficient parallel processing with predictable behavior
7779
8428
  * - Early exit when under token budget
7780
8429
  */
7781
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8430
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
8431
+ // Default raised to 4096 (#845) so the budget matches the
8432
+ // canonical service configs in `langchain/utils.ts`. The
8433
+ // previous 2048 default came from an earlier era when 4k
8434
+ // context was a stretch for fast models; today every shipped
8435
+ // service overrides it to 4096 anyway. Keeping this in sync
8436
+ // with the service defaults means a caller that omits
8437
+ // `maxTokens` doesn't accidentally fall into a tighter budget
8438
+ // than the rest of the system assumes.
8439
+ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
7782
8440
  // Calculate maxFileTokens as 25% of maxTokens if not specified
7783
8441
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
7784
8442
  // PHASE 1: Directory grouping & assessment
@@ -10797,10 +11455,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
10797
11455
  // 1. Pre-process large files to prevent bias
10798
11456
  // 2. Group by directory and assess token count
10799
11457
  // 3. Wave-based parallel summarization until under budget
11458
+ //
11459
+ // The 4096 fallback (#845) matches the default service configs
11460
+ // for openai / anthropic / ollama (`langchain/utils.ts`). It's a
11461
+ // safety net for users with custom service definitions that omit
11462
+ // `tokenLimit` — without it those users hit a degenerate 2048
11463
+ // budget that triggers needless pre-summarization on diffs the
11464
+ // model could absorb whole.
10800
11465
  logger.startTimer();
10801
11466
  const summary = await summarizeDiffs(diffs, {
10802
11467
  tokenizer,
10803
- maxTokens: maxTokens || 2048,
11468
+ maxTokens: maxTokens || 4096,
10804
11469
  minTokensForSummary,
10805
11470
  maxFileTokens,
10806
11471
  maxConcurrent,
@@ -15369,6 +16034,13 @@ const LOG_INK_KEY_BINDINGS = [
15369
16034
  description: 'Push the dedicated pull-request action panel for the current branch.',
15370
16035
  contexts: ['normal'],
15371
16036
  },
16037
+ {
16038
+ id: 'navigateConflicts',
16039
+ keys: ['gx'],
16040
+ label: 'conflicts',
16041
+ description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16042
+ contexts: ['normal'],
16043
+ },
15372
16044
  {
15373
16045
  id: 'navigateBack',
15374
16046
  keys: ['<', 'esc'],
@@ -15498,6 +16170,7 @@ const GLOBAL_BINDING_IDS = [
15498
16170
  'navigateStash',
15499
16171
  'navigateWorktrees',
15500
16172
  'navigatePullRequest',
16173
+ 'navigateConflicts',
15501
16174
  'navigateBack',
15502
16175
  ];
15503
16176
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15670,6 +16343,12 @@ function getLogInkFooterHints(options) {
15670
16343
  global: NORMAL_GLOBAL_HINTS,
15671
16344
  };
15672
16345
  }
16346
+ if (options.activeView === 'conflicts') {
16347
+ return {
16348
+ contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'],
16349
+ global: NORMAL_GLOBAL_HINTS,
16350
+ };
16351
+ }
15673
16352
  return {
15674
16353
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15675
16354
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16416,6 +17095,7 @@ function createLogInkState(rows, options = {}) {
16416
17095
  selectedTagIndex: 0,
16417
17096
  selectedStashIndex: 0,
16418
17097
  selectedWorktreeListIndex: 0,
17098
+ selectedConflictFileIndex: 0,
16419
17099
  branchSort: DEFAULT_BRANCH_SORT_MODE,
16420
17100
  tagSort: DEFAULT_TAG_SORT_MODE,
16421
17101
  paletteFilter: '',
@@ -16669,6 +17349,12 @@ function applyLogInkAction(state, action) {
16669
17349
  selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
16670
17350
  pendingKey: undefined,
16671
17351
  };
17352
+ case 'moveConflictFile':
17353
+ return {
17354
+ ...state,
17355
+ selectedConflictFileIndex: clampIndex$1(state.selectedConflictFileIndex + action.delta, action.count),
17356
+ pendingKey: undefined,
17357
+ };
16672
17358
  case 'cycleBranchSort':
16673
17359
  return {
16674
17360
  ...state,
@@ -17337,6 +18023,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
17337
18023
  return [action({ type: 'pushView', value: 'worktrees' })];
17338
18024
  case 'navigatePullRequest':
17339
18025
  return [action({ type: 'pushView', value: 'pull-request' })];
18026
+ case 'navigateConflicts':
18027
+ return [action({ type: 'pushView', value: 'conflicts' })];
17340
18028
  case 'navigateBack':
17341
18029
  return [action({ type: 'popView' })];
17342
18030
  case 'openSelected': {
@@ -17808,6 +18496,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17808
18496
  action({ type: 'setStatus', value: 'jumped to pull request' }),
17809
18497
  ];
17810
18498
  }
18499
+ if (state.pendingKey === 'g' && inputValue === 'x') {
18500
+ return [
18501
+ action({ type: 'pushView', value: 'conflicts' }),
18502
+ action({ type: 'setStatus', value: 'jumped to conflicts' }),
18503
+ ];
18504
+ }
17811
18505
  // `gH` chord: apply the cursored hunk to the index (`git apply
17812
18506
  // --cached`). Sibling of bare `H` which targets the worktree.
17813
18507
  // Discoverable via the footer hint on diff views and the help
@@ -18117,6 +18811,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18117
18811
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18118
18812
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18119
18813
  }
18814
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18815
+ return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })];
18816
+ }
18120
18817
  if (state.activeView === 'history' &&
18121
18818
  state.focus === 'commits' &&
18122
18819
  state.selectedIndex === 0 &&
@@ -18195,6 +18892,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18195
18892
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18196
18893
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
18197
18894
  }
18895
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18896
+ return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })];
18897
+ }
18198
18898
  return [
18199
18899
  action(state.focus === 'sidebar'
18200
18900
  ? { type: 'nextSidebarTab' }
@@ -18380,6 +19080,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18380
19080
  fileIndex: state.selectedWorktreeFileIndex,
18381
19081
  })];
18382
19082
  }
19083
+ // Enter on a conflict file opens the worktree diff for that file so
19084
+ // the user can inspect the conflict markers in context.
19085
+ if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19086
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }];
19087
+ }
18383
19088
  // Enter on a branch row checks the branch out. Non-destructive workflow
18384
19089
  // action — no confirmation prompt. Fires from either the dedicated
18385
19090
  // branches view or from the sidebar when the branches tab is focused
@@ -18521,6 +19226,32 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18521
19226
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
18522
19227
  return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
18523
19228
  }
19229
+ // --- Conflicts view per-row handlers ---
19230
+ // `o` opens the conflicted file in $EDITOR for manual resolution.
19231
+ if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19232
+ return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }];
19233
+ }
19234
+ // `s` stages the conflicted file (marks it resolved).
19235
+ if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19236
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }];
19237
+ }
19238
+ // `u` resolves by keeping theirs (incoming changes).
19239
+ if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19240
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }];
19241
+ }
19242
+ // `U` resolves by keeping ours (current branch).
19243
+ if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19244
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }];
19245
+ }
19246
+ // `C` continues the in-progress operation (available when no conflicts remain).
19247
+ if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) {
19248
+ return [{ type: 'runWorkflowAction', id: 'continue-operation' }];
19249
+ }
19250
+ // Always intercept `C` on the conflicts view to prevent fallthrough to
19251
+ // the global `C` (Create PR) binding when conflicts remain.
19252
+ if (inputValue === 'C' && state.activeView === 'conflicts') {
19253
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
19254
+ }
18524
19255
  // `c` on a stash diff cherry-picks the file under the cursor —
18525
19256
  // materializes that single path from the stash into the working tree
18526
19257
  // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
@@ -21817,6 +22548,21 @@ function skipOperation(git, operation) {
21817
22548
  }
21818
22549
  return runAction(() => git.raw(command.args), command.successMessage);
21819
22550
  }
22551
+ function resolveConflictOurs(git, path) {
22552
+ return runAction(async () => {
22553
+ await git.raw(['checkout', '--ours', '--', path]);
22554
+ await git.raw(['add', '--', path]);
22555
+ }, `Resolved ${path} (kept ours)`);
22556
+ }
22557
+ function resolveConflictTheirs(git, path) {
22558
+ return runAction(async () => {
22559
+ await git.raw(['checkout', '--theirs', '--', path]);
22560
+ await git.raw(['add', '--', path]);
22561
+ }, `Resolved ${path} (kept theirs)`);
22562
+ }
22563
+ function stageConflictResolved(git, path) {
22564
+ return runAction(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
22565
+ }
21820
22566
 
21821
22567
  function openProviderUrl(repository, target, openUrl = defaultOpenUrlRunner) {
21822
22568
  const url = repository ? buildProviderUrl(repository, target) : undefined;
@@ -25199,6 +25945,51 @@ function LogInkApp(deps) {
25199
25945
  }
25200
25946
  return abortOperation(git, operation);
25201
25947
  },
25948
+ 'resolve-conflict-ours': async () => {
25949
+ const path = payload?.trim();
25950
+ if (!path)
25951
+ return { ok: false, message: 'No conflict file selected' };
25952
+ return resolveConflictOurs(git, path);
25953
+ },
25954
+ 'resolve-conflict-theirs': async () => {
25955
+ const path = payload?.trim();
25956
+ if (!path)
25957
+ return { ok: false, message: 'No conflict file selected' };
25958
+ return resolveConflictTheirs(git, path);
25959
+ },
25960
+ 'resolve-conflict-stage': async () => {
25961
+ const path = payload?.trim();
25962
+ if (!path)
25963
+ return { ok: false, message: 'No conflict file selected' };
25964
+ return stageConflictResolved(git, path);
25965
+ },
25966
+ 'resolve-conflict-open-diff': async () => {
25967
+ // Push the diff view for the conflicted file so the user can
25968
+ // inspect conflict markers in context. We find the file's index
25969
+ // in the worktree file list and navigate to its diff.
25970
+ const path = payload?.trim();
25971
+ if (!path)
25972
+ return { ok: false, message: 'No conflict file selected' };
25973
+ const worktreeFiles = context.worktree?.files || [];
25974
+ const fileIndex = worktreeFiles.findIndex((f) => f.path === path);
25975
+ if (fileIndex >= 0) {
25976
+ dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex });
25977
+ return { ok: true, message: `Viewing diff for ${path}` };
25978
+ }
25979
+ // File not in worktree list (e.g. deleted-by-us) — open in
25980
+ // editor as fallback so the user can still inspect it.
25981
+ return { ok: true, message: `${path} not in worktree diff list` };
25982
+ },
25983
+ 'continue-operation': async () => {
25984
+ const operation = context.operation?.operation;
25985
+ if (!operation || operation === 'none') {
25986
+ return { ok: false, message: 'No git operation in progress' };
25987
+ }
25988
+ if ((context.operation?.conflictedFiles.length ?? 0) > 0) {
25989
+ return { ok: false, message: 'Resolve all conflicts before continuing' };
25990
+ }
25991
+ return continueOperation(git, operation);
25992
+ },
25202
25993
  'open-pr': async () => {
25203
25994
  const repo = context.provider?.repository;
25204
25995
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
@@ -25722,6 +26513,14 @@ function LogInkApp(deps) {
25722
26513
  ? selected?.hash
25723
26514
  : undefined,
25724
26515
  worktreeDirty,
26516
+ conflictFileCount: context.operation?.conflictedFiles.length,
26517
+ conflictSelectedPath: (() => {
26518
+ const files = context.operation?.conflictedFiles;
26519
+ if (!files || files.length === 0)
26520
+ return undefined;
26521
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
26522
+ return files[clamped]?.path;
26523
+ })(),
25725
26524
  // H / gH need the actual diff text (not just hunk offsets) to
25726
26525
  // slice the cursored hunk into a `git apply` patch. Stash uses
25727
26526
  // the full `git stash show -p` output; commit-diff uses the
@@ -26086,6 +26885,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
26086
26885
  if (state.activeView === 'pull-request') {
26087
26886
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26088
26887
  }
26888
+ if (state.activeView === 'conflicts') {
26889
+ return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26890
+ }
26089
26891
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
26090
26892
  }
26091
26893
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -26269,6 +27071,79 @@ function buildStatusSurfaceRows(groups) {
26269
27071
  }
26270
27072
  return rows;
26271
27073
  }
27074
+ function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27075
+ const { Box, Text } = components;
27076
+ const focused = state.focus === 'commits';
27077
+ const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
27078
+ const operation = context.operation;
27079
+ const conflictedFiles = operation?.conflictedFiles || [];
27080
+ const operationType = operation?.operation || 'none';
27081
+ // If no operation is in progress, show a fallback message.
27082
+ if (!loading && operationType === 'none') {
27083
+ return h(Box, {
27084
+ borderColor: focusBorderColor(theme, focused),
27085
+ borderStyle: theme.borderStyle,
27086
+ flexDirection: 'column',
27087
+ flexShrink: 0,
27088
+ paddingX: 1,
27089
+ width,
27090
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, 'no operation in progress')), h(Text, { key: 'conflicts-empty', dimColor: true }, 'No merge, rebase, cherry-pick, or revert in progress.'));
27091
+ }
27092
+ // All conflicts resolved — show the "continue" hint.
27093
+ if (!loading && conflictedFiles.length === 0 && operationType !== 'none') {
27094
+ return h(Box, {
27095
+ borderColor: focusBorderColor(theme, focused),
27096
+ borderStyle: theme.borderStyle,
27097
+ flexDirection: 'column',
27098
+ flexShrink: 0,
27099
+ paddingX: 1,
27100
+ width,
27101
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, `${operationType} — all conflicts resolved`)), h(Text, { key: 'conflicts-hint', dimColor: true }, `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.`));
27102
+ }
27103
+ const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1)));
27104
+ const listRows = Math.max(4, bodyRows - 4);
27105
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
27106
+ const visible = conflictedFiles.slice(startIndex, startIndex + listRows);
27107
+ const remaining = conflictedFiles.length;
27108
+ const headerRight = loading
27109
+ ? 'loading conflicts'
27110
+ : `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining`;
27111
+ const statusLabel = (file) => {
27112
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
27113
+ switch (code) {
27114
+ case 'UU': return 'both modified';
27115
+ case 'AA': return 'added by both';
27116
+ case 'DD': return 'both deleted';
27117
+ case 'AU':
27118
+ case 'UA': return 'added by one';
27119
+ case 'DU': return 'deleted by us';
27120
+ case 'UD': return 'deleted by them';
27121
+ default: return code;
27122
+ }
27123
+ };
27124
+ const lines = loading
27125
+ ? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))]
27126
+ : visible.map((file, offset) => {
27127
+ const index = startIndex + offset;
27128
+ const isSelected = index === selected;
27129
+ const cursor = isSelected ? '>' : ' ';
27130
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
27131
+ const label = statusLabel(file);
27132
+ return h(Text, {
27133
+ key: `conflict-${index}`,
27134
+ bold: isSelected,
27135
+ dimColor: !isSelected,
27136
+ }, truncate$1(`${cursor} ${code} ${file.path} (${label})`, width - 4));
27137
+ });
27138
+ return h(Box, {
27139
+ borderColor: focusBorderColor(theme, focused),
27140
+ borderStyle: theme.borderStyle,
27141
+ flexDirection: 'column',
27142
+ flexShrink: 0,
27143
+ paddingX: 1,
27144
+ width,
27145
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
27146
+ }
26272
27147
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26273
27148
  const { Box, Text } = components;
26274
27149
  const focused = state.focus === 'commits';
@@ -29006,6 +29881,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
29006
29881
  y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
29007
29882
  y.command(log.command, log.desc, log.builder, log.handler);
29008
29883
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
29884
+ y.command(cache.command, cache.desc, cache.builder, cache.handler);
29009
29885
  y.help().parse(process.argv.slice(2));
29010
29886
 
29011
29887
  /**
@@ -29457,4 +30333,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
29457
30333
  handleValidationErrors: handleValidationErrors
29458
30334
  });
29459
30335
 
29460
- export { changelog, commit, doctor, init, log, recap, types, ui };
30336
+ export { cache, changelog, commit, doctor, init, log, recap, types, ui };