Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .github/workflows/rfc-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
fetch-depth: 0
persist-credentials: false

- name: Setup Dart
Expand All @@ -38,14 +38,17 @@ jobs:
env:
BASE_REF: ${{ github.base_ref }}
run: |
BASE_TARGET="origin/${BASE_REF:-main}"
if ! git rev-parse --verify "$BASE_TARGET" >/dev/null 2>&1; then
BASE_TARGET="main"
fi
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_TARGET"...HEAD -- 'rfc/*.md' 2>/dev/null || true)
if [ -z "$FILES" ]; then
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_TARGET" -- 'rfc/*.md' 2>/dev/null || true)
TARGET_REF="${BASE_REF:-main}"
TARGET_REF="${TARGET_REF#origin/}"
if git rev-parse --verify "origin/$TARGET_REF" >/dev/null 2>&1; then
BASE_TARGET="origin/$TARGET_REF"
elif git rev-parse --verify "$TARGET_REF" >/dev/null 2>&1; then
BASE_TARGET="$TARGET_REF"
else
echo "::error::Cannot resolve base branch ref 'origin/$TARGET_REF' or '$TARGET_REF'."
exit 1
fi
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_TARGET"...HEAD -- 'rfc/*.md')
if [ -n "$FILES" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
{
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
fetch-depth: 0
persist-credentials: false

- name: Setup Dart
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate-rfc-number.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
fetch-depth: 0
persist-credentials: false

- name: Setup Dart
Expand Down
5 changes: 5 additions & 0 deletions lib/logprocess.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright 2026 The Flutter Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

export 'src/process_logger.dart';
1 change: 1 addition & 0 deletions lib/src/assigner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
import 'git_lister.dart';
import 'git_lister.dart' as git_lister;
import 'models/rfc_file.dart';
import 'process_runner.dart';

export 'git_lister.dart' show GitListFunction, defaultGitList;

Expand Down
76 changes: 39 additions & 37 deletions lib/src/git_lister.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
// found in the LICENSE file.

import 'dart:convert';
import 'dart:io' show Process, ProcessResult, stdout, stderr;
import 'dart:io' show Process, ProcessException, ProcessResult;

/// Signature for running an external process asynchronously.
typedef ProcessRunner =
Future<ProcessResult> Function(String executable, List<String> arguments);
import 'process_logger.dart';
import 'process_runner.dart';

/// Function signature for discovering RFC filenames in the main branch via git.
typedef GitListFunction = Future<Set<String>> Function({String baseBranch});
Expand All @@ -21,52 +20,55 @@ Set<String> parseLsTreeOutput(dynamic stdout) {
};
}

void _logGitError(ProcessResult result) {
stderr.writeln('exit code: ${result.exitCode}');
stdout.writeln('git ls-tree stdout:');
stdout.writeln(result.stdout);
stderr.writeln('git ls-tree stderr:');
stderr.writeln(result.stderr);
}

/// Discovers RFC filenames in main branch via git.
Future<Set<String>> defaultGitList({
String baseBranch = 'origin/main',
ProcessRunner processRunner = Process.run,
bool throwOnError = false,
}) async {
try {
final result = await processRunner('git', [
'ls-tree',
'-r',
'--name-only',
baseBranch,
'rfc/',
]);
if (result.exitCode == 0) {
return parseLsTreeOutput(result.stdout);
}
final trimmedBranch = baseBranch.trim();
final cleanBranch = trimmedBranch.replaceFirst(
RegExp(r'^(?:remotes\/)?(?:origin|upstream)\/'),
'',
);
final branchesToTry = [
trimmedBranch,
if (cleanBranch != trimmedBranch) cleanBranch,
];

final cleanBranch = baseBranch.replaceFirst(
RegExp(r'^(?:remotes\/)?(?:origin|upstream)\/'),
'',
);
if (cleanBranch != baseBranch) {
final locResult = await processRunner('git', [
ProcessResult? lastResult;
String? lastBranch;
for (final branch in branchesToTry) {
lastBranch = branch;
try {
final result = await processRunner('git', [
'ls-tree',
'-r',
'--name-only',
cleanBranch,
branch,
'rfc/',
]);
if (locResult.exitCode == 0) {
return parseLsTreeOutput(locResult.stdout);
if (result.exitCode == 0) {
return parseLsTreeOutput(result.stdout);
}
_logGitError(locResult);
} else {
_logGitError(result);
lastResult = result;
logProcessResult(result, command: 'git ls-tree');
} catch (e) {
logProcessError(e, command: 'git ls-tree');
if (throwOnError) rethrow;
}
} catch (e) {
stderr.writeln('git ls-tree exception: $e');
}

if (throwOnError && lastResult != null) {
final err = '${lastResult.stderr}'.trim();
throw ProcessException(
'git',
['ls-tree', '-r', '--name-only', lastBranch ?? trimmedBranch, 'rfc/'],
err.isNotEmpty
? err
: 'git ls-tree failed with exit code ${lastResult.exitCode}',
lastResult.exitCode,
);
}
return <String>{};
}
36 changes: 36 additions & 0 deletions lib/src/process_logger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2026 The Flutter Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io' show ProcessResult, stderr, stdout;

/// Logs the details of an external [ProcessResult] (exit code, stdout, stderr).
///
/// When [onLog] or [onError] are provided, log lines are dispatched to them;
/// otherwise, exit code and stderr output are sent to [stderr], and stdout
/// output is sent to [stdout].
void logProcessResult(
ProcessResult result, {
String command = 'process',
void Function(String message)? onLog,
void Function(String message)? onError,
}) {
final out = onLog ?? stdout.writeln;
final err = onError ?? stderr.writeln;

err('exit code: ${result.exitCode}');
out('$command stdout:');
out('${result.stdout}');
err('$command stderr:');
err('${result.stderr}');
}

/// Logs an exception encountered during process execution.
void logProcessError(
Object error, {
String command = 'process',
void Function(String message)? onError,
}) {
final err = onError ?? stderr.writeln;
err('$command exception: $error');
}
9 changes: 9 additions & 0 deletions lib/src/process_runner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright 2026 The Flutter Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io' show ProcessResult;

/// Signature for running an external process asynchronously.
typedef ProcessRunner =
Future<ProcessResult> Function(String executable, List<String> arguments);
41 changes: 37 additions & 4 deletions lib/src/validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io' show Process, ProcessException;
import 'dart:math';
import 'package:file/file.dart';
import 'package:path/path.dart' as p;

import 'git_lister.dart';
import 'git_lister.dart' as git_lister;
import 'github_annotation.dart';
import 'models/rfc_file.dart';
import 'process_runner.dart';

export 'git_lister.dart' show GitListFunction, defaultGitList;

Expand Down Expand Up @@ -64,7 +67,20 @@ class RfcValidator {
final FileSystem fs;
final GitListFunction gitList;

const RfcValidator({required this.fs, this.gitList = defaultGitList});
const RfcValidator({
required this.fs,
this.gitList = RfcValidator.defaultGitList,
});

/// Discovers RFC filenames in base branch via git, throwing on failure.
static Future<Set<String>> defaultGitList({
String baseBranch = 'origin/main',
ProcessRunner processRunner = Process.run,
}) => git_lister.defaultGitList(
baseBranch: baseBranch,
processRunner: processRunner,
throwOnError: true,
);

/// Runs semantic validation on all RFC files in the repository.
///
Expand Down Expand Up @@ -131,9 +147,26 @@ class RfcValidator {
}

// Discover and index base branch RFCs if --check-base is requested.
final baseBranchCategories = <String, _BaseBranchCategory>{
if (checkBase) ...await _indexBaseBranchBySubsystem(baseBranch),
};
Map<String, _BaseBranchCategory> baseBranchCategories = const {};
if (checkBase) {
try {
baseBranchCategories = await _indexBaseBranchBySubsystem(baseBranch);
} catch (e) {
final message = switch (e) {
ProcessException(:final message, :final errorCode) =>
message.isNotEmpty ? message : 'Exit code $errorCode',
_ => '$e',
};
errors.add(
ValidationError(
filePath: '',
message:
"Failed to discover RFCs on base branch '$baseBranch': $message",
),
);
return (isSuccess: false, errors: errors);
}
}

// For each category AAA:
// - verify no duplicates
Expand Down
89 changes: 68 additions & 21 deletions test/git_lister_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io' show ProcessException;

import 'package:rfc_tools/src/git_lister.dart';
import 'package:test/test.dart';

Expand Down Expand Up @@ -31,31 +33,76 @@ void main() {
);
});

test('returns empty set on non-zero exit code', () async {
final runner = MockProcessRunner(
exitCode: 128,
stderr: 'fatal: not a valid object name',
);
test(
'returns empty set on non-zero exit code when throwOnError is false',
() async {
final runner = MockProcessRunner(
exitCode: 128,
stderr: 'fatal: not a valid object name',
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
);
final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
throwOnError: false,
);

expect(files, isEmpty);
});
expect(files, isEmpty);
},
);

test('returns empty set when process throws', () async {
final runner = MockProcessRunner(
exceptionToThrow: Exception('process failed'),
);
test(
'throws ProcessException on non-zero exit code when throwOnError is true',
() async {
final runner = MockProcessRunner(
exitCode: 128,
stderr: 'fatal: not a valid object name',
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
);
expect(
() => defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
throwOnError: true,
),
throwsA(isA<ProcessException>()),
);
},
);

expect(files, isEmpty);
});
test(
'returns empty set when processRunner throws and throwOnError is false',
() async {
final runner = MockProcessRunner(
exceptionToThrow: Exception('process failed'),
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
throwOnError: false,
);

expect(files, isEmpty);
},
);

test(
'rethrows when processRunner throws and throwOnError is true',
() async {
final runner = MockProcessRunner(
exceptionToThrow: Exception('process failed'),
);

expect(
() => defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
throwOnError: true,
),
throwsA(isA<Exception>()),
);
},
);
});
}
Loading
Loading