66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- import { execSync } from 'node:child_process' ;
9+ import { execFileSync } from 'node:child_process' ;
1010import * as path from 'node:path' ;
1111
12+ /**
13+ * Execute a git command.
14+ * @param args Arguments to pass to the git command.
15+ * @param input Optional input to pass to the command via stdin.
16+ * @returns The output of the command.
17+ */
18+ function execGit ( args : string [ ] , input ?: string ) : string {
19+ return execFileSync ( 'git' , args , { encoding : 'utf8' , stdio : 'pipe' , input } ) ;
20+ }
21+
1222/**
1323 * Checks if the git repository is clean.
14- * @param root The root directory of the project.
15- * @returns True if the repository is clean, false otherwise.
24+ * This function only checks for changes that are within the specified root directory.
25+ * Changes outside the root directory are ignored.
26+ * @param root The root directory of the project to check.
27+ * @returns True if the repository is clean within the root, false otherwise.
1628 */
1729export function checkCleanGit ( root : string ) : boolean {
1830 try {
19- const topLevel = execSync ( 'git rev-parse --show-toplevel' , {
20- encoding : 'utf8' ,
21- stdio : 'pipe' ,
22- } ) ;
23- const result = execSync ( 'git status --porcelain' , { encoding : 'utf8' , stdio : 'pipe' } ) ;
24- if ( result . trim ( ) . length === 0 ) {
31+ const topLevel = execGit ( [ 'rev-parse' , '--show-toplevel' ] ) ;
32+ const result = execGit ( [ 'status' , '--porcelain' , '-z' ] ) ;
33+ if ( result . length === 0 ) {
2534 return true ;
2635 }
2736
28- // Only files inside the workspace root are relevant
29- for ( const entry of result . split ( '\n' ) ) {
30- const relativeEntry = path . relative (
31- path . resolve ( root ) ,
32- path . resolve ( topLevel . trim ( ) , entry . slice ( 3 ) . trim ( ) ) ,
33- ) ;
37+ const entries = result . split ( '\0' ) ;
38+ for ( let i = 0 ; i < entries . length ; i ++ ) {
39+ const line = entries [ i ] ;
40+ if ( ! line ) {
41+ continue ;
42+ }
43+
44+ // Status is the first 2 characters.
45+ // If the status is a rename ('R'), the next entry in the split array is the target path.
46+ let filePath = line . slice ( 3 ) ;
47+ const status = line . slice ( 0 , 2 ) ;
48+ if ( status [ 0 ] === 'R' ) {
49+ // Check the source path (filePath)
50+ if ( isPathInsideRoot ( filePath , root , topLevel . trim ( ) ) ) {
51+ return false ;
52+ }
53+
54+ // The next entry is the target path of the rename.
55+ i ++ ;
56+ filePath = entries [ i ] ;
57+ }
3458
35- if ( ! relativeEntry . startsWith ( '..' ) && ! path . isAbsolute ( relativeEntry ) ) {
59+ if ( isPathInsideRoot ( filePath , root , topLevel . trim ( ) ) ) {
3660 return false ;
3761 }
3862 }
@@ -41,15 +65,24 @@ export function checkCleanGit(root: string): boolean {
4165 return true ;
4266}
4367
68+ function isPathInsideRoot ( filePath : string , root : string , topLevel : string ) : boolean {
69+ const relativeEntry = path . relative ( path . resolve ( root ) , path . resolve ( topLevel , filePath ) ) ;
70+
71+ return ! relativeEntry . startsWith ( '..' ) && ! path . isAbsolute ( relativeEntry ) ;
72+ }
73+
4474/**
4575 * Checks if the working directory has pending changes to commit.
46- * @returns Whether or not the working directory has Git changes to commit.
76+ * @returns Whether or not the working directory has Git changes to commit. Returns false if not in a Git repository.
4777 */
4878export function hasChangesToCommit ( ) : boolean {
49- // List all modified files not covered by .gitignore.
50- // If any files are returned, then there must be something to commit.
51-
52- return execSync ( 'git ls-files -m -d -o --exclude-standard' ) . toString ( ) !== '' ;
79+ try {
80+ // List all modified files not covered by .gitignore.
81+ // If any files are returned, then there must be something to commit.
82+ return execGit ( [ 'ls-files' , '-m' , '-d' , '-o' , '--exclude-standard' ] ) . trim ( ) !== '' ;
83+ } catch {
84+ return false ;
85+ }
5386}
5487
5588/**
@@ -58,19 +91,19 @@ export function hasChangesToCommit(): boolean {
5891 */
5992export function createCommit ( message : string ) {
6093 // Stage entire working tree for commit.
61- execSync ( 'git add -A ', { encoding : 'utf8' , stdio : 'pipe' } ) ;
94+ execGit ( [ ' add', '-A' ] ) ;
6295
6396 // Commit with the message passed via stdin to avoid bash escaping issues.
64- execSync ( 'git commit --no-verify -F - ', { encoding : 'utf8 ', stdio : 'pipe' , input : message } ) ;
97+ execGit ( [ ' commit' , ' --no-verify', '-F ', '-' ] , message ) ;
6598}
6699
67100/**
68- * Finds the Git SHA hash of the HEAD commit.
69- * @returns The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash.
101+ * Finds the full Git SHA hash of the HEAD commit.
102+ * @returns The full Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash.
70103 */
71104export function findCurrentGitSha ( ) : string | null {
72105 try {
73- return execSync ( 'git rev-parse HEAD ', { encoding : 'utf8' , stdio : 'pipe' } ) . trim ( ) ;
106+ return execGit ( [ ' rev-parse', 'HEAD' ] ) . trim ( ) ;
74107 } catch {
75108 return null ;
76109 }
0 commit comments