@@ -182,6 +182,39 @@ const runRemoveReviewTree = (workspace, ...args) =>
182182 { env : { ...process . env , GITHUB_WORKSPACE : workspace } , encoding : 'utf8' } ,
183183 ) ;
184184
185+ // The skip-warning fixture executes the whole step body with `git`
186+ // stubbed to a function whose `worktree list --porcelain` returns
187+ // hostile registrations: the echoes under test sit in the loop, not in
188+ // git, and the stub keeps the fixture free of real worktree state.
189+ const runReviewCleanStep = ( workspace , hostileRegistrations ) =>
190+ spawnSync (
191+ 'bash' ,
192+ [
193+ '-c' ,
194+ [
195+ 'set -euo pipefail' ,
196+ 'git() {' ,
197+ ' case " $* " in' ,
198+ ' *" worktree list "*) printf \'%s\\n\' "$HOSTILE_REGISTRATIONS" ;;' ,
199+ ' esac' ,
200+ '}' ,
201+ reviewCleanStep ,
202+ ] . join ( '\n' ) ,
203+ 'clean-review-worktrees' ,
204+ ] ,
205+ {
206+ cwd : workspace ,
207+ env : {
208+ ...process . env ,
209+ GITHUB_WORKSPACE : workspace ,
210+ HOSTILE_REGISTRATIONS : hostileRegistrations
211+ . map ( ( path ) => `worktree ${ path } ` )
212+ . join ( '\n' ) ,
213+ } ,
214+ encoding : 'utf8' ,
215+ } ,
216+ ) ;
217+
185218// existsSync follows the link: a dangling leftover reports as absent while
186219// the link itself still survives, so link presence is asserted via lstat.
187220const linkExists = ( path ) => {
@@ -342,6 +375,25 @@ describe('review worktree cleanup steps', () => {
342375 expect (
343376 reviewCleanCode . match ( / \$ \{ a b s \/ \/ \[ \$ ' \\ r \\ n ' \] \/ \} / g) ,
344377 ) . toHaveLength ( 2 ) ;
378+ // The registered-worktree loop's two skip warnings reach the same
379+ // stdout with an untrusted registered path, so the identical strip
380+ // protects them: a bare `$worktree` there injects a standalone
381+ // workflow-command line on the runner's stdout (executed by the
382+ // CR-bearing-registration fixture below).
383+ const skipWarningLines = reviewCleanCode
384+ . split ( '\n' )
385+ . filter (
386+ ( line ) =>
387+ line . includes ( 'skipping suspicious review worktree' ) ||
388+ line . includes ( 'skipping unexpected review worktree' ) ,
389+ ) ;
390+ expect ( skipWarningLines ) . toHaveLength ( 2 ) ;
391+ for ( const line of skipWarningLines ) {
392+ expect ( line ) . not . toMatch ( / \$ w o r k t r e e \b / ) ;
393+ }
394+ expect (
395+ reviewCleanCode . match ( / \$ \{ w o r k t r e e \/ \/ \[ \$ ' \\ r \\ n ' \] \/ \} / g) ,
396+ ) . toHaveLength ( 2 ) ;
345397 expect ( reviewCleanCode ) . toContain ( "awk 'NR==1 {print $3}'" ) ;
346398 // The failure warning carries the deciding state (sudo probe + owner),
347399 // and the function returns 0 unconditionally: even a failed warning
@@ -588,4 +640,39 @@ describe('review worktree cleanup steps', () => {
588640 }
589641 } ,
590642 ) ;
643+ it . skipIf ( ! bashAvailable || ! awkAvailable ) (
644+ 'skip warnings keep a CR-bearing registered path on one runner line' ,
645+ ( ) => {
646+ const fixture = mkdtempSync ( join ( tmpdir ( ) , 'review-skip-echo-fixture-' ) ) ;
647+ try {
648+ // The step exits early without a checkout.
649+ mkdirSync ( join ( fixture , '.git' ) ) ;
650+ const hostile = [
651+ // `..` routes to the suspicious-skip echo; the other two fail
652+ // the workspace prefix check and route to the unexpected-skip
653+ // echo.
654+ `${ fixture } /.qwen/tmp/review-pr-1/../pwn\r::stop-commands::pwned` ,
655+ `/elsewhere/.qwen/tmp/review-pr-2\r::endgroup::` ,
656+ `/elsewhere/.qwen/tmp/review-pr-3\r::notice::forged/git` ,
657+ ] ;
658+ const out = runReviewCleanStep ( fixture , hostile ) ;
659+ expect ( out . status ) . toBe ( 0 ) ;
660+ // The runner splits step stdout on bare CR as well as LF and
661+ // parses every line for workflow commands: the stripped path must
662+ // stay inside its warning line, never surface a standalone `::`
663+ // line.
664+ const lines = out . stdout . split ( / [ \r \n ] / ) . filter ( ( line ) => line ) ;
665+ expect (
666+ lines . filter ( ( line ) => line . startsWith ( '::warning::skipping' ) ) ,
667+ ) . toHaveLength ( 3 ) ;
668+ expect (
669+ lines . filter (
670+ ( line ) => line . startsWith ( '::' ) && ! line . startsWith ( '::warning::' ) ,
671+ ) ,
672+ ) . toEqual ( [ ] ) ;
673+ } finally {
674+ rmSync ( fixture , { recursive : true , force : true } ) ;
675+ }
676+ } ,
677+ ) ;
591678} ) ;
0 commit comments