@fermindi/pwn-cli 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* Completed files are cleaned up at the end; failed are kept for review.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { spawn } from 'child_process';
|
|
12
|
+
import { spawn, exec } from 'child_process';
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, createWriteStream, appendFileSync, unlinkSync, readdirSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
|
+
import { promisify } from 'util';
|
|
15
16
|
import ora from 'ora';
|
|
17
|
+
|
|
18
|
+
const execAsync = promisify(exec);
|
|
16
19
|
import chalk from 'chalk';
|
|
17
20
|
import {
|
|
18
21
|
parsePrdTasks,
|
|
@@ -22,7 +25,12 @@ import {
|
|
|
22
25
|
loadConfig,
|
|
23
26
|
commitTask,
|
|
24
27
|
updateBatchState,
|
|
25
|
-
clearBatchState
|
|
28
|
+
clearBatchState,
|
|
29
|
+
getCurrentBranch,
|
|
30
|
+
createTaskBranch,
|
|
31
|
+
checkoutBranch,
|
|
32
|
+
createBatchBranch,
|
|
33
|
+
mergeBranch
|
|
26
34
|
} from './batch-service.js';
|
|
27
35
|
|
|
28
36
|
// --- Constants ---
|
|
@@ -274,10 +282,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
274
282
|
|
|
275
283
|
// NO custom SIGINT handler — Ctrl+C uses default Node.js behavior (kills process group)
|
|
276
284
|
|
|
285
|
+
// --- Save original branch for isolation ---
|
|
286
|
+
const originalBranch = await getCurrentBranch(cwd);
|
|
287
|
+
|
|
288
|
+
// --- Create batch branch ---
|
|
289
|
+
const batchBranch = await createBatchBranch(cwd);
|
|
290
|
+
console.log(chalk.blue(` Batch branch: ${chalk.bold(batchBranch)}`));
|
|
291
|
+
|
|
277
292
|
// --- Main loop ---
|
|
278
293
|
let iteration = 0;
|
|
279
294
|
let noProgressCount = 0;
|
|
280
295
|
let storiesCompleted = 0;
|
|
296
|
+
let mergedCount = 0;
|
|
297
|
+
const branchesCreated = [];
|
|
298
|
+
const mergedBranches = [];
|
|
299
|
+
const unmergedBranches = [];
|
|
281
300
|
const batchStart = Date.now();
|
|
282
301
|
|
|
283
302
|
updateBatchState({
|
|
@@ -372,6 +391,23 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
372
391
|
}
|
|
373
392
|
}
|
|
374
393
|
|
|
394
|
+
// --- Branch isolation: create/checkout task branch ---
|
|
395
|
+
let taskBranch = null;
|
|
396
|
+
try {
|
|
397
|
+
taskBranch = await createTaskBranch(task.id, cwd);
|
|
398
|
+
branchesCreated.push(taskBranch);
|
|
399
|
+
console.log(chalk.blue(` Branch: ${chalk.bold(taskBranch)}`));
|
|
400
|
+
|
|
401
|
+
// Track branch in task file
|
|
402
|
+
if (taskFile) {
|
|
403
|
+
taskFile.branch = taskBranch;
|
|
404
|
+
saveTaskFile(taskFile, cwd);
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.log(chalk.red(` Failed to create branch feat/${task.id}: ${err.message}`));
|
|
408
|
+
console.log(chalk.yellow(' Continuing on current branch...'));
|
|
409
|
+
}
|
|
410
|
+
|
|
375
411
|
// --- Phase 2: Execution ---
|
|
376
412
|
const phaseLabel = noPlan ? '' : 'Phase 2';
|
|
377
413
|
console.log(chalk.blue(` ${noPlan ? 'Executing' : 'Phase 2: Executing'} ${task.id}...`));
|
|
@@ -400,8 +436,14 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
400
436
|
// Killed by signal (user did kill or Ctrl+C) — don't retry, exit
|
|
401
437
|
if (result.signal) {
|
|
402
438
|
console.log(chalk.yellow(` Killed by ${result.signal}`));
|
|
439
|
+
if (taskBranch) {
|
|
440
|
+
try {
|
|
441
|
+
try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
|
|
442
|
+
await checkoutBranch(batchBranch, cwd, { force: true });
|
|
443
|
+
} catch {}
|
|
444
|
+
}
|
|
403
445
|
clearBatchState(cwd);
|
|
404
|
-
printSummary(cwd, iteration, storiesCompleted, batchStart);
|
|
446
|
+
printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
|
|
405
447
|
return;
|
|
406
448
|
}
|
|
407
449
|
|
|
@@ -504,6 +546,27 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
504
546
|
current_task: null,
|
|
505
547
|
last_completed_at: new Date().toISOString()
|
|
506
548
|
}, cwd);
|
|
549
|
+
|
|
550
|
+
// Merge successful task branch into batch branch
|
|
551
|
+
if (taskBranch) {
|
|
552
|
+
try {
|
|
553
|
+
// Stash uncommitted metadata (task files etc.) before switching
|
|
554
|
+
await execAsync('git stash --include-untracked', { cwd });
|
|
555
|
+
await checkoutBranch(batchBranch, cwd);
|
|
556
|
+
await mergeBranch(taskBranch, cwd);
|
|
557
|
+
mergedCount++;
|
|
558
|
+
mergedBranches.push(taskBranch);
|
|
559
|
+
console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
|
|
560
|
+
// Restore stashed metadata on batch branch
|
|
561
|
+
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
562
|
+
} catch (err) {
|
|
563
|
+
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
564
|
+
console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
|
|
565
|
+
unmergedBranches.push(taskBranch);
|
|
566
|
+
// Drop stash if checkout/merge failed (still on feat branch)
|
|
567
|
+
try { await execAsync('git stash drop', { cwd }); } catch {}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
507
570
|
} else {
|
|
508
571
|
console.log(chalk.red(` Quality gates FAILED`));
|
|
509
572
|
errorContext = gatesResult.errorOutput;
|
|
@@ -535,6 +598,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
535
598
|
noProgressCount++;
|
|
536
599
|
}
|
|
537
600
|
|
|
601
|
+
// --- Return to batch branch (stash untracked + force for dirty state) ---
|
|
602
|
+
if (taskBranch) {
|
|
603
|
+
try {
|
|
604
|
+
try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
|
|
605
|
+
await checkoutBranch(batchBranch, cwd, { force: true });
|
|
606
|
+
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
607
|
+
console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.log(chalk.red(` Warning: failed to return to ${batchBranch}: ${err.message}`));
|
|
610
|
+
}
|
|
611
|
+
if (!storyDone) {
|
|
612
|
+
unmergedBranches.push(taskBranch);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
538
616
|
if (noProgressCount >= MAX_NO_PROGRESS) {
|
|
539
617
|
console.log(chalk.red(`\nCIRCUIT BREAKER: ${MAX_NO_PROGRESS} consecutive failures. Stopping.`));
|
|
540
618
|
break;
|
|
@@ -542,7 +620,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
542
620
|
}
|
|
543
621
|
|
|
544
622
|
clearBatchState(cwd);
|
|
545
|
-
printSummary(cwd, iteration, storiesCompleted, batchStart);
|
|
623
|
+
printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
|
|
546
624
|
}
|
|
547
625
|
|
|
548
626
|
/**
|
|
@@ -791,7 +869,7 @@ function printHeader(maxIter, phase, total, done, noPlan = false, cwd = process.
|
|
|
791
869
|
console.log(chalk.dim('─'.repeat(40)));
|
|
792
870
|
}
|
|
793
871
|
|
|
794
|
-
function printSummary(cwd, iterations, completed, startTime) {
|
|
872
|
+
function printSummary(cwd, iterations, completed, startTime, branchesCreated = [], batchBranch = null, originalBranch = null, mergedCount = 0, unmergedBranches = []) {
|
|
795
873
|
const stories = parsePrdTasks(cwd);
|
|
796
874
|
const total = stories.length;
|
|
797
875
|
const done = stories.filter(s => s.passes).length;
|
|
@@ -828,6 +906,21 @@ function printSummary(cwd, iterations, completed, startTime) {
|
|
|
828
906
|
if (cleaned > 0 || failedKept > 0) {
|
|
829
907
|
console.log(` Cleanup: ${chalk.green(`${cleaned} completed`)} removed, ${failedKept > 0 ? chalk.red(`${failedKept} failed`) : '0 failed'} kept for review`);
|
|
830
908
|
}
|
|
909
|
+
if (batchBranch) {
|
|
910
|
+
console.log(` Batch branch: ${chalk.cyan(batchBranch)}`);
|
|
911
|
+
console.log(` Merged: ${chalk.green(mergedCount)} tasks merged into batch branch`);
|
|
912
|
+
if (unmergedBranches.length > 0) {
|
|
913
|
+
const unique = [...new Set(unmergedBranches)];
|
|
914
|
+
console.log(` Unmerged: ${chalk.yellow(unique.length)} isolated: ${unique.map(b => chalk.yellow(b)).join(', ')}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (branchesCreated.length > 0) {
|
|
918
|
+
const unique = [...new Set(branchesCreated)];
|
|
919
|
+
console.log(` Branches: ${unique.map(b => chalk.cyan(b)).join(', ')}`);
|
|
920
|
+
}
|
|
921
|
+
if (originalBranch) {
|
|
922
|
+
console.log(` Original branch: ${chalk.green(originalBranch)} (intact)`);
|
|
923
|
+
}
|
|
831
924
|
console.log(chalk.dim('─'.repeat(40)));
|
|
832
925
|
console.log('');
|
|
833
926
|
}
|
|
@@ -13,6 +13,79 @@ import { getState, updateState, hasWorkspace } from '../core/state.js';
|
|
|
13
13
|
|
|
14
14
|
const execAsync = promisify(exec);
|
|
15
15
|
|
|
16
|
+
// --- Git Helpers ---
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the current git branch name
|
|
20
|
+
* @param {string} cwd - Working directory
|
|
21
|
+
* @returns {Promise<string>} Current branch name
|
|
22
|
+
*/
|
|
23
|
+
export async function getCurrentBranch(cwd = process.cwd()) {
|
|
24
|
+
const { stdout } = await execAsync('git branch --show-current', { cwd });
|
|
25
|
+
return stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create and checkout a task branch from the current HEAD
|
|
30
|
+
* If branch already exists, just checkout.
|
|
31
|
+
* @param {string} taskId - Task ID (e.g. "SEC-007")
|
|
32
|
+
* @param {string} cwd - Working directory
|
|
33
|
+
* @returns {Promise<string>} Branch name
|
|
34
|
+
*/
|
|
35
|
+
export async function createTaskBranch(taskId, cwd = process.cwd()) {
|
|
36
|
+
const branch = `feat/${taskId}`;
|
|
37
|
+
let exists = false;
|
|
38
|
+
try {
|
|
39
|
+
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
|
|
40
|
+
exists = true;
|
|
41
|
+
} catch {
|
|
42
|
+
// Branch doesn't exist
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (exists) {
|
|
46
|
+
// Rerun — checkout and reset to current HEAD
|
|
47
|
+
// so we don't carry stale/dirty state from previous attempt
|
|
48
|
+
const { stdout } = await execAsync('git rev-parse HEAD', { cwd });
|
|
49
|
+
const baseRef = stdout.trim();
|
|
50
|
+
await execAsync(`git checkout ${branch}`, { cwd });
|
|
51
|
+
await execAsync(`git reset --hard ${baseRef}`, { cwd });
|
|
52
|
+
} else {
|
|
53
|
+
await execAsync(`git checkout -b ${branch}`, { cwd });
|
|
54
|
+
}
|
|
55
|
+
return branch;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Checkout an existing branch
|
|
60
|
+
* @param {string} branch - Branch name
|
|
61
|
+
* @param {string} cwd - Working directory
|
|
62
|
+
*/
|
|
63
|
+
export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
|
|
64
|
+
const flag = force ? ' --force' : '';
|
|
65
|
+
await execAsync(`git checkout${flag} ${branch}`, { cwd });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a batch branch from the current HEAD
|
|
70
|
+
* @param {string} cwd - Working directory
|
|
71
|
+
* @returns {Promise<string>} Branch name (batch/{timestamp})
|
|
72
|
+
*/
|
|
73
|
+
export async function createBatchBranch(cwd = process.cwd()) {
|
|
74
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
75
|
+
const branch = `batch/${ts}`;
|
|
76
|
+
await execAsync(`git checkout -b ${branch}`, { cwd });
|
|
77
|
+
return branch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Merge a source branch into the current branch
|
|
82
|
+
* @param {string} source - Source branch name
|
|
83
|
+
* @param {string} cwd - Working directory
|
|
84
|
+
*/
|
|
85
|
+
export async function mergeBranch(source, cwd = process.cwd()) {
|
|
86
|
+
await execAsync(`git merge ${source} --no-edit`, { cwd });
|
|
87
|
+
}
|
|
88
|
+
|
|
16
89
|
/**
|
|
17
90
|
* Default batch configuration
|
|
18
91
|
*/
|