@hifullmoon/aicommit 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aicommit.config.example.json +31 -8
- package/CHANGELOG.md +21 -1
- package/README.md +31 -5
- package/README.zh-CN.md +31 -5
- package/bin/aicommit.js +6 -1
- package/docs/distribution.md +1 -1
- package/docs/large-change-implementation-plan.md +193 -0
- package/docs/privacy.md +6 -0
- package/docs/provider-compatibility.md +44 -16
- package/docs/troubleshooting.md +10 -0
- package/package.json +4 -2
- package/schemas/aicommit-output.schema.json +120 -18
- package/src/analysis-budget.js +76 -0
- package/src/api.js +6 -316
- package/src/change-analysis.js +627 -0
- package/src/config.js +16 -0
- package/src/doctor.js +2 -4
- package/src/git-spool.js +108 -0
- package/src/git.js +25 -4
- package/src/local-analysis.js +271 -0
- package/src/main.js +114 -25
- package/src/model-client.js +403 -0
- package/src/provider-response.js +148 -0
- package/src/providers.js +134 -206
- package/src/runtime.js +6 -0
- package/src/split.js +193 -42
package/src/git-spool.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync, writeSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const active = new Set();
|
|
7
|
+
export function cleanupGitSpools() {
|
|
8
|
+
for (const source of active) source.dispose();
|
|
9
|
+
}
|
|
10
|
+
process.once('exit', cleanupGitSpools);
|
|
11
|
+
|
|
12
|
+
// Git writes directly to a private temporary file, never to a Node stdout
|
|
13
|
+
// buffer. Consumers scan the captured bytes without reopening the worktree.
|
|
14
|
+
export function spoolGit(commands, cwd) {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), 'aicommit-diff-'));
|
|
16
|
+
const path = join(dir, 'patch');
|
|
17
|
+
let fd;
|
|
18
|
+
try {
|
|
19
|
+
fd = openSync(path, 'wx+', 0o600);
|
|
20
|
+
for (const args of commands) {
|
|
21
|
+
execFileSync('git', ['--no-pager', ...args], {
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: ['ignore', fd, 'pipe'],
|
|
24
|
+
timeout: 120000,
|
|
25
|
+
maxBuffer: 1024 * 1024,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
let size = fstatSync(fd).size;
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
fd = undefined;
|
|
31
|
+
const source = {
|
|
32
|
+
get size() {
|
|
33
|
+
return size;
|
|
34
|
+
},
|
|
35
|
+
append(buffer) {
|
|
36
|
+
const output = openSync(path, 'r+');
|
|
37
|
+
try {
|
|
38
|
+
let written = 0;
|
|
39
|
+
while (written < buffer.length)
|
|
40
|
+
written += writeSync(output, buffer, written, buffer.length - written, size + written);
|
|
41
|
+
size += written;
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(output);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
*buffers() {
|
|
47
|
+
const input = openSync(path, 'r');
|
|
48
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
49
|
+
let offset = 0;
|
|
50
|
+
try {
|
|
51
|
+
while (offset < size) {
|
|
52
|
+
const count = readSync(input, buffer, 0, buffer.length, offset);
|
|
53
|
+
if (!count) throw new Error('Captured Git patch was truncated.');
|
|
54
|
+
offset += count;
|
|
55
|
+
yield buffer.subarray(0, count);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
closeSync(input);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
*lines(maxLineBytes = 1024 * 1024) {
|
|
62
|
+
let pending = Buffer.alloc(0);
|
|
63
|
+
for (const buffer of this.buffers()) {
|
|
64
|
+
const data = Buffer.concat([pending, buffer]);
|
|
65
|
+
let start = 0;
|
|
66
|
+
for (let end = data.indexOf(10); end !== -1; end = data.indexOf(10, start)) {
|
|
67
|
+
if (end - start > maxLineBytes)
|
|
68
|
+
throw new Error(
|
|
69
|
+
'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
|
|
70
|
+
);
|
|
71
|
+
yield data.subarray(start, end + 1).toString('utf8');
|
|
72
|
+
start = end + 1;
|
|
73
|
+
}
|
|
74
|
+
pending = Buffer.from(data.subarray(start));
|
|
75
|
+
if (pending.length > maxLineBytes)
|
|
76
|
+
throw new Error(
|
|
77
|
+
'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (pending.length) yield pending.toString('utf8');
|
|
81
|
+
},
|
|
82
|
+
text(limit) {
|
|
83
|
+
if (size > limit) return null;
|
|
84
|
+
return [...this.lines()].join('').trim();
|
|
85
|
+
},
|
|
86
|
+
dispose() {
|
|
87
|
+
if (!active.delete(source)) return;
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
active.add(source);
|
|
92
|
+
return source;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (fd !== undefined) closeSync(fd);
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
throw new Error(`Failed to capture Git patch: ${err.message}`, { cause: err });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function updateGitHash(hash, args, cwd) {
|
|
101
|
+
const source = spoolGit([args], cwd);
|
|
102
|
+
try {
|
|
103
|
+
for (const buffer of source.buffers()) hash.update(buffer);
|
|
104
|
+
} finally {
|
|
105
|
+
source.dispose();
|
|
106
|
+
}
|
|
107
|
+
return hash;
|
|
108
|
+
}
|
package/src/git.js
CHANGED
|
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { updateGitHash } from './git-spool.js';
|
|
6
7
|
|
|
7
8
|
// Big repos can produce multi-MB diffs; raise the default 1MB child-process
|
|
8
9
|
// buffer and surface a contextual error if the explicit limit is exceeded.
|
|
@@ -73,8 +74,11 @@ export function getStagedDiff(cwd, contextLines) {
|
|
|
73
74
|
// ids) so a long-running AI/review step cannot silently commit a different
|
|
74
75
|
// index from the one used to generate the message.
|
|
75
76
|
export function getIndexFingerprint(cwd) {
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
return updateGitHash(
|
|
78
|
+
createHash('sha256'),
|
|
79
|
+
['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'],
|
|
80
|
+
cwd,
|
|
81
|
+
).digest('hex');
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
function hashFileOrMissing(path) {
|
|
@@ -428,7 +432,7 @@ export function protectSensitiveDiff(diff) {
|
|
|
428
432
|
return { diff: out.join(''), findings: [...new Set(findings)] };
|
|
429
433
|
}
|
|
430
434
|
|
|
431
|
-
export function runGit(args, projectRoot, inherit = false) {
|
|
435
|
+
export function runGit(args, projectRoot, inherit = false, input = undefined) {
|
|
432
436
|
try {
|
|
433
437
|
const stdio =
|
|
434
438
|
inherit === 'stderr'
|
|
@@ -436,7 +440,7 @@ export function runGit(args, projectRoot, inherit = false) {
|
|
|
436
440
|
: inherit
|
|
437
441
|
? 'inherit'
|
|
438
442
|
: 'pipe';
|
|
439
|
-
execFileSync('git', args, { cwd: projectRoot, stdio });
|
|
443
|
+
execFileSync('git', args, { cwd: projectRoot, stdio, input });
|
|
440
444
|
} catch (err) {
|
|
441
445
|
const detail =
|
|
442
446
|
typeof err.stderr === 'string' ? err.stderr.trim() : err.stderr?.toString('utf-8').trim();
|
|
@@ -445,6 +449,23 @@ export function runGit(args, projectRoot, inherit = false) {
|
|
|
445
449
|
}
|
|
446
450
|
}
|
|
447
451
|
|
|
452
|
+
export function pathBatches(paths, limit = 6000) {
|
|
453
|
+
const batches = [];
|
|
454
|
+
let batch = [];
|
|
455
|
+
let size = 0;
|
|
456
|
+
for (const path of paths) {
|
|
457
|
+
if (batch.length && size + path.length * 2 + 4 > limit) {
|
|
458
|
+
batches.push(batch);
|
|
459
|
+
batch = [];
|
|
460
|
+
size = 0;
|
|
461
|
+
}
|
|
462
|
+
batch.push(path);
|
|
463
|
+
size += path.length * 2 + 4;
|
|
464
|
+
}
|
|
465
|
+
if (batch.length) batches.push(batch);
|
|
466
|
+
return batches;
|
|
467
|
+
}
|
|
468
|
+
|
|
448
469
|
export function hasHead(projectRoot) {
|
|
449
470
|
try {
|
|
450
471
|
execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isLockFile, matchStripPattern } from './git.js';
|
|
3
|
+
import { ERROR_CATEGORIES, fail } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// These are evidence-selection rules, not exclusions from Git or secret scans.
|
|
6
|
+
export function isGeneratedFile(path) {
|
|
7
|
+
return (
|
|
8
|
+
/(?:^|\/)(?:dist|build|coverage|vendor|node_modules)\//.test(path) ||
|
|
9
|
+
/(?:\.min\.(?:js|css)|\.map|\.snap|\.generated\.[^/]+)$/.test(path)
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isDeepAnalysis(config) {
|
|
14
|
+
return config.largeChange?.strategy === 'deep';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function clip(text, bytes) {
|
|
18
|
+
if (Buffer.byteLength(text) <= bytes) return text;
|
|
19
|
+
let result = '';
|
|
20
|
+
let size = 0;
|
|
21
|
+
for (const char of text) {
|
|
22
|
+
size += Buffer.byteLength(char);
|
|
23
|
+
if (size > Math.max(0, bytes - 3)) break;
|
|
24
|
+
result += char;
|
|
25
|
+
}
|
|
26
|
+
return result + '…';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function category(file, config) {
|
|
30
|
+
if (isLockFile(file.path)) return 'lockfile';
|
|
31
|
+
if (matchStripPattern(file.path, config.stripFiles)) return 'excluded';
|
|
32
|
+
if (isGeneratedFile(file.path)) return 'generated';
|
|
33
|
+
if (/(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\./.test(file.path)) return 'test';
|
|
34
|
+
if (/\.(?:md|rst|txt)$/.test(file.path)) return 'documentation';
|
|
35
|
+
if (/(?:^|\/)(?:[^/]*config[^/]*|package\.json)$|\.(?:ya?ml|toml)$/.test(file.path))
|
|
36
|
+
return 'configuration';
|
|
37
|
+
return 'source';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function moduleOf(path) {
|
|
41
|
+
const parts = path.split('/');
|
|
42
|
+
return parts.length === 1 ? '.' : parts.slice(0, Math.min(parts.length - 1, 2)).join('/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function localInputBytes(config) {
|
|
46
|
+
// UTF-8 bytes also bound non-ASCII input. Leave space for JSON escaping,
|
|
47
|
+
// repository context, policy, and the provider's message envelope.
|
|
48
|
+
return Math.max(
|
|
49
|
+
256,
|
|
50
|
+
Math.min(
|
|
51
|
+
config.maxDiffChars || 30000,
|
|
52
|
+
Math.floor((config.analysisBudget?.limits.chunkInputTokens || 12000) * 0.6),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function summaryOf(fact, evidence = true) {
|
|
58
|
+
return {
|
|
59
|
+
id: fact.id,
|
|
60
|
+
kind: fact.kind,
|
|
61
|
+
status: fact.status,
|
|
62
|
+
module: clip(fact.module, 160),
|
|
63
|
+
fileCount: fact.files.length,
|
|
64
|
+
additions: fact.additions,
|
|
65
|
+
deletions: fact.deletions,
|
|
66
|
+
...(fact.modeChange ? { modeChange: fact.modeChange } : {}),
|
|
67
|
+
...(fact.binary ? { binary: true } : {}),
|
|
68
|
+
examples: fact.files.slice(0, 2).map((path) => clip(path, 180)),
|
|
69
|
+
...(evidence && fact.evidence ? { representativeExcerpt: fact.evidence } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SAMPLE_NOTICE =
|
|
74
|
+
'Local change inventory with selected excerpts, not complete semantic analysis. ' +
|
|
75
|
+
'Counts cover every file. Excerpts are partial; omitted content and paths are not evidence of unchanged behavior. ' +
|
|
76
|
+
'Identical textual edits are grouped within a module. Describe supported changes conservatively; do not infer intent.';
|
|
77
|
+
|
|
78
|
+
function ranked(facts) {
|
|
79
|
+
const priority = { source: 0, configuration: 1, test: 2, documentation: 3 };
|
|
80
|
+
return [...facts].sort(
|
|
81
|
+
(a, b) =>
|
|
82
|
+
(priority[a.kind] ?? 4) - (priority[b.kind] ?? 4) ||
|
|
83
|
+
b.files.length - a.files.length ||
|
|
84
|
+
a.id.localeCompare(b.id),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function localOverview(config, facts) {
|
|
89
|
+
const kinds = {};
|
|
90
|
+
const statuses = {};
|
|
91
|
+
let totalFiles = 0;
|
|
92
|
+
let additions = 0;
|
|
93
|
+
let deletions = 0;
|
|
94
|
+
let modeChangedFiles = 0;
|
|
95
|
+
let binaryFiles = 0;
|
|
96
|
+
for (const fact of facts) {
|
|
97
|
+
totalFiles += fact.files.length;
|
|
98
|
+
additions += fact.additions;
|
|
99
|
+
deletions += fact.deletions;
|
|
100
|
+
if (fact.modeChange) modeChangedFiles += fact.files.length;
|
|
101
|
+
if (fact.binary) binaryFiles += fact.files.length;
|
|
102
|
+
kinds[fact.kind] = (kinds[fact.kind] || 0) + fact.files.length;
|
|
103
|
+
statuses[fact.status] = (statuses[fact.status] || 0) + fact.files.length;
|
|
104
|
+
}
|
|
105
|
+
const data = {
|
|
106
|
+
notice: SAMPLE_NOTICE,
|
|
107
|
+
totalFiles,
|
|
108
|
+
additions,
|
|
109
|
+
deletions,
|
|
110
|
+
modeChangedFiles,
|
|
111
|
+
binaryFiles,
|
|
112
|
+
lineCounts: 'tracked diff only',
|
|
113
|
+
kinds,
|
|
114
|
+
statuses,
|
|
115
|
+
totalGroups: facts.length,
|
|
116
|
+
omittedGroups: facts.length,
|
|
117
|
+
samples: [],
|
|
118
|
+
};
|
|
119
|
+
const cap = localInputBytes(config);
|
|
120
|
+
let sampledFiles = 0;
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const ordered = ranked(facts);
|
|
123
|
+
// First cover distinct modules/categories, then use remaining space for detail.
|
|
124
|
+
const diverse = [];
|
|
125
|
+
const rest = [];
|
|
126
|
+
for (const fact of ordered) {
|
|
127
|
+
const key = JSON.stringify([fact.module, fact.kind]);
|
|
128
|
+
if (seen.has(key)) rest.push(fact);
|
|
129
|
+
else {
|
|
130
|
+
seen.add(key);
|
|
131
|
+
diverse.push(fact);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const firstKinds = new Set();
|
|
135
|
+
const first = [];
|
|
136
|
+
const remaining = [];
|
|
137
|
+
for (const fact of diverse) {
|
|
138
|
+
if (firstKinds.has(fact.kind)) remaining.push(fact);
|
|
139
|
+
else {
|
|
140
|
+
firstKinds.add(fact.kind);
|
|
141
|
+
first.push(fact);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const fact of [...first, ...remaining, ...rest]) {
|
|
145
|
+
if (data.samples.length >= 16) break;
|
|
146
|
+
const sample = summaryOf(fact);
|
|
147
|
+
data.samples.push(sample);
|
|
148
|
+
if (Buffer.byteLength(JSON.stringify(data)) > cap) {
|
|
149
|
+
data.samples.pop();
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (fact.evidence) sampledFiles++;
|
|
153
|
+
data.omittedGroups--;
|
|
154
|
+
}
|
|
155
|
+
// A tiny configured budget may not even fit aggregate metadata. Fail before
|
|
156
|
+
// sending rather than silently slicing JSON or dropping the uncertainty notice.
|
|
157
|
+
const text = JSON.stringify(data);
|
|
158
|
+
if (Buffer.byteLength(text) > cap)
|
|
159
|
+
throw fail(
|
|
160
|
+
ERROR_CATEGORIES.CONFIG,
|
|
161
|
+
'Local change inventory does not fit the input budget. Increase largeChange.chunkInputTokens or maxDiffChars.',
|
|
162
|
+
);
|
|
163
|
+
return { text, sampledFiles };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function analyzeLocally(config, capture, protect, previews) {
|
|
167
|
+
const records = new Map(
|
|
168
|
+
capture.manifest.map((file) => [
|
|
169
|
+
file.id,
|
|
170
|
+
{
|
|
171
|
+
file,
|
|
172
|
+
hash: createHash('sha256'),
|
|
173
|
+
evidence: '',
|
|
174
|
+
textual: false,
|
|
175
|
+
},
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
for (const unit of capture.units(protect, previews)) {
|
|
179
|
+
if (unit.metadataOnly) continue;
|
|
180
|
+
const record = records.get(unit.fileId);
|
|
181
|
+
// Tracked fragments carry Git headers; untracked fragments are plain text.
|
|
182
|
+
const section = unit.id.match(/S\d+(?=P\d+$)/)?.[0];
|
|
183
|
+
if (section && section !== record.section) {
|
|
184
|
+
record.section = section;
|
|
185
|
+
record.inHunk = false;
|
|
186
|
+
}
|
|
187
|
+
for (const line of unit.text.split(/(?<=\n)/)) {
|
|
188
|
+
if (section) {
|
|
189
|
+
if (line.startsWith('@@')) record.inHunk = true;
|
|
190
|
+
if (!record.inHunk || !/^[+-]/.test(line)) continue;
|
|
191
|
+
}
|
|
192
|
+
record.hash.update(line);
|
|
193
|
+
record.textual = true;
|
|
194
|
+
const remaining = 900 - Buffer.byteLength(record.evidence);
|
|
195
|
+
if (remaining > 3) record.evidence += clip(line, remaining);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const grouped = new Map();
|
|
199
|
+
for (const { file, hash, evidence, textual } of records.values()) {
|
|
200
|
+
const kind = category(file, config);
|
|
201
|
+
const module = moduleOf(file.addPaths?.at(-1) || file.path);
|
|
202
|
+
const status = file.status.replace(/\d+$/, '');
|
|
203
|
+
const metadata = ['lockfile', 'excluded', 'generated'].includes(kind);
|
|
204
|
+
const modeChange =
|
|
205
|
+
file.oldMode && file.newMode && file.oldMode !== file.newMode
|
|
206
|
+
? `${file.oldMode} -> ${file.newMode}`
|
|
207
|
+
: null;
|
|
208
|
+
// Unknown/binary changes and renames stay independent for split safety.
|
|
209
|
+
const identity = metadata
|
|
210
|
+
? 'metadata'
|
|
211
|
+
: textual && !status.startsWith('R')
|
|
212
|
+
? hash.digest('hex')
|
|
213
|
+
: file.id;
|
|
214
|
+
const key = JSON.stringify([kind, module, status, identity, modeChange, !!file.binary]);
|
|
215
|
+
let fact = grouped.get(key);
|
|
216
|
+
if (!fact) {
|
|
217
|
+
fact = {
|
|
218
|
+
id: file.id,
|
|
219
|
+
kind,
|
|
220
|
+
module,
|
|
221
|
+
status,
|
|
222
|
+
files: [],
|
|
223
|
+
additions: 0,
|
|
224
|
+
deletions: 0,
|
|
225
|
+
modeChange,
|
|
226
|
+
binary: !!file.binary,
|
|
227
|
+
evidence: metadata ? '' : evidence,
|
|
228
|
+
};
|
|
229
|
+
grouped.set(key, fact);
|
|
230
|
+
}
|
|
231
|
+
fact.files.push(file.path);
|
|
232
|
+
fact.additions += file.additions;
|
|
233
|
+
fact.deletions += file.deletions;
|
|
234
|
+
}
|
|
235
|
+
const facts = [...grouped.values()];
|
|
236
|
+
const overview = localOverview(config, facts);
|
|
237
|
+
return {
|
|
238
|
+
facts,
|
|
239
|
+
summary: overview.text,
|
|
240
|
+
coverage: {
|
|
241
|
+
strategy: 'auto',
|
|
242
|
+
totalFiles: capture.manifest.length,
|
|
243
|
+
analyzedFiles: 0,
|
|
244
|
+
sampledFiles: overview.sampledFiles,
|
|
245
|
+
metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
|
|
246
|
+
failedFiles: 0,
|
|
247
|
+
completedChunks: 0,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function localPlanItems(config, facts, cap) {
|
|
253
|
+
const error = () =>
|
|
254
|
+
fail(
|
|
255
|
+
ERROR_CATEGORIES.CONFIG,
|
|
256
|
+
'The complete split candidate inventory does not fit one request. Stage a smaller logical change or explicitly set personal largeChange.strategy to deep. No planning request was sent.',
|
|
257
|
+
);
|
|
258
|
+
if (facts.length > (config.splitMaxPlanFiles || 100)) throw error();
|
|
259
|
+
const items = facts.map((fact) => ({
|
|
260
|
+
...summaryOf(fact, false),
|
|
261
|
+
summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
|
|
262
|
+
}));
|
|
263
|
+
if (Buffer.byteLength(JSON.stringify(items)) > cap) throw error();
|
|
264
|
+
for (const fact of ranked(facts)) {
|
|
265
|
+
if (!fact.evidence) continue;
|
|
266
|
+
const item = items.find((item) => item.id === fact.id);
|
|
267
|
+
item.representativeExcerpt = fact.evidence;
|
|
268
|
+
if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
|
|
269
|
+
}
|
|
270
|
+
return items;
|
|
271
|
+
}
|
package/src/main.js
CHANGED
|
@@ -6,9 +6,7 @@ import chalk from 'chalk';
|
|
|
6
6
|
import { parseArgs } from './cli.js';
|
|
7
7
|
import { getProjectRoot, loadConfig } from './config.js';
|
|
8
8
|
import {
|
|
9
|
-
getStagedDiff,
|
|
10
9
|
getChangedFiles,
|
|
11
|
-
getDiffStats,
|
|
12
10
|
getBranch,
|
|
13
11
|
gitCommit,
|
|
14
12
|
stripLockFileContent,
|
|
@@ -21,7 +19,16 @@ import {
|
|
|
21
19
|
getIndexFingerprint,
|
|
22
20
|
createIndexTransaction,
|
|
23
21
|
protectSensitiveDiff,
|
|
22
|
+
unifiedArg,
|
|
24
23
|
} from './git.js';
|
|
24
|
+
import {
|
|
25
|
+
captureChanges,
|
|
26
|
+
needsAnalysis,
|
|
27
|
+
analysisConfig,
|
|
28
|
+
analyzeChanges,
|
|
29
|
+
summarizeChanges,
|
|
30
|
+
} from './change-analysis.js';
|
|
31
|
+
import { cleanupGitSpools } from './git-spool.js';
|
|
25
32
|
import { generateCommitMessage } from './api.js';
|
|
26
33
|
import {
|
|
27
34
|
statusColor,
|
|
@@ -41,7 +48,13 @@ import {
|
|
|
41
48
|
stringifyConfigRedacted,
|
|
42
49
|
redactSensitiveUrl,
|
|
43
50
|
} from './utils.js';
|
|
44
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
abortSplit,
|
|
53
|
+
applySplitPlan,
|
|
54
|
+
resumeSplit,
|
|
55
|
+
splitFlow,
|
|
56
|
+
getStagedChangedFiles,
|
|
57
|
+
} from './split.js';
|
|
45
58
|
import { runModelTask } from './generation-ui.js';
|
|
46
59
|
import { runSetup } from './setup.js';
|
|
47
60
|
import { detectProviderType } from './providers.js';
|
|
@@ -357,8 +370,7 @@ async function runMain() {
|
|
|
357
370
|
indexTransaction ||= createIndexTransaction(projectRoot);
|
|
358
371
|
return indexTransaction;
|
|
359
372
|
};
|
|
360
|
-
|
|
361
|
-
if (!diff) {
|
|
373
|
+
if (!getChangedFiles(projectRoot).length) {
|
|
362
374
|
// Nothing staged. But git diff --staged is also empty for unstaged work
|
|
363
375
|
// and untracked files — surface what git status actually shows instead of
|
|
364
376
|
// falsely claiming there's nothing to commit.
|
|
@@ -429,7 +441,14 @@ async function runMain() {
|
|
|
429
441
|
|
|
430
442
|
try {
|
|
431
443
|
beginIndexTransaction();
|
|
432
|
-
runGit(
|
|
444
|
+
runGit(
|
|
445
|
+
toStage
|
|
446
|
+
? ['--literal-pathspecs', 'add', '--pathspec-from-file=-', '--pathspec-file-nul']
|
|
447
|
+
: ['add', '-A'],
|
|
448
|
+
projectRoot,
|
|
449
|
+
false,
|
|
450
|
+
toStage ? toStage.join('\0') + '\0' : undefined,
|
|
451
|
+
);
|
|
433
452
|
indexTransaction.markOwned();
|
|
434
453
|
} catch (err) {
|
|
435
454
|
indexTransaction?.restore({ force: true });
|
|
@@ -443,8 +462,7 @@ async function runMain() {
|
|
|
443
462
|
});
|
|
444
463
|
}
|
|
445
464
|
|
|
446
|
-
|
|
447
|
-
if (!diff) {
|
|
465
|
+
if (!getChangedFiles(projectRoot).length) {
|
|
448
466
|
console.log('\n ' + chalk.yellow('✗ Nothing staged — no diff to commit.\n'));
|
|
449
467
|
throw fail(ERROR_CATEGORIES.GIT_STATE, 'Nothing staged; no diff to commit.', {
|
|
450
468
|
reported: true,
|
|
@@ -455,7 +473,14 @@ async function runMain() {
|
|
|
455
473
|
// Re-read the final diff between two complete-index fingerprints so the
|
|
456
474
|
// prompt is guaranteed to describe one stable staged snapshot.
|
|
457
475
|
const plannedIndexFingerprint = getIndexFingerprint(projectRoot);
|
|
458
|
-
|
|
476
|
+
const changedFiles = getChangedFiles(projectRoot);
|
|
477
|
+
const captured = captureChanges(
|
|
478
|
+
[['diff', unifiedArg(config.diffContextLines), '--staged']],
|
|
479
|
+
projectRoot,
|
|
480
|
+
getStagedChangedFiles(projectRoot),
|
|
481
|
+
config,
|
|
482
|
+
);
|
|
483
|
+
const diff = captured.diff || '';
|
|
459
484
|
if (getIndexFingerprint(projectRoot) !== plannedIndexFingerprint) {
|
|
460
485
|
console.log(
|
|
461
486
|
'\n ' + chalk.red('✗ The staged changes are being modified concurrently; commit aborted.\n'),
|
|
@@ -467,8 +492,7 @@ async function runMain() {
|
|
|
467
492
|
);
|
|
468
493
|
}
|
|
469
494
|
|
|
470
|
-
const stats =
|
|
471
|
-
const changedFiles = getChangedFiles(projectRoot);
|
|
495
|
+
const stats = captured.stats;
|
|
472
496
|
const branch = getBranch(projectRoot);
|
|
473
497
|
const stageIcon = chalk.green('staged');
|
|
474
498
|
const changeStr = chalk.green(`+${stats.additions}`) + ' ' + chalk.red(`-${stats.deletions}`);
|
|
@@ -499,7 +523,9 @@ async function runMain() {
|
|
|
499
523
|
// Protect common secrets before any repository content leaves the machine.
|
|
500
524
|
// The protected diff affects only the model request, never the actual index.
|
|
501
525
|
const protectedInput = protectSensitiveDiff(diff);
|
|
526
|
+
protectedInput.findings = [...new Set([...protectedInput.findings, ...captured.findings])];
|
|
502
527
|
let diffForModel = diff;
|
|
528
|
+
let protectAnalysis = true;
|
|
503
529
|
if (protectedInput.findings.length) {
|
|
504
530
|
warnings.push('Sensitive data was detected and protected before the provider request.');
|
|
505
531
|
console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
|
|
@@ -529,21 +555,35 @@ async function runMain() {
|
|
|
529
555
|
return finishCancelled();
|
|
530
556
|
}
|
|
531
557
|
if (sensitiveAction === 'protect') diffForModel = protectedInput.diff;
|
|
558
|
+
if (sensitiveAction === 'original') protectAnalysis = false;
|
|
532
559
|
}
|
|
533
560
|
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
// plus truncated hunks, so token spend stays proportional to what the
|
|
538
|
-
// model needs.
|
|
561
|
+
// Small changes keep the existing prompt. Large changes use a local
|
|
562
|
+
// inventory by default; exhaustive model analysis is an explicit opt-in.
|
|
563
|
+
const large = needsAnalysis(captured, config);
|
|
539
564
|
const strippedDiff = stripLockFileContent(diffForModel, config.stripFiles);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
565
|
+
let { diff: modelDiff, truncated } = large
|
|
566
|
+
? { diff: '', truncated: false }
|
|
567
|
+
: condenseDiff(
|
|
568
|
+
strippedDiff,
|
|
569
|
+
config.maxDiffChars,
|
|
570
|
+
getDiffStat(projectRoot),
|
|
571
|
+
config.maxFileDiffChars,
|
|
572
|
+
);
|
|
573
|
+
let analysis;
|
|
574
|
+
let analyzedFacts;
|
|
575
|
+
let generationConfig = config;
|
|
576
|
+
if (large) {
|
|
577
|
+
generationConfig = analysisConfig(config);
|
|
578
|
+
console.log(
|
|
579
|
+
chalk.dim(
|
|
580
|
+
generationConfig.largeChange?.strategy === 'deep'
|
|
581
|
+
? ` Large change: analyzing all ${changedFiles.length} files in bounded chunks.`
|
|
582
|
+
: ` Large change: building a local inventory of ${changedFiles.length} files with bounded excerpts.`,
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (truncated && !large) {
|
|
547
587
|
warnings.push('The diff was condensed to fit the configured provider input limit.');
|
|
548
588
|
console.log(
|
|
549
589
|
chalk.dim(
|
|
@@ -575,8 +615,41 @@ async function runMain() {
|
|
|
575
615
|
machineOutput,
|
|
576
616
|
cancelMessage: 'Commit cancelled.',
|
|
577
617
|
failureMessage: 'API call failed',
|
|
578
|
-
task: (stream) =>
|
|
579
|
-
|
|
618
|
+
task: async (stream) => {
|
|
619
|
+
if (large && !analysis) {
|
|
620
|
+
analyzedFacts ||= await analyzeChanges(
|
|
621
|
+
generationConfig,
|
|
622
|
+
captured,
|
|
623
|
+
protectAnalysis,
|
|
624
|
+
null,
|
|
625
|
+
({ completedChunks }) =>
|
|
626
|
+
console.error(` Analysis: ${completedChunks} chunks completed`),
|
|
627
|
+
);
|
|
628
|
+
modelDiff =
|
|
629
|
+
analyzedFacts.summary ||
|
|
630
|
+
(await summarizeChanges(generationConfig, analyzedFacts.facts));
|
|
631
|
+
analysis = analyzedFacts;
|
|
632
|
+
console.error(
|
|
633
|
+
` Coverage: ${analysis.coverage.analyzedFiles} files analyzed; ${analysis.coverage.sampledFiles || 0} representative excerpts; ${analysis.coverage.metadataOnlyFiles} metadata only.`,
|
|
634
|
+
);
|
|
635
|
+
if (analysis.coverage.strategy === 'auto')
|
|
636
|
+
warnings.push(
|
|
637
|
+
'Large changes were summarized locally with selected excerpts; content was not fully analyzed.',
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const result = await generateCommitMessage(
|
|
641
|
+
generationConfig,
|
|
642
|
+
modelDiff,
|
|
643
|
+
regenerateCount,
|
|
644
|
+
message,
|
|
645
|
+
stream,
|
|
646
|
+
);
|
|
647
|
+
if (large) {
|
|
648
|
+
result.usage = generationConfig.analysisBudget.snapshot().usage;
|
|
649
|
+
result.elapsed = generationConfig.analysisBudget.snapshot().elapsedMs;
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
},
|
|
580
653
|
successMessage(result) {
|
|
581
654
|
let done = `Generated in ${chalk.bold(formatMs(result.elapsed))}`;
|
|
582
655
|
if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
|
|
@@ -697,6 +770,13 @@ async function runMain() {
|
|
|
697
770
|
usage,
|
|
698
771
|
warnings,
|
|
699
772
|
exitReason: 'dry_run',
|
|
773
|
+
...(analysis
|
|
774
|
+
? {
|
|
775
|
+
data: {
|
|
776
|
+
analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
: {}),
|
|
700
780
|
committed: false,
|
|
701
781
|
edited: wasEdited,
|
|
702
782
|
rewrites: regenerateCount + automaticCorrectionCount,
|
|
@@ -736,6 +816,13 @@ async function runMain() {
|
|
|
736
816
|
usage,
|
|
737
817
|
warnings,
|
|
738
818
|
exitReason: 'success',
|
|
819
|
+
...(analysis
|
|
820
|
+
? {
|
|
821
|
+
data: {
|
|
822
|
+
analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
|
|
823
|
+
},
|
|
824
|
+
}
|
|
825
|
+
: {}),
|
|
739
826
|
committed: true,
|
|
740
827
|
edited: wasEdited,
|
|
741
828
|
rewrites: regenerateCount + automaticCorrectionCount,
|
|
@@ -767,5 +854,7 @@ export async function main() {
|
|
|
767
854
|
edited: false,
|
|
768
855
|
rewrites: 0,
|
|
769
856
|
};
|
|
857
|
+
} finally {
|
|
858
|
+
cleanupGitSpools();
|
|
770
859
|
}
|
|
771
860
|
}
|