11// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22// See LICENSE in the project root for license information.
33
4+ import * as path from 'node:path' ;
5+ import type { ChildProcess } from 'node:child_process' ;
6+
7+ import { Executable , type IWaitForExitResult } from '@rushstack/node-core-library' ;
48import { type IChangeInfo , ChangeType } from '../../api/ChangeManagement' ;
59import { RushConfiguration } from '../../api/RushConfiguration' ;
610import type { RushConfigurationProject } from '../../api/RushConfigurationProject' ;
711import { PublishUtilities , type IChangeRequests } from '../PublishUtilities' ;
812import { ChangeFiles } from '../ChangeFiles' ;
13+ import { Git } from '../Git' ;
914
1015function createChangeFiles ( changesFolder : string ) : ChangeFiles {
1116 return new ChangeFiles ( { changesFolder } as unknown as RushConfiguration ) ;
1217}
1318
19+ function createGitResult (
20+ stdout : string ,
21+ exitCode : IWaitForExitResult < string > [ 'exitCode' ] = 0 ,
22+ signal : IWaitForExitResult < string > [ 'signal' ] = null
23+ ) : IWaitForExitResult < string > {
24+ return {
25+ stdout,
26+ stderr : '' ,
27+ exitCode,
28+ signal
29+ } ;
30+ }
31+
1432function generateChangeSnapshot (
1533 allPackages : ReadonlyMap < string , RushConfigurationProject > ,
1634 allChanges : IChangeRequests
@@ -83,6 +101,10 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => {
83101 repoRushConfiguration = RushConfiguration . loadFromConfigurationFile ( `${ __dirname } /repo/rush.json` ) ;
84102 } ) ;
85103
104+ afterEach ( ( ) => {
105+ jest . restoreAllMocks ( ) ;
106+ } ) ;
107+
86108 it ( 'returns no changes in an empty change folder' , async ( ) => {
87109 const allPackages : ReadonlyMap < string , RushConfigurationProject > =
88110 packagesRushConfiguration . projectsByName ;
@@ -96,6 +118,94 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => {
96118 expect ( allChanges . versionPolicyChanges . size ) . toEqual ( 0 ) ;
97119 } ) ;
98120
121+ it ( 'passes change file paths as discrete Git arguments' , async ( ) => {
122+ const gitPath : string = path . resolve ( 'git with spaces' , 'git.exe' ) ;
123+ const changeFilePath : string = path . resolve (
124+ 'repo with spaces' ,
125+ 'common' ,
126+ 'changes' ,
127+ 'change & echo injected.json'
128+ ) ;
129+ const changes : IChangeInfo [ ] = [ { packageName : 'd' } ] ;
130+ const git : Git = new Git ( packagesRushConfiguration ) ;
131+ const gitProcess : ChildProcess = { } as ChildProcess ;
132+
133+ jest . spyOn ( git , 'getGitPathOrThrow' ) . mockReturnValue ( gitPath ) ;
134+ const spawnSpy : jest . SpyInstance = jest . spyOn ( Executable , 'spawn' ) . mockReturnValue ( gitProcess ) ;
135+ const waitForExitSpy : jest . SpyInstance = jest
136+ . spyOn ( Executable , 'waitForExitAsync' )
137+ . mockResolvedValue (
138+ createGitResult ( 'commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n' )
139+ ) ;
140+
141+ await PublishUtilities [ '_updateCommitDetailsAsync' ] ( git , changeFilePath , changes ) ;
142+
143+ expect ( spawnSpy ) . toHaveBeenCalledWith (
144+ gitPath ,
145+ [ 'log' , '-n' , '1' , '--' , changeFilePath ] ,
146+ { currentWorkingDirectory : path . dirname ( changeFilePath ) }
147+ ) ;
148+ expect ( waitForExitSpy ) . toHaveBeenCalledWith ( gitProcess , { encoding : 'utf8' } ) ;
149+ expect ( changes ) . toEqual ( [
150+ {
151+ packageName : 'd' ,
152+ author : 'Test Author <test@example.com>' ,
153+ commit : '0123456789abcdef'
154+ }
155+ ] ) ;
156+ } ) ;
157+
158+ it ( 'delegates Git wrapper paths to Executable' , async ( ) => {
159+ const gitPath : string = path . resolve ( 'git-wrapper' , 'git.cmd' ) ;
160+ const changeFilePath : string = path . resolve ( 'repo' , 'common' , 'changes' , 'change.json' ) ;
161+ const changes : IChangeInfo [ ] = [ { packageName : 'd' } ] ;
162+ const git : Git = new Git ( packagesRushConfiguration ) ;
163+ const gitProcess : ChildProcess = { } as ChildProcess ;
164+
165+ jest . spyOn ( git , 'getGitPathOrThrow' ) . mockReturnValue ( gitPath ) ;
166+ const spawnSpy : jest . SpyInstance = jest . spyOn ( Executable , 'spawn' ) . mockReturnValue ( gitProcess ) ;
167+ jest
168+ . spyOn ( Executable , 'waitForExitAsync' )
169+ . mockResolvedValue (
170+ createGitResult ( 'commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n' )
171+ ) ;
172+
173+ await PublishUtilities [ '_updateCommitDetailsAsync' ] ( git , changeFilePath , changes ) ;
174+
175+ expect ( spawnSpy ) . toHaveBeenCalledWith (
176+ gitPath ,
177+ [ 'log' , '-n' , '1' , '--' , changeFilePath ] ,
178+ { currentWorkingDirectory : path . dirname ( changeFilePath ) }
179+ ) ;
180+ expect ( changes [ 0 ] . commit ) . toEqual ( '0123456789abcdef' ) ;
181+ } ) ;
182+
183+ it . each ( [
184+ { exitCode : 1 , signal : null } ,
185+ { exitCode : null , signal : 'SIGTERM' }
186+ ] ) (
187+ 'does not use Git output from an unsuccessful process ($exitCode, $signal)' ,
188+ async ( { exitCode, signal } ) => {
189+ const changes : IChangeInfo [ ] = [ { packageName : 'd' } ] ;
190+ const git : Git = new Git ( packagesRushConfiguration ) ;
191+ const gitProcess : ChildProcess = { } as ChildProcess ;
192+
193+ jest . spyOn ( git , 'getGitPathOrThrow' ) . mockReturnValue ( path . resolve ( 'git.exe' ) ) ;
194+ jest . spyOn ( Executable , 'spawn' ) . mockReturnValue ( gitProcess ) ;
195+ jest . spyOn ( Executable , 'waitForExitAsync' ) . mockResolvedValue (
196+ createGitResult (
197+ 'commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n' ,
198+ exitCode ,
199+ signal
200+ )
201+ ) ;
202+
203+ await PublishUtilities [ '_updateCommitDetailsAsync' ] ( git , path . resolve ( 'change.json' ) , changes ) ;
204+
205+ expect ( changes ) . toEqual ( [ { packageName : 'd' } ] ) ;
206+ }
207+ ) ;
208+
99209 it ( 'returns 1 change when changing a leaf package' , async ( ) => {
100210 const allPackages : ReadonlyMap < string , RushConfigurationProject > =
101211 packagesRushConfiguration . projectsByName ;
0 commit comments