xworkmate-app/tool/render_release_docs.dart

896 lines
26 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'package:yaml/yaml.dart';
const _platformOrder = <String>['mobile', 'desktop', 'web'];
const _tierOrder = <String>['stable', 'beta', 'experimental'];
const _buildModeOrder = <String>['debug', 'profile', 'release'];
void main() {
final manifest = FeatureManifest.load();
final git = GitSnapshot.capture();
_writeDoc(
'docs/planning/xworkmate-ui-feature-matrix.md',
_renderFeatureMatrix(manifest, git),
);
_writeDoc(
'docs/planning/xworkmate-ui-feature-roadmap.md',
_renderFeatureRoadmap(manifest, git),
);
_writeDoc(
'docs/releases/xworkmate-release-notes.md',
_renderReleaseNotes(manifest, git),
);
_writeDoc('docs/releases/xworkmate-changelog.md', _renderChangelog(git));
stdout.writeln(
'Rendered docs/planning/xworkmate-ui-feature-matrix.md, '
'docs/planning/xworkmate-ui-feature-roadmap.md, '
'docs/releases/xworkmate-release-notes.md, '
'and docs/releases/xworkmate-changelog.md',
);
}
void _writeDoc(String relativePath, String contents) {
final file = File(relativePath);
file.parent.createSync(recursive: true);
file.writeAsStringSync(contents);
}
String _renderFeatureMatrix(FeatureManifest manifest, GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate UI Feature Matrix')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Release Policy')
..writeln()
..writeln('| Build Mode | 可见 Tier | 说明 |')
..writeln('| --- | --- | --- |');
for (final buildMode in _buildModeOrder) {
final tiers = manifest.releasePolicy[buildMode] ?? const <String>[];
final note = switch (buildMode) {
'debug' => '内部开发与功能联调',
'profile' => '预发布验收与性能验证',
'release' => '面向用户交付的正式版本',
_ => '-',
};
buffer.writeln(
'| `${_escapeMarkdown(buildMode)}` | `${tiers.join(', ')}` | $note |',
);
}
buffer
..writeln()
..writeln(
'`release_policy` 是全局上限;单个 flag 还必须同时满足 '
'`enabled: true` 和自身 `build_modes` 才会真正出现在 UI 中。',
)
..writeln()
..writeln('## Snapshot Summary')
..writeln()
..writeln(
'| 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled |',
)
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
var total = 0;
var totalEnabled = 0;
var totalDisabled = 0;
final tierTotals = <String, int>{for (final tier in _tierOrder) tier: 0};
for (final platform in _platformOrder) {
final records = manifest.recordsFor(platform);
final enabled = records.where((record) => record.enabled).length;
final disabled = records.length - enabled;
total += records.length;
totalEnabled += enabled;
totalDisabled += disabled;
final perTier = <String, int>{for (final tier in _tierOrder) tier: 0};
for (final record in records.where((record) => record.enabled)) {
perTier[record.releaseTier] = (perTier[record.releaseTier] ?? 0) + 1;
tierTotals[record.releaseTier] =
(tierTotals[record.releaseTier] ?? 0) + 1;
}
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${records.length} | $enabled | '
'${perTier['stable']} | ${perTier['beta']} | '
'${perTier['experimental']} | $disabled |',
);
}
buffer
..writeln(
'| `total` | $total | $totalEnabled | ${tierTotals['stable']} | '
'${tierTotals['beta']} | ${tierTotals['experimental']} | $totalDisabled |',
)
..writeln();
for (final platform in _platformOrder) {
buffer
..writeln('## ${_titleCase(platform)}')
..writeln()
..writeln('| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |')
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
for (final record in manifest.recordsFor(platform)) {
final modes = record.buildModes.isEmpty
? '-'
: _escapeMarkdown(record.buildModes.join(', '));
final state = record.enabled ? 'enabled' : 'disabled';
buffer.writeln(
'| `${_escapeMarkdown(record.module)}` | '
'`${_escapeMarkdown(record.name)}` | $state | '
'`${_escapeMarkdown(record.releaseTier)}` | '
'`$modes` | '
'`${_escapeMarkdown(record.uiSurface)}` | '
'${_escapeMarkdown(record.description)} |',
);
}
buffer.writeln();
}
return buffer.toString();
}
String _renderFeatureRoadmap(FeatureManifest manifest, GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate UI Feature Flag Roadmap')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## 规划规则')
..writeln()
..writeln(
'- `release_policy` 决定 build mode 的总开关上限:`debug` 可见 '
'`stable / beta / experimental``profile` 可见 `stable / beta`'
'`release` 仅可见 `stable`。',
)
..writeln('- 单个 flag 的交付状态由三层共同决定:`enabled`、`release_tier`、`build_modes`。')
..writeln(
'- `enabled: false` 或 `build_modes: []` 的项,会在文档里继续保留,'
'但不会进入当前 build mode 的用户可见范围。',
)
..writeln()
..writeln('## Build Visibility Summary')
..writeln()
..writeln(
'| 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed |',
)
..writeln('| --- | --- | --- | --- | --- |');
for (final platform in _platformOrder) {
final debugVisible = manifest.visibleFlags(platform, 'debug').length;
final profileVisible = manifest.visibleFlags(platform, 'profile').length;
final releaseVisible = manifest.visibleFlags(platform, 'release').length;
final suppressed = manifest
.recordsFor(platform)
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
.length;
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | $debugVisible | $profileVisible | '
'$releaseVisible | $suppressed |',
);
}
buffer
..writeln()
..writeln('## Release Baseline')
..writeln()
..writeln('| 平台 | 数量 | Flag 列表 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final releaseFlags = manifest.visibleFlags(platform, 'release');
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${releaseFlags.length} | '
'${_flagList(releaseFlags)} |',
);
}
buffer
..writeln()
..writeln('## Profile-only Lane')
..writeln()
..writeln('| 平台 | 数量 | 相比 Release 新增 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final profileOnly = _difference(
manifest.visibleFlags(platform, 'profile'),
manifest.visibleFlags(platform, 'release'),
);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${profileOnly.length} | '
'${_flagList(profileOnly)} |',
);
}
buffer
..writeln()
..writeln('## Debug-only Experimental Lane')
..writeln()
..writeln('| 平台 | 数量 | 相比 Profile 新增 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final debugOnly = _difference(
manifest.visibleFlags(platform, 'debug'),
manifest.visibleFlags(platform, 'profile'),
);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${debugOnly.length} | '
'${_flagList(debugOnly)} |',
);
}
buffer
..writeln()
..writeln('## Explicitly Suppressed')
..writeln()
..writeln('| 平台 | 数量 | Flag 列表 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final suppressed = manifest
.recordsFor(platform)
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
.toList(growable: false);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${suppressed.length} | '
'${_flagList(suppressed)} |',
);
}
buffer
..writeln()
..writeln('## Tier Inventory')
..writeln();
for (final platform in _platformOrder) {
buffer.writeln('### ${_titleCase(platform)}');
buffer.writeln();
for (final tier in [..._tierOrder, 'disabled']) {
final records = manifest
.recordsFor(platform)
.where((record) {
if (!record.enabled) {
return tier == 'disabled';
}
return record.releaseTier == tier;
})
.toList(growable: false);
if (records.isEmpty) {
continue;
}
buffer.writeln('- `$tier`: ${_flagList(records)}');
}
buffer.writeln();
}
return buffer.toString();
}
String _renderReleaseNotes(FeatureManifest manifest, GitSnapshot git) {
final profileOnlyAll = <FeatureFlagRecord>[];
final debugOnlyAll = <FeatureFlagRecord>[];
for (final platform in _platformOrder) {
profileOnlyAll.addAll(
_difference(
manifest.visibleFlags(platform, 'profile'),
manifest.visibleFlags(platform, 'release'),
),
);
debugOnlyAll.addAll(
_difference(
manifest.visibleFlags(platform, 'debug'),
manifest.visibleFlags(platform, 'profile'),
),
);
}
final categorized = _categorizeCommits(git.commits);
final buffer = StringBuffer()
..writeln('# XWorkmate Release Notes')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Git Snapshot')
..writeln()
..writeln('| 字段 | 值 |')
..writeln('| --- | --- |')
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
..writeln(
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
)
..writeln('| Commit Count | ${git.commits.length} |')
..writeln()
..writeln('## Feature Snapshot')
..writeln()
..writeln('| 平台 | Debug | Profile | Release | Suppressed |')
..writeln('| --- | --- | --- | --- | --- |');
for (final platform in _platformOrder) {
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | '
'${manifest.visibleFlags(platform, 'debug').length} | '
'${manifest.visibleFlags(platform, 'profile').length} | '
'${manifest.visibleFlags(platform, 'release').length} | '
'${manifest.recordsFor(platform).where((record) => !record.visibleIn('debug', manifest.releasePolicy)).length} |',
);
}
buffer
..writeln()
..writeln('## Current Focus')
..writeln()
..writeln(
'- `release` 当前面向用户暴露 ${manifest.visibleFlagCount('release')} 个 UI feature flags'
'全部来自 `stable` tier。',
)
..writeln(
'- `profile` 相比 `release` 额外开放 ${profileOnlyAll.length} 个预发布条目:'
' ${_flagList(profileOnlyAll, includePlatform: true)}',
)
..writeln(
'- `debug` 相比 `profile` 额外开放 ${debugOnlyAll.length} 个实验条目:'
' ${_flagList(debugOnlyAll, includePlatform: true)}',
)
..writeln()
..writeln('## Commit Highlights')
..writeln();
if (git.commits.isEmpty) {
buffer.writeln('当前比较范围没有可渲染的 commits。');
return buffer.toString();
}
for (final entry in categorized.entries) {
if (entry.value.isEmpty) {
continue;
}
buffer.writeln('### ${entry.key}');
buffer.writeln();
for (final commit in entry.value) {
buffer.writeln(
'- `${_escapeMarkdown(commit.hash)}` ${_escapeMarkdown(commit.subject)}',
);
}
buffer.writeln();
}
return buffer.toString();
}
String _renderChangelog(GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate Changelog')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Git Snapshot')
..writeln()
..writeln('| 字段 | 值 |')
..writeln('| --- | --- |')
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
..writeln(
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
)
..writeln()
..writeln('## Recent Releases')
..writeln()
..writeln('| Version | Date | Branch | Tag |')
..writeln('| --- | --- | --- | --- |');
for (final release in git.recentReleases) {
buffer.writeln(
'| `${_escapeMarkdown(release.version)}` | '
'`${_escapeMarkdown(release.date)}` | '
'`${_escapeMarkdown(release.branch)}` | '
'`${_escapeMarkdown(release.tag)}` |',
);
}
buffer
..writeln()
..writeln('## Commits')
..writeln()
..writeln('| Hash | Date | Author | Subject |')
..writeln('| --- | --- | --- | --- |');
if (git.commits.isEmpty) {
buffer.writeln(
'| `-` | `-` | `-` | No commits found for the selected range |',
);
return buffer.toString();
}
for (final commit in git.commits) {
buffer.writeln(
'| `${_escapeMarkdown(commit.hash)}` | '
'`${_escapeMarkdown(commit.date)}` | '
'${_escapeMarkdown(commit.author)} | '
'${_escapeMarkdown(commit.subject)} |',
);
}
return buffer.toString();
}
String _generatedPreamble(GitSnapshot git) {
return [
'> Generated by `tool/render_release_docs.dart`',
'> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)',
'> Generated at: `${_escapeMarkdown(git.generatedAt)}`',
].join('\n');
}
String _flagList(
List<FeatureFlagRecord> records, {
bool includePlatform = false,
}) {
if (records.isEmpty) {
return '-';
}
return records
.map(
(record) =>
'`${_escapeMarkdown(includePlatform ? record.qualifiedId : record.id)}`',
)
.join(', ');
}
List<FeatureFlagRecord> _difference(
List<FeatureFlagRecord> left,
List<FeatureFlagRecord> right,
) {
final rightIds = right.map((record) => record.id).toSet();
return left
.where((record) => !rightIds.contains(record.id))
.toList(growable: false);
}
Map<String, List<GitCommit>> _categorizeCommits(List<GitCommit> commits) {
final ordered = <String, List<GitCommit>>{
'Features': <GitCommit>[],
'Fixes': <GitCommit>[],
'Build / Release': <GitCommit>[],
'Docs': <GitCommit>[],
'Tests': <GitCommit>[],
'Refactors': <GitCommit>[],
'Merges': <GitCommit>[],
'Other': <GitCommit>[],
};
for (final commit in commits) {
final subject = commit.subject.toLowerCase();
final bucket = switch (true) {
_ when subject.startsWith('merge ') => 'Merges',
_
when subject.startsWith('feat') ||
subject.startsWith('add ') ||
subject.startsWith('implement ') =>
'Features',
_ when subject.startsWith('fix') || subject.contains(' bug') => 'Fixes',
_ when subject.startsWith('docs') || subject.startsWith('readme') =>
'Docs',
_ when subject.startsWith('test') => 'Tests',
_ when subject.startsWith('refactor') => 'Refactors',
_
when subject.startsWith('build') ||
subject.startsWith('release') ||
subject.startsWith('ci') ||
subject.startsWith('package') ||
subject.contains('workflow') =>
'Build / Release',
_ => 'Other',
};
ordered[bucket]!.add(commit);
}
return ordered;
}
String _inlineValue(String value) {
final normalized = value.trim().isEmpty ? '-' : value.trim();
return '`${_escapeMarkdown(normalized)}`';
}
String _escapeMarkdown(String value) {
return value.replaceAll('|', r'\|');
}
String _titleCase(String value) {
if (value.isEmpty) {
return value;
}
return '${value[0].toUpperCase()}${value.substring(1)}';
}
class FeatureManifest {
FeatureManifest({required this.releasePolicy, required this.records});
factory FeatureManifest.load() {
final yaml = loadYaml(File('config/feature_flags.yaml').readAsStringSync());
final root = yaml as YamlMap;
final releasePolicyRoot = root['release_policy'] as YamlMap? ?? YamlMap();
final releasePolicy = <String, List<String>>{
for (final buildMode in _buildModeOrder)
buildMode: (releasePolicyRoot[buildMode] as YamlList? ?? YamlList())
.map((value) => value.toString())
.toList(growable: false),
};
final records = <FeatureFlagRecord>[];
for (final platform in _platformOrder) {
final platformRoot = root[platform] as YamlMap?;
if (platformRoot == null) {
continue;
}
for (final moduleEntry in platformRoot.entries) {
final module = moduleEntry.key.toString();
final featureRoot = moduleEntry.value as YamlMap;
for (final featureEntry in featureRoot.entries) {
final name = featureEntry.key.toString();
final raw = featureEntry.value as YamlMap;
records.add(
FeatureFlagRecord(
platform: platform,
module: module,
name: name,
enabled: raw['enabled'] == true,
releaseTier: raw['release_tier'].toString(),
buildModes: (raw['build_modes'] as YamlList? ?? YamlList())
.map((value) => value.toString())
.toList(growable: false),
description: raw['description'].toString(),
uiSurface: raw['ui_surface'].toString(),
),
);
}
}
}
return FeatureManifest(releasePolicy: releasePolicy, records: records);
}
final Map<String, List<String>> releasePolicy;
final List<FeatureFlagRecord> records;
List<FeatureFlagRecord> recordsFor(String platform) {
return records
.where((record) => record.platform == platform)
.toList(growable: false);
}
List<FeatureFlagRecord> visibleFlags(String platform, String buildMode) {
return recordsFor(platform)
.where((record) => record.visibleIn(buildMode, releasePolicy))
.toList(growable: false);
}
int visibleFlagCount(String buildMode) {
return _platformOrder.fold<int>(
0,
(total, platform) => total + visibleFlags(platform, buildMode).length,
);
}
}
class FeatureFlagRecord {
const FeatureFlagRecord({
required this.platform,
required this.module,
required this.name,
required this.enabled,
required this.releaseTier,
required this.buildModes,
required this.description,
required this.uiSurface,
});
final String platform;
final String module;
final String name;
final bool enabled;
final String releaseTier;
final List<String> buildModes;
final String description;
final String uiSurface;
String get id => '$module.$name';
String get qualifiedId => '$platform.$module.$name';
bool visibleIn(String buildMode, Map<String, List<String>> releasePolicy) {
final allowedTiers = releasePolicy[buildMode] ?? const <String>[];
return enabled &&
buildModes.contains(buildMode) &&
allowedTiers.contains(releaseTier);
}
}
class GitSnapshot {
GitSnapshot({
required this.branch,
required this.headShort,
required this.headLong,
required this.headTags,
required this.latestTag,
required this.previousTag,
required this.comparisonRangeLabel,
required this.generatedAt,
required this.commits,
required this.recentTags,
required this.recentReleases,
});
factory GitSnapshot.capture() {
final branch =
_git(['branch', '--show-current'], allowFailure: true).trim().isEmpty
? 'detached-head'
: _git(['branch', '--show-current']);
final headShort = _git(['rev-parse', '--short', 'HEAD']);
final headLong = _git(['rev-parse', 'HEAD']);
final headTags = _gitLines(['tag', '--points-at', 'HEAD']);
final allTags = _gitTagRefs();
final recentTags = allTags.take(5).toList(growable: false);
final recentReleases = _gitReleaseRefs(
allTags,
).take(8).toList(growable: false);
final latestTag = recentTags.isEmpty ? null : recentTags.first.name;
String? previousTag;
String comparisonRangeLabel;
String? comparisonRange;
if (headTags.isNotEmpty) {
previousTag = recentTags
.map((tag) => tag.name)
.firstWhere((tag) => !headTags.contains(tag), orElse: () => '')
.trim();
if (previousTag.isEmpty) {
previousTag = null;
}
final activeTag = headTags.first;
comparisonRange = previousTag == null ? null : '$previousTag..$activeTag';
comparisonRangeLabel = comparisonRange ?? activeTag;
} else if (latestTag != null) {
previousTag = recentTags.length > 1 ? recentTags[1].name : null;
comparisonRange = '$latestTag..HEAD';
comparisonRangeLabel = comparisonRange;
} else {
comparisonRange = null;
comparisonRangeLabel = 'HEAD (latest 20 commits)';
}
final commits = _gitCommitLog(comparisonRange);
return GitSnapshot(
branch: branch,
headShort: headShort,
headLong: headLong,
headTags: headTags,
latestTag: latestTag,
previousTag: previousTag,
comparisonRangeLabel: comparisonRangeLabel,
generatedAt: DateTime.now().toIso8601String(),
commits: commits,
recentTags: recentTags,
recentReleases: recentReleases,
);
}
final String branch;
final String headShort;
final String headLong;
final List<String> headTags;
final String? latestTag;
final String? previousTag;
final String comparisonRangeLabel;
final String generatedAt;
final List<GitCommit> commits;
final List<GitTagRef> recentTags;
final List<GitReleaseRef> recentReleases;
}
class GitCommit {
const GitCommit({
required this.hash,
required this.date,
required this.author,
required this.subject,
});
final String hash;
final String date;
final String author;
final String subject;
}
class GitTagRef {
const GitTagRef({required this.name, required this.date});
final String name;
final String date;
}
class GitReleaseRef {
const GitReleaseRef({
required this.version,
required this.date,
required this.branch,
required this.tag,
});
final String version;
final String date;
final String branch;
final String tag;
}
List<GitCommit> _gitCommitLog(String? comparisonRange) {
final args = <String>[
'log',
'--date=short',
'--pretty=format:%h%x09%ad%x09%an%x09%s',
];
if (comparisonRange == null) {
args.addAll(<String>['-n', '20']);
} else {
args.add(comparisonRange);
}
final lines = _gitLines(args, allowFailure: true);
return lines
.map((line) => line.split('\t'))
.where((parts) => parts.length >= 4)
.map(
(parts) => GitCommit(
hash: parts[0],
date: parts[1],
author: parts[2],
subject: parts.sublist(3).join('\t'),
),
)
.toList(growable: false);
}
List<GitTagRef> _gitTagRefs() {
final lines = _gitLines(<String>[
'for-each-ref',
'--sort=-creatordate',
'--format=%(refname:short)%09%(creatordate:short)',
'refs/tags',
], allowFailure: true);
return lines
.map((line) => line.split('\t'))
.where((parts) => parts.length >= 2)
.map((parts) => GitTagRef(name: parts[0], date: parts[1]))
.toList(growable: false);
}
List<GitReleaseRef> _gitReleaseRefs(List<GitTagRef> tags) {
final releases = <String, GitReleaseRef>{};
for (final tag in tags) {
final version = _normalizeReleaseVersion(tag.name);
if (version == null) {
continue;
}
releases[version] = GitReleaseRef(
version: version,
date: tag.date,
branch: _releaseBranchName(version),
tag: tag.name,
);
}
final branchLines = _gitLines(<String>[
'for-each-ref',
'--sort=-committerdate',
'--format=%(refname:short)%09%(committerdate:short)',
'refs/heads/release',
], allowFailure: true);
for (final line in branchLines) {
final parts = line.split('\t');
if (parts.length < 2) {
continue;
}
final branch = parts[0];
final date = parts[1];
final version = _normalizeReleaseVersion(branch);
if (version == null) {
continue;
}
releases[version] = GitReleaseRef(
version: version,
date: releases[version]?.date ?? date,
branch: branch,
tag: releases[version]?.tag ?? '-',
);
}
final values = releases.values.toList(growable: false);
values.sort(
(left, right) => _compareReleaseVersions(right.version, left.version),
);
return values;
}
String? _normalizeReleaseVersion(String refName) {
final match = RegExp(r'^(?:release/)?(v\d+(?:\.\d+)*)$').firstMatch(refName);
return match?.group(1);
}
String _releaseBranchName(String version) => 'release/$version';
int _compareReleaseVersions(String left, String right) {
final leftParts = _releaseVersionParts(left);
final rightParts = _releaseVersionParts(right);
final maxLength = leftParts.length > rightParts.length
? leftParts.length
: rightParts.length;
for (var index = 0; index < maxLength; index += 1) {
final leftPart = index < leftParts.length ? leftParts[index] : 0;
final rightPart = index < rightParts.length ? rightParts[index] : 0;
if (leftPart != rightPart) {
return leftPart.compareTo(rightPart);
}
}
return 0;
}
List<int> _releaseVersionParts(String version) {
return version
.replaceFirst('v', '')
.split('.')
.map(int.parse)
.toList(growable: false);
}
String _git(List<String> args, {bool allowFailure = false}) {
final result = Process.runSync('git', args);
if (result.exitCode != 0) {
if (allowFailure) {
return '';
}
throw ProcessException(
'git',
args,
(result.stderr as String).trim(),
result.exitCode,
);
}
return (result.stdout as String).trim();
}
List<String> _gitLines(List<String> args, {bool allowFailure = false}) {
final output = _git(args, allowFailure: allowFailure);
if (output.trim().isEmpty) {
return const <String>[];
}
return output
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList(growable: false);
}