@ 261105.4
current
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';

import PrepareSecurityRelease from '../../lib/prepare_security.js';
import { writeSecurityReleaseDraft } from '../../lib/security-release/draft.js';

import {
  checkoutOnSecurityReleaseBranch,
  commitAndPushVulnerabilitiesJSON,
  NEXT_SECURITY_RELEASE_REPOSITORY
} from '../../lib/security-release/security-release.js';

function git(...args) {
  return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}

function repository(t) {
  const previous = process.cwd();
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ncu-security-git-'));
  t.after(() => {
    process.chdir(previous);
    fs.rmSync(directory, { recursive: true, force: true });
  });
  process.chdir(directory);
  git('init', '-b', 'main');
  git('config', 'user.name', 'Test User');
  git('config', 'user.email', 'test@example.com');
  git('config', 'commit.gpgsign', 'false');
  git('config', 'core.hooksPath', path.join(directory, 'no-hooks'));
  git('commit', '--allow-empty', '-m', 'Initial commit');
  git('remote', 'add', 'origin', 'https://github.com/nodejs-private/security-release.git');
  return directory;
}

const cli = { info() {}, ok() {}, prompt: async() => true };

describe('security release git state', { concurrency: false }, () => {
  it('creates a new release branch from the current HEAD', async(t) => {
    repository(t);
    const head = git('rev-parse', 'HEAD');
    await checkoutOnSecurityReleaseBranch(cli, NEXT_SECURITY_RELEASE_REPOSITORY);
    assert.strictEqual(git('branch', '--show-current'), 'next-security-release');
    assert.strictEqual(git('rev-parse', 'HEAD'), head);
  });

  it('checks out an existing release branch without resetting it', async(t) => {
    repository(t);
    const releaseHead = git('rev-parse', 'HEAD');
    git('branch', 'next-security-release');
    git('commit', '--allow-empty', '-m', 'Unrelated work');
    assert.notStrictEqual(git('rev-parse', 'HEAD'), releaseHead);

    await checkoutOnSecurityReleaseBranch(cli, NEXT_SECURITY_RELEASE_REPOSITORY);

    assert.strictEqual(git('branch', '--show-current'), 'next-security-release');
    assert.strictEqual(git('rev-parse', 'HEAD'), releaseHead);
  });

  it('starts from an existing remote-tracking release branch', async(t) => {
    repository(t);
    const releaseHead = git('rev-parse', 'HEAD');
    git('update-ref', 'refs/remotes/origin/next-security-release', releaseHead);
    git('commit', '--allow-empty', '-m', 'Unrelated work');

    await checkoutOnSecurityReleaseBranch(cli, NEXT_SECURITY_RELEASE_REPOSITORY);

    assert.strictEqual(git('rev-parse', 'HEAD'), releaseHead);
    assert.strictEqual(
      git('rev-parse', '--abbrev-ref', '@{upstream}'), 'origin/next-security-release');
  });

  it('does not switch branches when the checkout is declined', async(t) => {
    repository(t);
    await assert.rejects(checkoutOnSecurityReleaseBranch({
      ...cli, prompt: async() => false
    }, NEXT_SECURITY_RELEASE_REPOSITORY), /Aborted/);
    assert.strictEqual(git('branch', '--show-current'), 'main');
    assert.strictEqual(git('branch', '--list', 'next-security-release'), '');
  });

  it('rejects unrelated staged work before staging the release file', async(t) => {
    repository(t);
    fs.writeFileSync('unrelated notes.txt', 'User work\n');
    git('add', 'unrelated notes.txt');
    fs.writeFileSync('vulnerabilities.json', '{}\n');
    const index = git('write-tree');
    const head = git('rev-parse', 'HEAD');
    await assert.rejects(commitAndPushVulnerabilitiesJSON(
      'vulnerabilities.json', 'Prepare release',
      { cli, repository: NEXT_SECURITY_RELEASE_REPOSITORY }
    ), /Unrelated staged changes/);
    assert.strictEqual(git('write-tree'), index);
    assert.strictEqual(git('rev-parse', 'HEAD'), head);
    assert.strictEqual(fs.readFileSync('unrelated notes.txt', 'utf8'), 'User work\n');
  });

  it('allows staged release files and stops at a declined commit', async(t) => {
    repository(t);
    fs.mkdirSync('security-release');
    fs.writeFileSync('security-release/vulnerabilities.json', '{}\n');
    git('add', 'security-release/vulnerabilities.json');
    const index = git('write-tree');
    const prompts = [];
    await assert.rejects(commitAndPushVulnerabilitiesJSON(
      'security-release', 'Prepare release', {
        cli: {
          ...cli,
          async prompt(message) {
            prompts.push(message);
            return false;
          }
        },
        repository: NEXT_SECURITY_RELEASE_REPOSITORY
      }
    ), /Aborted/);
    assert.strictEqual(prompts.length, 1);
    assert.match(prompts[0], /git commit/);
    assert.strictEqual(git('write-tree'), index);
  });

  it('rejects an existing draft before prompting or fetching reports', async(t) => {
    const dir = repository(t);
    const file = writeSecurityReleaseDraft(dir, { releaseDate: 'TBD', reports: [] });
    const before = fs.readFileSync(file);
    const release = new PrepareSecurityRelease({
      prompt() { assert.fail('Existing releases must not start again'); }
    });
    await assert.rejects(release.start(), /draft already exists/);
    assert.strictEqual(git('branch', '--show-current'), 'main');
    assert.deepStrictEqual(fs.readFileSync(file), before);
  });

  it('preserves Git state if draft preparation fails', async(t) => {
    repository(t);
    const release = new PrepareSecurityRelease(cli);
    release.chooseReports = async() => [];
    release.getDependencyUpdates = async() => {
      throw new Error('Preparation interrupted');
    };
    await assert.rejects(
      release.startVulnerabilitiesJSONCreation('TBD', 'Release'), /Preparation interrupted/);
    assert.strictEqual(git('branch', '--show-current'), 'main');
    assert.strictEqual(git('branch', '--list', 'next-security-release'), '');
    assert.strictEqual(git('status', '--porcelain'), '');
  });

  it('preserves a draft discovered on the existing release branch', async(t) => {
    const dir = repository(t);
    git('checkout', '-b', 'next-security-release');
    const file = writeSecurityReleaseDraft(dir, { releaseDate: 'TBD', reports: [] });
    const before = fs.readFileSync(file);
    git('add', 'security-release');
    git('commit', '-m', 'Existing release');
    const head = git('rev-parse', 'HEAD');
    git('checkout', 'main');
    assert.ok(!fs.existsSync(file));

    const release = new PrepareSecurityRelease(cli);
    release.chooseReports = async() => [];
    release.getDependencyUpdates = async() => ({});
    await assert.rejects(
      release.startVulnerabilitiesJSONCreation('2026-10-06', 'Release'), /draft already exists/);

    assert.deepStrictEqual(fs.readFileSync(file), before);
    assert.strictEqual(git('rev-parse', 'HEAD'), head);
    assert.strictEqual(git('status', '--porcelain'), '');
  });

  it('creates a local draft without committing when publication is declined', async(t) => {
    const dir = repository(t);
    fs.writeFileSync('user-notes.txt', 'Staged user work\n');
    git('add', 'user-notes.txt');
    const index = git('write-tree');
    const head = git('rev-parse', 'HEAD');
    const release = new PrepareSecurityRelease({
      ...cli, startSpinner() {}, stopSpinner() {}
    });
    release.chooseReports = async() => [];
    release.getDependencyUpdates = async() => ({
      undici: { affectedVersions: { '24.x': 'https://github.com/nodejs/node/pull/1' } }
    });
    release.promptReviewVulnerabilitiesJSON = async() => false;
    release.createPullRequest = async() => assert.fail('Do not publish a local draft');

    await release.startVulnerabilitiesJSONCreation('2026/10/06', 'Release');

    const file = path.join(dir, 'security-release/next-security-release/vulnerabilities.json');
    const draft = JSON.parse(fs.readFileSync(file, 'utf8'));
    assert.strictEqual(draft.releaseDate, '2026-10-06');
    assert.deepStrictEqual(draft.reports, []);
    assert.strictEqual(draft.dependencies.undici.affectedVersions['24.x'],
      'https://github.com/nodejs/node/pull/1');
    assert.strictEqual(git('write-tree'), index);
    assert.strictEqual(git('rev-parse', 'HEAD'), head);
    assert.strictEqual(git('branch', '--show-current'), 'next-security-release');
  });
});