Skip to content

Commit 8efc22b

Browse files
authored
Merge pull request #908 from fabiovincenzi/pre-receive
feat: Add support for executing pre-receive hooks
2 parents d043db7 + 774a55e commit 8efc22b

File tree

9 files changed

+189
-5
lines changed

9 files changed

+189
-5
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"server": "node index.js",
1010
"start": "concurrently \"npm run server\" \"npm run client\"",
1111
"build": "vite build",
12-
"test": "NODE_ENV=test mocha --exit",
12+
"test": "NODE_ENV=test mocha './test/**/*.js' --exit",
1313
"test-coverage": "nyc npm run test",
1414
"test-coverage-ci": "nyc --reporter=lcovonly --reporter=text npm run test",
1515
"prepare": "node ./scripts/prepare.js",

src/proxy/chain.js

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const pushActionChain = [
99
proc.push.checkIfWaitingAuth,
1010
proc.push.pullRemote,
1111
proc.push.writePack,
12+
proc.push.preReceive,
1213
proc.push.getDiff,
1314
proc.push.clearBareClone,
1415
proc.push.scanDiff,

src/proxy/processors/push-action/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
exports.parsePush = require('./parsePush').exec;
2+
exports.preReceive = require('./preReceive').exec;
23
exports.checkRepoInAuthorisedList = require('./checkRepoInAuthorisedList').exec;
34
exports.audit = require('./audit').exec;
45
exports.pullRemote = require('./pullRemote').exec;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const Step = require('../../actions').Step;
4+
const { spawnSync } = require('child_process');
5+
6+
const sanitizeInput = (_req, action) => {
7+
return `${action.commitFrom} ${action.commitTo} ${action.branch} \n`;
8+
};
9+
10+
const exec = async (req, action, hookFilePath = './hooks/pre-receive.sh') => {
11+
const step = new Step('executeExternalPreReceiveHook');
12+
13+
try {
14+
const resolvedPath = path.resolve(hookFilePath);
15+
const hookDir = path.dirname(resolvedPath);
16+
17+
if (!fs.existsSync(hookDir) || !fs.existsSync(resolvedPath)) {
18+
step.log('Pre-receive hook not found, skipping execution.');
19+
action.addStep(step);
20+
return action;
21+
}
22+
23+
const repoPath = `${action.proxyGitPath}/${action.repoName}`;
24+
25+
step.log(`Executing pre-receive hook from: ${resolvedPath}`);
26+
27+
const sanitizedInput = sanitizeInput(req, action);
28+
29+
const hookProcess = spawnSync(resolvedPath, [], {
30+
input: sanitizedInput,
31+
encoding: 'utf-8',
32+
cwd: repoPath,
33+
});
34+
35+
const { stdout, stderr, status } = hookProcess;
36+
37+
const stderrTrimmed = stderr ? stderr.trim() : '';
38+
const stdoutTrimmed = stdout ? stdout.trim() : '';
39+
40+
if (status !== 0) {
41+
step.error = true;
42+
step.log(`Hook stderr: ${stderrTrimmed}`);
43+
step.setError(stdoutTrimmed);
44+
action.addStep(step);
45+
return action;
46+
}
47+
48+
step.log('Pre-receive hook executed successfully');
49+
action.addStep(step);
50+
return action;
51+
} catch (error) {
52+
step.error = true;
53+
step.setError(`Hook execution error: ${error.message}`);
54+
action.addStep(step);
55+
return action;
56+
}
57+
};
58+
59+
exec.displayName = 'executeExternalPreReceiveHook.exec';
60+
exports.exec = exec;

test/chain.test.js

+16-4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const mockPushProcessors = {
2424
checkIfWaitingAuth: sinon.stub(),
2525
pullRemote: sinon.stub(),
2626
writePack: sinon.stub(),
27+
preReceive: sinon.stub(),
2728
getDiff: sinon.stub(),
2829
clearBareClone: sinon.stub(),
2930
scanDiff: sinon.stub(),
@@ -38,6 +39,7 @@ mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermissio
3839
mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth';
3940
mockPushProcessors.pullRemote.displayName = 'pullRemote';
4041
mockPushProcessors.writePack.displayName = 'writePack';
42+
mockPushProcessors.preReceive.displayName = 'preReceive';
4143
mockPushProcessors.getDiff.displayName = 'getDiff';
4244
mockPushProcessors.clearBareClone.displayName = 'clearBareClone';
4345
mockPushProcessors.scanDiff.displayName = 'scanDiff';
@@ -63,7 +65,7 @@ describe('proxy chain', function () {
6365
// Re-require the chain module after stubbing processors
6466
chain = require('../src/proxy/chain');
6567

66-
chain.chainPluginLoader = new PluginLoader([])
68+
chain.chainPluginLoader = new PluginLoader([]);
6769
});
6870

6971
afterEach(() => {
@@ -108,7 +110,11 @@ describe('proxy chain', function () {
108110
mockPushProcessors.checkUserPushPermission.resolves(continuingAction);
109111

110112
// this stops the chain from further execution
111-
mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => false, allowPush: false });
113+
mockPushProcessors.checkIfWaitingAuth.resolves({
114+
type: 'push',
115+
continue: () => false,
116+
allowPush: false,
117+
});
112118
const result = await chain.executeChain(req);
113119

114120
expect(mockPreProcessors.parseAction.called).to.be.true;
@@ -136,7 +142,11 @@ describe('proxy chain', function () {
136142
mockPushProcessors.checkAuthorEmails.resolves(continuingAction);
137143
mockPushProcessors.checkUserPushPermission.resolves(continuingAction);
138144
// this stops the chain from further execution
139-
mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => true, allowPush: true });
145+
mockPushProcessors.checkIfWaitingAuth.resolves({
146+
type: 'push',
147+
continue: () => true,
148+
allowPush: true,
149+
});
140150
const result = await chain.executeChain(req);
141151

142152
expect(mockPreProcessors.parseAction.called).to.be.true;
@@ -166,6 +176,7 @@ describe('proxy chain', function () {
166176
mockPushProcessors.checkIfWaitingAuth.resolves(continuingAction);
167177
mockPushProcessors.pullRemote.resolves(continuingAction);
168178
mockPushProcessors.writePack.resolves(continuingAction);
179+
mockPushProcessors.preReceive.resolves(continuingAction);
169180
mockPushProcessors.getDiff.resolves(continuingAction);
170181
mockPushProcessors.clearBareClone.resolves(continuingAction);
171182
mockPushProcessors.scanDiff.resolves(continuingAction);
@@ -182,6 +193,7 @@ describe('proxy chain', function () {
182193
expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true;
183194
expect(mockPushProcessors.pullRemote.called).to.be.true;
184195
expect(mockPushProcessors.writePack.called).to.be.true;
196+
expect(mockPushProcessors.preReceive.called).to.be.true;
185197
expect(mockPushProcessors.getDiff.called).to.be.true;
186198
expect(mockPushProcessors.clearBareClone.called).to.be.true;
187199
expect(mockPushProcessors.scanDiff.called).to.be.true;
@@ -232,5 +244,5 @@ describe('proxy chain', function () {
232244
expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.false;
233245
expect(mockPushProcessors.parsePush.called).to.be.false;
234246
expect(result).to.deep.equal(action);
235-
})
247+
});
236248
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Mock repository.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
while read oldrev newrev refname; do
3+
echo "Push allowed to $refname"
4+
done
5+
exit 0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
while read oldrev newrev refname; do
3+
echo "Push rejected to $refname"
4+
done
5+
exit 1

test/preReceive/preReceive.test.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
const { expect } = require('chai');
2+
const sinon = require('sinon');
3+
const path = require('path');
4+
const { exec } = require('../../src/proxy/processors/push-action/preReceive');
5+
6+
describe('Pre-Receive Hook Execution', function () {
7+
let action;
8+
let req;
9+
10+
beforeEach(() => {
11+
req = {};
12+
action = {
13+
steps: [],
14+
commitFrom: 'oldCommitHash',
15+
commitTo: 'newCommitHash',
16+
branch: 'feature-branch',
17+
proxyGitPath: 'test/preReceive/mock/repo',
18+
repoName: 'test-repo',
19+
addStep: function (step) {
20+
this.steps.push(step);
21+
},
22+
};
23+
});
24+
25+
afterEach(() => {
26+
sinon.restore();
27+
});
28+
29+
it('should execute hook successfully', async () => {
30+
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-allow.sh');
31+
32+
const result = await exec(req, action, scriptPath);
33+
34+
expect(result.steps).to.have.lengthOf(1);
35+
expect(result.steps[0].error).to.be.false;
36+
expect(
37+
result.steps[0].logs.some((log) => log.includes('Pre-receive hook executed successfully')),
38+
).to.be.true;
39+
});
40+
41+
it('should skip execution when hook file does not exist', async () => {
42+
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/missing-hook.sh');
43+
44+
const result = await exec(req, action, scriptPath);
45+
46+
expect(result.steps).to.have.lengthOf(1);
47+
expect(result.steps[0].error).to.be.false;
48+
expect(
49+
result.steps[0].logs.some((log) =>
50+
log.includes('Pre-receive hook not found, skipping execution.'),
51+
),
52+
).to.be.true;
53+
});
54+
55+
it('should skip execution when hook directory does not exist', async () => {
56+
const scriptPath = path.resolve(__dirname, 'non-existent-directory/pre-receive.sh');
57+
58+
const result = await exec(req, action, scriptPath);
59+
60+
expect(result.steps).to.have.lengthOf(1);
61+
expect(result.steps[0].error).to.be.false;
62+
expect(
63+
result.steps[0].logs.some((log) =>
64+
log.includes('Pre-receive hook not found, skipping execution.'),
65+
),
66+
).to.be.true;
67+
});
68+
69+
it('should fail when hook execution returns an error', async () => {
70+
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-reject.sh');
71+
72+
const result = await exec(req, action, scriptPath);
73+
74+
expect(result.steps).to.have.lengthOf(1);
75+
76+
const step = result.steps[0];
77+
78+
expect(step.error).to.be.true;
79+
expect(step.logs.some((log) => log.includes('Hook stderr:'))).to.be.true;
80+
81+
expect(step.errorMessage).to.exist;
82+
83+
expect(action.steps).to.deep.include(step);
84+
});
85+
86+
it('should catch and handle unexpected errors', async () => {
87+
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-allow.sh');
88+
89+
sinon.stub(require('fs'), 'existsSync').throws(new Error('Unexpected FS error'));
90+
91+
const result = await exec(req, action, scriptPath);
92+
93+
expect(result.steps).to.have.lengthOf(1);
94+
expect(result.steps[0].error).to.be.true;
95+
expect(
96+
result.steps[0].logs.some((log) => log.includes('Hook execution error: Unexpected FS error')),
97+
).to.be.true;
98+
});
99+
});

0 commit comments

Comments
 (0)