| // 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. |
| 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]. |
| BlobDiff._(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.group(1)!); |
| var dstLine = int.parse(match.group(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]. |
| CommitDelta._(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. |
| DiffHunk(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. |
| DiffRecord(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) { |
| final srcPath = this.srcPath; |
| final 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. |
| GitRepository(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]. |
| LinearCommitHistory(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]; |
| LinearCommitHistoryIterator(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); |
| } |