All files / scripts/package-release/commands start.js

0% Statements 0/67
0% Branches 0/26
0% Functions 0/9
0% Lines 0/64

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178                                                                                                                                                                                                                                                                                                                                                                   
import { execFileSync } from 'child_process';
import chalk from 'chalk';
import { enforceBranchGuardrail } from '../lib/branch.js';
import { getCurrentBranch, gitCheckoutNewBranch, gitBranchExists } from '../lib/git.js';
import { getConfig, getRepoRoot } from '../lib/config.js';
import { heading, info, success, warn, error, setDryRun } from '../lib/log.js';
 
export function registerStartCommand(program) {
  program
    .command('start')
    .description('Create a chore/release/MM-DD-YY branch off the current workstream branch')
    .option('--name <branch>', 'Override branch name (default: chore/release/MM-DD-YY from config)')
    .option('--from <ref>', 'Start point ref (default: current branch HEAD)')
    .option('--allow-branch <pattern>', 'Additive branch pattern override')
    .option('--no-branch-check', 'Bypass branch guardrail entirely')
    .option(
      '--delete-stale',
      'If a stale local branch with the target name exists (no upstream / merged), delete it without prompting',
    )
    .option('--dry-run', 'Print planned actions without executing')
    .action(async (opts) => {
      if (opts.dryRun) setDryRun(true);
      try {
        await runStart(opts);
      } catch (e) {
        error(e.message);
        process.exit(1);
      }
    });
}
 
function formatDate() {
  const d = new Date();
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  const yy = String(d.getFullYear()).slice(-2);
  return `${mm}-${dd}-${yy}`;
}
 
async function runStart(opts) {
  if (opts.branchCheck !== false) {
    enforceBranchGuardrail('start', {
      noBranchCheck: false,
      allowBranch: opts.allowBranch,
    });
  }
 
  const config = getConfig();
  const date = formatDate();
  const branchName = opts.name || config.releaseBranchFormat.replace('{date}', date);
  const from = opts.from || getCurrentBranch();
 
  heading('Start Release Branch');
  info(`  Branch:     ${chalk.bold(branchName)}`);
  info(`  Off of:     ${chalk.dim(from)}`);
 
  if (gitBranchExists(branchName)) {
    const status = inspectExistingBranch(branchName);
    if (status.isStale) {
      const reason = status.upstreamGone
        ? 'tracks an upstream that no longer exists (likely auto-deleted after merge)'
        : 'has no upstream and no unmerged commits relative to the start point';
      warn(`Branch "${branchName}" already exists locally; ${reason}.`);
 
      const shouldDelete =
        opts.deleteStale || (await promptYesNo(`Delete stale local branch "${branchName}"?`));
      if (!shouldDelete) {
        error(
          `Aborted. Pass --name <unique-branch> or delete the branch manually:\n    git branch -D ${branchName}`,
        );
        process.exit(1);
      }
      gitDeleteLocalBranch(branchName);
      success(`Deleted stale local branch ${branchName}`);
    } else {
      error(
        `Branch "${branchName}" already exists locally and has unmerged work.\n` +
          `  Pass --name <unique-branch> to disambiguate, or check it out manually:\n` +
          `    git checkout ${branchName}\n` +
          `  If you really want to overwrite it, delete it first:\n` +
          `    git branch -D ${branchName}`,
      );
      process.exit(1);
    }
  }
 
  gitCheckoutNewBranch(branchName, { startPoint: from });
  success(`Checked out ${branchName}`);
}
 
/**
 * Inspect a local branch to decide whether it's "stale" (safe to delete) or
 * carries unmerged work. A branch is stale if either:
 *   - its tracked upstream no longer exists on origin (post-merge cleanup), OR
 *   - it has no upstream AND no commits unique to it relative to its start point.
 */
function inspectExistingBranch(branchName) {
  const repoRoot = getRepoRoot();
  let upstream;
  try {
    upstream = execFileSync(
      'git',
      ['for-each-ref', '--format=%(upstream:short)', `refs/heads/${branchName}`],
      { cwd: repoRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
    ).trim();
  } catch {
    upstream = null;
  }
 
  if (upstream) {
    try {
      execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`], {
        cwd: repoRoot,
        encoding: 'utf8',
        stdio: ['pipe', 'pipe', 'pipe'],
      });
      // Upstream still exists; not safe to assume stale.
      return { isStale: false };
    } catch {
      return { isStale: true, upstreamGone: true };
    }
  }
 
  // No upstream. Check if the branch has commits unique to it relative to
  // common base branches (develop, develop-XXX-patch, main, main-patch).
  // If none, treat as stale.
  const candidates = ['develop', 'main', 'main-patch'];
  try {
    const branches = execFileSync('git', ['branch', '--list', 'develop-*-patch'], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    for (const line of branches.split('\n')) {
      const name = line.replace(/^[* ]+/, '').trim();
      if (name) candidates.push(name);
    }
  } catch {
    // ignore
  }
 
  for (const base of candidates) {
    try {
      const ahead = execFileSync('git', ['rev-list', '--count', `${base}..${branchName}`], {
        cwd: repoRoot,
        encoding: 'utf8',
        stdio: ['pipe', 'pipe', 'pipe'],
      }).trim();
      if (ahead === '0') {
        return { isStale: true, upstreamGone: false };
      }
    } catch {
      // base may not exist locally; try the next.
    }
  }
 
  return { isStale: false };
}
 
function gitDeleteLocalBranch(branchName) {
  execFileSync('git', ['branch', '-D', branchName], {
    cwd: getRepoRoot(),
    encoding: 'utf8',
  });
}
 
async function promptYesNo(question) {
  if (process.env.CI) return false;
  const readline = await import('readline');
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(`${question} (y/N) `, (ans) => {
      rl.close();
      resolve(ans.trim().toLowerCase() === 'y');
    });
  });
}