@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/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
+