diff --git a/commands/serve.ts b/commands/serve.ts index b527844f..c57c1a01 100644 --- a/commands/serve.ts +++ b/commands/serve.ts @@ -56,6 +56,13 @@ export default class Serve extends BaseCommand { '```', '{{ binaryName }} serve --assets-args="--debug --base=/public"', '```', + '', + 'When running inside a git worktree, the server automatically uses a deterministic', + 'port based on the worktree name, so multiple worktrees can run in parallel.', + 'You may disable this behavior using the --no-worktree-port flag.', + '```', + '{{ binaryName }} serve --no-worktree-port', + '```', ] /** @@ -101,6 +108,16 @@ export default class Serve extends BaseCommand { }) declare clear?: boolean + /** + * Use a deterministic port based on the git worktree name + */ + @flags.boolean({ + description: 'Use a deterministic port based on the git worktree name', + showNegatedVariantInHelp: true, + default: true, + }) + declare worktreePort: boolean + /** * Log a development dependency is missing * @@ -135,6 +152,21 @@ export default class Serve extends BaseCommand { return } + /** + * Use a deterministic port when running inside a git worktree, so that + * multiple worktrees of the same application can be started in parallel + * without port conflicts + */ + if (this.worktreePort) { + const worktreePort = await this.getWorktreePort() + if (worktreePort) { + process.env.PORT = String(worktreePort.port) + this.logger.info( + `Using worktree "${worktreePort.worktree.name}" on port ${worktreePort.port}` + ) + } + } + this.devServer = new assembler.DevServer(this.app.appRoot, { hmr: this.hmr === true ? true : false, clearScreen: this.clear === false ? false : true, diff --git a/modules/ace/commands.ts b/modules/ace/commands.ts index 79ef7dcb..54872e92 100644 --- a/modules/ace/commands.ts +++ b/modules/ace/commands.ts @@ -7,11 +7,14 @@ * file that was distributed with this source code. */ +import { fileURLToPath } from 'node:url' +import { getGitWorktree, type GitWorktree } from '@poppinss/utils' import { BaseCommand as AceBaseCommand, ListCommand as AceListCommand } from '@adonisjs/ace' import { type Kernel } from './kernel.ts' import type { ApplicationService } from '../../src/types.ts' import type { CommandOptions, ParsedOutput, UIPrimitives } from '../../types/ace.ts' +import { getBasePort, computeWorktreePort } from '../../src/helpers/worktree.ts' /** * The base command class for creating custom Ace commands in AdonisJS applications. @@ -75,6 +78,36 @@ export class BaseCommand extends AceBaseCommand { return codemods } + /** + * Returns the linked git worktree in which the application is running, + * alongside a deterministic port computed from the worktree name. Returns + * "null" when the application is not running inside a linked git worktree + * (for example the main checkout or a non-git directory). + * + * The port is stable for a given worktree name and base port, so multiple + * worktrees of the same application can run in parallel without port + * conflicts. The base port is read from the application dot-env files + * (via the "PORT" variable), with 3333 as the fallback. + * + * @example + * ```ts + * const worktreePort = await this.getWorktreePort() + * if (worktreePort) { + * console.log(worktreePort.worktree.name) + * console.log(worktreePort.port) + * } + * ``` + */ + async getWorktreePort(): Promise<{ worktree: GitWorktree; port: number } | null> { + const worktree = await getGitWorktree(fileURLToPath(this.app.appRoot)) + if (!worktree) { + return null + } + + const basePort = await getBasePort(this.app.appRoot) + return { worktree, port: computeWorktreePort(worktree.name, basePort) } + } + /** * The prepare template method is used to prepare the * state for the command. This is the first method @@ -205,6 +238,21 @@ export class ListCommand extends AceListCommand implements BaseCommand { return new Codemods(this.app, this.logger) } + /** + * Returns the linked git worktree in which the application is running, + * alongside a deterministic port computed from the worktree name. Returns + * "null" when the application is not running inside a linked git worktree. + */ + async getWorktreePort(): Promise<{ worktree: GitWorktree; port: number } | null> { + const worktree = await getGitWorktree(fileURLToPath(this.app.appRoot)) + if (!worktree) { + return null + } + + const basePort = await getBasePort(this.app.appRoot) + return { worktree, port: computeWorktreePort(worktree.name, basePort) } + } + /** * Terminate the app. A command should prefer calling this method * over the "app.terminate", because this method only triggers diff --git a/package-lock.json b/package-lock.json index 68dfcbb4..b4f033a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@poppinss/colors": "^4.1.6", "@poppinss/dumper": "^0.7.0", "@poppinss/macroable": "^1.1.2", - "@poppinss/utils": "^7.0.1", + "@poppinss/utils": "^7.1.0", "@sindresorhus/is": "^8.1.0", "@types/he": "^1.2.3", "error-stack-parser-es": "^2.0.1", @@ -142,7 +142,6 @@ "resolved": "https://registry.npmjs.org/@adonisjs/application/-/application-9.1.0.tgz", "integrity": "sha512-KWsTfkITDozW4csD4YofDEbbWuORKI056VgEr9WBwjIdrzIC7hqPxLtnszFVmyv14THPi0SRBTN/fIYQ6/SQ4Q==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/hooks": "^7.3.1", "@poppinss/macroable": "^1.1.2", @@ -170,7 +169,6 @@ "integrity": "sha512-/P0SEKMAeiIPlAqct/WPNTw5KdHfxmCDWJtiHDN+GL+Vz5gZezKFU8vVxBTuAwIIMhDNlTqkEFKGyrHCqNnSiQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@adonisjs/env": "^7.0.0", "@antfu/install-pkg": "^2.0.1", @@ -256,7 +254,6 @@ "resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-6.1.0.tgz", "integrity": "sha512-YVDRL8xHCtM6iMnAefOBaz6iXVpojwBPDQWPKxnVSucycYeNGrGitJiLy+cGaeAU7Gjm8al9SJRJt3rRPr5PKg==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/utils": "^7.0.0" }, @@ -395,7 +392,6 @@ "resolved": "https://registry.npmjs.org/@adonisjs/fold/-/fold-11.0.0.tgz", "integrity": "sha512-RnmDPWz2imVp/B74xitxCPqTdoP07bZvfJe1bh9CD9Rmia4jjDvehZF67KFyGNMZ24MuKasqs3jOcM1vGJp0GA==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/utils": "^7.0.0", "parse-imports": "^3.0.0" @@ -447,7 +443,6 @@ "resolved": "https://registry.npmjs.org/@adonisjs/http-server/-/http-server-9.2.0.tgz", "integrity": "sha512-yTrnmo1Sn3k6d1/O9J+8mpaMMAflFwYM1P+Y8drAr9thVT55G9ywxRUSXeEp2RnOyxNcGVdjfdRACt6M/aP5Dw==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/macroable": "^1.1.2", "@poppinss/matchit": "^3.2.0", @@ -507,7 +502,6 @@ "resolved": "https://registry.npmjs.org/@adonisjs/logger/-/logger-7.1.1.tgz", "integrity": "sha512-MmUlp8xBMT6zZy0+vnQcQjHIlNfU4pUJARlINr7Bqha9BvhIn03QZgJL5QJ+kJe1tl6ZpNAryoRTJUiOk/wINQ==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/utils": "^7.0.0", "abstract-logging": "^2.0.1", @@ -606,7 +600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -623,7 +616,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -640,7 +632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -657,7 +648,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,7 +664,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -691,7 +680,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -708,7 +696,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -725,7 +712,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -742,7 +728,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -802,7 +787,6 @@ "resolved": "https://registry.npmjs.org/@boringnode/encryption/-/encryption-1.0.1.tgz", "integrity": "sha512-+UL9erFUngnxNEDdXSKCnGQA8D62C/x2vxLF5vc9zaX7T2JolV/bGjpZAvFLzlY2gIffHyw+KsqsEWod5+zV0w==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/utils": "^7.0.1" }, @@ -1411,7 +1395,6 @@ "integrity": "sha512-Krgrcee01BN1StlVwK5JQP6LL5t3DE3uFNbfFoDTfW7kQuHB0xh6yfaV0hrgcoiEjsqmm2OOsVWeju9aXK4vIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@poppinss/macroable": "^1.1.0", "@types/chai": "^5.2.3", @@ -1499,7 +1482,6 @@ "integrity": "sha512-WCnTd1q2EpbKKa96NzL16kVxJXVLRj1VqbswNAn17hYSuMlNKKhPGNbAosB32QZVFcoe9fv4Ebh1HtjyAT/viw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@japa/core": "^10.4.0", "@japa/errors-printer": "^4.1.4", @@ -1718,7 +1700,6 @@ "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", @@ -2133,14 +2114,14 @@ "license": "MIT" }, "node_modules/@poppinss/utils": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-7.0.1.tgz", - "integrity": "sha512-mveSvLI2YPC114mK5HCuSYfUtjpClf1wHG1VCqZJCp4U2ypPhIt62Iku5urh0kPAFvnvCVHx2bXBSH14qMTOlQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-7.1.0.tgz", + "integrity": "sha512-/0+NFusRdWFACF75gm/PZSw11HSfPbyg7FT9p0RPsDHSqwJq2ZMCcFA60HwOjSWx2WBaGhdvwf78EI1hEOiXhQ==", "license": "MIT", "dependencies": { "@poppinss/exception": "^1.2.3", "@poppinss/object-builder": "^1.1.0", - "@poppinss/string": "^1.7.1", + "@poppinss/string": "^1.7.2", "@poppinss/types": "^1.2.1", "flattie": "^1.1.1" } @@ -3231,7 +3212,6 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -3817,7 +3797,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3938,7 +3917,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.1.0", @@ -4075,7 +4053,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" @@ -4137,7 +4114,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -5726,7 +5702,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -5786,7 +5761,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7447,7 +7421,6 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -8652,7 +8625,6 @@ "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", @@ -8757,7 +8729,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9061,8 +9032,7 @@ "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/raw-body": { "version": "3.0.2", @@ -9234,7 +9204,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@inquirer/prompts": "8.5.2", "@octokit/rest": "22.0.1", @@ -9380,7 +9349,6 @@ "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" @@ -10557,7 +10525,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11029,7 +10996,6 @@ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.1.tgz", "integrity": "sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/colors": "^4.1.6", "@poppinss/dumper": "^0.7.0", diff --git a/package.json b/package.json index 3b5659b3..ea0e6307 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "@poppinss/colors": "^4.1.6", "@poppinss/dumper": "^0.7.0", "@poppinss/macroable": "^1.1.2", - "@poppinss/utils": "^7.0.1", + "@poppinss/utils": "^7.1.0", "@sindresorhus/is": "^8.1.0", "@types/he": "^1.2.3", "error-stack-parser-es": "^2.0.1", diff --git a/src/helpers/worktree.ts b/src/helpers/worktree.ts new file mode 100644 index 00000000..9b2ad337 --- /dev/null +++ b/src/helpers/worktree.ts @@ -0,0 +1,60 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' + +import { EnvLoader, EnvParser } from '@adonisjs/env' + +/** + * The number of available port offsets. The offset is computed by + * hashing the worktree name and mapping it to a value within this range, + * so that the final port stays close to the base port. + */ +const PORT_RANGE = 1000 + +/** + * Returns the base port defined inside the application dot-env files. + * The PORT value from the loaded dot-env files is used (following the + * same priority as the environment loading), otherwise falls back to + * the default port 3333. + * + * @param appRoot - The application root directory URL + * + * @example + * await getBasePort(new URL('./', import.meta.url)) + */ +export async function getBasePort(appRoot: URL): Promise { + const files = await new EnvLoader(appRoot).load() + for (const file of files) { + const envVariables = await new EnvParser(file.contents, appRoot).parse() + if (envVariables.PORT) { + return Number(envVariables.PORT) + } + } + + return 3333 +} + +/** + * Computes a deterministic port for a given worktree name by adding a + * stable offset to the base port. The offset is derived from the hash of + * the worktree name, therefore the same worktree name always resolves to + * the same port, regardless of the machine or the worktree location. + * + * @param worktreeName - The name of the worktree + * @param basePort - The base port defined in the application + * + * @example + * computeWorktreePort('feature-login', 3333) + */ +export function computeWorktreePort(worktreeName: string, basePort: number): number { + const hash = createHash('sha1').update(worktreeName).digest() + const offset = hash.readUInt32BE(0) % PORT_RANGE + return basePort + offset +} diff --git a/tests/ace/base_command.spec.ts b/tests/ace/base_command.spec.ts index b1b8c0bd..2ed3a4a6 100644 --- a/tests/ace/base_command.spec.ts +++ b/tests/ace/base_command.spec.ts @@ -9,10 +9,12 @@ import sinon from 'sinon' import { test } from '@japa/runner' +import { setupGitWorktree } from '../helpers.ts' import { BaseCommand } from '../../modules/ace/main.ts' import { ListCommand } from '../../modules/ace/commands.ts' import { IgnitorFactory } from '../../factories/core/ignitor.ts' import { createAceKernel } from '../../modules/ace/create_kernel.ts' +import { computeWorktreePort } from '../../src/helpers/worktree.ts' const BASE_URL = new URL('./tmp/', import.meta.url) @@ -260,6 +262,51 @@ test.group('Base command', () => { await assert.rejects(() => command.exec(), 'completed failed') }) + test('get the worktree port when running inside a linked git worktree', async ({ + assert, + fs, + }) => { + const worktreeUrl = await setupGitWorktree(fs, 'feature-login') + await fs.create('feature-login/.env', 'PORT=4000\n') + + const ignitor = new IgnitorFactory().withCoreConfig().create(worktreeUrl) + + const app = ignitor.createApp('console') + await app.init() + + class MakeController extends BaseCommand { + static commandName: string = 'make:controller' + } + + const kernel = createAceKernel(app) + const command = await kernel.create(MakeController, []) + + const worktreePort = await command.getWorktreePort() + assert.equal(worktreePort!.worktree.name, 'feature-login') + assert.equal(worktreePort!.port, computeWorktreePort('feature-login', 4000)) + }) + + test('return null from getWorktreePort when not inside a linked git worktree', async ({ + assert, + fs, + }) => { + await fs.create('.env', 'PORT=4000\n') + + const ignitor = new IgnitorFactory().withCoreConfig().create(fs.baseUrl) + + const app = ignitor.createApp('console') + await app.init() + + class MakeController extends BaseCommand { + static commandName: string = 'make:controller' + } + + const kernel = createAceKernel(app) + const command = await kernel.create(MakeController, []) + + assert.isNull(await command.getWorktreePort()) + }) + test('call app terminate when main command terminate method is called', async () => { const ignitor = new IgnitorFactory().withCoreConfig().create(BASE_URL) diff --git a/tests/commands/serve.spec.ts b/tests/commands/serve.spec.ts index d166deab..9e7165c9 100644 --- a/tests/commands/serve.spec.ts +++ b/tests/commands/serve.spec.ts @@ -10,8 +10,9 @@ import { test } from '@japa/runner' import Serve from '../../commands/serve.ts' import { AceFactory } from '../../factories/core/ace.ts' -import { setupTypeScriptProject } from '../helpers.ts' +import { setupGitWorktree, setupTypeScriptProject } from '../helpers.ts' import { indexEntities } from '../../src/assembler_hooks/index_entities.ts' +import { computeWorktreePort } from '../../src/helpers/worktree.ts' const sleep = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) @@ -207,4 +208,64 @@ test.group('Serve command', () => { await assert.fileNotExists('.adonisjs/server/events.ts') await assert.fileNotExists('.adonisjs/server/listeners.ts') }) + + test('use a deterministic port when running inside a git worktree', async ({ + assert, + fs, + cleanup, + }) => { + const worktreeUrl = await setupGitWorktree(fs, 'feature-login') + await fs.create( + 'feature-login/bin/server.ts', + `process.send({ isAdonisJS: true, environment: 'web' });` + ) + await fs.create('feature-login/.env', 'PORT=3333\n') + await fs.create('feature-login/tsconfig.json', JSON.stringify({ include: ['**/*'] })) + await fs.create( + 'feature-login/node_modules/@poppinss/ts-exec/package.json', + JSON.stringify({ + name: '@poppinss/ts-exec', + exports: { '.': './index.js' }, + }) + ) + await fs.create('feature-login/node_modules/@poppinss/ts-exec/index.js', '') + + const ace = await new AceFactory().make(worktreeUrl, { + importer: (filePath) => import(filePath), + }) + + ace.ui.switchMode('raw') + + const originalPort = process.env.PORT + cleanup(() => { + if (originalPort === undefined) { + delete process.env.PORT + } else { + process.env.PORT = originalPort + } + }) + + const command = await ace.create(Serve, ['--no-clear']) + cleanup(() => command.devServer.close()) + await command.exec() + await sleep(600) + + const expectedPort = computeWorktreePort('feature-login', 3333) + assert.equal(Number(process.env.PORT), expectedPort) + assert.match(ace.ui.logger.getLogs()[0].message, new RegExp('Using worktree "feature-login"')) + }) + + test('do not override the port when not inside a git worktree', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(filePath), + }) + + ace.ui.switchMode('raw') + + const command = await ace.create(Serve, ['--no-clear']) + await command.exec() + await sleep(600) + + assert.isUndefined(process.env.PORT) + }) }) diff --git a/tests/helpers.ts b/tests/helpers.ts index d5206a4a..18cf7648 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,9 +7,15 @@ * file that was distributed with this source code. */ +import { join } from 'node:path' +import { promisify } from 'node:util' +import { mkdir } from 'node:fs/promises' +import { execFile } from 'node:child_process' import { test } from '@japa/runner' import { type FileSystem } from '@japa/file-system' +const execFileAsync = promisify(execFile) + /** * Setup a TypeScript project by creating a "tsconfig.json" file and * "@poppinss/ts-exec" package within the node_modules. @@ -38,6 +44,36 @@ export const setupTypeScriptProject = test.macro(async ({ context }) => { await fs.create('node_modules/@poppinss/ts-exec/index.js', '') }) +/** + * Creates a git repository with a linked worktree inside the file system + * root and returns the URL of the worktree directory. + */ +export async function setupGitWorktree(fs: FileSystem, worktreeName: string): Promise { + await mkdir(fs.basePath, { recursive: true }) + await execFileAsync('git', ['init', 'main-repo'], { cwd: fs.basePath }) + + const mainRepoPath = join(fs.basePath, 'main-repo') + await execFileAsync( + 'git', + [ + '-c', + 'user.name=AdonisJS', + '-c', + 'user.email=test@adonisjs.com', + 'commit', + '--allow-empty', + '-m', + 'initial commit', + ], + { cwd: mainRepoPath } + ) + await execFileAsync('git', ['worktree', 'add', join(fs.basePath, worktreeName)], { + cwd: mainRepoPath, + }) + + return new URL(`./${worktreeName}/`, fs.baseUrl) +} + /** * Setup a fake adonis project in the file system */ diff --git a/tests/helpers/worktree.spec.ts b/tests/helpers/worktree.spec.ts new file mode 100644 index 00000000..92a8ecad --- /dev/null +++ b/tests/helpers/worktree.spec.ts @@ -0,0 +1,42 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { getBasePort, computeWorktreePort } from '../../src/helpers/worktree.ts' + +test.group('Worktree port', () => { + test('read the base port from the .env file', async ({ assert, fs }) => { + await fs.create('.env', 'PORT=4000\n') + + assert.equal(await getBasePort(fs.baseUrl), 4000) + }) + + test('use 3333 as the base port when PORT is not defined', async ({ assert, fs }) => { + await fs.create('.env', 'APP_KEY=secret\n') + + assert.equal(await getBasePort(fs.baseUrl), 3333) + }) + + test('compute a deterministic port for a given worktree name', async ({ assert }) => { + const basePort = 3333 + + const port = computeWorktreePort('feature-login', basePort) + + assert.equal(computeWorktreePort('feature-login', basePort), port) + assert.isAtLeast(port, basePort) + assert.isBelow(port, basePort + 1000) + }) + + test('compute different ports for different worktree names', async ({ assert }) => { + const names = ['feature-login', 'feature-checkout', 'feature-export', 'feature-report'] + const ports = names.map((name) => computeWorktreePort(name, 3333)) + + assert.equal(new Set(ports).size, ports.length) + }) +})