import test from 'node:test'; import assert from 'node:assert/strict'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import cloneTargetModule from '../src/shared/clone-target.cjs'; import gitModule from '../src/main/git-service.cjs'; const exec = promisify(execFile); const { cloneDirectoryName, resolveCloneTarget } = cloneTargetModule; const { GitService } = gitModule; test('derives a safe repository folder name from HTTPS and SSH clone URLs', () => { assert.equal(cloneDirectoryName('https://gitea.example.test/jens/ForgeFlow.git'), 'ForgeFlow'); assert.equal(cloneDirectoryName('git@gitea.example.test:jens/my-app.git'), 'my-app'); assert.equal(cloneDirectoryName('ssh://git@gitea.example.test/jens/app.git?ref=main'), 'app'); }); test('resolves the automatic clone target inside the configured project root', () => { const root = path.join(os.tmpdir(), 'forgeflow-projects'); const plan = resolveCloneTarget(root, 'https://gitea.example.test/jens/portfolio.git'); assert.equal(plan.root, path.resolve(root)); assert.equal(plan.target, path.join(path.resolve(root), 'portfolio')); assert.equal(plan.directoryName, 'portfolio'); }); test('a clone target that would leave the project root is refused', () => { const root = path.join(os.tmpdir(), 'forgeflow-projects'); const resolved = path.resolve(root); // The escape guard inside resolveCloneTarget stays as a backstop, but no // sanitised folder name can reach it any more: the name is a single path // segment and a dots-only segment falls back to "repository". for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) { const plan = resolveCloneTarget(root, remote); assert.ok( plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved, `${remote} resolved outside the project root: ${plan.target}`, ); } for (const badRoot of ['', ' ', null, undefined]) { assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/); } }); test('a folder name that sanitises away still produces a usable directory', () => { // Windows strips trailing dots, so a dots-only name would land on the project // root itself instead of a subdirectory. assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository'); assert.equal(cloneDirectoryName('..'), 'repository'); assert.equal(cloneDirectoryName(''), 'repository'); assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app'); assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name'); }); test('clone target inspection accepts missing and empty destinations', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const service = new GitService(); const remote = 'https://gitea.example.test/jens/app.git'; const missing = await service.inspectCloneTarget(remote, path.join(root, 'missing-app')); assert.equal(missing.state, 'missing'); const emptyPath = path.join(root, 'empty-app'); await fs.mkdir(emptyPath); const empty = await service.inspectCloneTarget(remote, emptyPath); assert.equal(empty.state, 'empty'); }); test('clone target inspection reuses an existing checkout with the same origin', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-reuse-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const target = path.join(root, 'app'); await fs.mkdir(target); await exec('git', ['init'], { cwd: target, encoding: 'utf8' }); await exec('git', ['remote', 'add', 'origin', 'git@gitea.example.test:jens/app.git'], { cwd: target, encoding: 'utf8' }); const service = new GitService(); const assessment = await service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target); assert.equal(assessment.state, 'matching-repository'); }); test('clone target inspection blocks a different repository and ordinary files', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-conflict-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const service = new GitService(); const otherRepository = path.join(root, 'repository'); await fs.mkdir(otherRepository); await exec('git', ['init'], { cwd: otherRepository, encoding: 'utf8' }); await exec('git', ['remote', 'add', 'origin', 'https://gitea.example.test/jens/other.git'], { cwd: otherRepository, encoding: 'utf8' }); await assert.rejects( service.inspectCloneTarget('https://gitea.example.test/jens/app.git', otherRepository), (error) => error.code === 'CLONE_TARGET_DIFFERENT_REPOSITORY' ); const ordinaryFolder = path.join(root, 'ordinary'); await fs.mkdir(ordinaryFolder); await fs.writeFile(path.join(ordinaryFolder, 'notes.txt'), 'do not overwrite\n'); await assert.rejects( service.inspectCloneTarget('https://gitea.example.test/jens/app.git', ordinaryFolder), (error) => error.code === 'CLONE_TARGET_NOT_EMPTY' ); }); test('clone target inspection blocks a file at the automatic destination', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-file-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const target = path.join(root, 'app'); await fs.writeFile(target, 'not a directory'); const service = new GitService(); await assert.rejects( service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target), (error) => error.code === 'CLONE_TARGET_NOT_DIRECTORY' ); });