@hifullmoon/aicommit 1.4.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
package/src/setup.js ADDED
@@ -0,0 +1,268 @@
1
+ import { readFile, writeFile, chmod, copyFile, rename, unlink } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import input from '@inquirer/input';
8
+ import password from '@inquirer/password';
9
+ import confirm from '@inquirer/confirm';
10
+
11
+ import { checkConnection } from './api.js';
12
+ import { isSecureApiUrl } from './config.js';
13
+ import { vimSelect } from './ui.js';
14
+ import { fileExists, formatMs, indentError, maskApiKey } from './utils.js';
15
+ import { loadProviderPresetManifest } from './provider-presets.js';
16
+
17
+ // Top-level connection keys from a legacy flat config. When the wizard writes
18
+ // a providers map these are dropped from the top level, so the old flat
19
+ // values can't silently shadow or conflict with the provider entries.
20
+ const FLAT_CONNECTION_KEYS = ['apiUrl', 'apiKey', 'apiKeyEnv', 'modelId'];
21
+
22
+ // Merge the wizard's answers into an existing config object (or an empty
23
+ // one). Existing providers and unrelated settings are preserved; the new
24
+ // provider becomes the default. Pure — exported for tests.
25
+ export function mergeSetupConfig(existing, { providerName, entry, language }) {
26
+ const result = { ...existing };
27
+
28
+ // Legacy flat config: remove the top-level connection keys now that the
29
+ // connection lives under providers[providerName].
30
+ if (!result.providers) {
31
+ for (const key of FLAT_CONNECTION_KEYS) delete result[key];
32
+ }
33
+
34
+ result.providers = { ...existing.providers, [providerName]: entry };
35
+ result.defaultProvider = providerName;
36
+ result.language = language;
37
+ return result;
38
+ }
39
+
40
+ async function readExistingConfig(path) {
41
+ if (!(await fileExists(path))) return {};
42
+ try {
43
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
44
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
45
+ throw new Error('expected a JSON object');
46
+ }
47
+ return parsed;
48
+ } catch (err) {
49
+ const backup = `${path}.invalid-${Date.now()}.bak`;
50
+ await copyFile(path, backup);
51
+ console.log(chalk.yellow(` ⚠ Could not parse ${path} (${err.message}) — starting fresh.`));
52
+ console.log(chalk.dim(` The original file was preserved at ${backup}.`));
53
+ return {};
54
+ }
55
+ }
56
+
57
+ async function writeConfigAtomic(path, value) {
58
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
59
+ try {
60
+ await writeFile(tempPath, JSON.stringify(value, null, 2) + '\n', {
61
+ encoding: 'utf-8',
62
+ mode: 0o600,
63
+ });
64
+ await chmod(tempPath, 0o600).catch(() => {});
65
+ await rename(tempPath, path);
66
+ await chmod(path, 0o600).catch(() => {});
67
+ } catch (err) {
68
+ await unlink(tempPath).catch(() => {});
69
+ throw err;
70
+ }
71
+ }
72
+
73
+ export async function runSetup(dependencies = {}) {
74
+ const {
75
+ targetPath = join(homedir(), '.aicommit.config.json'),
76
+ selectPrompt = vimSelect,
77
+ inputPrompt = input,
78
+ passwordPrompt = password,
79
+ confirmPrompt = confirm,
80
+ connectionCheck = checkConnection,
81
+ spinnerFactory = ora,
82
+ presetLoader = loadProviderPresetManifest,
83
+ } = dependencies;
84
+
85
+ console.log('');
86
+ console.log(
87
+ ' ' + chalk.cyan.bold('⚡ aicommit setup ') + chalk.dim('interactive configuration'),
88
+ );
89
+ console.log(' ' + chalk.dim('─'.repeat(45)));
90
+
91
+ // Provider credentials are always user-owned. A repository may contain a
92
+ // project config for harmless generation preferences, but it must never be
93
+ // able to redirect a globally authenticated request.
94
+ console.log(chalk.dim(` Credentials will be stored in the user config: ${targetPath}`));
95
+
96
+ const existing = await readExistingConfig(targetPath);
97
+ const { manifest: presetManifest } = await presetLoader();
98
+ const providerPresets = presetManifest.providers;
99
+
100
+ // ── 2. Provider ─────────────────────────────────────────────────────
101
+
102
+ const presetName = await selectPrompt({
103
+ message: 'Choose a provider',
104
+ choices: [
105
+ ...providerPresets.map((p) => ({
106
+ name: p.label,
107
+ value: p.id,
108
+ description: `${p.apiUrl} — default model: ${p.modelId}`,
109
+ })),
110
+ { name: 'custom', value: 'custom', description: 'Any OpenAI-compatible endpoint' },
111
+ ],
112
+ });
113
+
114
+ let providerName, apiUrl, defaultModel, presetAdapter, presetExtraBody;
115
+ if (presetName === 'custom') {
116
+ providerName = await inputPrompt({
117
+ message: 'Provider name (used with aicommit -p <name>)',
118
+ validate: (v) => /^[\w.-]+$/.test(v.trim()) || 'Use letters, digits, dot, dash or underscore',
119
+ });
120
+ providerName = providerName.trim();
121
+ apiUrl = await inputPrompt({
122
+ message: 'API endpoint URL',
123
+ validate: (v) => isSecureApiUrl(v.trim()) || 'Use HTTPS, or HTTP only for localhost/loopback',
124
+ });
125
+ apiUrl = apiUrl.trim();
126
+ defaultModel = '';
127
+ } else {
128
+ const preset = providerPresets.find((p) => p.id === presetName);
129
+ if (!preset) throw new Error(`Provider preset not found: ${presetName}`);
130
+ providerName = preset.id;
131
+ apiUrl = preset.apiUrl;
132
+ defaultModel = preset.modelId;
133
+ presetAdapter = preset.adapter;
134
+ presetExtraBody = preset.extraBody;
135
+ }
136
+
137
+ const existingProvider = existing.providers?.[providerName] || {};
138
+
139
+ // ── 3. API key ──────────────────────────────────────────────────────
140
+
141
+ const hasKey = Boolean(existingProvider.apiKey);
142
+ const hasKeyEnv = Boolean(existingProvider.apiKeyEnv);
143
+ // An empty key is allowed — local models (Ollama, LM Studio, LiteLLM) are
144
+ // keyless; the optional connection test below still catches a missing key
145
+ // for providers that require one.
146
+ const keyInput = await passwordPrompt({
147
+ message: `API key for ${providerName}${
148
+ hasKey
149
+ ? ` (current: ${maskApiKey(existingProvider.apiKey)} — leave empty to keep)`
150
+ : hasKeyEnv
151
+ ? ` (current env: ${existingProvider.apiKeyEnv} — leave empty to keep)`
152
+ : ' (use env:VARIABLE, or leave empty for local models)'
153
+ }`,
154
+ mask: '*',
155
+ });
156
+ const enteredKey = keyInput.trim();
157
+ let apiKey = existingProvider.apiKey || '';
158
+ let apiKeyEnv = existingProvider.apiKeyEnv || '';
159
+ if (/^env:/i.test(enteredKey)) {
160
+ apiKeyEnv = enteredKey.slice(4).trim();
161
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
162
+ throw new Error('Invalid environment variable name after env:.');
163
+ }
164
+ if (!process.env[apiKeyEnv]) {
165
+ throw new Error(`Environment variable "${apiKeyEnv}" is not set.`);
166
+ }
167
+ apiKey = '';
168
+ } else if (enteredKey) {
169
+ apiKey = enteredKey;
170
+ apiKeyEnv = '';
171
+ }
172
+
173
+ // ── 4. Model ────────────────────────────────────────────────────────
174
+
175
+ const modelInput = await inputPrompt({
176
+ message: 'Model ID',
177
+ default: existingProvider.modelId || defaultModel || undefined,
178
+ validate: (v) => (v.trim() ? true : 'Model ID is required'),
179
+ });
180
+ const modelId = modelInput.trim();
181
+
182
+ // ── 5. Commit message language ──────────────────────────────────────
183
+
184
+ const language = await selectPrompt({
185
+ message: 'Commit message language',
186
+ default: existing.language === 'en' ? 'en' : 'zh',
187
+ choices: [
188
+ { name: '中文 (zh)', value: 'zh' },
189
+ { name: 'English (en)', value: 'en' },
190
+ ],
191
+ });
192
+
193
+ const entry = {
194
+ ...existingProvider,
195
+ apiUrl,
196
+ apiKey,
197
+ apiKeyEnv,
198
+ modelId,
199
+ ...(presetAdapter ? { providerType: presetAdapter } : {}),
200
+ ...(presetExtraBody && !existingProvider.extraBody ? { extraBody: presetExtraBody } : {}),
201
+ };
202
+
203
+ // ── 6. Connection test ──────────────────────────────────────────────
204
+
205
+ const runTest = await confirmPrompt({
206
+ message: 'Test the connection now?',
207
+ default: true,
208
+ });
209
+ let connectionVerified = false;
210
+
211
+ if (runTest) {
212
+ const spinner = spinnerFactory({
213
+ text: chalk.dim(`Checking ${chalk.bold(modelId)} ...`),
214
+ color: 'cyan',
215
+ }).start();
216
+
217
+ try {
218
+ const report = await connectionCheck({
219
+ ...entry,
220
+ apiKey: apiKeyEnv ? process.env[apiKeyEnv] : apiKey,
221
+ maxTokens: 64,
222
+ timeoutMs: 120000,
223
+ });
224
+ spinner.succeed(`Connection OK — ${formatMs(report.elapsed)}`);
225
+ connectionVerified = true;
226
+ } catch (err) {
227
+ spinner.fail('Connection failed');
228
+ console.log(`\n ${indentError(err)}\n`);
229
+ const saveAnyway = await confirmPrompt({
230
+ message: 'Save the config anyway?',
231
+ default: false,
232
+ });
233
+ if (!saveAnyway) {
234
+ console.log(chalk.dim('\n Setup cancelled — nothing was saved.\n'));
235
+ return;
236
+ }
237
+ }
238
+ }
239
+
240
+ // ── 7. Write config ─────────────────────────────────────────────────
241
+
242
+ const merged = mergeSetupConfig(existing, { providerName, entry, language });
243
+ // Write through a same-directory temporary file so interruption cannot
244
+ // leave a partially written config. Permissions are tightened before the
245
+ // atomic replacement as well as after it (best effort on Windows).
246
+ await writeConfigAtomic(targetPath, merged);
247
+
248
+ console.log('');
249
+ console.log(' ' + chalk.green.bold('✓ Config saved'));
250
+ console.log(' ' + chalk.dim(` Path: ${targetPath}`));
251
+ console.log(' ' + chalk.dim(` Provider: ${providerName} (${modelId})`));
252
+ console.log(
253
+ ' ' + chalk.dim(` API key: ${apiKeyEnv ? `env:${apiKeyEnv}` : maskApiKey(apiKey)}`),
254
+ );
255
+ console.log('');
256
+ if (connectionVerified) {
257
+ console.log(chalk.dim(' Connection verified. Run ') + chalk.bold('aicommit') + chalk.dim('.'));
258
+ } else {
259
+ console.log(
260
+ chalk.dim(' Run ') +
261
+ chalk.bold('aicommit doctor') +
262
+ chalk.dim(' to verify the setup, or ') +
263
+ chalk.bold('aicommit') +
264
+ chalk.dim(' to start committing.'),
265
+ );
266
+ }
267
+ console.log('');
268
+ }
@@ -0,0 +1,252 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { createHash } from 'node:crypto';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { dirname, isAbsolute, resolve } from 'node:path';
13
+
14
+ import { validateSplitPlanArtifact } from './split-plan.js';
15
+
16
+ export const SPLIT_CHECKPOINT_KIND = 'aicommit-split-checkpoint';
17
+ export const SPLIT_CHECKPOINT_VERSION = 1;
18
+ const MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024;
19
+ const OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
20
+
21
+ function object(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+
25
+ function checkpointFile(projectRoot) {
26
+ const raw = execFileSync('git', ['rev-parse', '--git-path', 'aicommit/split-checkpoint.json'], {
27
+ cwd: projectRoot,
28
+ encoding: 'utf8',
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+ return isAbsolute(raw) ? raw : resolve(projectRoot, raw);
32
+ }
33
+
34
+ function validateEntry(entry, label) {
35
+ if (entry === null) return null;
36
+ if (
37
+ !object(entry) ||
38
+ Object.keys(entry).some((key) => !['mode', 'oid'].includes(key)) ||
39
+ typeof entry.mode !== 'string' ||
40
+ !/^\d{6}$/.test(entry.mode) ||
41
+ typeof entry.oid !== 'string' ||
42
+ !OID_RE.test(entry.oid)
43
+ ) {
44
+ throw new Error(`${label} must be null or a Git mode/object pair.`);
45
+ }
46
+ return { mode: entry.mode, oid: entry.oid };
47
+ }
48
+
49
+ function validateCommitRecord(record, index) {
50
+ if (
51
+ !object(record) ||
52
+ Object.keys(record).some((key) => !['index', 'commit', 'parent', 'tree'].includes(key)) ||
53
+ record.index !== index ||
54
+ typeof record.commit !== 'string' ||
55
+ !OID_RE.test(record.commit) ||
56
+ (record.parent !== null &&
57
+ (typeof record.parent !== 'string' || !OID_RE.test(record.parent))) ||
58
+ typeof record.tree !== 'string' ||
59
+ !OID_RE.test(record.tree)
60
+ ) {
61
+ throw new Error(`completed[${index}] is invalid or non-contiguous.`);
62
+ }
63
+ return { index, commit: record.commit, parent: record.parent, tree: record.tree };
64
+ }
65
+
66
+ function validateInFlight(record, completedCount, groupCount) {
67
+ if (record === null) return null;
68
+ if (
69
+ !object(record) ||
70
+ Object.keys(record).some((key) => !['index', 'parent', 'tree'].includes(key)) ||
71
+ record.index !== completedCount ||
72
+ record.index >= groupCount ||
73
+ (record.parent !== null &&
74
+ (typeof record.parent !== 'string' || !OID_RE.test(record.parent))) ||
75
+ typeof record.tree !== 'string' ||
76
+ !OID_RE.test(record.tree)
77
+ ) {
78
+ throw new Error('Checkpoint inFlight record is invalid.');
79
+ }
80
+ return { index: record.index, parent: record.parent, tree: record.tree };
81
+ }
82
+
83
+ export function validateSplitCheckpoint(input) {
84
+ if (!object(input)) throw new Error('Split checkpoint must be a JSON object.');
85
+ const allowed = new Set([
86
+ 'kind',
87
+ 'version',
88
+ 'transactionId',
89
+ 'createdAt',
90
+ 'updatedAt',
91
+ 'plan',
92
+ 'snapshots',
93
+ 'completed',
94
+ 'inFlight',
95
+ ]);
96
+ if (Object.keys(input).some((key) => !allowed.has(key))) {
97
+ throw new Error('Split checkpoint contains an unknown property.');
98
+ }
99
+ if (input.kind !== SPLIT_CHECKPOINT_KIND || input.version !== SPLIT_CHECKPOINT_VERSION) {
100
+ throw new Error('Unsupported split checkpoint version.');
101
+ }
102
+ if (typeof input.transactionId !== 'string' || !/^[0-9a-f]{64}$/.test(input.transactionId)) {
103
+ throw new Error('Split checkpoint transactionId must be a SHA-256 digest.');
104
+ }
105
+ for (const key of ['createdAt', 'updatedAt']) {
106
+ if (typeof input[key] !== 'string' || !Number.isFinite(Date.parse(input[key]))) {
107
+ throw new Error(`Split checkpoint ${key} must be an ISO timestamp.`);
108
+ }
109
+ }
110
+ const plan = validateSplitPlanArtifact(input.plan);
111
+ const realPaths = new Set(plan.changes.flatMap((change) => change.addPaths));
112
+ if (!Array.isArray(input.snapshots) || input.snapshots.length !== realPaths.size) {
113
+ throw new Error('Split checkpoint snapshots must cover every real path exactly once.');
114
+ }
115
+ const seen = new Set();
116
+ const snapshots = input.snapshots.map((snapshot, index) => {
117
+ if (
118
+ !object(snapshot) ||
119
+ Object.keys(snapshot).some((key) => !['path', 'target', 'index'].includes(key)) ||
120
+ typeof snapshot.path !== 'string' ||
121
+ !realPaths.has(snapshot.path) ||
122
+ seen.has(snapshot.path)
123
+ ) {
124
+ throw new Error(`snapshots[${index}] has an unknown or duplicate path.`);
125
+ }
126
+ seen.add(snapshot.path);
127
+ return {
128
+ path: snapshot.path,
129
+ target: validateEntry(snapshot.target, `snapshots[${index}].target`),
130
+ index: validateEntry(snapshot.index, `snapshots[${index}].index`),
131
+ };
132
+ });
133
+ if (!Array.isArray(input.completed) || input.completed.length > plan.groups.length) {
134
+ throw new Error('Split checkpoint completed list is invalid.');
135
+ }
136
+ const completed = input.completed.map(validateCommitRecord);
137
+ const inFlight = validateInFlight(input.inFlight, completed.length, plan.groups.length);
138
+ return {
139
+ kind: SPLIT_CHECKPOINT_KIND,
140
+ version: SPLIT_CHECKPOINT_VERSION,
141
+ transactionId: input.transactionId,
142
+ createdAt: input.createdAt,
143
+ updatedAt: input.updatedAt,
144
+ plan,
145
+ snapshots: snapshots.sort((left, right) => left.path.localeCompare(right.path)),
146
+ completed,
147
+ inFlight,
148
+ };
149
+ }
150
+
151
+ export function splitCheckpointPath(projectRoot) {
152
+ return checkpointFile(projectRoot);
153
+ }
154
+
155
+ export function assertNoSplitCheckpoint(projectRoot) {
156
+ const path = checkpointFile(projectRoot);
157
+ if (existsSync(path)) {
158
+ throw new Error(
159
+ `An unfinished split checkpoint already exists: ${path}\n` +
160
+ 'Resume it with "aicommit split resume", or discard only the checkpoint with ' +
161
+ '"aicommit split abort". Discarding keeps existing commits and current changes.',
162
+ );
163
+ }
164
+ return path;
165
+ }
166
+
167
+ export function createSplitCheckpoint(projectRoot, plan, snapshots) {
168
+ const path = assertNoSplitCheckpoint(projectRoot);
169
+ const now = new Date().toISOString();
170
+ const transactionId = createHash('sha256')
171
+ .update(JSON.stringify({ plan, snapshots, createdAt: now }))
172
+ .digest('hex');
173
+ const checkpoint = validateSplitCheckpoint({
174
+ kind: SPLIT_CHECKPOINT_KIND,
175
+ version: SPLIT_CHECKPOINT_VERSION,
176
+ transactionId,
177
+ createdAt: now,
178
+ updatedAt: now,
179
+ plan,
180
+ snapshots,
181
+ completed: [],
182
+ inFlight: null,
183
+ });
184
+ writeSplitCheckpoint(projectRoot, checkpoint);
185
+ return { path, checkpoint };
186
+ }
187
+
188
+ export function readSplitCheckpoint(projectRoot) {
189
+ const path = checkpointFile(projectRoot);
190
+ let stat;
191
+ try {
192
+ stat = lstatSync(path);
193
+ } catch (err) {
194
+ if (err.code === 'ENOENT') throw new Error(`No split checkpoint found: ${path}`);
195
+ throw err;
196
+ }
197
+ if (!stat.isFile() || stat.isSymbolicLink()) {
198
+ throw new Error('Split checkpoint must be a regular, non-symbolic-link file.');
199
+ }
200
+ if (stat.size > MAX_CHECKPOINT_BYTES) throw new Error('Split checkpoint exceeds 2 MiB.');
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
204
+ } catch (err) {
205
+ throw new Error(`Failed to parse split checkpoint ${path}: ${err.message}`);
206
+ }
207
+ return { path, checkpoint: validateSplitCheckpoint(parsed) };
208
+ }
209
+
210
+ export function writeSplitCheckpoint(projectRoot, input) {
211
+ const path = checkpointFile(projectRoot);
212
+ const checkpoint = validateSplitCheckpoint({
213
+ ...input,
214
+ updatedAt: new Date().toISOString(),
215
+ });
216
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
217
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
218
+ try {
219
+ writeFileSync(temporary, JSON.stringify(checkpoint, null, 2) + '\n', {
220
+ encoding: 'utf8',
221
+ mode: 0o600,
222
+ });
223
+ renameSync(temporary, path);
224
+ } catch (err) {
225
+ rmSync(temporary, { force: true });
226
+ throw err;
227
+ }
228
+ return { path, checkpoint };
229
+ }
230
+
231
+ export function removeSplitCheckpoint(projectRoot) {
232
+ rmSync(checkpointFile(projectRoot), { force: true });
233
+ }
234
+
235
+ // Explicitly abandoning recovery removes only AICommit's metadata. It never
236
+ // rewrites HEAD, the index, or the worktree. A symlink at the fixed checkpoint
237
+ // path is safe to unlink, but directories and special files are left alone.
238
+ export function discardSplitCheckpoint(projectRoot) {
239
+ const path = checkpointFile(projectRoot);
240
+ let stat;
241
+ try {
242
+ stat = lstatSync(path);
243
+ } catch (err) {
244
+ if (err.code === 'ENOENT') throw new Error(`No split checkpoint found: ${path}`);
245
+ throw err;
246
+ }
247
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
248
+ throw new Error('Split checkpoint must be a regular file or symbolic link to discard it.');
249
+ }
250
+ rmSync(path);
251
+ return path;
252
+ }