@@ -11,19 +11,27 @@ describe("parseRemoteFromPushCommand", () => {
1111 } ) ;
1212
1313 test ( "extracts remote from 'git push my-fork feature'" , ( ) => {
14- expect ( parseRemoteFromPushCommand ( "git push my-fork feature" ) ) . toBe ( "my-fork" ) ;
14+ expect ( parseRemoteFromPushCommand ( "git push my-fork feature" ) ) . toBe (
15+ "my-fork" ,
16+ ) ;
1517 } ) ;
1618
1719 test ( "skips flags: 'git push --force origin main'" , ( ) => {
18- expect ( parseRemoteFromPushCommand ( "git push --force origin main" ) ) . toBe ( "origin" ) ;
20+ expect ( parseRemoteFromPushCommand ( "git push --force origin main" ) ) . toBe (
21+ "origin" ,
22+ ) ;
1923 } ) ;
2024
2125 test ( "skips flags: 'git push -u origin feature'" , ( ) => {
22- expect ( parseRemoteFromPushCommand ( "git push -u origin feature" ) ) . toBe ( "origin" ) ;
26+ expect ( parseRemoteFromPushCommand ( "git push -u origin feature" ) ) . toBe (
27+ "origin" ,
28+ ) ;
2329 } ) ;
2430
2531 test ( "skips compound flags: 'git push --set-upstream origin main'" , ( ) => {
26- expect ( parseRemoteFromPushCommand ( "git push --set-upstream origin main" ) ) . toBe ( "origin" ) ;
32+ expect (
33+ parseRemoteFromPushCommand ( "git push --set-upstream origin main" ) ,
34+ ) . toBe ( "origin" ) ;
2735 } ) ;
2836
2937 test ( "returns null for bare 'git push' (can't assume origin)" , ( ) => {
@@ -35,15 +43,23 @@ describe("parseRemoteFromPushCommand", () => {
3543 } ) ;
3644
3745 test ( "returns null when value-consuming flag -o is present" , ( ) => {
38- expect ( parseRemoteFromPushCommand ( "git push -o ci.skip origin main" ) ) . toBeNull ( ) ;
46+ expect (
47+ parseRemoteFromPushCommand ( "git push -o ci.skip origin main" ) ,
48+ ) . toBeNull ( ) ;
3949 } ) ;
4050
4151 test ( "returns null when --push-option is present" , ( ) => {
42- expect ( parseRemoteFromPushCommand ( "git push --push-option ci.skip origin main" ) ) . toBeNull ( ) ;
52+ expect (
53+ parseRemoteFromPushCommand ( "git push --push-option ci.skip origin main" ) ,
54+ ) . toBeNull ( ) ;
4355 } ) ;
4456
4557 test ( "returns null when --repo is present" , ( ) => {
46- expect ( parseRemoteFromPushCommand ( "git push --repo=https://example.com origin main" ) ) . toBeNull ( ) ;
58+ expect (
59+ parseRemoteFromPushCommand (
60+ "git push --repo=https://example.com origin main" ,
61+ ) ,
62+ ) . toBeNull ( ) ;
4763 } ) ;
4864
4965 test ( "returns null for non-git-push command" , ( ) => {
@@ -57,7 +73,9 @@ describe("parseRemoteFromPushCommand", () => {
5773
5874describe ( "extractGitHubOwner" , ( ) => {
5975 test ( "extracts owner from HTTPS URL" , ( ) => {
60- expect ( extractGitHubOwner ( "https://github.com/alice/repo.git" ) ) . toBe ( "alice" ) ;
76+ expect ( extractGitHubOwner ( "https://github.com/alice/repo.git" ) ) . toBe (
77+ "alice" ,
78+ ) ;
6179 } ) ;
6280
6381 test ( "extracts owner from HTTPS URL without .git" , ( ) => {
@@ -86,146 +104,162 @@ describe("extractGitHubOwner", () => {
86104} ) ;
87105
88106describe ( "isOwnedRemotePush" , ( ) => {
89- let originalEnv : string | undefined ;
90107 let originalSpawn : typeof Bun . spawn ;
91108
92109 beforeEach ( ( ) => {
93- originalEnv = process . env . GIT_USER_NAME ;
94110 originalSpawn = Bun . spawn ;
95111 } ) ;
96112
97113 afterEach ( ( ) => {
98- if ( originalEnv === undefined ) {
99- delete process . env . GIT_USER_NAME ;
100- } else {
101- process . env . GIT_USER_NAME = originalEnv ;
102- }
103114 Bun . spawn = originalSpawn ;
104115 } ) ;
105116
106- let lastSpawnArgs : string [ ] = [ ] ;
107-
108- function mockGitRemote ( url : string , exitCode = 0 ) {
117+ let spawnCalls : string [ ] [ ] = [ ] ;
118+
119+ /**
120+ * Mock Bun.spawn to handle two sequential calls:
121+ * 1. git config user.name → returns userName
122+ * 2. git remote get-url --push <remote> → returns remoteUrl
123+ *
124+ * configExitCode / remoteExitCode let individual tests simulate failures.
125+ */
126+ function mockGitSpawn (
127+ userName : string ,
128+ remoteUrl : string ,
129+ { configExitCode = 0 , remoteExitCode = 0 } = { } ,
130+ ) {
131+ spawnCalls = [ ] ;
132+ let callIndex = 0 ;
109133 // @ts -expect-error — partial mock of Bun.spawn for testing
110134 Bun . spawn = ( args : string [ ] ) => {
111- lastSpawnArgs = args ;
135+ spawnCalls . push ( args ) ;
136+ const idx = callIndex ++ ;
137+ const isConfigCall = idx === 0 ;
138+ const output = isConfigCall ? userName : remoteUrl ;
139+ const exit = isConfigCall ? configExitCode : remoteExitCode ;
112140 return {
113141 stdout : new ReadableStream ( {
114142 start ( controller ) {
115- controller . enqueue ( new TextEncoder ( ) . encode ( url + "\n" ) ) ;
143+ controller . enqueue ( new TextEncoder ( ) . encode ( output + "\n" ) ) ;
116144 controller . close ( ) ;
117145 } ,
118146 } ) ,
119147 stderr : new ReadableStream ( {
120- start ( controller ) { controller . close ( ) ; } ,
148+ start ( controller ) {
149+ controller . close ( ) ;
150+ } ,
121151 } ) ,
122- exited : Promise . resolve ( exitCode ) ,
152+ exited : Promise . resolve ( exit ) ,
123153 } ;
124154 } ;
125155 }
126156
127157 test ( "allows push to owned remote" , async ( ) => {
128- process . env . GIT_USER_NAME = "alice" ;
129- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
158+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
130159 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( true ) ;
131- // Verify spawn was called with --push and correct remote
132- expect ( lastSpawnArgs ) . toEqual ( [ "git" , "remote" , "get-url" , "--push" , "origin" ] ) ;
160+ // Verify both spawn calls
161+ expect ( spawnCalls [ 0 ] ) . toEqual ( [ "git" , "config" , "user.name" ] ) ;
162+ expect ( spawnCalls [ 1 ] ) . toEqual ( [
163+ "git" ,
164+ "remote" ,
165+ "get-url" ,
166+ "--push" ,
167+ "origin" ,
168+ ] ) ;
133169 } ) ;
134170
135171 test ( "denies push to non-owned remote" , async ( ) => {
136- process . env . GIT_USER_NAME = "alice" ;
137- mockGitRemote ( "https://github.com/upstream-org/repo.git" ) ;
172+ mockGitSpawn ( "alice" , "https://github.com/upstream-org/repo.git" ) ;
138173 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
139174 } ) ;
140175
141176 test ( "allows push to owned non-origin remote" , async ( ) => {
142- process . env . GIT_USER_NAME = "alice" ;
143- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
177+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
144178 expect ( await isOwnedRemotePush ( "git push my-fork feature" ) ) . toBe ( true ) ;
145- // Verify spawn was called with the parsed remote name, not hardcoded "origin"
146- expect ( lastSpawnArgs ) . toEqual ( [ "git" , "remote" , "get-url" , "--push" , "my-fork" ] ) ;
179+ expect ( spawnCalls [ 1 ] ) . toEqual ( [
180+ "git" ,
181+ "remote" ,
182+ "get-url" ,
183+ "--push" ,
184+ "my-fork" ,
185+ ] ) ;
147186 } ) ;
148187
149188 test ( "case-insensitive username match" , async ( ) => {
150- process . env . GIT_USER_NAME = "Alice" ;
151- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
189+ mockGitSpawn ( "Alice" , "https://github.com/alice/repo.git" ) ;
152190 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( true ) ;
153191 } ) ;
154192
155193 test ( "allows force push to owned remote" , async ( ) => {
156- process . env . GIT_USER_NAME = "alice" ;
157- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
194+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
158195 expect ( await isOwnedRemotePush ( "git push --force origin main" ) ) . toBe ( true ) ;
159196 } ) ;
160197
161198 test ( "blocks --delete even on owned remote" , async ( ) => {
162- process . env . GIT_USER_NAME = "alice" ;
163- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
164- expect ( await isOwnedRemotePush ( "git push --delete origin feature" ) ) . toBe ( false ) ;
199+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
200+ expect ( await isOwnedRemotePush ( "git push --delete origin feature" ) ) . toBe (
201+ false ,
202+ ) ;
165203 } ) ;
166204
167205 test ( "blocks -d even on owned remote" , async ( ) => {
168- process . env . GIT_USER_NAME = "alice" ;
169- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
206+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
170207 expect ( await isOwnedRemotePush ( "git push -d origin feature" ) ) . toBe ( false ) ;
171208 } ) ;
172209
173- test ( "returns false when GIT_USER_NAME is unset" , async ( ) => {
174- delete process . env . GIT_USER_NAME ;
175- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
210+ test ( "returns false when git config user.name fails" , async ( ) => {
211+ mockGitSpawn ( "" , "https://github.com/alice/repo.git" , {
212+ configExitCode : 1 ,
213+ } ) ;
214+ expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
215+ } ) ;
216+
217+ test ( "returns false when git config user.name is empty" , async ( ) => {
218+ mockGitSpawn ( "" , "https://github.com/alice/repo.git" ) ;
176219 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
177220 } ) ;
178221
179- test ( "returns false when git command fails" , async ( ) => {
180- process . env . GIT_USER_NAME = "alice" ;
181- mockGitRemote ( "" , 128 ) ;
222+ test ( "returns false when git remote get-url fails" , async ( ) => {
223+ mockGitSpawn ( "alice" , "" , { remoteExitCode : 128 } ) ;
182224 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
183225 } ) ;
184226
185227 test ( "returns false for non-GitHub remote" , async ( ) => {
186- process . env . GIT_USER_NAME = "alice" ;
187- mockGitRemote ( "https://gitlab.com/alice/repo.git" ) ;
228+ mockGitSpawn ( "alice" , "https://gitlab.com/alice/repo.git" ) ;
188229 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
189230 } ) ;
190231
191232 test ( "returns false for bare 'git push' (can't assume origin)" , async ( ) => {
192- process . env . GIT_USER_NAME = "alice" ;
193- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
233+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
194234 expect ( await isOwnedRemotePush ( "git push" ) ) . toBe ( false ) ;
195235 } ) ;
196236
197237 test ( "returns false when value-consuming flags are present" , async ( ) => {
198- process . env . GIT_USER_NAME = "alice" ;
199- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
200- expect ( await isOwnedRemotePush ( "git push -o ci.skip origin main" ) ) . toBe ( false ) ;
238+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
239+ expect ( await isOwnedRemotePush ( "git push -o ci.skip origin main" ) ) . toBe (
240+ false ,
241+ ) ;
201242 } ) ;
202243
203244 test ( "returns false for non-push commands" , async ( ) => {
204- process . env . GIT_USER_NAME = "alice" ;
205- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
245+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
206246 expect ( await isOwnedRemotePush ( "git status" ) ) . toBe ( false ) ;
207247 } ) ;
208248
209249 test ( "handles SSH remote URL" , async ( ) => {
210- process . env . GIT_USER_NAME = "alice" ;
211- mockGitRemote ( "git@github.com:alice/repo.git" ) ;
250+ mockGitSpawn ( "alice" , "git@github.com:alice/repo.git" ) ;
212251 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( true ) ;
213252 } ) ;
214253
215254 test ( "skips -u flag and finds remote" , async ( ) => {
216- process . env . GIT_USER_NAME = "alice" ;
217- mockGitRemote ( "https://github.com/alice/repo.git" ) ;
255+ mockGitSpawn ( "alice" , "https://github.com/alice/repo.git" ) ;
218256 expect ( await isOwnedRemotePush ( "git push -u origin feature" ) ) . toBe ( true ) ;
219257 } ) ;
220258
221259 // SECURITY: pushurl vs url divergence — ownership check must use pushurl
222260 // because that is the URL git actually pushes to.
223261 test ( "uses push URL for ownership check (pushurl security invariant)" , async ( ) => {
224- process . env . GIT_USER_NAME = "alice" ;
225- // Simulate a repo where pushurl differs from url.
226- // The mock returns what --push would return (the pushurl).
227- // If pushurl points to a non-owned remote, the check must fail.
228- mockGitRemote ( "https://github.com/victim-org/repo.git" ) ;
262+ mockGitSpawn ( "alice" , "https://github.com/victim-org/repo.git" ) ;
229263 expect ( await isOwnedRemotePush ( "git push origin main" ) ) . toBe ( false ) ;
230264 } ) ;
231265} ) ;
0 commit comments