diff --git a/packages/shorebird_cli/lib/src/executables/git.dart b/packages/shorebird_cli/lib/src/executables/git.dart index 5c092be7..62313540 100644 --- a/packages/shorebird_cli/lib/src/executables/git.dart +++ b/packages/shorebird_cli/lib/src/executables/git.dart @@ -1,4 +1,4 @@ -// cspell:words unmatch +// cspell:words unmatch githubstatus import 'dart:io'; import 'package:scoped_deps/scoped_deps.dart'; @@ -10,6 +10,44 @@ final gitRef = create(Git.new); /// The [Git] instance available in the current zone. Git get git => read(gitRef); +/// {@template git_server_unreachable_exception} +/// Exception thrown when a git command fails due to a server error. +/// {@endtemplate} +class GitServerUnreachableException extends ProcessException { + /// {@macro git_server_unreachable_exception} + GitServerUnreachableException( + super.executable, + super.arguments, [ + super.message = '', + super.errorCode = 0, + ]); +} + +/// Pattern that matches HTTP server errors (500, 502, 503, 504) or "Internal +/// Server Error" in git output. +final _serverErrorPattern = RegExp( + 'The requested URL returned error: (500|502|503|504)|Internal Server Error', +); + +/// Pattern that extracts the hostname from a git remote URL in stderr. +/// Matches URLs like `https://github.com/org/repo.git/`. +final _remoteHostPattern = RegExp(r"https?://([^/']+)"); + +String _buildServerErrorMessage(String stderr) { + final hostMatch = _remoteHostPattern.firstMatch(stderr); + final host = hostMatch?.group(1); + final serverName = host ?? 'the remote git server'; + + final buffer = StringBuffer('Unable to reach $serverName.'); + if (host == 'github.com') { + buffer.write( + '\nIf your network connection is working, check ' + 'https://www.githubstatus.com for service status.', + ); + } + return buffer.toString(); +} + /// A wrapper around all git related functionality. class Git { /// Name of the git executable. @@ -26,10 +64,19 @@ class Git { workingDirectory: workingDirectory, ); if (result.exitCode != 0) { + final stderr = '${result.stderr}'; + if (_serverErrorPattern.hasMatch(stderr)) { + throw GitServerUnreachableException( + executable, + arguments, + _buildServerErrorMessage(stderr), + result.exitCode, + ); + } throw ProcessException( executable, arguments, - '${result.stderr}', + stderr, result.exitCode, ); } diff --git a/packages/shorebird_cli/test/src/executables/git_test.dart b/packages/shorebird_cli/test/src/executables/git_test.dart index d04e8b6a..504adb31 100644 --- a/packages/shorebird_cli/test/src/executables/git_test.dart +++ b/packages/shorebird_cli/test/src/executables/git_test.dart @@ -1,3 +1,4 @@ +// cspell:words githubstatus import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; @@ -37,6 +38,82 @@ void main() { when(() => processResult.exitCode).thenReturn(ExitCode.success.code); }); + group('server error detection', () { + test('includes github.com and status link for GitHub URLs', () async { + when(() => processResult.exitCode).thenReturn(128); + when(() => processResult.stderr).thenReturn( + "fatal: unable to access 'https://github.com/" + "shorebirdtech/flutter.git/': " + 'The requested URL returned error: 500', + ); + expect( + () => runWithOverrides( + () => git.clone( + url: 'https://github.com/shorebirdtech/flutter.git', + outputDirectory: './output', + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('github.com'), contains('githubstatus.com')), + ), + ), + ); + }); + + test('includes host name for non-GitHub URLs', () async { + when(() => processResult.exitCode).thenReturn(128); + when(() => processResult.stderr).thenReturn( + "fatal: unable to access 'https://gitlab.com/" + "org/repo.git/': " + 'The requested URL returned error: 502', + ); + expect( + () => runWithOverrides(() => git.fetch(directory: 'repo')), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('gitlab.com'), + isNot(contains('githubstatus.com')), + ), + ), + ), + ); + }); + + test('falls back to generic message when no URL in stderr', () async { + when(() => processResult.exitCode).thenReturn(128); + when( + () => processResult.stderr, + ).thenReturn('remote: Internal Server Error'); + expect( + () => runWithOverrides(() => git.fetch(directory: 'repo')), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('the remote git server'), + ), + ), + ); + }); + + test('throws ProcessException for non-server errors', () async { + when(() => processResult.exitCode).thenReturn(128); + when( + () => processResult.stderr, + ).thenReturn('fatal: repository not found'); + expect( + () => runWithOverrides(() => git.fetch(directory: 'repo')), + throwsA(isA()), + ); + }); + }); + group('clone', () { const url = 'https://github.com/shorebirdtech/shorebird'; const outputDirectory = './output';