| // Copyright (c) 2015, 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. | |
| /// Support for interacting with a git repository. | |
| library; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:analyzer/src/util/glob.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'logger.dart'; | |
| /// A representation of the differences between two blobs. | |
| class BlobDiff { | |
| /// The regular expression used to identify the beginning of a hunk. | |
| static final RegExp hunkHeaderRegExp = RegExp( | |
| r'@@ -([0-9]+)(?:,[0-9]+)? \+([0-9]+)(?:,[0-9]+)? @@', | |
| ); | |
| /// A list of the hunks in the diff. | |
| List<DiffHunk> hunks = <DiffHunk>[]; | |
| /// Initialize a newly created blob diff by parsing the result of the git diff | |
| /// command (the [input]). | |
| /// | |
| /// This is only intended to be invoked from [GitRepository.getBlobDiff]. | |
| new _(List<String> input) { | |
| _parseInput(input); | |
| } | |
| /// Parse the result of the git diff command (the [input]). | |
| void _parseInput(List<String> input) { | |
| for (var line in input) { | |
| _parseLine(line); | |
| } | |
| } | |
| /// Parse a single [line] from the result of the git diff command. | |
| void _parseLine(String line) { | |
| var currentHunk = hunks.isEmpty ? null : hunks.last; | |
| if (line.startsWith('@@')) { | |
| var match = hunkHeaderRegExp.matchAsPrefix(line)!; | |
| var srcLine = int.parse(match[1]!); | |
| var dstLine = int.parse(match[2]!); | |
| hunks.add(DiffHunk(srcLine, dstLine)); | |
| } else if (currentHunk != null && line.startsWith('+')) { | |
| currentHunk.addLines.add(line.substring(1)); | |
| } else if (currentHunk != null && line.startsWith('-')) { | |
| currentHunk.removeLines.add(line.substring(1)); | |
| } | |
| } | |
| } | |
| /// A representation of the differences between two commits. | |
| class CommitDelta { | |
| /// The length (in characters) of a SHA. | |
| static final int SHA_LENGTH = 40; | |
| /// The code-point for a colon (':'). | |
| static final int COLON = ':'.codeUnitAt(0); | |
| /// The code-point for a nul character. | |
| static final int NUL = 0; | |
| /// The code-point for a tab. | |
| static final int TAB = '\t'.codeUnitAt(0); | |
| /// The repository from which the commits were taken. | |
| final GitRepository repository; | |
| /// The records of the files that were changed. | |
| final List<DiffRecord> diffRecords = <DiffRecord>[]; | |
| /// Initialize a newly created representation of the differences between two | |
| /// commits. The differences are computed by parsing the result of a git diff | |
| /// command (the [diffResults]). | |
| /// | |
| /// This is only intended to be invoked from [GitRepository.getBlobDiff]. | |
| new _(this.repository, String diffResults) { | |
| _parseInput(diffResults); | |
| } | |
| /// Return `true` if there are differences. | |
| bool get hasDiffs => diffRecords.isNotEmpty; | |
| /// Return the absolute paths of all of the files in this commit whose name | |
| /// matches the given [fileName]. | |
| Iterable<String> filesMatching(String fileName) { | |
| return diffRecords | |
| .where((DiffRecord record) => record.isFor(fileName)) | |
| .map((DiffRecord record) => record.srcPath!); | |
| } | |
| /// Remove any diffs for files that are either (a) outside the given | |
| /// [inclusionPaths], or (b) are files that do not match one of the given | |
| /// [globPatterns]. | |
| void filterDiffs(List<String> inclusionPaths, List<Glob> globPatterns) { | |
| diffRecords.retainWhere((DiffRecord record) { | |
| var filePath = (record.srcPath ?? record.dstPath)!; | |
| for (var inclusionPath in inclusionPaths) { | |
| if (path.isWithin(inclusionPath, filePath)) { | |
| for (var glob in globPatterns) { | |
| if (glob.matches(filePath)) { | |
| return true; | |
| } | |
| } | |
| } | |
| } | |
| return false; | |
| }); | |
| } | |
| /// Return the index of the first nul character in the given [string] that is | |
| /// at or after the given [start] index. | |
| int _findEnd(String string, int start) { | |
| var length = string.length; | |
| var end = start; | |
| while (end < length && string.codeUnitAt(end) != NUL) { | |
| end++; | |
| } | |
| return end; | |
| } | |
| /// Return the result of converting the given [relativePath] to an absolute | |
| /// path. The path is assumed to be relative to the root of the repository. | |
| String _makeAbsolute(String relativePath) { | |
| return path.join(repository.path, relativePath); | |
| } | |
| /// Parse all of the diff records in the given [input]. | |
| void _parseInput(String input) { | |
| var length = input.length; | |
| var start = 0; | |
| while (start < length) { | |
| start = _parseRecord(input, start); | |
| } | |
| } | |
| /// Parse a single record from the given [input], assuming that the record | |
| /// starts at the given [startIndex]. | |
| /// | |
| /// Each record is formatted as a sequence of fields. The fields are, from the | |
| /// left to the right: | |
| /// | |
| /// 1. a colon. | |
| /// 2. mode for "src"; 000000 if creation or unmerged. | |
| /// 3. a space. | |
| /// 4. mode for "dst"; 000000 if deletion or unmerged. | |
| /// 5. a space. | |
| /// 6. sha1 for "src"; 0{40} if creation or unmerged. | |
| /// 7. a space. | |
| /// 8. sha1 for "dst"; 0{40} if creation, unmerged or "look at work tree". | |
| /// 9. a space. | |
| /// 10. status, followed by optional "score" number. | |
| /// 11. a tab or a NUL when -z option is used. | |
| /// 12. path for "src" | |
| /// 13. a tab or a NUL when -z option is used; only exists for C or R. | |
| /// 14. path for "dst"; only exists for C or R. | |
| /// 15. an LF or a NUL when -z option is used, to terminate the record. | |
| int _parseRecord(String input, int startIndex) { | |
| // Skip the first five fields. | |
| startIndex += 15; | |
| // Parse field 6 | |
| var srcSha = input.substring(startIndex, startIndex + SHA_LENGTH); | |
| startIndex += SHA_LENGTH + 1; | |
| // Parse field 8 | |
| var dstSha = input.substring(startIndex, startIndex + SHA_LENGTH); | |
| startIndex += SHA_LENGTH + 1; | |
| // Parse field 10 | |
| var endIndex = _findEnd(input, startIndex); | |
| var status = input.substring(startIndex, endIndex); | |
| startIndex = endIndex + 1; | |
| // Parse field 12 | |
| endIndex = _findEnd(input, startIndex); | |
| var srcPath = _makeAbsolute(input.substring(startIndex, endIndex)); | |
| startIndex = endIndex + 1; | |
| // Parse field 14 | |
| String? dstPath; | |
| if (status.startsWith('C') || status.startsWith('R')) { | |
| endIndex = _findEnd(input, startIndex); | |
| dstPath = _makeAbsolute(input.substring(startIndex, endIndex)); | |
| } | |
| // Create the record. | |
| diffRecords.add( | |
| DiffRecord(repository, srcSha, dstSha, status, srcPath, dstPath), | |
| ); | |
| return endIndex + 1; | |
| } | |
| } | |
| /// Representation of a single diff hunk. | |
| class DiffHunk { | |
| /// The index of the first line that was changed in the src as returned by the | |
| /// diff command. The diff command numbers lines starting at 1, but it | |
| /// subtracts 1 from the line number if there are no lines on the source side | |
| /// of the hunk. | |
| int diffSrcLine; | |
| /// The index of the first line that was changed in the dst as returned by the | |
| /// diff command. The diff command numbers lines starting at 1, but it | |
| /// subtracts 1 from the line number if there are no lines on the destination | |
| /// side of the hunk. | |
| int diffDstLine; | |
| /// A list of the individual lines that were removed from the src. | |
| List<String> removeLines = <String>[]; | |
| /// A list of the individual lines that were added to the dst. | |
| List<String> addLines = <String>[]; | |
| /// Initialize a newly created hunk. The lines will be added after the object | |
| /// has been created. | |
| new(this.diffSrcLine, this.diffDstLine); | |
| /// Return the index of the first line that was changed in the dst. Unlike the | |
| /// [diffDstLine] field, this getter adjusts the line number to be consistent | |
| /// whether or not there were any changed lines. | |
| int get dstLine { | |
| return addLines.isEmpty ? diffDstLine : diffDstLine - 1; | |
| } | |
| /// Return the index of the first line that was changed in the src. Unlike the | |
| /// [diffDstLine] field, this getter adjusts the line number to be consistent | |
| /// whether or not there were any changed lines. | |
| int get srcLine { | |
| return removeLines.isEmpty ? diffSrcLine : diffSrcLine - 1; | |
| } | |
| } | |
| /// A representation of a single line (record) from a raw diff. | |
| class DiffRecord { | |
| /// The repository containing the file(s) that were modified. | |
| final GitRepository repository; | |
| /// The SHA1 of the blob in the src. | |
| final String srcBlob; | |
| /// The SHA1 of the blob in the dst. | |
| final String dstBlob; | |
| /// The status of the change. Valid values are: | |
| /// * A: addition of a file | |
| /// * C: copy of a file into a new one | |
| /// * D: deletion of a file | |
| /// * M: modification of the contents or mode of a file | |
| /// * R: renaming of a file | |
| /// * T: change in the type of the file | |
| /// * U: file is unmerged (you must complete the merge before it can be | |
| /// committed) | |
| /// * X: "unknown" change type (most probably a bug, please report it) | |
| /// | |
| /// Status letters C and R are always followed by a score (denoting the | |
| /// percentage of similarity between the source and target of the move or | |
| /// copy), and are the only ones to be so. | |
| final String status; | |
| /// The path of the src. | |
| final String? srcPath; | |
| /// The path of the dst if this was either a copy or a rename operation. | |
| final String? dstPath; | |
| /// Initialize a newly created diff record. | |
| new( | |
| this.repository, | |
| this.srcBlob, | |
| this.dstBlob, | |
| this.status, | |
| this.srcPath, | |
| this.dstPath, | |
| ); | |
| /// Return `true` if this record represents a file that was added. | |
| bool get isAddition => status == 'A'; | |
| /// Return `true` if this record represents a file that was copied. | |
| bool get isCopy => status.startsWith('C'); | |
| /// Return `true` if this record represents a file that was deleted. | |
| bool get isDeletion => status == 'D'; | |
| /// Return `true` if this record represents a file that was modified. | |
| bool get isModification => status == 'M'; | |
| /// Return `true` if this record represents a file that was renamed. | |
| bool get isRename => status.startsWith('R'); | |
| /// Return `true` if this record represents an entity whose type was changed | |
| /// (for example, from a file to a directory). | |
| bool get isTypeChange => status == 'T'; | |
| /// Return a representation of the individual blobs within this diff. | |
| BlobDiff getBlobDiff() => repository.getBlobDiff(srcBlob, dstBlob); | |
| /// Return `true` if this diff applies to a file with the given name. | |
| bool isFor(String fileName) { | |
| var srcPath = this.srcPath; | |
| var dstPath = this.dstPath; | |
| return (srcPath != null && fileName == path.basename(srcPath)) || | |
| (dstPath != null && fileName == path.basename(dstPath)); | |
| } | |
| @override | |
| String toString() => srcPath ?? dstPath ?? '<unknown>'; | |
| } | |
| /// A representation of a git repository. | |
| class GitRepository { | |
| /// The absolute path of the directory containing the repository. | |
| final String path; | |
| /// The logger to which git commands should be written, or `null` if the | |
| /// commands should not be written. | |
| final Logger? logger; | |
| /// Initialize a newly created repository to represent the git repository at | |
| /// the given [path]. | |
| /// | |
| /// If a [commandSink] is provided, any calls to git will be written to it. | |
| new(this.path, {this.logger}); | |
| /// Checkout the given [commit] from the repository. This is done by running | |
| /// the command `git checkout <sha>`. | |
| void checkout(String commit) { | |
| _run(['checkout', commit]); | |
| } | |
| /// Return details about the differences between the two blobs identified by | |
| /// the SHA1 of the [srcBlob] and the SHA1 of the [dstBlob]. This is done by | |
| /// running the command `git diff <blob> <blob>`. | |
| BlobDiff getBlobDiff(String srcBlob, String dstBlob) { | |
| var result = _run(['diff', '-U0', srcBlob, dstBlob]); | |
| var diffResults = LineSplitter.split(result.stdout as String).toList(); | |
| return BlobDiff._(diffResults); | |
| } | |
| /// Return details about the differences between the two commits identified by | |
| /// the [srcCommit] and [dstCommit]. This is done by running the command | |
| /// `git diff --raw --no-abbrev --no-renames -z <sha> <sha>`. | |
| CommitDelta getCommitDiff(String srcCommit, String dstCommit) { | |
| // Consider --find-renames instead of --no-renames if rename information is | |
| // desired. | |
| var result = _run([ | |
| 'diff', | |
| '--raw', | |
| '--no-abbrev', | |
| '--no-renames', | |
| '-z', | |
| srcCommit, | |
| dstCommit, | |
| ]); | |
| return CommitDelta._(this, result.stdout as String); | |
| } | |
| /// Return a representation of the history of this repository. This is done by | |
| /// running the command `git rev-list --first-parent HEAD`. | |
| LinearCommitHistory getCommitHistory() { | |
| var result = _run(['rev-list', '--first-parent', 'HEAD']); | |
| var commitIds = LineSplitter.split(result.stdout as String).toList(); | |
| return LinearCommitHistory(this, commitIds); | |
| } | |
| /// Synchronously run the given [executable] with the given [arguments]. | |
| /// Return the result of running the process. | |
| ProcessResult _run(List<String> arguments) { | |
| logger?.log('git', 'git', arguments: arguments); | |
| return Process.runSync( | |
| 'git', | |
| arguments, | |
| stderrEncoding: utf8, | |
| stdoutEncoding: utf8, | |
| workingDirectory: path, | |
| ); | |
| } | |
| } | |
| /// A representation of the history of a Git repository. This only represents a | |
| /// single linear path in the history graph. | |
| class LinearCommitHistory { | |
| /// The repository whose history is being represented. | |
| final GitRepository repository; | |
| /// The id's (SHA's) of the commits in the repository, with the most recent | |
| /// commit being first and the oldest commit being last. | |
| final List<String> commitIds; | |
| /// Initialize a commit history for the given [repository] to have the given | |
| /// [commitIds]. | |
| new(this.repository, this.commitIds); | |
| /// Return an iterator that can be used to iterate over this commit history. | |
| LinearCommitHistoryIterator iterator() { | |
| return LinearCommitHistoryIterator(this); | |
| } | |
| } | |
| /// An iterator over the history of a Git repository. | |
| class LinearCommitHistoryIterator { | |
| /// The commit history being iterated over. | |
| final LinearCommitHistory history; | |
| /// The index of the current commit in the list of [commitIds]. | |
| late int currentCommit; | |
| /// Initialize a newly created iterator to iterate over the commits with the | |
| /// given [commitIds]; | |
| new(this.history) { | |
| currentCommit = history.commitIds.length; | |
| } | |
| /// Return the SHA1 of the commit after the current commit (the 'dst' of the | |
| /// [next] diff). | |
| String get dstCommit => history.commitIds[currentCommit - 1]; | |
| /// Return the SHA1 of the current commit (the 'src' of the [next] diff). | |
| String get srcCommit => history.commitIds[currentCommit]; | |
| /// Advance to the next commit in the history. Return `true` if it is safe to | |
| /// ask for the [next] diff. | |
| bool moveNext() { | |
| if (currentCommit <= 1) { | |
| return false; | |
| } | |
| currentCommit--; | |
| return true; | |
| } | |
| /// Return the difference between the current commit and the commit that | |
| /// followed it. | |
| CommitDelta next() => history.repository.getCommitDiff(srcCommit, dstCommit); | |
| } |