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.
- package/dist/index.d.ts +15 -2
- package/dist/index.esm.mjs +947 -71
- package/dist/index.js +947 -71
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ var example_selectors = require('@langchain/core/example_selectors');
|
|
|
6
6
|
var yargs = require('yargs');
|
|
7
7
|
var chalk = require('chalk');
|
|
8
8
|
var fs = require('fs');
|
|
9
|
+
var crypto = require('node:crypto');
|
|
9
10
|
var ini = require('ini');
|
|
10
11
|
var os = require('os');
|
|
11
12
|
var path = require('path');
|
|
@@ -13,6 +14,9 @@ var Ajv = require('ajv');
|
|
|
13
14
|
var ora = require('ora');
|
|
14
15
|
var now = require('performance-now');
|
|
15
16
|
var prettyMilliseconds = require('pretty-ms');
|
|
17
|
+
var fs$1 = require('node:fs');
|
|
18
|
+
var os$1 = require('node:os');
|
|
19
|
+
var path$1 = require('node:path');
|
|
16
20
|
var anthropic = require('@langchain/anthropic');
|
|
17
21
|
var ollama = require('@langchain/ollama');
|
|
18
22
|
var openai = require('@langchain/openai');
|
|
@@ -37,10 +41,6 @@ require('@langchain/core/utils/async_caller');
|
|
|
37
41
|
var tiktoken = require('tiktoken');
|
|
38
42
|
var child_process = require('child_process');
|
|
39
43
|
var node_child_process = require('node:child_process');
|
|
40
|
-
var fs$1 = require('node:fs');
|
|
41
|
-
var os$1 = require('node:os');
|
|
42
|
-
var path$1 = require('node:path');
|
|
43
|
-
var crypto = require('node:crypto');
|
|
44
44
|
var readline = require('readline');
|
|
45
45
|
var util$1 = require('util');
|
|
46
46
|
var crypto$1 = require('crypto');
|
|
@@ -64,13 +64,13 @@ function _interopNamespaceDefault(e) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
67
|
+
var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
|
|
67
68
|
var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
|
|
68
69
|
var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
|
|
69
70
|
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
|
|
70
71
|
var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
|
|
71
72
|
var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
|
|
72
73
|
var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
|
|
73
|
-
var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
|
|
74
74
|
var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
75
75
|
|
|
76
76
|
// This file is auto-generated - DO NOT EDIT
|
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.43.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -254,6 +254,19 @@ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
|
|
|
254
254
|
inputVariables: inputVariables$4,
|
|
255
255
|
template: template$4,
|
|
256
256
|
});
|
|
257
|
+
/**
|
|
258
|
+
* Stable fingerprint of the active summarization template (#845, PR 5).
|
|
259
|
+
*
|
|
260
|
+
* The diff-summary cache keys include this hash so any prompt edit
|
|
261
|
+
* invalidates prior cache entries automatically — no manual bumps,
|
|
262
|
+
* no stale outputs that no longer reflect the current prompt's voice
|
|
263
|
+
* or rules. Only the template body matters; whitespace differences
|
|
264
|
+
* still re-key the cache, which is the safe default.
|
|
265
|
+
*/
|
|
266
|
+
const SUMMARIZE_PROMPT_HASH = crypto.createHash('sha256')
|
|
267
|
+
.update(template$4)
|
|
268
|
+
.digest('hex')
|
|
269
|
+
.slice(0, 16);
|
|
257
270
|
|
|
258
271
|
/**
|
|
259
272
|
* Base class for all LangChain-related errors
|
|
@@ -437,10 +450,21 @@ function getDefaultServiceApiKey(config) {
|
|
|
437
450
|
}
|
|
438
451
|
const DEFAULT_OPENAI_LLM_SERVICE = {
|
|
439
452
|
provider: 'openai',
|
|
440
|
-
|
|
453
|
+
// Bumped from `gpt-4o-mini` to `gpt-4.1-nano` (#854). Diff
|
|
454
|
+
// condensing is bounded summarization — the cheaper / faster
|
|
455
|
+
// tier is the right default for it; quality is on par for this
|
|
456
|
+
// class of task. Users who want the older 4o-mini can still
|
|
457
|
+
// override via service config.
|
|
458
|
+
model: 'gpt-4.1-nano',
|
|
441
459
|
tokenLimit: 4096,
|
|
442
460
|
temperature: 0.32,
|
|
443
|
-
|
|
461
|
+
// Bumped 12 → 24 (#845, PR 3). The OpenAI fast tier comfortably
|
|
462
|
+
// handles ~30 concurrent on the per-key default rate limit; 24
|
|
463
|
+
// leaves headroom for retries while still doubling throughput.
|
|
464
|
+
// The summarize chain has a 429-aware backoff (`summarize`
|
|
465
|
+
// helper) so a temporary rate-limit hit no longer kills the
|
|
466
|
+
// whole pipeline.
|
|
467
|
+
maxConcurrent: 24,
|
|
444
468
|
minTokensForSummary: 800,
|
|
445
469
|
maxFileTokens: 2000,
|
|
446
470
|
authentication: {
|
|
@@ -452,10 +476,20 @@ const DEFAULT_OPENAI_LLM_SERVICE = {
|
|
|
452
476
|
};
|
|
453
477
|
const DEFAULT_ANTHROPIC_LLM_SERVICE = {
|
|
454
478
|
provider: 'anthropic',
|
|
455
|
-
|
|
479
|
+
// Bumped from `claude-3-5-sonnet-20240620` to
|
|
480
|
+
// `claude-haiku-4-5-20251001` (#854). The Sonnet 3.5 default
|
|
481
|
+
// was nearly two model generations stale; Haiku 4.5 is the
|
|
482
|
+
// current fast tier and the right fit for diff summarization.
|
|
483
|
+
// Users who want Sonnet for quality-sensitive runs can still
|
|
484
|
+
// override via service config (recommended: `claude-sonnet-4-6`).
|
|
485
|
+
model: 'claude-haiku-4-5-20251001',
|
|
456
486
|
temperature: 0.32,
|
|
457
487
|
tokenLimit: 4096,
|
|
458
|
-
|
|
488
|
+
// Bumped 12 → 24 (#845, PR 3). Matches the OpenAI default;
|
|
489
|
+
// Anthropic's per-key concurrency on Haiku is generous enough
|
|
490
|
+
// that 24 stays under the rate ceiling for typical fast-model
|
|
491
|
+
// request shapes. Backoff in `summarize` handles spikes.
|
|
492
|
+
maxConcurrent: 24,
|
|
459
493
|
minTokensForSummary: 800,
|
|
460
494
|
maxFileTokens: 2000,
|
|
461
495
|
authentication: {
|
|
@@ -1439,6 +1473,10 @@ const schema$1 = {
|
|
|
1439
1473
|
"AnthropicModel": {
|
|
1440
1474
|
"type": "string",
|
|
1441
1475
|
"enum": [
|
|
1476
|
+
"claude-sonnet-4-6",
|
|
1477
|
+
"claude-haiku-4-5-20251001",
|
|
1478
|
+
"claude-haiku-4-5",
|
|
1479
|
+
"claude-opus-4-7",
|
|
1442
1480
|
"claude-sonnet-4-0",
|
|
1443
1481
|
"claude-3-7-sonnet-latest",
|
|
1444
1482
|
"claude-3-5-haiku-latest",
|
|
@@ -1972,6 +2010,44 @@ function parseServiceConfig(service) {
|
|
|
1972
2010
|
}
|
|
1973
2011
|
}
|
|
1974
2012
|
|
|
2013
|
+
/**
|
|
2014
|
+
* Ensure the canonical default ignore lists are always present in
|
|
2015
|
+
* the resolved config (#851). User-provided `ignoredFiles` /
|
|
2016
|
+
* `ignoredExtensions` arrays from XDG / git / project / env config
|
|
2017
|
+
* sources used to *replace* the defaults wholesale via the shallow
|
|
2018
|
+
* spread in each loader, which silently dropped lockfile + node_modules
|
|
2019
|
+
* entries the moment a user provided their own list. The reported
|
|
2020
|
+
* symptom: `pnpm-lock.yaml` reaching the diff-condensing pipeline
|
|
2021
|
+
* after a user added `.coco.config.json` for unrelated overrides.
|
|
2022
|
+
*
|
|
2023
|
+
* Now: user values are *unioned* with the defaults. Order is preserved
|
|
2024
|
+
* (defaults first, then user-only additions in their original order).
|
|
2025
|
+
* Duplicates are de-duped. The defaults can no longer be opted out of —
|
|
2026
|
+
* the cost of accidentally summarizing a lockfile (minutes of LLM time
|
|
2027
|
+
* per commit) outweighs the niche case of intentionally excluding a
|
|
2028
|
+
* default lockfile pattern.
|
|
2029
|
+
*/
|
|
2030
|
+
function unionPreservingOrder(base, extras) {
|
|
2031
|
+
if (!extras || extras.length === 0)
|
|
2032
|
+
return [...base];
|
|
2033
|
+
const seen = new Set(base);
|
|
2034
|
+
const merged = [...base];
|
|
2035
|
+
for (const value of extras) {
|
|
2036
|
+
if (!seen.has(value)) {
|
|
2037
|
+
seen.add(value);
|
|
2038
|
+
merged.push(value);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return merged;
|
|
2042
|
+
}
|
|
2043
|
+
function mergeIgnoreLists(config) {
|
|
2044
|
+
return {
|
|
2045
|
+
...config,
|
|
2046
|
+
ignoredFiles: unionPreservingOrder(DEFAULT_IGNORED_FILES$1, config.ignoredFiles),
|
|
2047
|
+
ignoredExtensions: unionPreservingOrder(DEFAULT_IGNORED_EXTENSIONS$1, config.ignoredExtensions),
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1975
2051
|
/**
|
|
1976
2052
|
* Tracked config sources populated during the last loadConfig call.
|
|
1977
2053
|
* Useful for diagnostics (e.g. `coco doctor`).
|
|
@@ -2022,6 +2098,13 @@ function loadConfig(argv = {}) {
|
|
|
2022
2098
|
config = envConfig;
|
|
2023
2099
|
if (envActive)
|
|
2024
2100
|
sources.push({ source: 'env' });
|
|
2101
|
+
// Re-apply the canonical default ignore lists after every loader has
|
|
2102
|
+
// had a chance to override (#851). Each loader replaces ignoredFiles
|
|
2103
|
+
// / ignoredExtensions wholesale via shallow spread, which used to
|
|
2104
|
+
// silently drop the lockfile + node_modules defaults the moment a
|
|
2105
|
+
// user provided their own list. The merge is a union — defaults first,
|
|
2106
|
+
// user-only entries appended.
|
|
2107
|
+
config = mergeIgnoreLists(config);
|
|
2025
2108
|
_lastConfigSources = sources;
|
|
2026
2109
|
return { ...config, ...argv };
|
|
2027
2110
|
}
|
|
@@ -2196,6 +2279,232 @@ function commandExecutor(handler) {
|
|
|
2196
2279
|
};
|
|
2197
2280
|
}
|
|
2198
2281
|
|
|
2282
|
+
const command$8 = 'cache <subcommand>';
|
|
2283
|
+
const builder$8 = (yargs) => {
|
|
2284
|
+
return yargs
|
|
2285
|
+
.positional('subcommand', {
|
|
2286
|
+
describe: 'Cache action to run (clear, info)',
|
|
2287
|
+
type: 'string',
|
|
2288
|
+
choices: ['clear', 'info'],
|
|
2289
|
+
})
|
|
2290
|
+
.usage(getCommandUsageHeader(command$8));
|
|
2291
|
+
};
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a
|
|
2295
|
+
* re-run of `coco commit` after a small change, most files have
|
|
2296
|
+
* unchanged content and unchanged diffs — caching their summaries
|
|
2297
|
+
* by content hash means the second run skips the LLM entirely for
|
|
2298
|
+
* those files and only pays for what's actually different.
|
|
2299
|
+
*
|
|
2300
|
+
* Strict best-effort: read failures fall back to "no cache" (the
|
|
2301
|
+
* pipeline runs the LLM as before), and write failures are
|
|
2302
|
+
* swallowed silently. The cache is never load-bearing.
|
|
2303
|
+
*
|
|
2304
|
+
* Repos are keyed by a short hash of their absolute path. No PII
|
|
2305
|
+
* in the cache filename, and re-creating a repo at the same path
|
|
2306
|
+
* keeps the same cache.
|
|
2307
|
+
*
|
|
2308
|
+
* Cache key: `sha256(diff + ':' + model + ':' + promptHash)`.
|
|
2309
|
+
* - diff: the literal diff text being summarized
|
|
2310
|
+
* - model: switching models invalidates (different summaries)
|
|
2311
|
+
* - promptHash: editing the SUMMARIZE_PROMPT template invalidates
|
|
2312
|
+
*
|
|
2313
|
+
* Cap: 500 entries per repo. LRU eviction on overflow keeps the
|
|
2314
|
+
* cache file under ~500 KB on a typical repo (each entry is a
|
|
2315
|
+
* sha256 hash + 200-500-byte summary).
|
|
2316
|
+
*/
|
|
2317
|
+
const CACHE_SCHEMA_VERSION$1 = 1;
|
|
2318
|
+
const CACHE_DIR_NAME$1 = 'diff-summaries';
|
|
2319
|
+
const CACHE_ENTRY_HARD_CAP = 500;
|
|
2320
|
+
function resolveCacheDir$4() {
|
|
2321
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
2322
|
+
if (xdg && xdg.trim().length > 0) {
|
|
2323
|
+
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
|
|
2324
|
+
}
|
|
2325
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
|
|
2326
|
+
}
|
|
2327
|
+
function repoKey$3(repoPath) {
|
|
2328
|
+
// sha256 here is a non-security cache-key derivation — deterministic
|
|
2329
|
+
// short identifier for the cache filename so two repos at different
|
|
2330
|
+
// paths never collide. We truncate to 16 chars; collision-resistance
|
|
2331
|
+
// against an adversary is not required.
|
|
2332
|
+
return crypto__namespace.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
|
|
2333
|
+
}
|
|
2334
|
+
function getDiffSummaryCachePath(repoPath) {
|
|
2335
|
+
return path__namespace$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* Build the cache key for a (diff, model, prompt) tuple. sha256
|
|
2339
|
+
* because we want a strong content-hash; the per-entry storage cost
|
|
2340
|
+
* is dominated by the summary text anyway.
|
|
2341
|
+
*/
|
|
2342
|
+
function diffSummaryKey(diff, model, promptHash) {
|
|
2343
|
+
return crypto__namespace
|
|
2344
|
+
.createHash('sha256')
|
|
2345
|
+
.update(`${diff}\x1f${model}\x1f${promptHash}`)
|
|
2346
|
+
.digest('hex');
|
|
2347
|
+
}
|
|
2348
|
+
function readEnvelope(filePath) {
|
|
2349
|
+
try {
|
|
2350
|
+
const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
|
|
2351
|
+
const parsed = JSON.parse(raw);
|
|
2352
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$1)
|
|
2353
|
+
return undefined;
|
|
2354
|
+
if (!parsed.entries || typeof parsed.entries !== 'object')
|
|
2355
|
+
return undefined;
|
|
2356
|
+
return parsed;
|
|
2357
|
+
}
|
|
2358
|
+
catch {
|
|
2359
|
+
return undefined;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
function readDiffSummary(repoPath, key) {
|
|
2363
|
+
const envelope = readEnvelope(getDiffSummaryCachePath(repoPath));
|
|
2364
|
+
if (!envelope)
|
|
2365
|
+
return undefined;
|
|
2366
|
+
const entry = envelope.entries[key];
|
|
2367
|
+
if (!entry)
|
|
2368
|
+
return undefined;
|
|
2369
|
+
return entry;
|
|
2370
|
+
}
|
|
2371
|
+
function writeDiffSummary(repoPath, key, entry) {
|
|
2372
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2373
|
+
const existing = readEnvelope(filePath) || {
|
|
2374
|
+
version: CACHE_SCHEMA_VERSION$1,
|
|
2375
|
+
savedAt: new Date().toISOString(),
|
|
2376
|
+
entries: {},
|
|
2377
|
+
};
|
|
2378
|
+
existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() };
|
|
2379
|
+
existing.savedAt = new Date().toISOString();
|
|
2380
|
+
const evictedEntries = enforceHardCap(existing.entries);
|
|
2381
|
+
if (evictedEntries.length > 0) {
|
|
2382
|
+
for (const evicted of evictedEntries) {
|
|
2383
|
+
delete existing.entries[evicted];
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
try {
|
|
2387
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(filePath), { recursive: true });
|
|
2388
|
+
fs__namespace$1.writeFileSync(filePath, JSON.stringify(existing));
|
|
2389
|
+
}
|
|
2390
|
+
catch {
|
|
2391
|
+
// Best-effort persistence; swallow.
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Touch an existing entry's lastAccessedAt so LRU eviction prefers
|
|
2396
|
+
* dropping older / unused entries. Caller is expected to know the
|
|
2397
|
+
* entry exists (read returned a hit).
|
|
2398
|
+
*/
|
|
2399
|
+
function touchDiffSummary(repoPath, key) {
|
|
2400
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2401
|
+
const envelope = readEnvelope(filePath);
|
|
2402
|
+
if (!envelope || !envelope.entries[key])
|
|
2403
|
+
return;
|
|
2404
|
+
envelope.entries[key] = {
|
|
2405
|
+
...envelope.entries[key],
|
|
2406
|
+
lastAccessedAt: new Date().toISOString(),
|
|
2407
|
+
};
|
|
2408
|
+
envelope.savedAt = new Date().toISOString();
|
|
2409
|
+
try {
|
|
2410
|
+
fs__namespace$1.writeFileSync(filePath, JSON.stringify(envelope));
|
|
2411
|
+
}
|
|
2412
|
+
catch {
|
|
2413
|
+
// Swallow.
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
function enforceHardCap(entries) {
|
|
2417
|
+
const keys = Object.keys(entries);
|
|
2418
|
+
if (keys.length <= CACHE_ENTRY_HARD_CAP)
|
|
2419
|
+
return [];
|
|
2420
|
+
// Sort by lastAccessedAt ascending (oldest first), drop the
|
|
2421
|
+
// oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries.
|
|
2422
|
+
const sorted = keys
|
|
2423
|
+
.map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 }))
|
|
2424
|
+
.sort((a, b) => a.accessed - b.accessed);
|
|
2425
|
+
const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key);
|
|
2426
|
+
return toEvict;
|
|
2427
|
+
}
|
|
2428
|
+
/** Remove the entire cache file for the repo. Used by `coco cache:clear`. */
|
|
2429
|
+
function clearDiffSummaryCache(repoPath) {
|
|
2430
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2431
|
+
if (!fs__namespace$1.existsSync(filePath)) {
|
|
2432
|
+
return { ok: true, removed: false };
|
|
2433
|
+
}
|
|
2434
|
+
try {
|
|
2435
|
+
fs__namespace$1.unlinkSync(filePath);
|
|
2436
|
+
return { ok: true, removed: true };
|
|
2437
|
+
}
|
|
2438
|
+
catch {
|
|
2439
|
+
return { ok: false, removed: false };
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function readEnvelopeOrUndefined(filePath) {
|
|
2444
|
+
try {
|
|
2445
|
+
if (!fs__namespace$1.existsSync(filePath))
|
|
2446
|
+
return undefined;
|
|
2447
|
+
const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
|
|
2448
|
+
return JSON.parse(raw);
|
|
2449
|
+
}
|
|
2450
|
+
catch {
|
|
2451
|
+
return undefined;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
function formatBytes(bytes) {
|
|
2455
|
+
if (bytes < 1024)
|
|
2456
|
+
return `${bytes} B`;
|
|
2457
|
+
if (bytes < 1024 * 1024)
|
|
2458
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2459
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
2460
|
+
}
|
|
2461
|
+
const handler$8 = async (argv, logger) => {
|
|
2462
|
+
const subcommand = argv.subcommand;
|
|
2463
|
+
const repoPath = process.cwd();
|
|
2464
|
+
const cachePath = getDiffSummaryCachePath(repoPath);
|
|
2465
|
+
if (subcommand === 'clear') {
|
|
2466
|
+
const result = clearDiffSummaryCache(repoPath);
|
|
2467
|
+
if (!result.ok) {
|
|
2468
|
+
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
2469
|
+
process.exitCode = 1;
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
if (result.removed) {
|
|
2473
|
+
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
2474
|
+
}
|
|
2475
|
+
else {
|
|
2476
|
+
logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`));
|
|
2477
|
+
}
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
if (subcommand === 'info') {
|
|
2481
|
+
const envelope = readEnvelopeOrUndefined(cachePath);
|
|
2482
|
+
if (!envelope) {
|
|
2483
|
+
logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`));
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
const stat = fs__namespace$1.statSync(cachePath);
|
|
2487
|
+
const entryCount = Object.keys(envelope.entries).length;
|
|
2488
|
+
const totalSummaryTokens = Object.values(envelope.entries).reduce((sum, entry) => sum + entry.tokens, 0);
|
|
2489
|
+
logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`);
|
|
2490
|
+
logger.log(` ${chalk.green('entries')} ${entryCount}`);
|
|
2491
|
+
logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`);
|
|
2492
|
+
logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`);
|
|
2493
|
+
logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`);
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
2497
|
+
logger.log(chalk.dim('Use one of: clear, info'));
|
|
2498
|
+
process.exitCode = 1;
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
var cache = {
|
|
2502
|
+
command: command$8,
|
|
2503
|
+
desc: 'Manage the diff-summary cache (clear, info)',
|
|
2504
|
+
builder: builder$8,
|
|
2505
|
+
handler: commandExecutor(handler$8),
|
|
2506
|
+
};
|
|
2507
|
+
|
|
2199
2508
|
var util;
|
|
2200
2509
|
(function (util) {
|
|
2201
2510
|
util.assertEqual = (_) => { };
|
|
@@ -6368,6 +6677,7 @@ function resolveDynamicService(config, task) {
|
|
|
6368
6677
|
};
|
|
6369
6678
|
}
|
|
6370
6679
|
|
|
6680
|
+
const benchCalls = [];
|
|
6371
6681
|
const telemetryByCommand = new Map();
|
|
6372
6682
|
function estimatePromptTokens(tokenizer, renderedPrompt) {
|
|
6373
6683
|
if (!tokenizer)
|
|
@@ -6379,10 +6689,28 @@ function estimatePromptTokens(tokenizer, renderedPrompt) {
|
|
|
6379
6689
|
return undefined;
|
|
6380
6690
|
}
|
|
6381
6691
|
}
|
|
6692
|
+
function isBenchModeActive() {
|
|
6693
|
+
return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0');
|
|
6694
|
+
}
|
|
6695
|
+
function recordBenchCall(metadata) {
|
|
6696
|
+
if (!isBenchModeActive())
|
|
6697
|
+
return;
|
|
6698
|
+
benchCalls.push({
|
|
6699
|
+
task: metadata.task,
|
|
6700
|
+
command: metadata.command,
|
|
6701
|
+
provider: metadata.provider,
|
|
6702
|
+
model: metadata.model,
|
|
6703
|
+
promptTokens: metadata.promptTokens,
|
|
6704
|
+
elapsedMs: metadata.elapsedMs,
|
|
6705
|
+
inputDocuments: metadata.inputDocuments,
|
|
6706
|
+
inputChunks: metadata.inputChunks,
|
|
6707
|
+
});
|
|
6708
|
+
}
|
|
6382
6709
|
function logLlmCall(logger, metadata) {
|
|
6383
6710
|
if (!logger)
|
|
6384
6711
|
return;
|
|
6385
6712
|
recordLlmTelemetry(metadata);
|
|
6713
|
+
recordBenchCall(metadata);
|
|
6386
6714
|
const fields = [
|
|
6387
6715
|
`task=${metadata.task}`,
|
|
6388
6716
|
metadata.command ? `command=${metadata.command}` : undefined,
|
|
@@ -7511,6 +7839,56 @@ function getPathFromFilePath(filePath) {
|
|
|
7511
7839
|
return filePath.split('/').slice(0, -1).join('/');
|
|
7512
7840
|
}
|
|
7513
7841
|
|
|
7842
|
+
/**
|
|
7843
|
+
* Adaptive backoff (#845, PR 3). Wraps the chain invocation so a
|
|
7844
|
+
* transient 429 (rate limit) or 5xx no longer kills the whole
|
|
7845
|
+
* pipeline — instead we wait briefly and retry up to N times
|
|
7846
|
+
* before surfacing the failure.
|
|
7847
|
+
*
|
|
7848
|
+
* Cap is intentionally short. Diff condensing fans out to many
|
|
7849
|
+
* concurrent calls; if rate limits hit hard, queueing requests
|
|
7850
|
+
* indefinitely just makes the user wait longer for a result the
|
|
7851
|
+
* pipeline ultimately handles via fewer concurrent passes anyway.
|
|
7852
|
+
* 3 retries with 1s/2s/4s waits trade ~7s of worst-case extra
|
|
7853
|
+
* latency for resilience to brief rate-limit blips.
|
|
7854
|
+
*/
|
|
7855
|
+
const BACKOFF_RETRIES = 3;
|
|
7856
|
+
const BACKOFF_BASE_MS = 1000;
|
|
7857
|
+
const BACKOFF_CAP_MS = 5000;
|
|
7858
|
+
function isRetryableError(error) {
|
|
7859
|
+
if (!error || typeof error !== 'object')
|
|
7860
|
+
return false;
|
|
7861
|
+
const err = error;
|
|
7862
|
+
if (err.status === 429 || err.status === 503 || err.status === 502 || err.status === 504) {
|
|
7863
|
+
return true;
|
|
7864
|
+
}
|
|
7865
|
+
if (err.code === 429 || err.code === 'rate_limit_exceeded' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
|
|
7866
|
+
return true;
|
|
7867
|
+
}
|
|
7868
|
+
if (typeof err.message === 'string' && /(rate.?limit|429|too many requests|timeout|temporarily unavailable)/i.test(err.message)) {
|
|
7869
|
+
return true;
|
|
7870
|
+
}
|
|
7871
|
+
return false;
|
|
7872
|
+
}
|
|
7873
|
+
async function invokeWithBackoff(chain, input, logger) {
|
|
7874
|
+
let lastError;
|
|
7875
|
+
for (let attempt = 0; attempt <= BACKOFF_RETRIES; attempt++) {
|
|
7876
|
+
try {
|
|
7877
|
+
return await chain.invoke(input);
|
|
7878
|
+
}
|
|
7879
|
+
catch (error) {
|
|
7880
|
+
lastError = error;
|
|
7881
|
+
if (!isRetryableError(error) || attempt === BACKOFF_RETRIES) {
|
|
7882
|
+
throw error;
|
|
7883
|
+
}
|
|
7884
|
+
const wait = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, attempt));
|
|
7885
|
+
logger?.verbose(`[summarize] retryable error (attempt ${attempt + 1}/${BACKOFF_RETRIES}); backing off ${wait}ms`, { color: 'yellow' });
|
|
7886
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
7887
|
+
}
|
|
7888
|
+
}
|
|
7889
|
+
// Unreachable — the loop either returns or rethrows above.
|
|
7890
|
+
throw lastError;
|
|
7891
|
+
}
|
|
7514
7892
|
async function summarize(documents$1, { chain, textSplitter, options, logger, tokenizer, metadata }) {
|
|
7515
7893
|
const { returnIntermediateSteps = false } = options || {};
|
|
7516
7894
|
const docs = await textSplitter.splitDocuments(documents$1.map((doc) => new documents.Document(doc)));
|
|
@@ -7518,10 +7896,10 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
|
|
|
7518
7896
|
? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
|
|
7519
7897
|
: undefined;
|
|
7520
7898
|
const startedAt = Date.now();
|
|
7521
|
-
const res = await chain
|
|
7899
|
+
const res = await invokeWithBackoff(chain, {
|
|
7522
7900
|
input_documents: docs,
|
|
7523
7901
|
returnIntermediateSteps,
|
|
7524
|
-
});
|
|
7902
|
+
}, logger);
|
|
7525
7903
|
const elapsedMs = Date.now() - startedAt;
|
|
7526
7904
|
logLlmCall(logger, {
|
|
7527
7905
|
task: 'summarize',
|
|
@@ -7536,10 +7914,175 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
|
|
|
7536
7914
|
return res.text && res.text.trim();
|
|
7537
7915
|
}
|
|
7538
7916
|
|
|
7917
|
+
/**
|
|
7918
|
+
* Inspect a unified-diff string and report its shape, or undefined
|
|
7919
|
+
* if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
|
|
7920
|
+
*
|
|
7921
|
+
* Detection rules (cheap on purpose — we're called per-file and the
|
|
7922
|
+
* goal is to skip work, not be exhaustive):
|
|
7923
|
+
*
|
|
7924
|
+
* - `Binary files ... differ` header → 'binary'
|
|
7925
|
+
* - `rename from`/`rename to` headers and no `+`/`-` content
|
|
7926
|
+
* lines → 'rename'
|
|
7927
|
+
* - All content lines are `+` (and at least one is) → 'addition'
|
|
7928
|
+
* - All content lines are `-` (and at least one is) → 'deletion'
|
|
7929
|
+
* - Otherwise → undefined (let the LLM handle it)
|
|
7930
|
+
*/
|
|
7931
|
+
function detectTrivialDiffShape(diff) {
|
|
7932
|
+
if (!diff)
|
|
7933
|
+
return undefined;
|
|
7934
|
+
// Binary marker is unambiguous and short-circuits early.
|
|
7935
|
+
if (/^Binary files .+ and .+ differ$/m.test(diff)) {
|
|
7936
|
+
return 'binary';
|
|
7937
|
+
}
|
|
7938
|
+
// Pure rename: git emits `rename from` / `rename to` and no body.
|
|
7939
|
+
// We require BOTH markers AND no `+`/`-` content lines. Some
|
|
7940
|
+
// renames-with-edit show rename headers AND a hunk; those should
|
|
7941
|
+
// fall through to the LLM path.
|
|
7942
|
+
const hasRenameFrom = /^rename from /m.test(diff);
|
|
7943
|
+
const hasRenameTo = /^rename to /m.test(diff);
|
|
7944
|
+
if (hasRenameFrom && hasRenameTo) {
|
|
7945
|
+
const hasContentChange = diff
|
|
7946
|
+
.split('\n')
|
|
7947
|
+
.some((line) => isContentChangeLine(line));
|
|
7948
|
+
if (!hasContentChange) {
|
|
7949
|
+
return 'rename';
|
|
7950
|
+
}
|
|
7951
|
+
}
|
|
7952
|
+
// Walk the body once classifying content lines. We skip header
|
|
7953
|
+
// lines (diff --git, index, ---, +++, @@, etc.) and only inspect
|
|
7954
|
+
// the lines that represent actual change content.
|
|
7955
|
+
let plus = 0;
|
|
7956
|
+
let minus = 0;
|
|
7957
|
+
for (const line of diff.split('\n')) {
|
|
7958
|
+
if (isHeaderLine(line))
|
|
7959
|
+
continue;
|
|
7960
|
+
if (line.startsWith('+'))
|
|
7961
|
+
plus++;
|
|
7962
|
+
else if (line.startsWith('-'))
|
|
7963
|
+
minus++;
|
|
7964
|
+
// Context lines (' ' prefix) are ignored for shape classification:
|
|
7965
|
+
// a pure addition can still have surrounding context if a hunk
|
|
7966
|
+
// anchors at line 0, though `git diff` for a brand-new file
|
|
7967
|
+
// typically has none.
|
|
7968
|
+
}
|
|
7969
|
+
if (plus > 0 && minus === 0)
|
|
7970
|
+
return 'addition';
|
|
7971
|
+
if (minus > 0 && plus === 0)
|
|
7972
|
+
return 'deletion';
|
|
7973
|
+
return undefined;
|
|
7974
|
+
}
|
|
7975
|
+
/**
|
|
7976
|
+
* Build a deterministic summary string for a trivial diff. Returns
|
|
7977
|
+
* undefined when the shape can't be templated (caller should fall
|
|
7978
|
+
* back to the LLM path).
|
|
7979
|
+
*/
|
|
7980
|
+
function summarizeTrivialDiff(fileDiff) {
|
|
7981
|
+
const shape = detectTrivialDiffShape(fileDiff.diff);
|
|
7982
|
+
if (!shape)
|
|
7983
|
+
return undefined;
|
|
7984
|
+
const lineCount = countContentLines(fileDiff.diff, shape);
|
|
7985
|
+
switch (shape) {
|
|
7986
|
+
case 'addition':
|
|
7987
|
+
return `Added \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
|
|
7988
|
+
case 'deletion':
|
|
7989
|
+
return `Removed \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
|
|
7990
|
+
case 'rename': {
|
|
7991
|
+
const oldPath = extractRenameOldPath(fileDiff.diff);
|
|
7992
|
+
return oldPath
|
|
7993
|
+
? `Renamed \`${oldPath}\` → \`${fileDiff.file}\`.`
|
|
7994
|
+
: `Renamed file to \`${fileDiff.file}\`.`;
|
|
7995
|
+
}
|
|
7996
|
+
case 'binary':
|
|
7997
|
+
return `Updated binary file \`${fileDiff.file}\`.`;
|
|
7998
|
+
}
|
|
7999
|
+
}
|
|
8000
|
+
function isHeaderLine(line) {
|
|
8001
|
+
return (line.startsWith('diff --git') ||
|
|
8002
|
+
line.startsWith('index ') ||
|
|
8003
|
+
line.startsWith('--- ') ||
|
|
8004
|
+
line.startsWith('+++ ') ||
|
|
8005
|
+
line.startsWith('@@') ||
|
|
8006
|
+
line.startsWith('new file mode') ||
|
|
8007
|
+
line.startsWith('deleted file mode') ||
|
|
8008
|
+
line.startsWith('similarity index') ||
|
|
8009
|
+
line.startsWith('rename from ') ||
|
|
8010
|
+
line.startsWith('rename to ') ||
|
|
8011
|
+
line.startsWith('Binary files '));
|
|
8012
|
+
}
|
|
8013
|
+
function isContentChangeLine(line) {
|
|
8014
|
+
if (isHeaderLine(line))
|
|
8015
|
+
return false;
|
|
8016
|
+
return line.startsWith('+') || line.startsWith('-');
|
|
8017
|
+
}
|
|
8018
|
+
function countContentLines(diff, shape) {
|
|
8019
|
+
if (shape === 'binary' || shape === 'rename')
|
|
8020
|
+
return 0;
|
|
8021
|
+
const prefix = shape === 'addition' ? '+' : '-';
|
|
8022
|
+
let count = 0;
|
|
8023
|
+
for (const line of diff.split('\n')) {
|
|
8024
|
+
if (isHeaderLine(line))
|
|
8025
|
+
continue;
|
|
8026
|
+
if (line.startsWith(prefix))
|
|
8027
|
+
count++;
|
|
8028
|
+
}
|
|
8029
|
+
return count;
|
|
8030
|
+
}
|
|
8031
|
+
function extractRenameOldPath(diff) {
|
|
8032
|
+
const match = diff.match(/^rename from (.+)$/m);
|
|
8033
|
+
return match ? match[1].trim() : undefined;
|
|
8034
|
+
}
|
|
8035
|
+
|
|
8036
|
+
/**
|
|
8037
|
+
* Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
|
|
8038
|
+
* for the diff-summary cache (#845, PR 5). Default is enabled.
|
|
8039
|
+
*/
|
|
8040
|
+
function isCacheEnabled$1() {
|
|
8041
|
+
return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
|
|
8042
|
+
}
|
|
7539
8043
|
/**
|
|
7540
8044
|
* Summarize a single file diff that exceeds the token threshold.
|
|
8045
|
+
*
|
|
8046
|
+
* Trivial-shape short-circuit (#845, PR 2): pure additions / deletions
|
|
8047
|
+
* / renames / binary changes have no information content beyond the
|
|
8048
|
+
* diff's shape, so we templated-summarize them instead of paying for
|
|
8049
|
+
* an LLM call. On initial-commit fixtures (lots of pure adds) this
|
|
8050
|
+
* collapses the per-file summary phase entirely; the resulting tiny
|
|
8051
|
+
* synthetic summaries usually drop the directory token totals under
|
|
8052
|
+
* budget so wave consolidation skips too.
|
|
7541
8053
|
*/
|
|
7542
8054
|
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
|
|
8055
|
+
const trivialSummary = summarizeTrivialDiff(fileDiff);
|
|
8056
|
+
if (trivialSummary !== undefined) {
|
|
8057
|
+
logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
|
|
8058
|
+
return {
|
|
8059
|
+
...fileDiff,
|
|
8060
|
+
diff: trivialSummary,
|
|
8061
|
+
tokenCount: tokenizer(trivialSummary),
|
|
8062
|
+
};
|
|
8063
|
+
}
|
|
8064
|
+
// Cache lookup (#845, PR 5). Keyed on the file's literal diff
|
|
8065
|
+
// content + the active model + the summarization prompt hash.
|
|
8066
|
+
// A hit returns the prior summary instantly; on iterative
|
|
8067
|
+
// `coco commit` re-runs after small edits, the unchanged files
|
|
8068
|
+
// never go to the LLM.
|
|
8069
|
+
const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
|
|
8070
|
+
const cacheRepo = process.cwd();
|
|
8071
|
+
const cacheKey = isCacheEnabled$1() && cacheModel
|
|
8072
|
+
? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH)
|
|
8073
|
+
: undefined;
|
|
8074
|
+
if (cacheKey) {
|
|
8075
|
+
const cached = readDiffSummary(cacheRepo, cacheKey);
|
|
8076
|
+
if (cached) {
|
|
8077
|
+
logger.verbose(` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
|
|
8078
|
+
touchDiffSummary(cacheRepo, cacheKey);
|
|
8079
|
+
return {
|
|
8080
|
+
...fileDiff,
|
|
8081
|
+
diff: cached.summary,
|
|
8082
|
+
tokenCount: cached.tokens,
|
|
8083
|
+
};
|
|
8084
|
+
}
|
|
8085
|
+
}
|
|
7543
8086
|
try {
|
|
7544
8087
|
const fileSummary = await summarize([
|
|
7545
8088
|
{
|
|
@@ -7563,6 +8106,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
7563
8106
|
},
|
|
7564
8107
|
});
|
|
7565
8108
|
const newTokenCount = tokenizer(fileSummary);
|
|
8109
|
+
if (cacheKey && cacheModel) {
|
|
8110
|
+
writeDiffSummary(cacheRepo, cacheKey, {
|
|
8111
|
+
summary: fileSummary,
|
|
8112
|
+
model: cacheModel,
|
|
8113
|
+
tokens: newTokenCount,
|
|
8114
|
+
});
|
|
8115
|
+
}
|
|
7566
8116
|
return {
|
|
7567
8117
|
...fileDiff,
|
|
7568
8118
|
diff: fileSummary,
|
|
@@ -7576,16 +8126,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
7576
8126
|
}
|
|
7577
8127
|
}
|
|
7578
8128
|
/**
|
|
7579
|
-
*
|
|
8129
|
+
* Continuous-queue scheduler (#845, PR 4). Mirrors the directory-
|
|
8130
|
+
* level scheduler in `summarizeDiffs.ts` and replaces the previous
|
|
8131
|
+
* fixed-wave Promise.all loop, which made the slowest call in
|
|
8132
|
+
* each wave block the next wave from starting. With realistic LLM
|
|
8133
|
+
* tail variance, that wave-locking adds dead time at every wave
|
|
8134
|
+
* boundary; continuous queue fills slots as in-flight calls
|
|
8135
|
+
* resolve, so the wall-clock tracks the slowest *call*, not the
|
|
8136
|
+
* sum of slowest-per-wave.
|
|
7580
8137
|
*/
|
|
7581
8138
|
async function processInWaves$1(items, processor, maxConcurrent) {
|
|
7582
|
-
const
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
8139
|
+
const limit = createLimit$2(maxConcurrent);
|
|
8140
|
+
return Promise.all(items.map((item) => limit(() => processor(item))));
|
|
8141
|
+
}
|
|
8142
|
+
function createLimit$2(maxConcurrent) {
|
|
8143
|
+
const limit = Math.max(1, maxConcurrent);
|
|
8144
|
+
let active = 0;
|
|
8145
|
+
const queue = [];
|
|
8146
|
+
const runNext = () => {
|
|
8147
|
+
active--;
|
|
8148
|
+
const next = queue.shift();
|
|
8149
|
+
if (next)
|
|
8150
|
+
next();
|
|
8151
|
+
};
|
|
8152
|
+
return async (operation) => {
|
|
8153
|
+
if (active >= limit) {
|
|
8154
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
8155
|
+
}
|
|
8156
|
+
active++;
|
|
8157
|
+
try {
|
|
8158
|
+
return await operation();
|
|
8159
|
+
}
|
|
8160
|
+
finally {
|
|
8161
|
+
runNext();
|
|
8162
|
+
}
|
|
8163
|
+
};
|
|
7589
8164
|
}
|
|
7590
8165
|
/**
|
|
7591
8166
|
* Pre-summarize individual files that exceed the maxFileTokens threshold.
|
|
@@ -7650,6 +8225,13 @@ async function preprocessLargeFiles(rootNode, options) {
|
|
|
7650
8225
|
return rebuildNode(rootNode);
|
|
7651
8226
|
}
|
|
7652
8227
|
|
|
8228
|
+
/**
|
|
8229
|
+
* Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
|
|
8230
|
+
* for the diff-summary cache (#845, PR 5). Default is enabled.
|
|
8231
|
+
*/
|
|
8232
|
+
function isCacheEnabled() {
|
|
8233
|
+
return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
|
|
8234
|
+
}
|
|
7653
8235
|
/**
|
|
7654
8236
|
* Create groups from a given node info.
|
|
7655
8237
|
* @param {DiffNode} node - The node info to start grouping.
|
|
@@ -7675,6 +8257,32 @@ function createDirectoryDiffs(node) {
|
|
|
7675
8257
|
* Summarize a directory diff asynchronously.
|
|
7676
8258
|
*/
|
|
7677
8259
|
async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer, logger, metadata }) {
|
|
8260
|
+
// Cache lookup (#845, PR 5). Joined per-file diffs become the
|
|
8261
|
+
// payload signature; if every file in the directory is unchanged
|
|
8262
|
+
// since the last run (and the model + prompt match), the prior
|
|
8263
|
+
// directory-level summary is reused instead of paying for another
|
|
8264
|
+
// map_reduce pass.
|
|
8265
|
+
const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
|
|
8266
|
+
const cacheRepo = process.cwd();
|
|
8267
|
+
const cachePayload = directory.diffs
|
|
8268
|
+
.map((diff) => `${diff.file}\x1e${diff.diff}`)
|
|
8269
|
+
.join('\x1d');
|
|
8270
|
+
const cacheKey = isCacheEnabled() && cacheModel
|
|
8271
|
+
? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH)
|
|
8272
|
+
: undefined;
|
|
8273
|
+
if (cacheKey) {
|
|
8274
|
+
const cached = readDiffSummary(cacheRepo, cacheKey);
|
|
8275
|
+
if (cached) {
|
|
8276
|
+
logger?.verbose?.(` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
|
|
8277
|
+
touchDiffSummary(cacheRepo, cacheKey);
|
|
8278
|
+
return {
|
|
8279
|
+
diffs: directory.diffs,
|
|
8280
|
+
path: directory.path,
|
|
8281
|
+
summary: cached.summary,
|
|
8282
|
+
tokenCount: cached.tokens,
|
|
8283
|
+
};
|
|
8284
|
+
}
|
|
8285
|
+
}
|
|
7678
8286
|
try {
|
|
7679
8287
|
const directorySummary = await summarize(directory.diffs.map((diff) => ({
|
|
7680
8288
|
pageContent: diff.diff,
|
|
@@ -7696,6 +8304,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
|
|
|
7696
8304
|
},
|
|
7697
8305
|
});
|
|
7698
8306
|
const newTokenTotal = tokenizer(directorySummary);
|
|
8307
|
+
if (cacheKey && cacheModel) {
|
|
8308
|
+
writeDiffSummary(cacheRepo, cacheKey, {
|
|
8309
|
+
summary: directorySummary,
|
|
8310
|
+
model: cacheModel,
|
|
8311
|
+
tokens: newTokenTotal,
|
|
8312
|
+
});
|
|
8313
|
+
}
|
|
7699
8314
|
return {
|
|
7700
8315
|
diffs: directory.diffs,
|
|
7701
8316
|
path: directory.path,
|
|
@@ -7730,66 +8345,99 @@ const defaultOutputCallback = (group) => {
|
|
|
7730
8345
|
return output;
|
|
7731
8346
|
};
|
|
7732
8347
|
/**
|
|
7733
|
-
*
|
|
7734
|
-
*
|
|
8348
|
+
* Continuous-queue scheduler for the directory summarization pass
|
|
8349
|
+
* (#845, PR 4). The previous wave-by-wave Promise.all forced the
|
|
8350
|
+
* scheduler to wait for the slowest call in a wave before starting
|
|
8351
|
+
* the next wave; on a fixture like `refactor` (20 directories, mixed
|
|
8352
|
+
* sizes) one big directory could pin the wave at ~its own latency
|
|
8353
|
+
* even though the other 19 calls finished long before.
|
|
8354
|
+
*
|
|
8355
|
+
* The continuous queue dispatches all eligible directories through
|
|
8356
|
+
* a `createLimit(maxConcurrent)` semaphore — same primitive
|
|
8357
|
+
* `collectDiffs` already uses. As soon as any in-flight summary
|
|
8358
|
+
* resolves, the next eligible directory takes its slot. Each
|
|
8359
|
+
* scheduled call also re-checks the budget at the moment it would
|
|
8360
|
+
* fire; if the budget is already met (because earlier completions
|
|
8361
|
+
* dropped the total under maxTokens), it returns the original
|
|
8362
|
+
* directory without an LLM call. So the work scales with what's
|
|
8363
|
+
* actually needed, not with the worst-case wave count.
|
|
8364
|
+
*
|
|
8365
|
+
* Order discipline is preserved: directories are sorted by token
|
|
8366
|
+
* count descending and dispatched in that order. The biggest
|
|
8367
|
+
* candidates land in the first batch of in-flight calls; as smaller
|
|
8368
|
+
* candidates reach the queue front, the budget is more likely to
|
|
8369
|
+
* already be met and they short-circuit.
|
|
7735
8370
|
*/
|
|
7736
8371
|
async function summarizeInWaves(directories, options) {
|
|
7737
8372
|
const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
|
|
7738
8373
|
let totalTokenCount = initialTotal;
|
|
7739
8374
|
const results = [...directories];
|
|
7740
|
-
//
|
|
7741
|
-
const
|
|
8375
|
+
// Pick eligible directories upfront, sorted big-first.
|
|
8376
|
+
const eligibleIndices = directories
|
|
7742
8377
|
.map((d, i) => ({ index: i, tokens: d.tokenCount }))
|
|
7743
|
-
.
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
cursor = i + 1;
|
|
7758
|
-
continue;
|
|
7759
|
-
}
|
|
7760
|
-
wave.push(index);
|
|
7761
|
-
cursor = i + 1;
|
|
7762
|
-
}
|
|
7763
|
-
// No more eligible candidates
|
|
7764
|
-
if (wave.length === 0) {
|
|
7765
|
-
break;
|
|
8378
|
+
.filter((entry) => entry.tokens >= minTokensForSummary && !results[entry.index].summary)
|
|
8379
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
8380
|
+
.map((entry) => entry.index);
|
|
8381
|
+
if (eligibleIndices.length === 0 || totalTokenCount <= maxTokens) {
|
|
8382
|
+
return { directories: results, totalTokenCount };
|
|
8383
|
+
}
|
|
8384
|
+
const limit = createLimit$1(maxConcurrent);
|
|
8385
|
+
logger.verbose(`\nProcessing ${eligibleIndices.length} directories with continuous queue (concurrency ${maxConcurrent})...`, { color: 'blue' });
|
|
8386
|
+
await Promise.all(eligibleIndices.map((idx) => limit(async () => {
|
|
8387
|
+
// Re-check the budget at dispatch time. Earlier completions
|
|
8388
|
+
// may have already dropped the total under the cap; in that
|
|
8389
|
+
// case skip the LLM call entirely.
|
|
8390
|
+
if (totalTokenCount <= maxTokens) {
|
|
8391
|
+
return;
|
|
7766
8392
|
}
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
const originalTokens = results[idx].tokenCount;
|
|
7774
|
-
const newTokens = result.tokenCount;
|
|
7775
|
-
const reduction = originalTokens - newTokens;
|
|
7776
|
-
totalTokenCount -= reduction;
|
|
7777
|
-
results[idx] = result;
|
|
7778
|
-
logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
|
|
7779
|
-
color: 'magenta',
|
|
7780
|
-
});
|
|
8393
|
+
const result = await summarizeDirectoryDiff(results[idx], {
|
|
8394
|
+
chain,
|
|
8395
|
+
textSplitter,
|
|
8396
|
+
tokenizer,
|
|
8397
|
+
logger,
|
|
8398
|
+
metadata,
|
|
7781
8399
|
});
|
|
7782
|
-
|
|
7783
|
-
|
|
8400
|
+
const originalTokens = results[idx].tokenCount;
|
|
8401
|
+
const newTokens = result.tokenCount;
|
|
8402
|
+
totalTokenCount -= (originalTokens - newTokens);
|
|
8403
|
+
results[idx] = result;
|
|
8404
|
+
logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
|
|
8405
|
+
color: 'magenta',
|
|
7784
8406
|
});
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
}
|
|
7790
|
-
}
|
|
8407
|
+
})));
|
|
8408
|
+
logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
|
|
8409
|
+
color: totalTokenCount > maxTokens ? 'yellow' : 'green',
|
|
8410
|
+
});
|
|
7791
8411
|
return { directories: results, totalTokenCount };
|
|
7792
8412
|
}
|
|
8413
|
+
/**
|
|
8414
|
+
* Tiny semaphore mirroring `collectDiffs.createLimit` (kept private
|
|
8415
|
+
* here to avoid a cross-module import for one helper). Schedules at
|
|
8416
|
+
* most `maxConcurrent` operations concurrently; the rest queue FIFO.
|
|
8417
|
+
*/
|
|
8418
|
+
function createLimit$1(maxConcurrent) {
|
|
8419
|
+
const limit = Math.max(1, maxConcurrent);
|
|
8420
|
+
let active = 0;
|
|
8421
|
+
const queue = [];
|
|
8422
|
+
const runNext = () => {
|
|
8423
|
+
active--;
|
|
8424
|
+
const next = queue.shift();
|
|
8425
|
+
if (next)
|
|
8426
|
+
next();
|
|
8427
|
+
};
|
|
8428
|
+
return async (operation) => {
|
|
8429
|
+
if (active >= limit) {
|
|
8430
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
8431
|
+
}
|
|
8432
|
+
active++;
|
|
8433
|
+
try {
|
|
8434
|
+
return await operation();
|
|
8435
|
+
}
|
|
8436
|
+
finally {
|
|
8437
|
+
runNext();
|
|
8438
|
+
}
|
|
8439
|
+
};
|
|
8440
|
+
}
|
|
7793
8441
|
/**
|
|
7794
8442
|
* Summarize diffs using a three-phase approach:
|
|
7795
8443
|
*
|
|
@@ -7803,7 +8451,16 @@ async function summarizeInWaves(directories, options) {
|
|
|
7803
8451
|
* - Efficient parallel processing with predictable behavior
|
|
7804
8452
|
* - Early exit when under token budget
|
|
7805
8453
|
*/
|
|
7806
|
-
async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
8454
|
+
async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
8455
|
+
// Default raised to 4096 (#845) so the budget matches the
|
|
8456
|
+
// canonical service configs in `langchain/utils.ts`. The
|
|
8457
|
+
// previous 2048 default came from an earlier era when 4k
|
|
8458
|
+
// context was a stretch for fast models; today every shipped
|
|
8459
|
+
// service overrides it to 4096 anyway. Keeping this in sync
|
|
8460
|
+
// with the service defaults means a caller that omits
|
|
8461
|
+
// `maxTokens` doesn't accidentally fall into a tighter budget
|
|
8462
|
+
// than the rest of the system assumes.
|
|
8463
|
+
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
7807
8464
|
// Calculate maxFileTokens as 25% of maxTokens if not specified
|
|
7808
8465
|
const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
|
|
7809
8466
|
// PHASE 1: Directory grouping & assessment
|
|
@@ -10822,10 +11479,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
10822
11479
|
// 1. Pre-process large files to prevent bias
|
|
10823
11480
|
// 2. Group by directory and assess token count
|
|
10824
11481
|
// 3. Wave-based parallel summarization until under budget
|
|
11482
|
+
//
|
|
11483
|
+
// The 4096 fallback (#845) matches the default service configs
|
|
11484
|
+
// for openai / anthropic / ollama (`langchain/utils.ts`). It's a
|
|
11485
|
+
// safety net for users with custom service definitions that omit
|
|
11486
|
+
// `tokenLimit` — without it those users hit a degenerate 2048
|
|
11487
|
+
// budget that triggers needless pre-summarization on diffs the
|
|
11488
|
+
// model could absorb whole.
|
|
10825
11489
|
logger.startTimer();
|
|
10826
11490
|
const summary = await summarizeDiffs(diffs, {
|
|
10827
11491
|
tokenizer,
|
|
10828
|
-
maxTokens: maxTokens ||
|
|
11492
|
+
maxTokens: maxTokens || 4096,
|
|
10829
11493
|
minTokensForSummary,
|
|
10830
11494
|
maxFileTokens,
|
|
10831
11495
|
maxConcurrent,
|
|
@@ -15394,6 +16058,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
15394
16058
|
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
15395
16059
|
contexts: ['normal'],
|
|
15396
16060
|
},
|
|
16061
|
+
{
|
|
16062
|
+
id: 'navigateConflicts',
|
|
16063
|
+
keys: ['gx'],
|
|
16064
|
+
label: 'conflicts',
|
|
16065
|
+
description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
|
|
16066
|
+
contexts: ['normal'],
|
|
16067
|
+
},
|
|
15397
16068
|
{
|
|
15398
16069
|
id: 'navigateBack',
|
|
15399
16070
|
keys: ['<', 'esc'],
|
|
@@ -15523,6 +16194,7 @@ const GLOBAL_BINDING_IDS = [
|
|
|
15523
16194
|
'navigateStash',
|
|
15524
16195
|
'navigateWorktrees',
|
|
15525
16196
|
'navigatePullRequest',
|
|
16197
|
+
'navigateConflicts',
|
|
15526
16198
|
'navigateBack',
|
|
15527
16199
|
];
|
|
15528
16200
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -15695,6 +16367,12 @@ function getLogInkFooterHints(options) {
|
|
|
15695
16367
|
global: NORMAL_GLOBAL_HINTS,
|
|
15696
16368
|
};
|
|
15697
16369
|
}
|
|
16370
|
+
if (options.activeView === 'conflicts') {
|
|
16371
|
+
return {
|
|
16372
|
+
contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'],
|
|
16373
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16374
|
+
};
|
|
16375
|
+
}
|
|
15698
16376
|
return {
|
|
15699
16377
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15700
16378
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
@@ -16441,6 +17119,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16441
17119
|
selectedTagIndex: 0,
|
|
16442
17120
|
selectedStashIndex: 0,
|
|
16443
17121
|
selectedWorktreeListIndex: 0,
|
|
17122
|
+
selectedConflictFileIndex: 0,
|
|
16444
17123
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
16445
17124
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
16446
17125
|
paletteFilter: '',
|
|
@@ -16694,6 +17373,12 @@ function applyLogInkAction(state, action) {
|
|
|
16694
17373
|
selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
|
|
16695
17374
|
pendingKey: undefined,
|
|
16696
17375
|
};
|
|
17376
|
+
case 'moveConflictFile':
|
|
17377
|
+
return {
|
|
17378
|
+
...state,
|
|
17379
|
+
selectedConflictFileIndex: clampIndex$1(state.selectedConflictFileIndex + action.delta, action.count),
|
|
17380
|
+
pendingKey: undefined,
|
|
17381
|
+
};
|
|
16697
17382
|
case 'cycleBranchSort':
|
|
16698
17383
|
return {
|
|
16699
17384
|
...state,
|
|
@@ -17362,6 +18047,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
17362
18047
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
17363
18048
|
case 'navigatePullRequest':
|
|
17364
18049
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
18050
|
+
case 'navigateConflicts':
|
|
18051
|
+
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
17365
18052
|
case 'navigateBack':
|
|
17366
18053
|
return [action({ type: 'popView' })];
|
|
17367
18054
|
case 'openSelected': {
|
|
@@ -17833,6 +18520,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17833
18520
|
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
17834
18521
|
];
|
|
17835
18522
|
}
|
|
18523
|
+
if (state.pendingKey === 'g' && inputValue === 'x') {
|
|
18524
|
+
return [
|
|
18525
|
+
action({ type: 'pushView', value: 'conflicts' }),
|
|
18526
|
+
action({ type: 'setStatus', value: 'jumped to conflicts' }),
|
|
18527
|
+
];
|
|
18528
|
+
}
|
|
17836
18529
|
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
17837
18530
|
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
17838
18531
|
// Discoverable via the footer hint on diff views and the help
|
|
@@ -18142,6 +18835,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18142
18835
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18143
18836
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
18144
18837
|
}
|
|
18838
|
+
if (state.activeView === 'conflicts' && context.conflictFileCount) {
|
|
18839
|
+
return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })];
|
|
18840
|
+
}
|
|
18145
18841
|
if (state.activeView === 'history' &&
|
|
18146
18842
|
state.focus === 'commits' &&
|
|
18147
18843
|
state.selectedIndex === 0 &&
|
|
@@ -18220,6 +18916,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18220
18916
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18221
18917
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
18222
18918
|
}
|
|
18919
|
+
if (state.activeView === 'conflicts' && context.conflictFileCount) {
|
|
18920
|
+
return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })];
|
|
18921
|
+
}
|
|
18223
18922
|
return [
|
|
18224
18923
|
action(state.focus === 'sidebar'
|
|
18225
18924
|
? { type: 'nextSidebarTab' }
|
|
@@ -18405,6 +19104,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18405
19104
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
18406
19105
|
})];
|
|
18407
19106
|
}
|
|
19107
|
+
// Enter on a conflict file opens the worktree diff for that file so
|
|
19108
|
+
// the user can inspect the conflict markers in context.
|
|
19109
|
+
if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
|
|
19110
|
+
return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }];
|
|
19111
|
+
}
|
|
18408
19112
|
// Enter on a branch row checks the branch out. Non-destructive workflow
|
|
18409
19113
|
// action — no confirmation prompt. Fires from either the dedicated
|
|
18410
19114
|
// branches view or from the sidebar when the branches tab is focused
|
|
@@ -18546,6 +19250,32 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18546
19250
|
if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
|
|
18547
19251
|
return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
|
|
18548
19252
|
}
|
|
19253
|
+
// --- Conflicts view per-row handlers ---
|
|
19254
|
+
// `o` opens the conflicted file in $EDITOR for manual resolution.
|
|
19255
|
+
if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
|
|
19256
|
+
return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }];
|
|
19257
|
+
}
|
|
19258
|
+
// `s` stages the conflicted file (marks it resolved).
|
|
19259
|
+
if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
|
|
19260
|
+
return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }];
|
|
19261
|
+
}
|
|
19262
|
+
// `u` resolves by keeping theirs (incoming changes).
|
|
19263
|
+
if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
|
|
19264
|
+
return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }];
|
|
19265
|
+
}
|
|
19266
|
+
// `U` resolves by keeping ours (current branch).
|
|
19267
|
+
if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
|
|
19268
|
+
return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }];
|
|
19269
|
+
}
|
|
19270
|
+
// `C` continues the in-progress operation (available when no conflicts remain).
|
|
19271
|
+
if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) {
|
|
19272
|
+
return [{ type: 'runWorkflowAction', id: 'continue-operation' }];
|
|
19273
|
+
}
|
|
19274
|
+
// Always intercept `C` on the conflicts view to prevent fallthrough to
|
|
19275
|
+
// the global `C` (Create PR) binding when conflicts remain.
|
|
19276
|
+
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
19277
|
+
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
19278
|
+
}
|
|
18549
19279
|
// `c` on a stash diff cherry-picks the file under the cursor —
|
|
18550
19280
|
// materializes that single path from the stash into the working tree
|
|
18551
19281
|
// (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
|
|
@@ -21842,6 +22572,21 @@ function skipOperation(git, operation) {
|
|
|
21842
22572
|
}
|
|
21843
22573
|
return runAction(() => git.raw(command.args), command.successMessage);
|
|
21844
22574
|
}
|
|
22575
|
+
function resolveConflictOurs(git, path) {
|
|
22576
|
+
return runAction(async () => {
|
|
22577
|
+
await git.raw(['checkout', '--ours', '--', path]);
|
|
22578
|
+
await git.raw(['add', '--', path]);
|
|
22579
|
+
}, `Resolved ${path} (kept ours)`);
|
|
22580
|
+
}
|
|
22581
|
+
function resolveConflictTheirs(git, path) {
|
|
22582
|
+
return runAction(async () => {
|
|
22583
|
+
await git.raw(['checkout', '--theirs', '--', path]);
|
|
22584
|
+
await git.raw(['add', '--', path]);
|
|
22585
|
+
}, `Resolved ${path} (kept theirs)`);
|
|
22586
|
+
}
|
|
22587
|
+
function stageConflictResolved(git, path) {
|
|
22588
|
+
return runAction(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
|
|
22589
|
+
}
|
|
21845
22590
|
|
|
21846
22591
|
function openProviderUrl(repository, target, openUrl = defaultOpenUrlRunner) {
|
|
21847
22592
|
const url = repository ? buildProviderUrl(repository, target) : undefined;
|
|
@@ -25224,6 +25969,51 @@ function LogInkApp(deps) {
|
|
|
25224
25969
|
}
|
|
25225
25970
|
return abortOperation(git, operation);
|
|
25226
25971
|
},
|
|
25972
|
+
'resolve-conflict-ours': async () => {
|
|
25973
|
+
const path = payload?.trim();
|
|
25974
|
+
if (!path)
|
|
25975
|
+
return { ok: false, message: 'No conflict file selected' };
|
|
25976
|
+
return resolveConflictOurs(git, path);
|
|
25977
|
+
},
|
|
25978
|
+
'resolve-conflict-theirs': async () => {
|
|
25979
|
+
const path = payload?.trim();
|
|
25980
|
+
if (!path)
|
|
25981
|
+
return { ok: false, message: 'No conflict file selected' };
|
|
25982
|
+
return resolveConflictTheirs(git, path);
|
|
25983
|
+
},
|
|
25984
|
+
'resolve-conflict-stage': async () => {
|
|
25985
|
+
const path = payload?.trim();
|
|
25986
|
+
if (!path)
|
|
25987
|
+
return { ok: false, message: 'No conflict file selected' };
|
|
25988
|
+
return stageConflictResolved(git, path);
|
|
25989
|
+
},
|
|
25990
|
+
'resolve-conflict-open-diff': async () => {
|
|
25991
|
+
// Push the diff view for the conflicted file so the user can
|
|
25992
|
+
// inspect conflict markers in context. We find the file's index
|
|
25993
|
+
// in the worktree file list and navigate to its diff.
|
|
25994
|
+
const path = payload?.trim();
|
|
25995
|
+
if (!path)
|
|
25996
|
+
return { ok: false, message: 'No conflict file selected' };
|
|
25997
|
+
const worktreeFiles = context.worktree?.files || [];
|
|
25998
|
+
const fileIndex = worktreeFiles.findIndex((f) => f.path === path);
|
|
25999
|
+
if (fileIndex >= 0) {
|
|
26000
|
+
dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex });
|
|
26001
|
+
return { ok: true, message: `Viewing diff for ${path}` };
|
|
26002
|
+
}
|
|
26003
|
+
// File not in worktree list (e.g. deleted-by-us) — open in
|
|
26004
|
+
// editor as fallback so the user can still inspect it.
|
|
26005
|
+
return { ok: true, message: `${path} not in worktree diff list` };
|
|
26006
|
+
},
|
|
26007
|
+
'continue-operation': async () => {
|
|
26008
|
+
const operation = context.operation?.operation;
|
|
26009
|
+
if (!operation || operation === 'none') {
|
|
26010
|
+
return { ok: false, message: 'No git operation in progress' };
|
|
26011
|
+
}
|
|
26012
|
+
if ((context.operation?.conflictedFiles.length ?? 0) > 0) {
|
|
26013
|
+
return { ok: false, message: 'Resolve all conflicts before continuing' };
|
|
26014
|
+
}
|
|
26015
|
+
return continueOperation(git, operation);
|
|
26016
|
+
},
|
|
25227
26017
|
'open-pr': async () => {
|
|
25228
26018
|
const repo = context.provider?.repository;
|
|
25229
26019
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
@@ -25747,6 +26537,14 @@ function LogInkApp(deps) {
|
|
|
25747
26537
|
? selected?.hash
|
|
25748
26538
|
: undefined,
|
|
25749
26539
|
worktreeDirty,
|
|
26540
|
+
conflictFileCount: context.operation?.conflictedFiles.length,
|
|
26541
|
+
conflictSelectedPath: (() => {
|
|
26542
|
+
const files = context.operation?.conflictedFiles;
|
|
26543
|
+
if (!files || files.length === 0)
|
|
26544
|
+
return undefined;
|
|
26545
|
+
const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
|
|
26546
|
+
return files[clamped]?.path;
|
|
26547
|
+
})(),
|
|
25750
26548
|
// H / gH need the actual diff text (not just hunk offsets) to
|
|
25751
26549
|
// slice the cursored hunk into a `git apply` patch. Stash uses
|
|
25752
26550
|
// the full `git stash show -p` output; commit-diff uses the
|
|
@@ -26111,6 +26909,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
26111
26909
|
if (state.activeView === 'pull-request') {
|
|
26112
26910
|
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26113
26911
|
}
|
|
26912
|
+
if (state.activeView === 'conflicts') {
|
|
26913
|
+
return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26914
|
+
}
|
|
26114
26915
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
26115
26916
|
}
|
|
26116
26917
|
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
@@ -26294,6 +27095,79 @@ function buildStatusSurfaceRows(groups) {
|
|
|
26294
27095
|
}
|
|
26295
27096
|
return rows;
|
|
26296
27097
|
}
|
|
27098
|
+
function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
27099
|
+
const { Box, Text } = components;
|
|
27100
|
+
const focused = state.focus === 'commits';
|
|
27101
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
|
|
27102
|
+
const operation = context.operation;
|
|
27103
|
+
const conflictedFiles = operation?.conflictedFiles || [];
|
|
27104
|
+
const operationType = operation?.operation || 'none';
|
|
27105
|
+
// If no operation is in progress, show a fallback message.
|
|
27106
|
+
if (!loading && operationType === 'none') {
|
|
27107
|
+
return h(Box, {
|
|
27108
|
+
borderColor: focusBorderColor(theme, focused),
|
|
27109
|
+
borderStyle: theme.borderStyle,
|
|
27110
|
+
flexDirection: 'column',
|
|
27111
|
+
flexShrink: 0,
|
|
27112
|
+
paddingX: 1,
|
|
27113
|
+
width,
|
|
27114
|
+
}, 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.'));
|
|
27115
|
+
}
|
|
27116
|
+
// All conflicts resolved — show the "continue" hint.
|
|
27117
|
+
if (!loading && conflictedFiles.length === 0 && operationType !== 'none') {
|
|
27118
|
+
return h(Box, {
|
|
27119
|
+
borderColor: focusBorderColor(theme, focused),
|
|
27120
|
+
borderStyle: theme.borderStyle,
|
|
27121
|
+
flexDirection: 'column',
|
|
27122
|
+
flexShrink: 0,
|
|
27123
|
+
paddingX: 1,
|
|
27124
|
+
width,
|
|
27125
|
+
}, 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.`));
|
|
27126
|
+
}
|
|
27127
|
+
const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1)));
|
|
27128
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
27129
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
27130
|
+
const visible = conflictedFiles.slice(startIndex, startIndex + listRows);
|
|
27131
|
+
const remaining = conflictedFiles.length;
|
|
27132
|
+
const headerRight = loading
|
|
27133
|
+
? 'loading conflicts'
|
|
27134
|
+
: `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining`;
|
|
27135
|
+
const statusLabel = (file) => {
|
|
27136
|
+
const code = `${file.indexStatus}${file.worktreeStatus}`;
|
|
27137
|
+
switch (code) {
|
|
27138
|
+
case 'UU': return 'both modified';
|
|
27139
|
+
case 'AA': return 'added by both';
|
|
27140
|
+
case 'DD': return 'both deleted';
|
|
27141
|
+
case 'AU':
|
|
27142
|
+
case 'UA': return 'added by one';
|
|
27143
|
+
case 'DU': return 'deleted by us';
|
|
27144
|
+
case 'UD': return 'deleted by them';
|
|
27145
|
+
default: return code;
|
|
27146
|
+
}
|
|
27147
|
+
};
|
|
27148
|
+
const lines = loading
|
|
27149
|
+
? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))]
|
|
27150
|
+
: visible.map((file, offset) => {
|
|
27151
|
+
const index = startIndex + offset;
|
|
27152
|
+
const isSelected = index === selected;
|
|
27153
|
+
const cursor = isSelected ? '>' : ' ';
|
|
27154
|
+
const code = `${file.indexStatus}${file.worktreeStatus}`;
|
|
27155
|
+
const label = statusLabel(file);
|
|
27156
|
+
return h(Text, {
|
|
27157
|
+
key: `conflict-${index}`,
|
|
27158
|
+
bold: isSelected,
|
|
27159
|
+
dimColor: !isSelected,
|
|
27160
|
+
}, truncate$1(`${cursor} ${code} ${file.path} (${label})`, width - 4));
|
|
27161
|
+
});
|
|
27162
|
+
return h(Box, {
|
|
27163
|
+
borderColor: focusBorderColor(theme, focused),
|
|
27164
|
+
borderStyle: theme.borderStyle,
|
|
27165
|
+
flexDirection: 'column',
|
|
27166
|
+
flexShrink: 0,
|
|
27167
|
+
paddingX: 1,
|
|
27168
|
+
width,
|
|
27169
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
|
|
27170
|
+
}
|
|
26297
27171
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
26298
27172
|
const { Box, Text } = components;
|
|
26299
27173
|
const focused = state.focus === 'commits';
|
|
@@ -29031,6 +29905,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
|
|
|
29031
29905
|
y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
|
|
29032
29906
|
y.command(log.command, log.desc, log.builder, log.handler);
|
|
29033
29907
|
y.command(ui.command, ui.desc, ui.builder, ui.handler);
|
|
29908
|
+
y.command(cache.command, cache.desc, cache.builder, cache.handler);
|
|
29034
29909
|
y.help().parse(process.argv.slice(2));
|
|
29035
29910
|
|
|
29036
29911
|
/**
|
|
@@ -29482,6 +30357,7 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
|
|
|
29482
30357
|
handleValidationErrors: handleValidationErrors
|
|
29483
30358
|
});
|
|
29484
30359
|
|
|
30360
|
+
exports.cache = cache;
|
|
29485
30361
|
exports.changelog = changelog;
|
|
29486
30362
|
exports.commit = commit;
|
|
29487
30363
|
exports.doctor = doctor;
|