@@ -269,11 +269,12 @@ function parseOpencodeEventLine(string $line, int $verbose = 4): ?string
269269 * Stream opencode output in real-time, parsing NDJSON lines and displaying them
270270 *
271271 * @param resource $stdout Pipe from proc_open
272+ * @param resource $interruptFd Interrupt pipe read end — signal handler writes here to wake stream_select()
272273 * @param int $verbose Verbosity level
273274 * @param string|null $logFile Optional log file path
274275 * @return string Accumulated raw output
275276 */
276- function streamOpencodeOutput ($ stdout , int $ verbose , ?string $ logFile = null , int $ timeout = 0 ): string
277+ function streamOpencodeOutput ($ stdout , $ interruptFd , int $ verbose , ?string $ logFile = null , int $ timeout = 0 ): string
277278{
278279 $ accumulated = '' ;
279280 $ renderer = null ;
@@ -291,9 +292,10 @@ function streamOpencodeOutput($stdout, int $verbose, ?string $logFile = null, in
291292 $ deadline = $ startTime + $ timeoutSecs ;
292293
293294 while (!feof ($ stdout )) {
295+ global $ running ;
294296 // Calculate remaining time for this select call
295297 $ remaining = max (1 , (int )($ deadline - microtime (true )));
296- $ read = [$ stdout ];
298+ $ read = [$ stdout, $ interruptFd ];
297299 $ write = null ;
298300 $ except = null ;
299301 $ changed = @stream_select ($ read , $ write , $ except , 1 , 0 ); // 1 second timeout + microseconds
@@ -311,6 +313,16 @@ function streamOpencodeOutput($stdout, int $verbose, ?string $logFile = null, in
311313 continue ; // No data ready yet, loop and try again
312314 }
313315
316+ // Check if interrupt pipe fired (SIGINT/SIGTERM) — must be first
317+ if (in_array ($ interruptFd , $ read , true )) {
318+ // Drain the interrupt byte(s)
319+ do { $ b = fgets ($ interruptFd ); } while ($ b !== false && !feof ($ interruptFd ));
320+ if (!$ running ) {
321+ verbose_log ("streamOpencodeOutput: shutdown requested, stopping " , 2 );
322+ break ;
323+ }
324+ }
325+
314326 $ line = fgets ($ stdout );
315327 if ($ line === false ) {
316328 break ;
@@ -466,36 +478,79 @@ function applyPRDiff(string $checkoutPath, ?string $diff): bool
466478 // Try multiple strategies in order of preference
467479 // Strategy order: most reliable first, least desperate last
468480 $ strategies = [
469- // Strategy 1: patch command first - most lenient, handles binary files, long lines, etc.
481+ // Strategy 1: patch --dry-run to validate, then patch for real if validation passes
482+ // Split into two steps so we don't run real patch as part of same && chain
470483 [
471- 'cmd ' => 'patch -p1 --no-backup-if-mismatch --dry-run < %s 2>&1 && patch -p1 --no-backup-if-mismatch < %s ' ,
472- 'label ' => 'patch-dryrun-then-apply ' ,
473- 'doubleFile ' => true ,
484+ 'label ' => 'patch-dryrun ' ,
474485 ],
475- // Strategy 2: git apply with --binary (required for binary file changes )
486+ // Strategy 2: git apply with --3way (requires base in repo for best results )
476487 [
477- 'cmd ' => 'git apply --binary --3way --whitespace=nowarn %s 2>&1 ' ,
478- 'label ' => 'git-apply-binary-3way ' ,
479- ],
480- // Strategy 3: git apply binary without 3way (pure binary apply)
481- [
482- 'cmd ' => 'git apply --binary --whitespace=nowarn --ignore-whitespace %s 2>&1 ' ,
483- 'label ' => 'git-apply-binary-ignorews ' ,
488+ 'cmd ' => 'git apply --3way --whitespace=nowarn %s 2>&1 ' ,
489+ 'label ' => 'git-apply-3way ' ,
484490 ],
485- // Strategy 4 : git apply with --whitespace=fix (auto-fix whitespace issues)
491+ // Strategy 3 : git apply with --whitespace=fix (auto-fix whitespace issues)
486492 [
487493 'cmd ' => 'git apply --whitespace=fix %s 2>&1 ' ,
488494 'label ' => 'git-apply-whitespace-fix ' ,
489495 ],
496+ // Strategy 4: git apply binary (handles binary file mode changes)
497+ [
498+ 'cmd ' => 'git apply --binary --3way --whitespace=nowarn %s 2>&1 ' ,
499+ 'label ' => 'git-apply-binary-3way ' ,
500+ ],
501+ // Strategy 5: plain git apply ignoring whitespace
502+ [
503+ 'cmd ' => 'git apply --whitespace=nowarn --ignore-whitespace %s 2>&1 ' ,
504+ 'label ' => 'git-apply-ignorews ' ,
505+ ],
490506 ];
491507
492508 $ lastOutput = '' ;
493509
494510 foreach ($ strategies as $ strategy ) {
511+ // Strategy 1: separate dry-run step, then real patch only if dry-run succeeded
512+ if ($ strategy ['label ' ] === 'patch-dryrun ' ) {
513+ $ dryRunCmd = sprintf (
514+ 'cd %s && patch -p1 --no-backup-if-mismatch --dry-run < %s 2>&1 ' ,
515+ escapeshellarg ($ checkoutPath ),
516+ escapeshellarg ($ diffFile )
517+ );
518+ $ output = [];
519+ $ ret = 0 ;
520+ exec ($ dryRunCmd , $ output , $ ret );
521+ $ dryRunOutput = implode ("\n" , $ output );
522+
523+ if ($ ret !== 0 ) {
524+ verbose_log ("applyPRDiff: patch --dry-run failed: " . substr ($ dryRunOutput , 0 , 200 ), 3 );
525+ continue ; // try next strategy
526+ }
527+
528+ // Dry-run passed — now apply for real
529+ verbose_log ("applyPRDiff: patch --dry-run passed, applying for real... " , 3 );
530+ $ applyCmd = sprintf (
531+ 'cd %s && patch -p1 --no-backup-if-mismatch < %s 2>&1 ' ,
532+ escapeshellarg ($ checkoutPath ),
533+ escapeshellarg ($ diffFile )
534+ );
535+ $ output = [];
536+ $ ret = 0 ;
537+ exec ($ applyCmd , $ output , $ ret );
538+ $ applyOutput = implode ("\n" , $ output );
539+
540+ if ($ ret === 0 ) {
541+ verbose_log ("applyPRDiff: diff applied successfully (strategy: patch) " , 3 );
542+ return true ;
543+ }
544+
545+ verbose_log ("applyPRDiff: patch --dry-run passed but real patch failed: " . substr ($ applyOutput , 0 , 200 ), 3 );
546+ $ lastOutput = $ applyOutput ;
547+ continue ; // try next strategy
548+ }
549+
550+ // Remaining strategies use a single command
495551 $ cmd = sprintf (
496552 'cd %s && ' . $ strategy ['cmd ' ],
497553 escapeshellarg ($ checkoutPath ),
498- escapeshellarg ($ diffFile ),
499554 escapeshellarg ($ diffFile )
500555 );
501556
@@ -562,11 +617,18 @@ function applyPRDiff(string $checkoutPath, ?string $diff): bool
562617 global $ running ;
563618 $ running = false ;
564619 verbose_log ("received SIGINT, shutting down... " , 1 );
620+ // Wake up stream_select() immediately so the loop exits
621+ if (isset ($ GLOBALS ['interruptWriteFd ' ]) && is_resource ($ GLOBALS ['interruptWriteFd ' ])) {
622+ @fwrite ($ GLOBALS ['interruptWriteFd ' ], "i " );
623+ }
565624 });
566625 pcntl_signal (SIGTERM , function () {
567626 global $ running ;
568627 $ running = false ;
569628 verbose_log ("received SIGTERM, shutting down... " , 1 );
629+ if (isset ($ GLOBALS ['interruptWriteFd ' ]) && is_resource ($ GLOBALS ['interruptWriteFd ' ])) {
630+ @fwrite ($ GLOBALS ['interruptWriteFd ' ], "t " );
631+ }
570632 });
571633}
572634
@@ -764,6 +826,11 @@ function processJob(array $job): string
764826 }
765827
766828 // Step 3: Initialize git repo and run opencode analysis on the modified working dir
829+ // Reset checkout to clean state before analysis (important for cached checkouts)
830+ if (!resetCheckoutToCleanState ($ checkoutPath , $ baseBranch )) {
831+ return 'checkout reset failed ' ;
832+ }
833+
767834 initGitRepo ($ checkoutPath );
768835
769836 verbose_log ("starting opencode analysis for {$ repo }# {$ prNumber } in {$ checkoutPath }" . ($ opencodeTimeout > 0 ? " (timeout: " . ($ opencodeTimeout >= 3600 ? round ($ opencodeTimeout /3600 ,1 )."h " : round ($ opencodeTimeout /60 ,1 )."m " ) . ") " : "" ), 2 );
@@ -1176,6 +1243,114 @@ function extractFixedContent(array $data, string $file): ?string
11761243
11771244 return null ;
11781245}
1246+ /**
1247+ * Redis-backed checkout cache: reuse repo checkouts for up to 24 hours.
1248+ *
1249+ * Key: github:checkout:v1:{repo}:{branch} → value: absolute path
1250+ * TTL: 86400 seconds (24 hours)
1251+ * Prefix: github:checkout:v1:
1252+ */
1253+
1254+ // Singleton Redis connection for checkout cache
1255+ $ checkoutRedis = null ;
1256+
1257+ function getCheckoutRedis (): ?\Predis \Client
1258+ {
1259+ global $ checkoutRedis ;
1260+ if ($ checkoutRedis !== null ) {
1261+ return $ checkoutRedis ;
1262+ }
1263+ try {
1264+ $ host = defined ('REDIS_HOST ' ) && REDIS_HOST !== '' ? REDIS_HOST : (getenv ('REDIS_HOST ' ) ?: '67.217.60.234 ' );
1265+ $ port = defined ('REDIS_PORT ' ) && REDIS_PORT !== '' ? (int )REDIS_PORT : (int )(getenv ('REDIS_PORT ' ) ?: 6379 );
1266+ $ checkoutRedis = new \Predis \Client (['host ' => $ host , 'port ' => $ port , 'read_write_timeout ' => 2 ]);
1267+ // Probe with a ping — if it throws, don't use the cache
1268+ $ checkoutRedis ->ping ();
1269+ return $ checkoutRedis ;
1270+ } catch (\Throwable $ e ) {
1271+ verbose_log ("checkout cache: Redis unavailable ( {$ e ->getMessage ()}), cache disabled " , 1 );
1272+ $ checkoutRedis = null ;
1273+ return null ;
1274+ }
1275+ }
1276+
1277+ /**
1278+ * Get a cached checkout path for a repo+branch, or null if not cached / invalid.
1279+ */
1280+ function getCachedCheckoutPath (string $ repo , string $ branch ): ?string
1281+ {
1282+ $ redis = getCheckoutRedis ();
1283+ if ($ redis === null ) {
1284+ return null ;
1285+ }
1286+ $ key = "github:checkout:v1: {$ repo }: {$ branch }" ;
1287+ $ path = $ redis ->get ($ key );
1288+ if ($ path === null || $ path === false ) {
1289+ return null ;
1290+ }
1291+ // Validate: path must exist, be a git repo, and have correct branch checked out
1292+ if (!is_dir ($ path ) || !is_dir ("{$ path }/.git " )) {
1293+ return null ;
1294+ }
1295+ // Verify branch matches
1296+ $ currentBranch = trim ((string )shell_exec ("cd " . escapeshellarg ($ path ) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null " ));
1297+ if ($ currentBranch !== $ branch ) {
1298+ verbose_log ("checkout cache: branch mismatch for {$ repo }: {$ branch } (cached: {$ currentBranch }), invalidating " , 2 );
1299+ $ redis ->del ($ key );
1300+ return null ;
1301+ }
1302+ return $ path ;
1303+ }
1304+
1305+ /**
1306+ * Store a checkout path in the cache with 24h TTL.
1307+ */
1308+ function cacheCheckoutPath (string $ repo , string $ branch , string $ path ): void
1309+ {
1310+ $ redis = getCheckoutRedis ();
1311+ if ($ redis === null ) {
1312+ return ;
1313+ }
1314+ $ key = "github:checkout:v1: {$ repo }: {$ branch }" ;
1315+ $ redis ->setex ($ key , 86400 , $ path );
1316+ verbose_log ("checkout cached: {$ repo }: {$ branch } → {$ path } (TTL=24h) " , 3 );
1317+ }
1318+
1319+ /**
1320+ * Invalidate a cached checkout for a repo+branch.
1321+ */
1322+ function invalidateCachedCheckout (string $ repo , string $ branch ): void
1323+ {
1324+ $ redis = getCheckoutRedis ();
1325+ if ($ redis === null ) {
1326+ return ;
1327+ }
1328+ $ key = "github:checkout:v1: {$ repo }: {$ branch }" ;
1329+ $ redis ->del ($ key );
1330+ }
1331+
1332+ /**
1333+ * Reset a cached checkout to clean state: hard-reset working tree and
1334+ * ensure correct branch is checked out. This is called before each PR
1335+ * analysis to ensure a pristine base.
1336+ */
1337+ function resetCheckoutToCleanState (string $ checkoutPath , string $ baseBranch ): bool
1338+ {
1339+ $ cmd = sprintf (
1340+ 'cd %s && git reset --hard HEAD 2>&1 && git checkout %s 2>&1 ' ,
1341+ escapeshellarg ($ checkoutPath ),
1342+ escapeshellarg ($ baseBranch )
1343+ );
1344+ $ output = [];
1345+ $ ret = 0 ;
1346+ exec ($ cmd , $ output , $ ret );
1347+ if ($ ret !== 0 ) {
1348+ verbose_log ("resetCheckoutToCleanState: failed for {$ checkoutPath }: " . substr (implode ("\n" , $ output ), 0 , 200 ), 1 );
1349+ return false ;
1350+ }
1351+ verbose_log ("resetCheckoutToCleanState: {$ checkoutPath } reset to clean {$ baseBranch }" , 3 );
1352+ return true ;
1353+ }
11791354
11801355/**
11811356 * Initialize git repo in checkout directory for diff generation
@@ -1295,6 +1470,33 @@ function checkoutBranch(string $repo, string $branch, string $checkoutPath): str
12951470 return 'shutdown ' ;
12961471 }
12971472
1473+ // Try to reuse a cached checkout — caller will reset to clean state before analysis
1474+ $ cachedPath = getCachedCheckoutPath ($ repo , $ branch );
1475+ if ($ cachedPath !== null ) {
1476+ // Cache hit: the path already has the correct branch checked out.
1477+ // Sync with remote to pick up new commits, then use it.
1478+ verbose_log ("checkoutBranch: cache hit for {$ repo }: {$ branch } at {$ cachedPath } - syncing with remote " , 2 );
1479+ $ syncCmd = sprintf (
1480+ 'cd %s && git fetch origin %s 2>&1 && git checkout %s 2>&1 ' ,
1481+ escapeshellarg ($ cachedPath ),
1482+ escapeshellarg ($ branch ),
1483+ escapeshellarg ($ branch )
1484+ );
1485+ $ syncOutput = [];
1486+ $ syncRet = 0 ;
1487+ exec ($ syncCmd , $ syncOutput , $ syncRet );
1488+ verbose_raw ("checkout_cache_sync " , "cmd: {$ syncCmd }\noutput: " . implode ("\n" , $ syncOutput ) . "\nret: {$ syncRet }" );
1489+ if ($ syncRet === 0 ) {
1490+ verbose_log ("checkoutBranch: cache hit reused (synced) for {$ repo }: {$ branch }" , 2 );
1491+ // Refresh TTL on reuse so frequently-used checkouts don't expire
1492+ cacheCheckoutPath ($ repo , $ branch , $ cachedPath );
1493+ return 'true ' ;
1494+ }
1495+ // Sync failed — fall through to fresh clone
1496+ verbose_log ("checkoutBranch: cache sync failed for {$ repo }: {$ branch }, will re-clone " , 2 );
1497+ }
1498+
1499+ // No cache or cache miss — do a fresh clone
12981500 // Create parent directory if needed
12991501 $ parentDir = dirname ($ checkoutPath );
13001502 if (!is_dir ($ parentDir )) {
@@ -1396,6 +1598,8 @@ function checkoutBranch(string $repo, string $branch, string $checkoutPath): str
13961598 }
13971599
13981600 verbose_log ("checkoutBranch: success " , 2 );
1601+ // Cache the successful checkout for future reuse (24h TTL)
1602+ cacheCheckoutPath ($ repo , $ branch , $ checkoutPath );
13991603 return 'true ' ;
14001604}
14011605
@@ -1450,7 +1654,23 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
14501654 global $ running , $ verbose , $ logFile ;
14511655
14521656 // When showAgent is enabled, use proc_open with streaming
1657+ $ interrupt = null ;
14531658 if ($ showAgent ) {
1659+ // Interrupt pipe: signal handler writes here to wake up stream_select() immediately
1660+ $ interruptSockets = stream_socket_pair (STREAM_PF_UNIX , STREAM_SOCK_STREAM , STREAM_IPPROTO_IP );
1661+ if ($ interruptSockets === false ) {
1662+ verbose_log ("runOpencodeAnalysis: failed to create interrupt pipe, falling back to buffered " , 1 );
1663+ $ showAgent = false ;
1664+ } else {
1665+ stream_set_blocking ($ interruptSockets [0 ], false );
1666+ stream_set_blocking ($ interruptSockets [1 ], false );
1667+ $ GLOBALS ['interruptWriteFd ' ] = $ interruptSockets [1 ];
1668+ $ interrupt = $ interruptSockets ;
1669+ }
1670+ }
1671+
1672+ // Streaming path: only when interrupt pipe was successfully created
1673+ if ($ showAgent && $ interrupt !== null ) {
14541674 $ desc = [
14551675 0 => ['pipe ' , 'r ' ], // stdin
14561676 1 => ['pipe ' , 'w ' ], // stdout
@@ -1481,7 +1701,7 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
14811701 pcntl_alarm ($ timeout );
14821702 }
14831703
1484- $ result = streamOpencodeOutput ($ pipes [1 ], $ verbose , $ logFile , $ timeout );
1704+ $ result = streamOpencodeOutput ($ pipes [1 ], $ interrupt [ 0 ], $ verbose , $ logFile , $ timeout );
14851705
14861706 // Cancel the alarm (process finished before timeout)
14871707 pcntl_alarm (0 );
@@ -1501,6 +1721,13 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
15011721 fclose ($ pipe );
15021722 }
15031723
1724+ // Clean up interrupt pipe and clear global
1725+ if (is_array ($ interrupt )) {
1726+ @fclose ($ interrupt [0 ]);
1727+ @fclose ($ interrupt [1 ]);
1728+ }
1729+ unset($ GLOBALS ['interruptWriteFd ' ]);
1730+
15041731 $ exitCode = proc_close ($ proc );
15051732
15061733 if (!$ running ) {
0 commit comments