// 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 hunks = []; /** * 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 input) { _parseInput(input); } /** * Parse the result of the git diff command (the [input]). */ void _parseInput(List 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 diffRecords = []; /** * 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 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 inclusionPaths, List 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 removeLines = []; /** * A list of the individual lines that were added to the dst. */ List addLines = []; /** * 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 `. */ 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 `. */ BlobDiff getBlobDiff(String srcBlob, String dstBlob) { ProcessResult result = _run(['diff', '-U0', srcBlob, dstBlob]); List 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 `. */ 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 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 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 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); }