@jojonax/codex-copilot 1.5.5 → 1.6.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/LICENSE +21 -21
- package/README.md +144 -44
- package/bin/cli.js +189 -182
- package/package.json +39 -39
- package/src/commands/evolve.js +316 -316
- package/src/commands/fix.js +447 -447
- package/src/commands/init.js +298 -298
- package/src/commands/reset.js +61 -61
- package/src/commands/retry.js +190 -190
- package/src/commands/run.js +958 -958
- package/src/commands/skip.js +62 -62
- package/src/commands/status.js +95 -95
- package/src/commands/usage.js +361 -361
- package/src/utils/automator.js +279 -279
- package/src/utils/checkpoint.js +246 -246
- package/src/utils/detect-prd.js +137 -137
- package/src/utils/git.js +388 -388
- package/src/utils/github.js +486 -486
- package/src/utils/json.js +220 -220
- package/src/utils/logger.js +41 -41
- package/src/utils/prompt.js +49 -49
- package/src/utils/provider.js +770 -769
- package/src/utils/self-heal.js +330 -330
- package/src/utils/shell-bootstrap.js +404 -0
- package/src/utils/update-check.js +103 -103
package/src/utils/git.js
CHANGED
|
@@ -1,388 +1,388 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git operations utility module
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { execSync } from 'child_process';
|
|
6
|
-
import { existsSync, statSync, unlinkSync } from 'fs';
|
|
7
|
-
import { resolve } from 'path';
|
|
8
|
-
import { log } from './logger.js';
|
|
9
|
-
|
|
10
|
-
function exec(cmd, cwd) {
|
|
11
|
-
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function execSafe(cmd, cwd) {
|
|
15
|
-
try {
|
|
16
|
-
return { ok: true, output: exec(cmd, cwd) };
|
|
17
|
-
} catch (err) {
|
|
18
|
-
return { ok: false, output: err.stderr || err.message };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Validate branch name to prevent shell injection
|
|
23
|
-
function validateBranch(name) {
|
|
24
|
-
if (!name || typeof name !== 'string') throw new Error('Branch name cannot be empty');
|
|
25
|
-
if (/[;&|`$(){}[\]!\\<>"'\s]/.test(name)) {
|
|
26
|
-
throw new Error(`Branch name contains unsafe characters: ${name}`);
|
|
27
|
-
}
|
|
28
|
-
return name;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Resolve a corrupted git index by aborting any in-progress merge/rebase/cherry-pick.
|
|
33
|
-
*
|
|
34
|
-
* Common causes:
|
|
35
|
-
* - A prior `git rebase` left conflict markers in the index
|
|
36
|
-
* - A `git merge` was interrupted (SIGKILL, power loss, etc.)
|
|
37
|
-
* - A `git cherry-pick` left unresolved conflicts
|
|
38
|
-
*
|
|
39
|
-
* After aborting, the index is reset to HEAD so checkout can proceed.
|
|
40
|
-
*
|
|
41
|
-
* @returns {boolean} true if index was resolved, false if no action needed
|
|
42
|
-
*/
|
|
43
|
-
function resolveIndex(cwd) {
|
|
44
|
-
const gitDir = resolve(cwd, '.git');
|
|
45
|
-
|
|
46
|
-
let resolved = false;
|
|
47
|
-
|
|
48
|
-
// 1. Abort in-progress rebase (interactive or non-interactive)
|
|
49
|
-
const rebaseDir = resolve(gitDir, 'rebase-merge');
|
|
50
|
-
const rebaseApplyDir = resolve(gitDir, 'rebase-apply');
|
|
51
|
-
if (existsSync(rebaseDir) || existsSync(rebaseApplyDir)) {
|
|
52
|
-
log.warn('Detected stuck rebase — aborting...');
|
|
53
|
-
execSafe('git rebase --abort', cwd);
|
|
54
|
-
resolved = true;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 2. Abort in-progress merge
|
|
58
|
-
const mergeHeadFile = resolve(gitDir, 'MERGE_HEAD');
|
|
59
|
-
if (existsSync(mergeHeadFile)) {
|
|
60
|
-
log.warn('Detected stuck merge — aborting...');
|
|
61
|
-
execSafe('git merge --abort', cwd);
|
|
62
|
-
resolved = true;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// 3. Abort in-progress cherry-pick
|
|
66
|
-
const cherryPickFile = resolve(gitDir, 'CHERRY_PICK_HEAD');
|
|
67
|
-
if (existsSync(cherryPickFile)) {
|
|
68
|
-
log.warn('Detected stuck cherry-pick — aborting...');
|
|
69
|
-
execSafe('git cherry-pick --abort', cwd);
|
|
70
|
-
resolved = true;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 4. If nothing specific was detected but index is still bad, force-reset index to HEAD
|
|
74
|
-
if (!resolved) {
|
|
75
|
-
log.warn('Resetting index to HEAD...');
|
|
76
|
-
execSafe('git reset HEAD', cwd);
|
|
77
|
-
resolved = true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return resolved;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Safely execute a git checkout with auto-recovery for index issues.
|
|
85
|
-
* If checkout fails with "resolve your current index", automatically resolves
|
|
86
|
-
* the index and retries once.
|
|
87
|
-
* @param {string} cmd - The full git checkout command to execute
|
|
88
|
-
* @param {string} cwd - Working directory
|
|
89
|
-
*/
|
|
90
|
-
function safeCheckout(cmd, cwd) {
|
|
91
|
-
const result = execSafe(cmd, cwd);
|
|
92
|
-
if (result.ok) return;
|
|
93
|
-
|
|
94
|
-
const errMsg = result.output || '';
|
|
95
|
-
if (errMsg.includes('resolve your current index') ||
|
|
96
|
-
errMsg.includes('needs merge') ||
|
|
97
|
-
errMsg.includes('not possible because you have unmerged files') ||
|
|
98
|
-
errMsg.includes('overwritten by checkout')) {
|
|
99
|
-
log.warn(`Checkout failed: ${errMsg.split('\n')[0]}`);
|
|
100
|
-
resolveIndex(cwd);
|
|
101
|
-
|
|
102
|
-
// After resolving, stash any leftover dirty files to ensure clean state
|
|
103
|
-
const stillDirty = !isClean(cwd);
|
|
104
|
-
if (stillDirty) {
|
|
105
|
-
log.dim('Stashing leftover changes after index resolution...');
|
|
106
|
-
execSafe('git stash --include-untracked', cwd);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Retry checkout
|
|
110
|
-
const retry = execSafe(cmd, cwd);
|
|
111
|
-
if (!retry.ok) {
|
|
112
|
-
// Last resort: hard reset to HEAD and try once more
|
|
113
|
-
log.warn('Retry failed — hard resetting to HEAD...');
|
|
114
|
-
execSafe('git reset --hard HEAD', cwd);
|
|
115
|
-
exec(cmd, cwd); // This will throw if it still fails — unrecoverable
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Restore stashed changes
|
|
119
|
-
if (stillDirty) {
|
|
120
|
-
execSafe('git stash pop', cwd);
|
|
121
|
-
}
|
|
122
|
-
} else {
|
|
123
|
-
// Non-index-related error — re-throw
|
|
124
|
-
throw new Error(`Command failed: ${cmd}\n${errMsg}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Check if the git working tree is clean
|
|
130
|
-
*/
|
|
131
|
-
export function isClean(cwd) {
|
|
132
|
-
const result = exec('git status --porcelain', cwd);
|
|
133
|
-
return result.length === 0;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Get current branch name
|
|
138
|
-
*/
|
|
139
|
-
export function currentBranch(cwd) {
|
|
140
|
-
return exec('git branch --show-current', cwd);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Get remote owner/repo info
|
|
145
|
-
*/
|
|
146
|
-
export function getRepoInfo(cwd) {
|
|
147
|
-
const url = exec('git remote get-url origin', cwd);
|
|
148
|
-
// Supports https://github.com/owner/repo.git and git@github.com:owner/repo.git
|
|
149
|
-
const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/);
|
|
150
|
-
if (!match) throw new Error(`Cannot parse GitHub repository URL: ${url}`);
|
|
151
|
-
return { owner: match[1], repo: match[2] };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export function checkoutBranch(cwd, branch, baseBranch = 'main') {
|
|
155
|
-
validateBranch(branch);
|
|
156
|
-
validateBranch(baseBranch);
|
|
157
|
-
const current = currentBranch(cwd);
|
|
158
|
-
if (current === branch) return;
|
|
159
|
-
|
|
160
|
-
// Pre-flight: resolve any stuck index state before attempting anything
|
|
161
|
-
const indexCheck = execSafe('git diff --check', cwd);
|
|
162
|
-
if (!indexCheck.ok && indexCheck.output && indexCheck.output.includes('conflict')) {
|
|
163
|
-
log.warn('Pre-flight: detected conflict markers in index');
|
|
164
|
-
resolveIndex(cwd);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Stash any uncommitted changes to allow safe branch switching
|
|
168
|
-
const hasChanges = !isClean(cwd);
|
|
169
|
-
if (hasChanges) {
|
|
170
|
-
const stashResult = execSafe('git stash --include-untracked', cwd);
|
|
171
|
-
if (!stashResult.ok) {
|
|
172
|
-
// Stash itself can fail if the index is in a bad state
|
|
173
|
-
log.warn('Stash failed — resolving index first...');
|
|
174
|
-
resolveIndex(cwd);
|
|
175
|
-
execSafe('git stash --include-untracked', cwd);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Check if the branch already exists locally
|
|
180
|
-
const branchExists = execSafe(`git rev-parse --verify ${branch}`, cwd).ok;
|
|
181
|
-
|
|
182
|
-
if (branchExists) {
|
|
183
|
-
// Branch exists — just switch to it (preserving all previous work)
|
|
184
|
-
safeCheckout(`git checkout ${branch}`, cwd);
|
|
185
|
-
|
|
186
|
-
// Try to rebase onto latest base to pick up any new changes
|
|
187
|
-
execSafe(`git fetch origin ${baseBranch}`, cwd);
|
|
188
|
-
const rebaseResult = execSafe(`git rebase origin/${baseBranch}`, cwd);
|
|
189
|
-
// If rebase fails (conflicts), abort and continue with existing state
|
|
190
|
-
if (!rebaseResult.ok) {
|
|
191
|
-
log.dim('Rebase had conflicts — aborting to preserve current state');
|
|
192
|
-
execSafe('git rebase --abort', cwd);
|
|
193
|
-
}
|
|
194
|
-
} else {
|
|
195
|
-
// Branch doesn't exist — create from latest base
|
|
196
|
-
safeCheckout(`git checkout ${baseBranch}`, cwd);
|
|
197
|
-
execSafe(`git pull origin ${baseBranch}`, cwd);
|
|
198
|
-
exec(`git checkout -b ${branch}`, cwd);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Restore stashed changes if we stashed earlier
|
|
202
|
-
if (hasChanges) {
|
|
203
|
-
execSafe('git stash pop', cwd);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Commit all changes
|
|
209
|
-
*/
|
|
210
|
-
export function commitAll(cwd, message) {
|
|
211
|
-
exec('git add -A', cwd);
|
|
212
|
-
const result = execSafe(`git diff --cached --quiet`, cwd);
|
|
213
|
-
if (result.ok) {
|
|
214
|
-
log.dim('No changes to commit');
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
exec(`git commit -m ${shellEscape(message)}`, cwd);
|
|
218
|
-
return true;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function shellEscape(str) {
|
|
222
|
-
return `'${str.replace(/'/g, "'\\''")}'`
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Push branch to remote (handles new branches without upstream)
|
|
227
|
-
*/
|
|
228
|
-
export function pushBranch(cwd, branch) {
|
|
229
|
-
validateBranch(branch);
|
|
230
|
-
const result = execSafe(`git push origin ${branch} --force-with-lease`, cwd);
|
|
231
|
-
if (!result.ok) {
|
|
232
|
-
// Force-with-lease fails on new branches — fall back to regular push with upstream
|
|
233
|
-
exec(`git push -u origin ${branch}`, cwd);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Switch back to main branch (with safe checkout recovery)
|
|
239
|
-
*/
|
|
240
|
-
export function checkoutMain(cwd, baseBranch = 'main') {
|
|
241
|
-
validateBranch(baseBranch);
|
|
242
|
-
safeCheckout(`git checkout ${baseBranch}`, cwd);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ──────────────────────────────────────────────
|
|
246
|
-
// Self-Healing Utilities (Layer 2)
|
|
247
|
-
// ──────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* A2: Clear stale git lock files left behind by crashed processes.
|
|
251
|
-
* Only removes lock files older than `maxAgeMs` to avoid deleting
|
|
252
|
-
* locks from concurrent, legitimate operations.
|
|
253
|
-
* @param {string} cwd - Working directory
|
|
254
|
-
* @param {number} maxAgeMs - Max age in ms before lock is considered stale (default 5s)
|
|
255
|
-
* @returns {string[]} list of removed lock files
|
|
256
|
-
*/
|
|
257
|
-
export function clearStaleLocks(cwd, maxAgeMs = 5000) {
|
|
258
|
-
const gitDir = resolve(cwd, '.git');
|
|
259
|
-
const removed = [];
|
|
260
|
-
|
|
261
|
-
for (const lockName of ['index.lock', 'HEAD.lock', 'config.lock']) {
|
|
262
|
-
const lockPath = resolve(gitDir, lockName);
|
|
263
|
-
if (existsSync(lockPath)) {
|
|
264
|
-
try {
|
|
265
|
-
const stats = statSync(lockPath);
|
|
266
|
-
const age = Date.now() - stats.mtimeMs;
|
|
267
|
-
if (age > maxAgeMs) {
|
|
268
|
-
unlinkSync(lockPath);
|
|
269
|
-
removed.push(lockName);
|
|
270
|
-
log.warn(`Removed stale lock: ${lockName} (age: ${Math.round(age / 1000)}s)`);
|
|
271
|
-
}
|
|
272
|
-
} catch {
|
|
273
|
-
// If we can't stat, try to remove anyway (it's stale if the process is gone)
|
|
274
|
-
try {
|
|
275
|
-
unlinkSync(lockPath);
|
|
276
|
-
removed.push(lockName);
|
|
277
|
-
log.warn(`Removed lock file: ${lockName}`);
|
|
278
|
-
} catch { /* locked by active process — leave it */ }
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return removed;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* A3: Recover from detached HEAD state.
|
|
288
|
-
* When HEAD is detached (e.g., after interrupted rebase), find
|
|
289
|
-
* the most recent branch that points to HEAD and checkout.
|
|
290
|
-
* @returns {string|null} branch name restored to, or null if not detached
|
|
291
|
-
*/
|
|
292
|
-
export function recoverDetachedHead(cwd) {
|
|
293
|
-
const branch = currentBranch(cwd);
|
|
294
|
-
if (branch) return null; // Not detached — nothing to do
|
|
295
|
-
|
|
296
|
-
log.warn('Detected detached HEAD state');
|
|
297
|
-
|
|
298
|
-
// Find branches containing the current commit
|
|
299
|
-
const result = execSafe('git branch --contains HEAD', cwd);
|
|
300
|
-
if (!result.ok) {
|
|
301
|
-
log.warn('Cannot determine branches containing HEAD');
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Parse branch list, filter out detached indicator
|
|
306
|
-
const branches = result.output
|
|
307
|
-
.split('\n')
|
|
308
|
-
.map(b => b.replace(/^\*?\s+/, '').trim())
|
|
309
|
-
.filter(b => b && !b.startsWith('(') && !b.includes('HEAD detached'));
|
|
310
|
-
|
|
311
|
-
if (branches.length === 0) {
|
|
312
|
-
log.warn('No branch contains current HEAD — manual intervention required');
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Prefer main/master, then the first branch found
|
|
317
|
-
const preferred = branches.find(b => b === 'main' || b === 'master') || branches[0];
|
|
318
|
-
log.warn(`Re-attaching HEAD to branch: ${preferred}`);
|
|
319
|
-
execSafe(`git checkout ${preferred}`, cwd);
|
|
320
|
-
return preferred;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* A4: Check for orphaned stash entries from interrupted codex-copilot operations.
|
|
325
|
-
* Stash entries created by checkoutBranch() may leak if the process is killed
|
|
326
|
-
* between stash push and stash pop.
|
|
327
|
-
* @returns {{ found: boolean, count: number }} stash status
|
|
328
|
-
*/
|
|
329
|
-
export function checkOrphanedStash(cwd) {
|
|
330
|
-
const result = execSafe('git stash list', cwd);
|
|
331
|
-
if (!result.ok || !result.output.trim()) {
|
|
332
|
-
return { found: false, count: 0 };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const entries = result.output.trim().split('\n');
|
|
336
|
-
if (entries.length > 0) {
|
|
337
|
-
log.warn(`Found ${entries.length} stash entry(s) — recent stash(es):`);
|
|
338
|
-
for (const entry of entries.slice(0, 3)) {
|
|
339
|
-
log.dim(` ${entry}`);
|
|
340
|
-
}
|
|
341
|
-
// Only warn — automatic pop is risky (may cause merge conflicts)
|
|
342
|
-
// User can decide to pop or drop
|
|
343
|
-
if (entries.length > 3) {
|
|
344
|
-
log.dim(` ... and ${entries.length - 3} more`);
|
|
345
|
-
}
|
|
346
|
-
return { found: true, count: entries.length };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return { found: false, count: 0 };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* A7: Quick git repository integrity check.
|
|
354
|
-
* Runs `git fsck --no-full --no-dangling` for a fast check,
|
|
355
|
-
* then `git gc --auto` if issues are found.
|
|
356
|
-
* @returns {{ ok: boolean, issues: string[] }}
|
|
357
|
-
*/
|
|
358
|
-
export function verifyRepo(cwd) {
|
|
359
|
-
const result = execSafe('git fsck --no-full --no-dangling --connectivity-only 2>&1', cwd);
|
|
360
|
-
if (result.ok) {
|
|
361
|
-
return { ok: true, issues: [] };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const issues = (result.output || '')
|
|
365
|
-
.split('\n')
|
|
366
|
-
.filter(l => l.trim() && !l.includes('notice') && !l.includes('Checking'))
|
|
367
|
-
.slice(0, 10);
|
|
368
|
-
|
|
369
|
-
if (issues.length > 0) {
|
|
370
|
-
log.warn(`Git integrity issues detected (${issues.length}):`);
|
|
371
|
-
for (const issue of issues.slice(0, 3)) {
|
|
372
|
-
log.dim(` ${issue}`);
|
|
373
|
-
}
|
|
374
|
-
// Attempt auto-repair
|
|
375
|
-
log.info('Running git gc to repair...');
|
|
376
|
-
execSafe('git gc --auto', cwd);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return { ok: issues.length === 0, issues };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export const git = {
|
|
383
|
-
isClean, currentBranch, getRepoInfo, checkoutBranch,
|
|
384
|
-
commitAll, pushBranch, checkoutMain, exec, execSafe,
|
|
385
|
-
resolveIndex, clearStaleLocks, recoverDetachedHead,
|
|
386
|
-
checkOrphanedStash, verifyRepo,
|
|
387
|
-
};
|
|
388
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Git operations utility module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { existsSync, statSync, unlinkSync } from 'fs';
|
|
7
|
+
import { resolve } from 'path';
|
|
8
|
+
import { log } from './logger.js';
|
|
9
|
+
|
|
10
|
+
function exec(cmd, cwd) {
|
|
11
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function execSafe(cmd, cwd) {
|
|
15
|
+
try {
|
|
16
|
+
return { ok: true, output: exec(cmd, cwd) };
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return { ok: false, output: err.stderr || err.message };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Validate branch name to prevent shell injection
|
|
23
|
+
function validateBranch(name) {
|
|
24
|
+
if (!name || typeof name !== 'string') throw new Error('Branch name cannot be empty');
|
|
25
|
+
if (/[;&|`$(){}[\]!\\<>"'\s]/.test(name)) {
|
|
26
|
+
throw new Error(`Branch name contains unsafe characters: ${name}`);
|
|
27
|
+
}
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a corrupted git index by aborting any in-progress merge/rebase/cherry-pick.
|
|
33
|
+
*
|
|
34
|
+
* Common causes:
|
|
35
|
+
* - A prior `git rebase` left conflict markers in the index
|
|
36
|
+
* - A `git merge` was interrupted (SIGKILL, power loss, etc.)
|
|
37
|
+
* - A `git cherry-pick` left unresolved conflicts
|
|
38
|
+
*
|
|
39
|
+
* After aborting, the index is reset to HEAD so checkout can proceed.
|
|
40
|
+
*
|
|
41
|
+
* @returns {boolean} true if index was resolved, false if no action needed
|
|
42
|
+
*/
|
|
43
|
+
function resolveIndex(cwd) {
|
|
44
|
+
const gitDir = resolve(cwd, '.git');
|
|
45
|
+
|
|
46
|
+
let resolved = false;
|
|
47
|
+
|
|
48
|
+
// 1. Abort in-progress rebase (interactive or non-interactive)
|
|
49
|
+
const rebaseDir = resolve(gitDir, 'rebase-merge');
|
|
50
|
+
const rebaseApplyDir = resolve(gitDir, 'rebase-apply');
|
|
51
|
+
if (existsSync(rebaseDir) || existsSync(rebaseApplyDir)) {
|
|
52
|
+
log.warn('Detected stuck rebase — aborting...');
|
|
53
|
+
execSafe('git rebase --abort', cwd);
|
|
54
|
+
resolved = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Abort in-progress merge
|
|
58
|
+
const mergeHeadFile = resolve(gitDir, 'MERGE_HEAD');
|
|
59
|
+
if (existsSync(mergeHeadFile)) {
|
|
60
|
+
log.warn('Detected stuck merge — aborting...');
|
|
61
|
+
execSafe('git merge --abort', cwd);
|
|
62
|
+
resolved = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Abort in-progress cherry-pick
|
|
66
|
+
const cherryPickFile = resolve(gitDir, 'CHERRY_PICK_HEAD');
|
|
67
|
+
if (existsSync(cherryPickFile)) {
|
|
68
|
+
log.warn('Detected stuck cherry-pick — aborting...');
|
|
69
|
+
execSafe('git cherry-pick --abort', cwd);
|
|
70
|
+
resolved = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. If nothing specific was detected but index is still bad, force-reset index to HEAD
|
|
74
|
+
if (!resolved) {
|
|
75
|
+
log.warn('Resetting index to HEAD...');
|
|
76
|
+
execSafe('git reset HEAD', cwd);
|
|
77
|
+
resolved = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Safely execute a git checkout with auto-recovery for index issues.
|
|
85
|
+
* If checkout fails with "resolve your current index", automatically resolves
|
|
86
|
+
* the index and retries once.
|
|
87
|
+
* @param {string} cmd - The full git checkout command to execute
|
|
88
|
+
* @param {string} cwd - Working directory
|
|
89
|
+
*/
|
|
90
|
+
function safeCheckout(cmd, cwd) {
|
|
91
|
+
const result = execSafe(cmd, cwd);
|
|
92
|
+
if (result.ok) return;
|
|
93
|
+
|
|
94
|
+
const errMsg = result.output || '';
|
|
95
|
+
if (errMsg.includes('resolve your current index') ||
|
|
96
|
+
errMsg.includes('needs merge') ||
|
|
97
|
+
errMsg.includes('not possible because you have unmerged files') ||
|
|
98
|
+
errMsg.includes('overwritten by checkout')) {
|
|
99
|
+
log.warn(`Checkout failed: ${errMsg.split('\n')[0]}`);
|
|
100
|
+
resolveIndex(cwd);
|
|
101
|
+
|
|
102
|
+
// After resolving, stash any leftover dirty files to ensure clean state
|
|
103
|
+
const stillDirty = !isClean(cwd);
|
|
104
|
+
if (stillDirty) {
|
|
105
|
+
log.dim('Stashing leftover changes after index resolution...');
|
|
106
|
+
execSafe('git stash --include-untracked', cwd);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Retry checkout
|
|
110
|
+
const retry = execSafe(cmd, cwd);
|
|
111
|
+
if (!retry.ok) {
|
|
112
|
+
// Last resort: hard reset to HEAD and try once more
|
|
113
|
+
log.warn('Retry failed — hard resetting to HEAD...');
|
|
114
|
+
execSafe('git reset --hard HEAD', cwd);
|
|
115
|
+
exec(cmd, cwd); // This will throw if it still fails — unrecoverable
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Restore stashed changes
|
|
119
|
+
if (stillDirty) {
|
|
120
|
+
execSafe('git stash pop', cwd);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
// Non-index-related error — re-throw
|
|
124
|
+
throw new Error(`Command failed: ${cmd}\n${errMsg}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if the git working tree is clean
|
|
130
|
+
*/
|
|
131
|
+
export function isClean(cwd) {
|
|
132
|
+
const result = exec('git status --porcelain', cwd);
|
|
133
|
+
return result.length === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get current branch name
|
|
138
|
+
*/
|
|
139
|
+
export function currentBranch(cwd) {
|
|
140
|
+
return exec('git branch --show-current', cwd);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get remote owner/repo info
|
|
145
|
+
*/
|
|
146
|
+
export function getRepoInfo(cwd) {
|
|
147
|
+
const url = exec('git remote get-url origin', cwd);
|
|
148
|
+
// Supports https://github.com/owner/repo.git and git@github.com:owner/repo.git
|
|
149
|
+
const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/);
|
|
150
|
+
if (!match) throw new Error(`Cannot parse GitHub repository URL: ${url}`);
|
|
151
|
+
return { owner: match[1], repo: match[2] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function checkoutBranch(cwd, branch, baseBranch = 'main') {
|
|
155
|
+
validateBranch(branch);
|
|
156
|
+
validateBranch(baseBranch);
|
|
157
|
+
const current = currentBranch(cwd);
|
|
158
|
+
if (current === branch) return;
|
|
159
|
+
|
|
160
|
+
// Pre-flight: resolve any stuck index state before attempting anything
|
|
161
|
+
const indexCheck = execSafe('git diff --check', cwd);
|
|
162
|
+
if (!indexCheck.ok && indexCheck.output && indexCheck.output.includes('conflict')) {
|
|
163
|
+
log.warn('Pre-flight: detected conflict markers in index');
|
|
164
|
+
resolveIndex(cwd);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Stash any uncommitted changes to allow safe branch switching
|
|
168
|
+
const hasChanges = !isClean(cwd);
|
|
169
|
+
if (hasChanges) {
|
|
170
|
+
const stashResult = execSafe('git stash --include-untracked', cwd);
|
|
171
|
+
if (!stashResult.ok) {
|
|
172
|
+
// Stash itself can fail if the index is in a bad state
|
|
173
|
+
log.warn('Stash failed — resolving index first...');
|
|
174
|
+
resolveIndex(cwd);
|
|
175
|
+
execSafe('git stash --include-untracked', cwd);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check if the branch already exists locally
|
|
180
|
+
const branchExists = execSafe(`git rev-parse --verify ${branch}`, cwd).ok;
|
|
181
|
+
|
|
182
|
+
if (branchExists) {
|
|
183
|
+
// Branch exists — just switch to it (preserving all previous work)
|
|
184
|
+
safeCheckout(`git checkout ${branch}`, cwd);
|
|
185
|
+
|
|
186
|
+
// Try to rebase onto latest base to pick up any new changes
|
|
187
|
+
execSafe(`git fetch origin ${baseBranch}`, cwd);
|
|
188
|
+
const rebaseResult = execSafe(`git rebase origin/${baseBranch}`, cwd);
|
|
189
|
+
// If rebase fails (conflicts), abort and continue with existing state
|
|
190
|
+
if (!rebaseResult.ok) {
|
|
191
|
+
log.dim('Rebase had conflicts — aborting to preserve current state');
|
|
192
|
+
execSafe('git rebase --abort', cwd);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// Branch doesn't exist — create from latest base
|
|
196
|
+
safeCheckout(`git checkout ${baseBranch}`, cwd);
|
|
197
|
+
execSafe(`git pull origin ${baseBranch}`, cwd);
|
|
198
|
+
exec(`git checkout -b ${branch}`, cwd);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Restore stashed changes if we stashed earlier
|
|
202
|
+
if (hasChanges) {
|
|
203
|
+
execSafe('git stash pop', cwd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Commit all changes
|
|
209
|
+
*/
|
|
210
|
+
export function commitAll(cwd, message) {
|
|
211
|
+
exec('git add -A', cwd);
|
|
212
|
+
const result = execSafe(`git diff --cached --quiet`, cwd);
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
log.dim('No changes to commit');
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
exec(`git commit -m ${shellEscape(message)}`, cwd);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function shellEscape(str) {
|
|
222
|
+
return `'${str.replace(/'/g, "'\\''")}'`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Push branch to remote (handles new branches without upstream)
|
|
227
|
+
*/
|
|
228
|
+
export function pushBranch(cwd, branch) {
|
|
229
|
+
validateBranch(branch);
|
|
230
|
+
const result = execSafe(`git push origin ${branch} --force-with-lease`, cwd);
|
|
231
|
+
if (!result.ok) {
|
|
232
|
+
// Force-with-lease fails on new branches — fall back to regular push with upstream
|
|
233
|
+
exec(`git push -u origin ${branch}`, cwd);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Switch back to main branch (with safe checkout recovery)
|
|
239
|
+
*/
|
|
240
|
+
export function checkoutMain(cwd, baseBranch = 'main') {
|
|
241
|
+
validateBranch(baseBranch);
|
|
242
|
+
safeCheckout(`git checkout ${baseBranch}`, cwd);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ──────────────────────────────────────────────
|
|
246
|
+
// Self-Healing Utilities (Layer 2)
|
|
247
|
+
// ──────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A2: Clear stale git lock files left behind by crashed processes.
|
|
251
|
+
* Only removes lock files older than `maxAgeMs` to avoid deleting
|
|
252
|
+
* locks from concurrent, legitimate operations.
|
|
253
|
+
* @param {string} cwd - Working directory
|
|
254
|
+
* @param {number} maxAgeMs - Max age in ms before lock is considered stale (default 5s)
|
|
255
|
+
* @returns {string[]} list of removed lock files
|
|
256
|
+
*/
|
|
257
|
+
export function clearStaleLocks(cwd, maxAgeMs = 5000) {
|
|
258
|
+
const gitDir = resolve(cwd, '.git');
|
|
259
|
+
const removed = [];
|
|
260
|
+
|
|
261
|
+
for (const lockName of ['index.lock', 'HEAD.lock', 'config.lock']) {
|
|
262
|
+
const lockPath = resolve(gitDir, lockName);
|
|
263
|
+
if (existsSync(lockPath)) {
|
|
264
|
+
try {
|
|
265
|
+
const stats = statSync(lockPath);
|
|
266
|
+
const age = Date.now() - stats.mtimeMs;
|
|
267
|
+
if (age > maxAgeMs) {
|
|
268
|
+
unlinkSync(lockPath);
|
|
269
|
+
removed.push(lockName);
|
|
270
|
+
log.warn(`Removed stale lock: ${lockName} (age: ${Math.round(age / 1000)}s)`);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// If we can't stat, try to remove anyway (it's stale if the process is gone)
|
|
274
|
+
try {
|
|
275
|
+
unlinkSync(lockPath);
|
|
276
|
+
removed.push(lockName);
|
|
277
|
+
log.warn(`Removed lock file: ${lockName}`);
|
|
278
|
+
} catch { /* locked by active process — leave it */ }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return removed;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A3: Recover from detached HEAD state.
|
|
288
|
+
* When HEAD is detached (e.g., after interrupted rebase), find
|
|
289
|
+
* the most recent branch that points to HEAD and checkout.
|
|
290
|
+
* @returns {string|null} branch name restored to, or null if not detached
|
|
291
|
+
*/
|
|
292
|
+
export function recoverDetachedHead(cwd) {
|
|
293
|
+
const branch = currentBranch(cwd);
|
|
294
|
+
if (branch) return null; // Not detached — nothing to do
|
|
295
|
+
|
|
296
|
+
log.warn('Detected detached HEAD state');
|
|
297
|
+
|
|
298
|
+
// Find branches containing the current commit
|
|
299
|
+
const result = execSafe('git branch --contains HEAD', cwd);
|
|
300
|
+
if (!result.ok) {
|
|
301
|
+
log.warn('Cannot determine branches containing HEAD');
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Parse branch list, filter out detached indicator
|
|
306
|
+
const branches = result.output
|
|
307
|
+
.split('\n')
|
|
308
|
+
.map(b => b.replace(/^\*?\s+/, '').trim())
|
|
309
|
+
.filter(b => b && !b.startsWith('(') && !b.includes('HEAD detached'));
|
|
310
|
+
|
|
311
|
+
if (branches.length === 0) {
|
|
312
|
+
log.warn('No branch contains current HEAD — manual intervention required');
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Prefer main/master, then the first branch found
|
|
317
|
+
const preferred = branches.find(b => b === 'main' || b === 'master') || branches[0];
|
|
318
|
+
log.warn(`Re-attaching HEAD to branch: ${preferred}`);
|
|
319
|
+
execSafe(`git checkout ${preferred}`, cwd);
|
|
320
|
+
return preferred;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A4: Check for orphaned stash entries from interrupted codex-copilot operations.
|
|
325
|
+
* Stash entries created by checkoutBranch() may leak if the process is killed
|
|
326
|
+
* between stash push and stash pop.
|
|
327
|
+
* @returns {{ found: boolean, count: number }} stash status
|
|
328
|
+
*/
|
|
329
|
+
export function checkOrphanedStash(cwd) {
|
|
330
|
+
const result = execSafe('git stash list', cwd);
|
|
331
|
+
if (!result.ok || !result.output.trim()) {
|
|
332
|
+
return { found: false, count: 0 };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const entries = result.output.trim().split('\n');
|
|
336
|
+
if (entries.length > 0) {
|
|
337
|
+
log.warn(`Found ${entries.length} stash entry(s) — recent stash(es):`);
|
|
338
|
+
for (const entry of entries.slice(0, 3)) {
|
|
339
|
+
log.dim(` ${entry}`);
|
|
340
|
+
}
|
|
341
|
+
// Only warn — automatic pop is risky (may cause merge conflicts)
|
|
342
|
+
// User can decide to pop or drop
|
|
343
|
+
if (entries.length > 3) {
|
|
344
|
+
log.dim(` ... and ${entries.length - 3} more`);
|
|
345
|
+
}
|
|
346
|
+
return { found: true, count: entries.length };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { found: false, count: 0 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A7: Quick git repository integrity check.
|
|
354
|
+
* Runs `git fsck --no-full --no-dangling` for a fast check,
|
|
355
|
+
* then `git gc --auto` if issues are found.
|
|
356
|
+
* @returns {{ ok: boolean, issues: string[] }}
|
|
357
|
+
*/
|
|
358
|
+
export function verifyRepo(cwd) {
|
|
359
|
+
const result = execSafe('git fsck --no-full --no-dangling --connectivity-only 2>&1', cwd);
|
|
360
|
+
if (result.ok) {
|
|
361
|
+
return { ok: true, issues: [] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const issues = (result.output || '')
|
|
365
|
+
.split('\n')
|
|
366
|
+
.filter(l => l.trim() && !l.includes('notice') && !l.includes('Checking'))
|
|
367
|
+
.slice(0, 10);
|
|
368
|
+
|
|
369
|
+
if (issues.length > 0) {
|
|
370
|
+
log.warn(`Git integrity issues detected (${issues.length}):`);
|
|
371
|
+
for (const issue of issues.slice(0, 3)) {
|
|
372
|
+
log.dim(` ${issue}`);
|
|
373
|
+
}
|
|
374
|
+
// Attempt auto-repair
|
|
375
|
+
log.info('Running git gc to repair...');
|
|
376
|
+
execSafe('git gc --auto', cwd);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { ok: issues.length === 0, issues };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const git = {
|
|
383
|
+
isClean, currentBranch, getRepoInfo, checkoutBranch,
|
|
384
|
+
commitAll, pushBranch, checkoutMain, exec, execSafe,
|
|
385
|
+
resolveIndex, clearStaleLocks, recoverDetachedHead,
|
|
386
|
+
checkOrphanedStash, verifyRepo,
|
|
387
|
+
};
|
|
388
|
+
|