47e988b781
Change-Id: Iee3110f92ab0f24312ddd1eff407d2229f895321 Reviewed-on: https://dart-review.googlesource.com/c/89101 Reviewed-by: Konstantin Shcheglov <scheglov@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
554 lines
15 KiB
Dart
554 lines
15 KiB
Dart
// 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 =
|
|
new 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 (String line in input) {
|
|
_parseLine(line);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a single [line] from the result of the git diff command.
|
|
*/
|
|
void _parseLine(String line) {
|
|
DiffHunk currentHunk = hunks.isEmpty ? null : hunks.last;
|
|
if (line.startsWith('@@')) {
|
|
Match match = hunkHeaderRegExp.matchAsPrefix(line);
|
|
int srcLine = int.parse(match.group(1));
|
|
int dstLine = int.parse(match.group(2));
|
|
hunks.add(new 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) {
|
|
String filePath = record.srcPath ?? record.dstPath;
|
|
for (String inclusionPath in inclusionPaths) {
|
|
if (path.isWithin(inclusionPath, filePath)) {
|
|
for (Glob 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) {
|
|
int length = string.length;
|
|
int 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) {
|
|
int length = input.length;
|
|
int 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
|
|
String srcSha = input.substring(startIndex, startIndex + SHA_LENGTH);
|
|
startIndex += SHA_LENGTH + 1;
|
|
// Parse field 8
|
|
String dstSha = input.substring(startIndex, startIndex + SHA_LENGTH);
|
|
startIndex += SHA_LENGTH + 1;
|
|
// Parse field 10
|
|
int endIndex = _findEnd(input, startIndex);
|
|
String status = input.substring(startIndex, endIndex);
|
|
startIndex = endIndex + 1;
|
|
// Parse field 12
|
|
endIndex = _findEnd(input, startIndex);
|
|
String srcPath = _makeAbsolute(input.substring(startIndex, endIndex));
|
|
startIndex = endIndex + 1;
|
|
// Parse field 14
|
|
String dstPath = null;
|
|
if (status.startsWith('C') || status.startsWith('R')) {
|
|
endIndex = _findEnd(input, startIndex);
|
|
dstPath = _makeAbsolute(input.substring(startIndex, endIndex));
|
|
}
|
|
// Create the record.
|
|
diffRecords.add(
|
|
new 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) =>
|
|
(srcPath != null && fileName == path.basename(srcPath)) ||
|
|
(dstPath != null && fileName == path.basename(dstPath));
|
|
|
|
@override
|
|
String toString() => srcPath ?? dstPath;
|
|
}
|
|
|
|
/**
|
|
* 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 = null});
|
|
|
|
/**
|
|
* 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) {
|
|
ProcessResult result = _run(['diff', '-U0', srcBlob, dstBlob]);
|
|
List<String> diffResults =
|
|
LineSplitter.split(result.stdout as String).toList();
|
|
return new 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.
|
|
ProcessResult result = _run([
|
|
'diff',
|
|
'--raw',
|
|
'--no-abbrev',
|
|
'--no-renames',
|
|
'-z',
|
|
srcCommit,
|
|
dstCommit
|
|
]);
|
|
return new 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() {
|
|
ProcessResult result = _run(['rev-list', '--first-parent', 'HEAD']);
|
|
List<String> commitIds =
|
|
LineSplitter.split(result.stdout as String).toList();
|
|
return new 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 new 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].
|
|
*/
|
|
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);
|
|
}
|