| // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| /// Helper functionality for invoking Git. | |
| library; | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'dart:typed_data'; | |
| import 'package:collection/collection.dart'; | |
| import 'package:pub_semver/pub_semver.dart'; | |
| import 'command_runner.dart'; | |
| import 'exceptions.dart'; | |
| import 'io.dart'; | |
| import 'log.dart' as log; | |
| import 'path.dart'; | |
| import 'platform_info.dart'; | |
| import 'utils.dart'; | |
| /// An exception thrown because a git command failed. | |
| class GitException implements ApplicationException { | |
| /// The arguments to the git command. | |
| final List<String> args; | |
| /// The standard error emitted by git. | |
| final dynamic stderr; | |
| /// The standard out emitted by git. | |
| final dynamic stdout; | |
| /// The error code | |
| final int exitCode; | |
| @override | |
| String get message => | |
| 'Git error. Command: `git ${args.join(' ')}`\n' | |
| 'stdout: ${stdout is String ? stdout : '<binary>'}\n' | |
| 'stderr: ${stderr is String ? stderr : '<binary>'}\n' | |
| 'exit code: $exitCode'; | |
| GitException(Iterable<String> args, this.stdout, this.stderr, this.exitCode) | |
| : args = args.toList(); | |
| @override | |
| String toString() => message; | |
| } | |
| /// Tests whether or not the git command-line app is available for use. | |
| bool get isInstalled => command != null; | |
| /// Splits the [output] of a git -z command at \0. | |
| /// | |
| /// The first [skipPrefix] bytes of each substring will be ignored (useful for | |
| /// `git status -z`). If there are not enough bytes to skip, throws a | |
| /// [FormatException]. | |
| List<Uint8List> splitZeroTerminated(Uint8List output, {int skipPrefix = 0}) { | |
| final result = <Uint8List>[]; | |
| var start = 0; | |
| for (var i = 0; i < output.length; i++) { | |
| if (output[i] != 0) { | |
| continue; | |
| } | |
| if (start + skipPrefix > i) { | |
| throw FormatException('Substring too short for prefix at $start'); | |
| } | |
| result.add( | |
| Uint8List.sublistView( | |
| output, | |
| // The first 3 bytes are the modification status. | |
| // Skip those. | |
| start + skipPrefix, | |
| i, | |
| ), | |
| ); | |
| start = i + 1; | |
| } | |
| return result; | |
| } | |
| /// Run a git process with [args] from [workingDir]. | |
| /// | |
| /// Returns the stdout if it succeeded. Completes to ans exception if it failed. | |
| Future<String> run( | |
| List<String> args, { | |
| String? workingDir, | |
| Map<String, String>? environment, | |
| Encoding stdoutEncoding = systemEncoding, | |
| Encoding stderrEncoding = systemEncoding, | |
| }) async { | |
| if (!isInstalled) { | |
| fail( | |
| 'Cannot find a Git executable.\n' | |
| 'Please ensure Git is correctly installed.', | |
| ); | |
| } | |
| log.muteProgress(); | |
| try { | |
| final result = await runProcess( | |
| command!, | |
| args, | |
| workingDir: workingDir, | |
| environment: {...?environment, ..._gitEnvironment}, | |
| stdoutEncoding: stdoutEncoding, | |
| stderrEncoding: stderrEncoding, | |
| ); | |
| if (!result.success) { | |
| throw GitException(args, result.stdout, result.stderr, result.exitCode); | |
| } | |
| return result.stdout; | |
| } finally { | |
| log.unmuteProgress(); | |
| } | |
| } | |
| /// Like [run], but synchronous. | |
| String runSync( | |
| List<String> args, { | |
| String? workingDir, | |
| Map<String, String>? environment, | |
| Encoding stdoutEncoding = systemEncoding, | |
| Encoding stderrEncoding = systemEncoding, | |
| }) { | |
| if (!isInstalled) { | |
| fail( | |
| 'Cannot find a Git executable.\n' | |
| 'Please ensure Git is correctly installed.', | |
| ); | |
| } | |
| final result = runProcessSync( | |
| command!, | |
| args, | |
| workingDir: workingDir, | |
| environment: {...?environment, ..._gitEnvironment}, | |
| stdoutEncoding: stdoutEncoding, | |
| stderrEncoding: stderrEncoding, | |
| ); | |
| if (!result.success) { | |
| throw GitException(args, result.stdout, result.stderr, result.exitCode); | |
| } | |
| return result.stdout; | |
| } | |
| /// Like [run], but synchronous. Returns raw stdout as `Uint8List`. | |
| Uint8List runSyncBytes( | |
| List<String> args, { | |
| String? workingDir, | |
| Map<String, String>? environment, | |
| Encoding stderrEncoding = systemEncoding, | |
| }) { | |
| if (!isInstalled) { | |
| fail( | |
| 'Cannot find a Git executable.\n' | |
| 'Please ensure Git is correctly installed.', | |
| ); | |
| } | |
| final result = runProcessSyncBytes( | |
| command!, | |
| args, | |
| workingDir: workingDir, | |
| environment: {...?environment, ..._gitEnvironment}, | |
| stderrEncoding: stderrEncoding, | |
| ); | |
| if (!result.success) { | |
| throw GitException(args, result.stdout, result.stderr, result.exitCode); | |
| } | |
| return result.stdout; | |
| } | |
| /// The name of the git command-line app, or `null` if Git could not be found on | |
| /// the user's PATH. | |
| final String? command = ['git', 'git.cmd'].firstWhereOrNull(_tryGitCommand); | |
| /// Returns the root of the git repo [dir] belongs to. Returns `null` if not | |
| /// in a git repo or git is not installed. | |
| String? repoRoot(String dir) { | |
| if (isInstalled) { | |
| try { | |
| return p.normalize( | |
| runSync(['rev-parse', '--show-toplevel'], workingDir: dir).trim(), | |
| ); | |
| } on GitException { | |
| // Not in a git folder. | |
| return null; | |
| } | |
| } | |
| return null; | |
| } | |
| /// '--recourse-submodules' was introduced in Git 2.14 | |
| /// (https://git-scm.com/book/en/v2/Git-Tools-Submodules). | |
| final _minSupportedGitVersion = Version(2, 14, 0); | |
| /// Checks whether [command] is the Git command for this computer. | |
| bool _tryGitCommand(String command) { | |
| // If "git --version" prints something familiar, git is working. | |
| try { | |
| final result = runProcessSync(command, [ | |
| '--version', | |
| ], environment: _gitEnvironment); | |
| final output = result.stdout; | |
| // Some users may have configured commands such as autorun, which may | |
| // produce additional output, so we need to look for "git version" | |
| // in every line of the output. | |
| final match = RegExp( | |
| r'^git version (\d+)\.(\d+)\..*$', | |
| multiLine: true, | |
| ).matchAsPrefix(output); | |
| if (match == null) return false; | |
| final versionString = match[0]!.substring('git version '.length); | |
| // Git seems to use many parts in the version number. We just check the | |
| // first two. | |
| final major = int.parse(match[1]!); | |
| final minor = int.parse(match[2]!); | |
| if (Version(major, minor, 0) < _minSupportedGitVersion) { | |
| // We just warn here, as some features might work with older versions of | |
| // git. | |
| log.warning(''' | |
| You have a very old version of git (version $versionString), | |
| for $topLevelProgram it is recommended to use git version 2.14 or newer. | |
| '''); | |
| } | |
| log.fine('Determined git command $command.'); | |
| return true; | |
| } on RunProcessException catch (err) { | |
| // If the process failed, they probably don't have it. | |
| log.error('Git command is not "$command": $err'); | |
| return false; | |
| } | |
| } | |
| /// The environment variables to force Git to use a UTF-8 locale and English | |
| /// messages, to prevent parsing failures. | |
| Map<String, String> get _gitEnvironment => { | |
| 'LANG': platform.isMacOS ? 'en_US.UTF-8' : 'C.UTF-8', | |
| 'LC_ALL': platform.isMacOS ? 'en_US.UTF-8' : 'C.UTF-8', | |
| 'LC_MESSAGES': platform.isMacOS ? 'en_US.UTF-8' : 'C.UTF-8', | |
| 'LANGUAGE': platform.isMacOS ? 'en_US.UTF-8' : 'C.UTF-8', | |
| }; |