prepare method

Future<PreparedReleaseSet> prepare({
  1. required Map<String, ReleaseType> bumps,
  2. bool dryRun = false,
})

Preflights every target, then writes and commits the release as one unit.

Implementation

Future<PreparedReleaseSet> prepare({
  required Map<String, ReleaseType> bumps,
  bool dryRun = false,
}) async {
  if (bumps.isEmpty) {
    throw const ShipworldException(
      'At least one release target is required',
      code: 'missing_target',
    );
  }
  final staged = await _git(['diff', '--cached', '--name-only']);
  if (staged.isNotEmpty && !dryRun) {
    throw const ShipworldException(
      'Staged changes are not allowed before release preparation',
      code: 'dirty_worktree',
    );
  }
  final branch = await _git(['branch', '--show-current']);
  final allowedDirtyPaths = <String>{
    for (final name in bumps.keys)
      if (config.target(name).changelog case final changelog?)
        _repoRelative(
          config.repoRoot,
          config
              .target(name)
              .targetPath(config.repoRoot, changelog, 'changelog'),
        ),
  };
  final status = await _git(['status', '--porcelain']);
  if (status.isNotEmpty && !dryRun) {
    final dirtyPaths = status
        .split('\n')
        .where((line) => line.trim().isNotEmpty)
        .map((line) => line.length > 3 ? line.substring(3).trim() : line)
        .toSet();
    final unexpected = dirtyPaths.difference(allowedDirtyPaths);
    if (unexpected.isNotEmpty) {
      throw ShipworldException(
        'Git worktree contains unrelated changes: ${unexpected.join(', ')}',
        code: 'dirty_worktree',
      );
    }
  }

  final prepared = <PreparedReleaseTarget>[];
  final renderedFiles = <String, String>{};
  final originalFiles = <String, String>{};
  final stagePaths = <String>{};
  for (final entry in bumps.entries) {
    final target = config.target(entry.key);
    if (!dryRun && branch != target.branch) {
      throw ShipworldException(
        'Target ${target.name} must be prepared from ${target.branch}; '
        'current branch is $branch',
        code: 'wrong_branch',
      );
    }
    final pubspecPath = target.versionPath(config.repoRoot);
    final pubspecContent = await File(pubspecPath).readAsString();
    final previousVersion = await readPubspecVersion(pubspecPath);
    final version = _bumpConfiguredVersion(
      previousVersion,
      entry.value,
      incrementBuild: target.kind == ShipworldTargetKind.flutterApplication,
    );
    final tag = target.renderTag(version);
    if (await _hasLocalTag(tag) || await _hasRemoteTag(tag)) {
      throw ShipworldException(
        'Release tag already exists: $tag',
        code: 'tag_exists',
      );
    }
    await _validateChangelog(config.repoRoot, target, version);
    originalFiles[pubspecPath] = pubspecContent;
    renderedFiles[pubspecPath] = renderPubspecVersion(
      pubspecContent,
      version,
    );
    stagePaths.add(_repoRelative(config.repoRoot, pubspecPath));
    for (final writer in target.version.synchronized) {
      final writerPath = target.targetPath(
        config.repoRoot,
        writer.path,
        'synchronized version file',
      );
      final content = await File(writerPath).readAsString();
      originalFiles[writerPath] = content;
      renderedFiles[writerPath] = switch (writer.kind) {
        VersionWriterKind.dartConstant => renderVersionConstant(
          version,
          constant: writer.constant,
        ),
      };
      stagePaths.add(_repoRelative(config.repoRoot, writerPath));
    }
    if (target.changelog case final changelog?) {
      stagePaths.add(
        _repoRelative(
          config.repoRoot,
          target.targetPath(config.repoRoot, changelog, 'changelog'),
        ),
      );
    }
    prepared.add(
      PreparedReleaseTarget(
        name: target.name,
        previousVersion: previousVersion,
        version: version,
        tag: tag,
      ),
    );
  }
  final commitMessage = prepared.length == 1
      ? config
            .target(prepared.single.name)
            .renderCommit(prepared.single.version)
      : config.renderBatchCommit([
          for (final item in prepared)
            (name: item.name, version: item.version),
        ]);
  if (dryRun) {
    for (final item in prepared) {
      context.logger.info('Would update ${item.name} to ${item.version}');
    }
    return PreparedReleaseSet(
      dryRun: true,
      targets: List.unmodifiable(prepared),
      commitMessage: commitMessage,
    );
  }

  try {
    for (final entry in renderedFiles.entries) {
      await File(entry.key).writeAsString(entry.value);
    }
    await _git(['add', '--', ...stagePaths]);
    await _git(['commit', '-m', commitMessage]);
  } catch (error) {
    try {
      await _git(['restore', '--staged', '--', ...stagePaths]);
    } on Object {
      // Preserve the original error; file restoration below is authoritative.
    }
    for (final entry in originalFiles.entries) {
      await File(entry.key).writeAsString(entry.value);
    }
    rethrow;
  }
  return PreparedReleaseSet(
    dryRun: false,
    targets: List.unmodifiable(prepared),
    commitMessage: commitMessage,
  );
}