// 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 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 (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 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) { 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 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) { 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 ?? ''; } /// 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 `. 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) { 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 `. 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 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 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); }