Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/rstack/tests/setup/directories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { expect, test } from 'rstack/test';
import { installHooks } from '../../src/setup/install.ts';
import { hooksPath, runGit, runHook, withRepository, writeHook } from './helpers.ts';

test('installs a custom hooks directory from the Git root and runs its hook', () => {
withRepository((cwd) => {
const customHooksDir = 'frontend/custom hooks';
const customHooksPath = `${customHooksDir}/_`;
writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", customHooksDir);

expect(installHooks({ cwd, hooksDir: customHooksDir })).toEqual({
status: 'installed',
hooksPath: customHooksPath,
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(customHooksPath);
expect(existsSync(path.join(cwd, customHooksPath, 'runner'))).toBe(true);

expect(runHook(cwd).status).toBe(0);
expect(readFileSync(path.join(cwd, 'custom-hook-ran'), 'utf8')).toBe('ran\n');
});
});

test('installs the default hooks directory from a nested project', () => {
withRepository((cwd) => {
const projectDirectory = path.join(cwd, 'frontend');
const nestedHooksPath = `frontend/${hooksPath}`;
mkdirSync(projectDirectory);
writeHook(
projectDirectory,
`printf 'root\\n' > nested-hook-cwd
cd frontend
printf 'nested\\n' > nested-hook-ran
`,
);

expect(installHooks({ cwd: projectDirectory })).toEqual({
status: 'installed',
hooksPath: nestedHooksPath,
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(nestedHooksPath);
expect(existsSync(path.join(projectDirectory, hooksPath, 'runner'))).toBe(true);

expect(runHook(cwd).status).toBe(0);
expect(readFileSync(path.join(cwd, 'nested-hook-cwd'), 'utf8')).toBe('root\n');
expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('nested\n');
});
});

test('installs a custom hooks directory from a nested project', () => {
withRepository((cwd) => {
const projectDirectory = path.join(cwd, 'frontend app');
mkdirSync(projectDirectory);

expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({
status: 'installed',
hooksPath: 'frontend app/config/hooks/_',
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(
'frontend app/config/hooks/_',
);
expect(existsSync(path.join(projectDirectory, 'config', 'hooks', '_', 'runner'))).toBe(true);
});
});
83 changes: 83 additions & 0 deletions packages/rstack/tests/setup/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type SpawnSyncReturns, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

const hooksDir = '.rstack/hooks';

export const hooksPath: string = `${hooksDir}/_`;

export const git = (
cwd: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): SpawnSyncReturns<string> => spawnSync('git', args, { cwd, encoding: 'utf8', env });

export const runGit = (cwd: string, args: string[]): string => {
const result = git(cwd, args);
if (result.status !== 0) {
throw new Error(result.stderr || `Git exited with status ${result.status}`);
}
return result.stdout.trim();
};

export const withDirectory = (callback: (cwd: string) => void): void => {
const cwd = mkdtempSync(path.join(tmpdir(), 'rstack hooks '));
try {
callback(cwd);
} finally {
rmSync(cwd, { force: true, recursive: true });
}
};

export const restoreEnv = (name: string, value: string | undefined): void => {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
};

const hookEnv = (cwd: string, value?: string): NodeJS.ProcessEnv => {
const env: NodeJS.ProcessEnv = {
...process.env,
XDG_CONFIG_HOME: path.join(cwd, '.git', 'xdg'),
};

if (value !== undefined) {
env.RSTACK_HOOKS = value;
}

return env;
};

export const writeHook = (cwd: string, content: string, directory: string = hooksDir): void => {
const filePath = path.join(cwd, directory, 'pre-commit');
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
};

export const writeInit = (cwd: string, content: string): void => {
const filePath = path.join(cwd, '.git', 'xdg', 'rstack', 'hooks-init.sh');
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
};

export const runHook = (cwd: string, value?: string): SpawnSyncReturns<string> =>
git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value));

export const withRepository = (callback: (cwd: string) => void): void =>
withDirectory((cwd) => {
const globalConfig = process.env.GIT_CONFIG_GLOBAL;
const noSystemConfig = process.env.GIT_CONFIG_NOSYSTEM;
process.env.GIT_CONFIG_GLOBAL = path.join(cwd, 'global.gitconfig');
process.env.GIT_CONFIG_NOSYSTEM = '1';

try {
runGit(cwd, ['init', '--quiet']);
callback(cwd);
} finally {
restoreEnv('GIT_CONFIG_GLOBAL', globalConfig);
restoreEnv('GIT_CONFIG_NOSYSTEM', noSystemConfig);
}
});
222 changes: 2 additions & 220 deletions packages/rstack/tests/setup/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,9 @@
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { expect, test } from 'rstack/test';
import { createHookFiles } from '../../src/setup/hooks.ts';
import { installHooks } from '../../src/setup/install.ts';

const hooksDir = '.rstack/hooks';
const hooksPath = `${hooksDir}/_`;

const git = (cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env) =>
spawnSync('git', args, { cwd, encoding: 'utf8', env });

const runGit = (cwd: string, args: string[]): string => {
const result = git(cwd, args);
if (result.status !== 0) {
throw new Error(result.stderr || `Git exited with status ${result.status}`);
}
return result.stdout.trim();
};

const withDirectory = (callback: (cwd: string) => void): void => {
const cwd = mkdtempSync(path.join(tmpdir(), 'rstack hooks '));
try {
callback(cwd);
} finally {
rmSync(cwd, { force: true, recursive: true });
}
};

const restoreEnv = (name: string, value: string | undefined): void => {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
};

const hookEnv = (cwd: string, value?: string): NodeJS.ProcessEnv => {
const env: NodeJS.ProcessEnv = {
...process.env,
XDG_CONFIG_HOME: path.join(cwd, '.git', 'xdg'),
};

if (value !== undefined) {
env.RSTACK_HOOKS = value;
}

return env;
};

const writeHook = (cwd: string, content: string, directory = hooksDir): void => {
const filePath = path.join(cwd, directory, 'pre-commit');
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
};

const writeInit = (cwd: string, content: string): void => {
const filePath = path.join(cwd, '.git', 'xdg', 'rstack', 'hooks-init.sh');
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
};

const runHook = (cwd: string, value?: string) =>
git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value));

const withRepository = (callback: (cwd: string) => void): void =>
withDirectory((cwd) => {
const globalConfig = process.env.GIT_CONFIG_GLOBAL;
const noSystemConfig = process.env.GIT_CONFIG_NOSYSTEM;
process.env.GIT_CONFIG_GLOBAL = path.join(cwd, 'global.gitconfig');
process.env.GIT_CONFIG_NOSYSTEM = '1';

try {
runGit(cwd, ['init', '--quiet']);
callback(cwd);
} finally {
restoreEnv('GIT_CONFIG_GLOBAL', globalConfig);
restoreEnv('GIT_CONFIG_NOSYSTEM', noSystemConfig);
}
});
import { git, hooksPath, restoreEnv, runGit, withDirectory, withRepository } from './helpers.ts';

test('installs generated hooks and configures the repository', () => {
withRepository((cwd) => {
Expand All @@ -109,66 +24,6 @@ test('installs generated hooks and configures the repository', () => {
});
});

test('installs a custom hooks directory from the Git root and runs its hook', () => {
withRepository((cwd) => {
const customHooksDir = 'frontend/custom hooks';
const customHooksPath = `${customHooksDir}/_`;
writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", customHooksDir);

expect(installHooks({ cwd, hooksDir: customHooksDir })).toEqual({
status: 'installed',
hooksPath: customHooksPath,
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(customHooksPath);
expect(existsSync(path.join(cwd, customHooksPath, 'runner'))).toBe(true);

expect(runHook(cwd).status).toBe(0);
expect(readFileSync(path.join(cwd, 'custom-hook-ran'), 'utf8')).toBe('ran\n');
});
});

test('installs the default hooks directory from a nested project', () => {
withRepository((cwd) => {
const projectDirectory = path.join(cwd, 'frontend');
const nestedHooksPath = `frontend/${hooksPath}`;
mkdirSync(projectDirectory);
writeHook(
projectDirectory,
`printf 'root\\n' > nested-hook-cwd
cd frontend
printf 'nested\\n' > nested-hook-ran
`,
);

expect(installHooks({ cwd: projectDirectory })).toEqual({
status: 'installed',
hooksPath: nestedHooksPath,
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(nestedHooksPath);
expect(existsSync(path.join(projectDirectory, hooksPath, 'runner'))).toBe(true);

expect(runHook(cwd).status).toBe(0);
expect(readFileSync(path.join(cwd, 'nested-hook-cwd'), 'utf8')).toBe('root\n');
expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('nested\n');
});
});

test('installs a custom hooks directory from a nested project', () => {
withRepository((cwd) => {
const projectDirectory = path.join(cwd, 'frontend app');
mkdirSync(projectDirectory);

expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({
status: 'installed',
hooksPath: 'frontend app/config/hooks/_',
});
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(
'frontend app/config/hooks/_',
);
expect(existsSync(path.join(projectDirectory, 'config', 'hooks', '_', 'runner'))).toBe(true);
});
});

test('is idempotent and preserves user hooks', () => {
withRepository((cwd) => {
const userDirectory = path.join(cwd, '.rstack', 'hooks');
Expand Down Expand Up @@ -245,76 +100,3 @@ test('reports Git configuration failures without changing hooksPath', () => {
expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true);
});
});

test('loads user init and project binaries', () => {
withRepository((cwd) => {
const binDirectory = path.join(cwd, 'node_modules', '.bin');
mkdirSync(binDirectory, { recursive: true });
writeInit(cwd, 'set -u\nexport RSTACK_INIT=loaded\n');

const command = path.join(binDirectory, 'rstack-hook-command');
writeFileSync(
command,
`#!/usr/bin/env sh
printf 'ran\\n' > project-bin-ran
`,
);
chmodSync(command, 0o755);

writeHook(
cwd,
`printf '%s\\n' "$RSTACK_INIT" > init-ran
rstack-hook-command
`,
);

expect(installHooks({ cwd }).status).toBe('installed');

expect(runHook(cwd).status).toBe(0);
expect(readFileSync(path.join(cwd, 'init-ran'), 'utf8')).toBe('loaded\n');
expect(readFileSync(path.join(cwd, 'project-bin-ran'), 'utf8')).toBe('ran\n');
});
});

test('skips user hooks when disabled by the environment or init', () => {
withRepository((cwd) => {
writeHook(cwd, 'echo ran >> hook-ran\n');
expect(installHooks({ cwd }).status).toBe('installed');

expect(runHook(cwd, '0').status).toBe(0);
expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false);

writeInit(cwd, 'set -u\nexport RSTACK_HOOKS=0\n');

expect(runHook(cwd).status).toBe(0);
expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false);
});
});

test('traces and reports hook failures and command lookup errors', () => {
withRepository((cwd) => {
writeHook(cwd, 'exit 23\n');
expect(installHooks({ cwd }).status).toBe('installed');

const failed = runHook(cwd, '2');
expect(failed.status).toBe(23);
expect(failed.stderr).toContain('+ sh -e');
expect(`${failed.stdout}${failed.stderr}`).toContain(
'Rstack - pre-commit hook failed (code 23)',
);

writeHook(
cwd,
`printf '%s\\n' "$PATH" > hook-path
missing-command
`,
);
const missing = runHook(cwd);
const actualPath = readFileSync(path.join(cwd, 'hook-path'), 'utf8').trim();
const output = `${missing.stdout}${missing.stderr}`;

expect(missing.status).toBe(127);
expect(output).toContain('Rstack - pre-commit hook failed (code 127)');
expect(output).toContain(`Rstack - command not found in PATH=${actualPath}`);
});
});
Loading