create-claude-workspace 2.3.8 → 2.3.9
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.
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// ─── CI pipeline status polling ───
|
|
2
2
|
// Uses gh/glab CLI to watch pipeline status after push.
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
4
|
const POLL_INTERVAL = 15_000;
|
|
5
5
|
const MAX_POLL_TIME = 30 * 60_000; // 30 minutes
|
|
6
|
+
const CLI_TIMEOUT = 15_000;
|
|
7
|
+
function run(cmd, args, cwd, timeout = CLI_TIMEOUT) {
|
|
8
|
+
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout }).trim();
|
|
9
|
+
}
|
|
6
10
|
export function detectCIPlatform(projectDir) {
|
|
7
11
|
try {
|
|
8
|
-
const remote =
|
|
12
|
+
const remote = run('git', ['remote', 'get-url', 'origin'], projectDir);
|
|
9
13
|
if (remote.includes('github.com') || remote.includes('github:'))
|
|
10
14
|
return 'github';
|
|
11
15
|
if (remote.includes('gitlab.com') || remote.includes('gitlab:') || remote.includes('gitlab'))
|
|
@@ -51,13 +55,13 @@ export async function watchPipeline(branch, platform, projectDir, logger, signal
|
|
|
51
55
|
}
|
|
52
56
|
function pollGitHub(branch, cwd, logger) {
|
|
53
57
|
try {
|
|
54
|
-
const output =
|
|
58
|
+
const output = run('gh', ['run', 'list', '--branch', branch, '--limit', '1', '--json', 'status,conclusion'], cwd);
|
|
55
59
|
const runs = JSON.parse(output);
|
|
56
60
|
if (!runs.length)
|
|
57
61
|
return 'not-found';
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
62
|
+
const run_ = runs[0];
|
|
63
|
+
if (run_.status === 'completed') {
|
|
64
|
+
return run_.conclusion === 'success' ? 'passed' : 'failed';
|
|
61
65
|
}
|
|
62
66
|
return 'pending';
|
|
63
67
|
}
|
|
@@ -68,7 +72,7 @@ function pollGitHub(branch, cwd, logger) {
|
|
|
68
72
|
}
|
|
69
73
|
function pollGitLab(branch, cwd, logger) {
|
|
70
74
|
try {
|
|
71
|
-
const output =
|
|
75
|
+
const output = run('glab', ['ci', 'list', '--branch', branch, '--output', 'json'], cwd);
|
|
72
76
|
const pipelines = JSON.parse(output);
|
|
73
77
|
if (!pipelines.length)
|
|
74
78
|
return 'not-found';
|
|
@@ -89,16 +93,15 @@ function pollGitLab(branch, cwd, logger) {
|
|
|
89
93
|
export function fetchFailureLogs(branch, platform, cwd) {
|
|
90
94
|
try {
|
|
91
95
|
if (platform === 'github') {
|
|
92
|
-
|
|
93
|
-
const runsOutput = execSync(`gh run list --branch "${branch}" --limit 1 --json databaseId,conclusion`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
|
|
96
|
+
const runsOutput = run('gh', ['run', 'list', '--branch', branch, '--limit', '1', '--json', 'databaseId,conclusion'], cwd);
|
|
94
97
|
const runs = JSON.parse(runsOutput);
|
|
95
98
|
if (!runs.length)
|
|
96
99
|
return undefined;
|
|
97
|
-
const runId = runs[0].databaseId;
|
|
98
|
-
return
|
|
100
|
+
const runId = String(runs[0].databaseId);
|
|
101
|
+
return run('gh', ['run', 'view', runId, '--log-failed'], cwd, 30_000);
|
|
99
102
|
}
|
|
100
103
|
if (platform === 'gitlab') {
|
|
101
|
-
return
|
|
104
|
+
return run('glab', ['ci', 'trace', '--branch', branch], cwd, 30_000);
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
catch { /* ignore */ }
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// ─── Deterministic git operations ───
|
|
2
|
-
// All operations use
|
|
3
|
-
import {
|
|
2
|
+
// All operations use execFileSync — safe from shell injection.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
6
|
const GIT_TIMEOUT = 30_000;
|
|
7
7
|
const PUSH_TIMEOUT = 60_000;
|
|
8
8
|
function git(args, cwd, timeout = GIT_TIMEOUT) {
|
|
9
|
-
return
|
|
9
|
+
return execFileSync('git', args, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
10
10
|
}
|
|
11
11
|
// ─── Worktree operations ───
|
|
12
12
|
export function createWorktree(projectDir, branchSlug, baseBranch) {
|
|
@@ -15,21 +15,27 @@ export function createWorktree(projectDir, branchSlug, baseBranch) {
|
|
|
15
15
|
if (existsSync(worktreePath)) {
|
|
16
16
|
return worktreePath;
|
|
17
17
|
}
|
|
18
|
-
git(
|
|
18
|
+
git(['worktree', 'add', worktreePath, '-b', branchSlug, base], projectDir);
|
|
19
19
|
return worktreePath;
|
|
20
20
|
}
|
|
21
21
|
export function cleanupWorktree(projectDir, worktreePath, branch) {
|
|
22
22
|
try {
|
|
23
|
-
git(
|
|
23
|
+
git(['worktree', 'remove', worktreePath, '--force'], projectDir);
|
|
24
24
|
}
|
|
25
25
|
catch { /* may already be removed */ }
|
|
26
26
|
try {
|
|
27
|
-
git(
|
|
27
|
+
git(['branch', '-d', branch], projectDir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// -d may fail if not fully merged — force delete since we know it was merged or abandoned
|
|
31
|
+
try {
|
|
32
|
+
git(['branch', '-D', branch], projectDir);
|
|
33
|
+
}
|
|
34
|
+
catch { /* may already be deleted */ }
|
|
28
35
|
}
|
|
29
|
-
catch { /* may already be deleted */ }
|
|
30
36
|
}
|
|
31
37
|
export function listWorktrees(projectDir) {
|
|
32
|
-
const output = git('worktree list --porcelain', projectDir);
|
|
38
|
+
const output = git(['worktree', 'list', '--porcelain'], projectDir);
|
|
33
39
|
const mainNorm = normalizePath(resolve(projectDir));
|
|
34
40
|
const paths = [];
|
|
35
41
|
for (const line of output.split('\n')) {
|
|
@@ -50,24 +56,24 @@ export function listOrphanedWorktrees(projectDir, knownWorktrees) {
|
|
|
50
56
|
}
|
|
51
57
|
// ─── Commit operations ───
|
|
52
58
|
export function commitInWorktree(worktreePath, message) {
|
|
53
|
-
git('add -A', worktreePath);
|
|
59
|
+
git(['add', '-A'], worktreePath);
|
|
54
60
|
// Check if there's anything to commit
|
|
55
|
-
const status = git('status --porcelain', worktreePath);
|
|
61
|
+
const status = git(['status', '--porcelain'], worktreePath);
|
|
56
62
|
if (!status)
|
|
57
63
|
return '';
|
|
58
|
-
git(
|
|
59
|
-
return git('rev-parse HEAD', worktreePath);
|
|
64
|
+
git(['commit', '-m', message], worktreePath);
|
|
65
|
+
return git(['rev-parse', 'HEAD'], worktreePath);
|
|
60
66
|
}
|
|
61
67
|
export function getChangedFiles(worktreePath) {
|
|
62
68
|
try {
|
|
63
69
|
const mainBranch = getMainBranch(worktreePath);
|
|
64
|
-
const output = git(
|
|
70
|
+
const output = git(['diff', '--name-only', `${mainBranch}...HEAD`], worktreePath);
|
|
65
71
|
return output ? output.split('\n').filter(f => f.length > 0) : [];
|
|
66
72
|
}
|
|
67
73
|
catch {
|
|
68
74
|
// Fallback: diff against parent
|
|
69
75
|
try {
|
|
70
|
-
const output = git('diff --name-only HEAD~1', worktreePath);
|
|
76
|
+
const output = git(['diff', '--name-only', 'HEAD~1'], worktreePath);
|
|
71
77
|
return output ? output.split('\n').filter(f => f.length > 0) : [];
|
|
72
78
|
}
|
|
73
79
|
catch {
|
|
@@ -76,14 +82,14 @@ export function getChangedFiles(worktreePath) {
|
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
export function hasUncommittedChanges(dir) {
|
|
79
|
-
const status = git('status --porcelain', dir);
|
|
85
|
+
const status = git(['status', '--porcelain'], dir);
|
|
80
86
|
return status.length > 0;
|
|
81
87
|
}
|
|
82
88
|
// ─── Push operations ───
|
|
83
89
|
export function pushWorktree(worktreePath) {
|
|
84
90
|
try {
|
|
85
|
-
const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
|
|
86
|
-
git(
|
|
91
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
92
|
+
git(['push', '-u', 'origin', branch], worktreePath, PUSH_TIMEOUT);
|
|
87
93
|
return true;
|
|
88
94
|
}
|
|
89
95
|
catch {
|
|
@@ -92,8 +98,8 @@ export function pushWorktree(worktreePath) {
|
|
|
92
98
|
}
|
|
93
99
|
export function forcePushWorktree(worktreePath) {
|
|
94
100
|
try {
|
|
95
|
-
const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
|
|
96
|
-
git(
|
|
101
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
102
|
+
git(['push', '--force-with-lease', '-u', 'origin', branch], worktreePath, PUSH_TIMEOUT);
|
|
97
103
|
return true;
|
|
98
104
|
}
|
|
99
105
|
catch {
|
|
@@ -101,39 +107,66 @@ export function forcePushWorktree(worktreePath) {
|
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
export function mergeToMain(projectDir, branch) {
|
|
104
|
-
const main = getMainBranch(projectDir);
|
|
105
110
|
try {
|
|
106
|
-
git(
|
|
107
|
-
const sha = git('rev-parse HEAD', projectDir);
|
|
111
|
+
git(['merge', branch, '--no-ff', '-m', `Merge ${branch}`], projectDir);
|
|
112
|
+
const sha = git(['rev-parse', 'HEAD'], projectDir);
|
|
108
113
|
return { success: true, sha, conflict: false };
|
|
109
114
|
}
|
|
110
115
|
catch (err) {
|
|
111
116
|
const isConflict = hasConflicts(projectDir);
|
|
117
|
+
const conflictFiles = isConflict ? getConflictFiles(projectDir) : undefined;
|
|
112
118
|
if (isConflict) {
|
|
113
|
-
git('merge --abort', projectDir);
|
|
119
|
+
git(['merge', '--abort'], projectDir);
|
|
114
120
|
}
|
|
115
121
|
return {
|
|
116
122
|
success: false,
|
|
117
123
|
sha: null,
|
|
118
124
|
conflict: isConflict,
|
|
125
|
+
conflictFiles,
|
|
119
126
|
error: err.message,
|
|
120
127
|
};
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
function hasConflicts(dir) {
|
|
124
131
|
try {
|
|
125
|
-
const status = git('status --porcelain', dir);
|
|
132
|
+
const status = git(['status', '--porcelain'], dir);
|
|
126
133
|
return status.includes('UU ') || status.includes('AA ') || status.includes('DD ');
|
|
127
134
|
}
|
|
128
135
|
catch {
|
|
129
136
|
return false;
|
|
130
137
|
}
|
|
131
138
|
}
|
|
139
|
+
function getConflictFiles(dir) {
|
|
140
|
+
try {
|
|
141
|
+
const status = git(['status', '--porcelain'], dir);
|
|
142
|
+
return status.split('\n')
|
|
143
|
+
.filter(l => /^(UU|AA|DD|DU|UD|AU|UA) /.test(l))
|
|
144
|
+
.map(l => l.slice(3));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function abortMerge(dir) {
|
|
151
|
+
try {
|
|
152
|
+
git(['merge', '--abort'], dir);
|
|
153
|
+
}
|
|
154
|
+
catch { /* no merge in progress */ }
|
|
155
|
+
}
|
|
156
|
+
export function deleteBranchRemote(cwd, branch) {
|
|
157
|
+
try {
|
|
158
|
+
git(['push', 'origin', '--delete', branch], cwd, PUSH_TIMEOUT);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
132
165
|
// ─── Branch / sync operations ───
|
|
133
166
|
export function syncMain(projectDir) {
|
|
134
167
|
try {
|
|
135
|
-
git('fetch --quiet', projectDir);
|
|
136
|
-
git('pull --ff-only --quiet', projectDir, PUSH_TIMEOUT);
|
|
168
|
+
git(['fetch', '--quiet'], projectDir);
|
|
169
|
+
git(['pull', '--ff-only', '--quiet'], projectDir, PUSH_TIMEOUT);
|
|
137
170
|
return true;
|
|
138
171
|
}
|
|
139
172
|
catch {
|
|
@@ -143,13 +176,13 @@ export function syncMain(projectDir) {
|
|
|
143
176
|
export function getMainBranch(projectDir) {
|
|
144
177
|
try {
|
|
145
178
|
// Check for remote HEAD
|
|
146
|
-
const ref = git('symbolic-ref refs/remotes/origin/HEAD', projectDir);
|
|
179
|
+
const ref = git(['symbolic-ref', 'refs/remotes/origin/HEAD'], projectDir);
|
|
147
180
|
return ref.replace('refs/remotes/origin/', '');
|
|
148
181
|
}
|
|
149
182
|
catch {
|
|
150
183
|
// Fallback: check common names
|
|
151
184
|
try {
|
|
152
|
-
git('rev-parse --verify main', projectDir);
|
|
185
|
+
git(['rev-parse', '--verify', 'main'], projectDir);
|
|
153
186
|
return 'main';
|
|
154
187
|
}
|
|
155
188
|
catch {
|
|
@@ -158,11 +191,11 @@ export function getMainBranch(projectDir) {
|
|
|
158
191
|
}
|
|
159
192
|
}
|
|
160
193
|
export function getCurrentBranch(dir) {
|
|
161
|
-
return git('rev-parse --abbrev-ref HEAD', dir);
|
|
194
|
+
return git(['rev-parse', '--abbrev-ref', 'HEAD'], dir);
|
|
162
195
|
}
|
|
163
196
|
export function branchExists(projectDir, branch) {
|
|
164
197
|
try {
|
|
165
|
-
git(
|
|
198
|
+
git(['rev-parse', '--verify', branch], projectDir);
|
|
166
199
|
return true;
|
|
167
200
|
}
|
|
168
201
|
catch {
|
|
@@ -173,7 +206,7 @@ export function isBranchMerged(projectDir, branch) {
|
|
|
173
206
|
try {
|
|
174
207
|
const main = getMainBranch(projectDir);
|
|
175
208
|
// Check if branch tip is an ancestor of main (i.e., was merged)
|
|
176
|
-
git(
|
|
209
|
+
git(['merge-base', '--is-ancestor', branch, main], projectDir);
|
|
177
210
|
return true;
|
|
178
211
|
}
|
|
179
212
|
catch {
|
|
@@ -183,8 +216,8 @@ export function isBranchMerged(projectDir, branch) {
|
|
|
183
216
|
// ─── Git identity ───
|
|
184
217
|
export function hasGitIdentity(projectDir) {
|
|
185
218
|
try {
|
|
186
|
-
git('config user.name', projectDir);
|
|
187
|
-
git('config user.email', projectDir);
|
|
219
|
+
git(['config', 'user.name'], projectDir);
|
|
220
|
+
git(['config', 'user.email'], projectDir);
|
|
188
221
|
return true;
|
|
189
222
|
}
|
|
190
223
|
catch {
|
|
@@ -194,8 +227,10 @@ export function hasGitIdentity(projectDir) {
|
|
|
194
227
|
// ─── Tag operations ───
|
|
195
228
|
export function createTag(projectDir, tag, message) {
|
|
196
229
|
try {
|
|
197
|
-
const
|
|
198
|
-
|
|
230
|
+
const args = message
|
|
231
|
+
? ['tag', '-a', tag, '-m', message]
|
|
232
|
+
: ['tag', tag];
|
|
233
|
+
git(args, projectDir);
|
|
199
234
|
return true;
|
|
200
235
|
}
|
|
201
236
|
catch {
|
|
@@ -204,7 +239,7 @@ export function createTag(projectDir, tag, message) {
|
|
|
204
239
|
}
|
|
205
240
|
export function getLatestTag(projectDir) {
|
|
206
241
|
try {
|
|
207
|
-
return git('describe --tags --abbrev=0', projectDir);
|
|
242
|
+
return git(['describe', '--tags', '--abbrev=0'], projectDir);
|
|
208
243
|
}
|
|
209
244
|
catch {
|
|
210
245
|
return null;
|
|
@@ -212,7 +247,7 @@ export function getLatestTag(projectDir) {
|
|
|
212
247
|
}
|
|
213
248
|
export function pushTags(projectDir) {
|
|
214
249
|
try {
|
|
215
|
-
git('push --tags', projectDir, PUSH_TIMEOUT);
|
|
250
|
+
git(['push', '--tags'], projectDir, PUSH_TIMEOUT);
|
|
216
251
|
return true;
|
|
217
252
|
}
|
|
218
253
|
catch {
|
|
@@ -222,23 +257,23 @@ export function pushTags(projectDir) {
|
|
|
222
257
|
export function rebaseOnMain(worktreePath, projectDir) {
|
|
223
258
|
try {
|
|
224
259
|
const main = getMainBranch(projectDir);
|
|
225
|
-
git(
|
|
226
|
-
git(
|
|
260
|
+
git(['fetch', 'origin', main], worktreePath, PUSH_TIMEOUT);
|
|
261
|
+
git(['rebase', `origin/${main}`], worktreePath, PUSH_TIMEOUT);
|
|
227
262
|
return { success: true, conflict: false };
|
|
228
263
|
}
|
|
229
264
|
catch (err) {
|
|
230
265
|
// Check if rebase is in progress (conflict)
|
|
231
266
|
try {
|
|
232
|
-
const status = git('status --porcelain', worktreePath);
|
|
267
|
+
const status = git(['status', '--porcelain'], worktreePath);
|
|
233
268
|
if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
|
|
234
|
-
git('rebase --abort', worktreePath);
|
|
269
|
+
git(['rebase', '--abort'], worktreePath);
|
|
235
270
|
return { success: false, conflict: true, error: err.message };
|
|
236
271
|
}
|
|
237
272
|
}
|
|
238
273
|
catch { /* ignore */ }
|
|
239
274
|
// Abort any in-progress rebase
|
|
240
275
|
try {
|
|
241
|
-
git('rebase --abort', worktreePath);
|
|
276
|
+
git(['rebase', '--abort'], worktreePath);
|
|
242
277
|
}
|
|
243
278
|
catch { /* ignore */ }
|
|
244
279
|
return { success: false, conflict: false, error: err.message };
|
|
@@ -253,30 +288,30 @@ export function rebaseOnMain(worktreePath, projectDir) {
|
|
|
253
288
|
export function cleanMainForMerge(projectDir) {
|
|
254
289
|
// Abort any in-progress merge (conflict state from previous failed merge)
|
|
255
290
|
try {
|
|
256
|
-
git('merge --abort', projectDir);
|
|
291
|
+
git(['merge', '--abort'], projectDir);
|
|
257
292
|
}
|
|
258
293
|
catch { /* no merge in progress */ }
|
|
259
294
|
// Abort any in-progress rebase
|
|
260
295
|
try {
|
|
261
|
-
git('rebase --abort', projectDir);
|
|
296
|
+
git(['rebase', '--abort'], projectDir);
|
|
262
297
|
}
|
|
263
298
|
catch { /* no rebase in progress */ }
|
|
264
299
|
// Reset any staged changes back to unstaged
|
|
265
300
|
try {
|
|
266
|
-
git('reset HEAD', projectDir);
|
|
301
|
+
git(['reset', 'HEAD'], projectDir);
|
|
267
302
|
}
|
|
268
303
|
catch { /* nothing staged */ }
|
|
269
304
|
// If there are still uncommitted changes, stash them (preserves user edits)
|
|
270
|
-
const status = git('status --porcelain', projectDir);
|
|
305
|
+
const status = git(['status', '--porcelain'], projectDir);
|
|
271
306
|
if (status) {
|
|
272
307
|
try {
|
|
273
|
-
git('stash --include-untracked -m
|
|
308
|
+
git(['stash', '--include-untracked', '-m', 'scheduler: auto-stash before merge'], projectDir);
|
|
274
309
|
return true;
|
|
275
310
|
}
|
|
276
311
|
catch {
|
|
277
312
|
// If stash fails, force-clean as last resort
|
|
278
313
|
try {
|
|
279
|
-
git('checkout -- .', projectDir);
|
|
314
|
+
git(['checkout', '--', '.'], projectDir);
|
|
280
315
|
}
|
|
281
316
|
catch { /* ignore */ }
|
|
282
317
|
return false;
|
|
@@ -286,11 +321,11 @@ export function cleanMainForMerge(projectDir) {
|
|
|
286
321
|
}
|
|
287
322
|
// ─── Stash operations ───
|
|
288
323
|
export function stashChanges(dir) {
|
|
289
|
-
const status = git('status --porcelain', dir);
|
|
324
|
+
const status = git(['status', '--porcelain'], dir);
|
|
290
325
|
if (!status)
|
|
291
326
|
return false;
|
|
292
327
|
try {
|
|
293
|
-
git('stash --include-untracked', dir);
|
|
328
|
+
git(['stash', '--include-untracked'], dir);
|
|
294
329
|
return true;
|
|
295
330
|
}
|
|
296
331
|
catch {
|
|
@@ -299,7 +334,7 @@ export function stashChanges(dir) {
|
|
|
299
334
|
}
|
|
300
335
|
export function popStash(dir) {
|
|
301
336
|
try {
|
|
302
|
-
git('stash pop', dir);
|
|
337
|
+
git(['stash', 'pop'], dir);
|
|
303
338
|
return true;
|
|
304
339
|
}
|
|
305
340
|
catch {
|
|
@@ -309,7 +344,7 @@ export function popStash(dir) {
|
|
|
309
344
|
// ─── Dirty main evaluation ───
|
|
310
345
|
const TRACKING_FILES = new Set(['TODO.md', 'MEMORY.md']);
|
|
311
346
|
export function evaluateDirtyMain(projectDir) {
|
|
312
|
-
const status = git('status --porcelain', projectDir);
|
|
347
|
+
const status = git(['status', '--porcelain'], projectDir);
|
|
313
348
|
if (!status)
|
|
314
349
|
return { trackingFiles: [], userFiles: [] };
|
|
315
350
|
const trackingFiles = [];
|
|
@@ -331,16 +366,12 @@ export function evaluateDirtyMain(projectDir) {
|
|
|
331
366
|
export function discardFiles(dir, files) {
|
|
332
367
|
if (files.length === 0)
|
|
333
368
|
return;
|
|
334
|
-
const fileArgs = files.map(f => `"${f}"`).join(' ');
|
|
335
369
|
try {
|
|
336
|
-
git(
|
|
370
|
+
git(['checkout', '--', ...files], dir);
|
|
337
371
|
}
|
|
338
372
|
catch { /* file may not exist in index */ }
|
|
339
373
|
}
|
|
340
374
|
// ─── Helpers ───
|
|
341
|
-
function escapeMsg(msg) {
|
|
342
|
-
return msg.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
343
|
-
}
|
|
344
375
|
function normalizePath(p) {
|
|
345
376
|
return p.replace(/\\/g, '/').toLowerCase();
|
|
346
377
|
}
|
|
@@ -10,6 +10,15 @@ function run(bin, args, cwd, timeout = READ_TIMEOUT) {
|
|
|
10
10
|
return execFileSync(bin, args, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
11
11
|
}
|
|
12
12
|
export function createPR(opts) {
|
|
13
|
+
// Check if a PR/MR already exists for this branch
|
|
14
|
+
try {
|
|
15
|
+
const existing = getPRStatus(opts.cwd, opts.platform, opts.branch);
|
|
16
|
+
if (existing.status === 'open' || existing.status === 'merged') {
|
|
17
|
+
return existing;
|
|
18
|
+
}
|
|
19
|
+
// If closed, fall through to create a new one
|
|
20
|
+
}
|
|
21
|
+
catch { /* no existing PR — create one */ }
|
|
13
22
|
const body = opts.issueNumber
|
|
14
23
|
? `${opts.body}\n\nCloses #${opts.issueNumber}`
|
|
15
24
|
: opts.body;
|
package/dist/scheduler/loop.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-s
|
|
|
7
7
|
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
8
8
|
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
9
9
|
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
10
|
-
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, } from './git/manager.mjs';
|
|
10
|
+
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, abortMerge, deleteBranchRemote, } from './git/manager.mjs';
|
|
11
11
|
import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
|
|
12
12
|
import { scanAgents } from './agents/health-checker.mjs';
|
|
13
13
|
import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
|
|
@@ -58,8 +58,19 @@ export async function runIteration(deps) {
|
|
|
58
58
|
// Caller (scheduler.mts) handles pause state
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
//
|
|
61
|
+
// Pre-flight: commit uncommitted changes on main before any work
|
|
62
62
|
if (!state._recoveryDone) {
|
|
63
|
+
if (hasUncommittedChanges(projectDir)) {
|
|
64
|
+
logger.warn('Uncommitted changes on main — auto-committing before starting work');
|
|
65
|
+
try {
|
|
66
|
+
commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start');
|
|
67
|
+
logger.info('Uncommitted changes committed on main');
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
logger.error(`Failed to commit main changes: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Recover orphaned worktrees — once per scheduler run (first iteration only)
|
|
63
74
|
state._recoveryDone = true;
|
|
64
75
|
await recoverOrphanedWorktrees(projectDir, state, logger, deps);
|
|
65
76
|
}
|
|
@@ -71,8 +82,14 @@ export async function runIteration(deps) {
|
|
|
71
82
|
logger.error('Platform mode requires a git remote (github/gitlab)');
|
|
72
83
|
return false;
|
|
73
84
|
}
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
try {
|
|
86
|
+
tasks = fetchOpenIssues(projectDir, platform)
|
|
87
|
+
.map(i => issueToTask(i, state.currentPhase));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
logger.error(`Failed to fetch remote issues: ${err.message?.split('\n')[0]}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
76
93
|
}
|
|
77
94
|
else {
|
|
78
95
|
const todoPath = resolve(projectDir, '.claude/scheduler/tasks.json');
|
|
@@ -433,6 +450,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
433
450
|
// Sync main to get the merge commit
|
|
434
451
|
syncMain(projectDir);
|
|
435
452
|
cleanupWorktree(projectDir, worktreePath, slug);
|
|
453
|
+
deleteBranchRemote(projectDir, slug);
|
|
436
454
|
pipeline.step = 'done';
|
|
437
455
|
clearSession(state, task.id);
|
|
438
456
|
delete state.pipelines[task.id];
|
|
@@ -490,8 +508,9 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
490
508
|
}
|
|
491
509
|
if (stashed)
|
|
492
510
|
popStash(projectDir);
|
|
493
|
-
// Cleanup
|
|
511
|
+
// Cleanup: worktree + local branch + remote branch
|
|
494
512
|
cleanupWorktree(projectDir, worktreePath, slug);
|
|
513
|
+
deleteBranchRemote(projectDir, slug);
|
|
495
514
|
pipeline.step = 'done';
|
|
496
515
|
clearSession(state, task.id);
|
|
497
516
|
delete state.pipelines[task.id];
|
|
@@ -555,6 +574,22 @@ function isRefactoringTask(task) {
|
|
|
555
574
|
|| lower.includes('rename') || lower.includes('migrate') || lower.includes('upgrade');
|
|
556
575
|
}
|
|
557
576
|
// ─── Orphaned worktree recovery ───
|
|
577
|
+
/** Mark issue as done on the platform after a successful recovery merge */
|
|
578
|
+
function closeRecoveredIssue(projectDir, branch, logger) {
|
|
579
|
+
const platform = detectCIPlatform(projectDir);
|
|
580
|
+
if (platform === 'none')
|
|
581
|
+
return;
|
|
582
|
+
const issueNum = extractIssueFromBranch(branch);
|
|
583
|
+
if (!issueNum)
|
|
584
|
+
return;
|
|
585
|
+
try {
|
|
586
|
+
updateIssueStatus(projectDir, platform, issueNum, 'done');
|
|
587
|
+
logger.info(`[recovery] Issue #${issueNum} marked as done and closed`);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
558
593
|
async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
559
594
|
const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
|
|
560
595
|
const orphans = listOrphanedWorktrees(projectDir, knownPaths);
|
|
@@ -569,6 +604,8 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
569
604
|
// Already merged — just clean up
|
|
570
605
|
logger.info(`[recovery] ${branch} already merged — cleaning up worktree`);
|
|
571
606
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
607
|
+
deleteBranchRemote(projectDir, branch);
|
|
608
|
+
closeRecoveredIssue(projectDir, branch, logger);
|
|
572
609
|
continue;
|
|
573
610
|
}
|
|
574
611
|
// Check if worktree has commits ahead of main
|
|
@@ -615,6 +652,8 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
615
652
|
logger.info(`[recovery] ${branch} merged via platform`);
|
|
616
653
|
syncMain(projectDir);
|
|
617
654
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
655
|
+
deleteBranchRemote(projectDir, branch);
|
|
656
|
+
closeRecoveredIssue(projectDir, branch, logger);
|
|
618
657
|
if (stashed)
|
|
619
658
|
popStash(projectDir);
|
|
620
659
|
continue;
|
|
@@ -635,24 +674,69 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
635
674
|
if (mergeResult.success) {
|
|
636
675
|
logger.info(`[recovery] ${branch} merged locally (${mergeResult.sha?.slice(0, 7)})`);
|
|
637
676
|
appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery: ${branch}` }));
|
|
677
|
+
closeRecoveredIssue(projectDir, branch, logger);
|
|
638
678
|
}
|
|
639
679
|
else if (mergeResult.conflict) {
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
680
|
+
// mergeToMain already aborted the merge internally
|
|
681
|
+
// Delegate conflict resolution to orchestrator AI
|
|
682
|
+
logger.info(`[recovery] ${branch} has merge conflicts — delegating to AI`);
|
|
683
|
+
let resolved = false;
|
|
684
|
+
try {
|
|
685
|
+
const conflictDecision = await deps.orchestrator.handleMergeConflict(branch, mergeResult.conflictFiles ?? []);
|
|
686
|
+
if (conflictDecision.action === 'resolve') {
|
|
687
|
+
// Spawn agent to resolve conflicts in the worktree via rebase
|
|
688
|
+
logger.info(`[recovery] Spawning agent to resolve conflicts in ${branch}`);
|
|
689
|
+
const conflictPrompt = [
|
|
690
|
+
`You are in a worktree for branch "${branch}".`,
|
|
691
|
+
`Rebase this branch onto main and resolve all merge conflicts.`,
|
|
692
|
+
``,
|
|
693
|
+
`Steps:`,
|
|
694
|
+
`1. Run: git rebase main`,
|
|
695
|
+
`2. For each conflict, read the conflicting files and resolve them sensibly`,
|
|
696
|
+
`3. After resolving each file: git add <file>`,
|
|
697
|
+
`4. Continue rebase: git rebase --continue`,
|
|
698
|
+
`5. Repeat until rebase is complete`,
|
|
699
|
+
``,
|
|
700
|
+
`Conflicting files: ${(mergeResult.conflictFiles ?? []).join(', ') || 'unknown'}`,
|
|
701
|
+
``,
|
|
702
|
+
`Important: preserve the intent of both sides. When in doubt, prefer the feature branch changes.`,
|
|
703
|
+
].join('\n');
|
|
704
|
+
const slot = deps.pool.idleSlot();
|
|
705
|
+
if (slot) {
|
|
706
|
+
await deps.pool.spawn(slot.id, {
|
|
707
|
+
cwd: worktreePath,
|
|
708
|
+
prompt: conflictPrompt,
|
|
709
|
+
model: 'claude-sonnet-4-6',
|
|
710
|
+
onMessage: deps.onMessage,
|
|
711
|
+
});
|
|
712
|
+
// Retry merge after agent resolved conflicts
|
|
713
|
+
const retryResult = mergeToMain(projectDir, branch);
|
|
714
|
+
if (retryResult.success) {
|
|
715
|
+
logger.info(`[recovery] ${branch} merged after AI conflict resolution`);
|
|
716
|
+
appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery-ai-resolve: ${branch}` }));
|
|
717
|
+
resolved = true;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
646
720
|
}
|
|
647
|
-
else {
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
721
|
+
else if (conflictDecision.action === 'rebase') {
|
|
722
|
+
const rebaseResult = rebaseOnMain(worktreePath, projectDir);
|
|
723
|
+
if (rebaseResult.success) {
|
|
724
|
+
const retryResult = mergeToMain(projectDir, branch);
|
|
725
|
+
if (retryResult.success) {
|
|
726
|
+
logger.info(`[recovery] ${branch} merged after rebase`);
|
|
727
|
+
resolved = true;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
652
730
|
}
|
|
731
|
+
// action === 'skip' → resolved stays false
|
|
653
732
|
}
|
|
654
|
-
|
|
733
|
+
catch (err) {
|
|
734
|
+
logger.warn(`[recovery] AI conflict resolution failed for ${branch}: ${err.message}`);
|
|
735
|
+
abortMerge(projectDir);
|
|
736
|
+
}
|
|
737
|
+
if (!resolved) {
|
|
655
738
|
logger.error(`[recovery] ${branch} has unresolvable conflicts — skipping`);
|
|
739
|
+
abortMerge(projectDir);
|
|
656
740
|
if (stashed)
|
|
657
741
|
popStash(projectDir);
|
|
658
742
|
continue;
|
|
@@ -660,11 +744,15 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
660
744
|
}
|
|
661
745
|
else {
|
|
662
746
|
logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error} — skipping`);
|
|
747
|
+
abortMerge(projectDir);
|
|
663
748
|
if (stashed)
|
|
664
749
|
popStash(projectDir);
|
|
665
750
|
continue;
|
|
666
751
|
}
|
|
752
|
+
// Post-merge cleanup: worktree + local branch + remote branch + close issue
|
|
667
753
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
754
|
+
deleteBranchRemote(projectDir, branch);
|
|
755
|
+
closeRecoveredIssue(projectDir, branch, logger);
|
|
668
756
|
if (stashed)
|
|
669
757
|
popStash(projectDir);
|
|
670
758
|
}
|
|
@@ -788,6 +876,11 @@ function extractIssueNumber(marker) {
|
|
|
788
876
|
const match = marker.match(/#(\d+)/);
|
|
789
877
|
return match ? parseInt(match[1], 10) : null;
|
|
790
878
|
}
|
|
879
|
+
/** Extract issue number from branch name like "feat/#94-description" or "feat/94-description" */
|
|
880
|
+
function extractIssueFromBranch(branch) {
|
|
881
|
+
const match = branch.match(/#?(\d+)/);
|
|
882
|
+
return match ? parseInt(match[1], 10) : null;
|
|
883
|
+
}
|
|
791
884
|
function getCurrentBranchFromProject(projectDir) {
|
|
792
885
|
return getMainBranch(projectDir);
|
|
793
886
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ─── Platform issue source ───
|
|
2
2
|
// Reads tasks from GitHub/GitLab issues instead of TODO.md.
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
4
|
const CLI_TIMEOUT = 30_000;
|
|
5
|
-
function
|
|
6
|
-
return
|
|
5
|
+
function run(cmd, args, cwd) {
|
|
6
|
+
return execFileSync(cmd, args, { cwd, timeout: CLI_TIMEOUT, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
7
7
|
}
|
|
8
8
|
// ─── Fetch open issues ───
|
|
9
9
|
export function fetchOpenIssues(cwd, platform) {
|
|
@@ -13,39 +13,29 @@ export function fetchOpenIssues(cwd, platform) {
|
|
|
13
13
|
return fetchGitLabIssues(cwd);
|
|
14
14
|
}
|
|
15
15
|
function fetchGitHubIssues(cwd) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
16
|
+
const output = run('gh', ['issue', 'list', '--json', 'number,title,body,labels,milestone,assignees', '--state', 'open', '--limit', '200'], cwd);
|
|
17
|
+
const issues = JSON.parse(output);
|
|
18
|
+
// Filter out pull requests (GitHub API sometimes includes them)
|
|
19
|
+
return issues.map(i => ({
|
|
20
|
+
issueNumber: i.number,
|
|
21
|
+
title: i.title,
|
|
22
|
+
body: i.body ?? '',
|
|
23
|
+
labels: i.labels.map(l => l.name),
|
|
24
|
+
milestone: i.milestone?.title ?? null,
|
|
25
|
+
assignee: i.assignees[0]?.login ?? null,
|
|
26
|
+
}));
|
|
32
27
|
}
|
|
33
28
|
function fetchGitLabIssues(cwd) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}));
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return [];
|
|
48
|
-
}
|
|
29
|
+
const output = run('glab', ['issue', 'list', '--output', 'json', '--per-page', '200'], cwd);
|
|
30
|
+
const issues = JSON.parse(output);
|
|
31
|
+
return issues.map(i => ({
|
|
32
|
+
issueNumber: i.iid,
|
|
33
|
+
title: i.title,
|
|
34
|
+
body: i.description ?? '',
|
|
35
|
+
labels: i.labels,
|
|
36
|
+
milestone: i.milestone?.title ?? null,
|
|
37
|
+
assignee: i.assignees[0]?.username ?? null,
|
|
38
|
+
}));
|
|
49
39
|
}
|
|
50
40
|
// ─── Convert issue to Task ───
|
|
51
41
|
const PHASE_RE = /^Phase\s+(\d+)/i;
|
|
@@ -137,20 +127,27 @@ export function updateIssueStatus(cwd, platform, issueNumber, status) {
|
|
|
137
127
|
const statusLabel = `status::${status}`;
|
|
138
128
|
const allStatuses = ['status::todo', 'status::in-progress', 'status::in-review', 'status::done', 'status::skipped', 'status::blocked'];
|
|
139
129
|
const removeLabels = allStatuses.filter(s => s !== statusLabel);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
catch { /* label may not exist */ }
|
|
130
|
+
const num = String(issueNumber);
|
|
131
|
+
if (platform === 'github') {
|
|
132
|
+
for (const label of removeLabels) {
|
|
133
|
+
try {
|
|
134
|
+
run('gh', ['issue', 'edit', num, '--remove-label', label], cwd);
|
|
147
135
|
}
|
|
148
|
-
|
|
136
|
+
catch { /* label may not exist */ }
|
|
137
|
+
}
|
|
138
|
+
run('gh', ['issue', 'edit', num, '--add-label', statusLabel], cwd);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
run('glab', ['issue', 'update', num, '--unlabel', removeLabels.join(',')], cwd);
|
|
142
|
+
run('glab', ['issue', 'update', num, '--label', statusLabel], cwd);
|
|
143
|
+
}
|
|
144
|
+
// Close the issue when done or skipped
|
|
145
|
+
if (status === 'done' || status === 'skipped') {
|
|
146
|
+
if (platform === 'github') {
|
|
147
|
+
run('gh', ['issue', 'close', num], cwd);
|
|
149
148
|
}
|
|
150
149
|
else {
|
|
151
|
-
|
|
152
|
-
cli(`glab issue update ${issueNumber} --label "${statusLabel}"`, cwd);
|
|
150
|
+
run('glab', ['issue', 'close', num], cwd);
|
|
153
151
|
}
|
|
154
152
|
}
|
|
155
|
-
catch { /* best-effort */ }
|
|
156
153
|
}
|