diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e611ce8..b691d7de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: [ main ] + branches: [ main, 1.x ] pull_request: jobs: @@ -12,11 +12,11 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] composer-flags: [''] name: [''] include: - - php: '8.0' + - php: '8.1' composer-flags: '--prefer-lowest' name: '(prefer lowest dependencies)' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..061e75e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). + +1.7 prepares the upgrade to 2.0: it has the API of 2.0 that can be added without breaking 1.x, +and deprecates what 2.0 replaces or breaks. Code without deprecations with 1.7 runs with 2.0 +too, so a library can require `^1.7 || ^2.0` with a single code path. The exceptions, the +changes of 2.0 1.7 can't bring, are listed in the `UPGRADE-2.0.md` of 2.0. + +1.7 breaks nothing of 1.6: the parameters keep their names, the arguments added to the methods +of classes that aren't final are read with `func_get_arg()`, commented out in their signature +(`@psalm-variadic` for Psalm), and gitlib keeps calling the deprecated methods that a subclass +overrides. Deprecations are triggered through `symfony/deprecation-contracts`, a new +dependency. + +1.7 requires PHP 8.1, for the `DiffLineType` enum: on PHP 8.0, Composer keeps 1.6. + +### Features + +- `Gitonomy\Git\Driver\Cli\CliDriver` configures git: the binary, the environment variables, + the timeout and the logger. It takes the arguments of the `CliDriver` of 2.0. +- `new Repository($dir, $workingDir, $driver)`: the second argument may be the working + directory, the third one is a `CliDriver`. +- `Repository::init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()`, `mirrorFrom()`, + `isValidRepository()` and `isValidRepositoryAndBranch()` take the URL before the path and a + `CliDriver` as last argument. Like in 2.0, `isValidRepositoryAndBranch()` checks the exact + name of the branch, and both read a relative path as a path, never as the name of a remote + of the working directory of PHP. +- `Repository::getDiff($from, $to)`, with two arguments, compares two revisions, `Repository::getMergeBase()` + returns their best common ancestor, `WorkingCopy::getDiff()` compares the working tree with + a revision. Like in 2.0, they throw a `ReferenceNotFoundException` for an unknown revision, + as `WorkingCopy::checkoutNewBranch()`. +- `Diff::parse()`, `Diff::fromArray()` and `File::fromArray()` take the repository to read the + blobs from, the constructor of `Diff\File` the changes and the repository. + `File::isModeChange()`, `File::isCopy()`, `FileChange::getAdditions()` and + `FileChange::getDeletions()`. +- `FileChange::getDiffLines()` returns the lines of a hunk as `Diff\DiffLine` objects, with + `getType()`, a `Diff\DiffLineType`, and `getContent()`. The constructor of `FileChange` + takes `DiffLine` objects. +- `Repository::getBlame()` and `Blame` take the first and the last lines to blame, as + integers. + `Blame::getHunks()` returns `Blame\Hunk` objects, numbered like the file; + `Blame\Line::getNumber()` and `getOriginalNumber()`. +- `Log::withOffset()` and `withLimit()` return another log. +- `Branch::isMergedInto()` and `Branch::moveTo()`. `ReferenceBag::createBranch()` and + `createTag()` take a `Revision` too. +- `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`. +- Iterating a `Tree` gives its entries, `TreeEntry` objects with `getName()`, `getMode()` and + `getObject()`, by name; `Tree::getBlobs()`, `getTrees()` and `getSubmodules()` return those + of one type. +- `Submodule`, with `getHash()`. The submodules of trees are still `CommitReference` + objects, which extend `Submodule`. +- `WorkingCopy::checkoutNewBranch()`. `WorkingCopy::checkout()` takes any `Revision`. +- `Repository::getSizeInBytes()`, which doesn't follow links. + +### Deprecations + +- An array of options as second argument of `Repository`, the `debug` option set to `false`, + `Repository::setLogger()` and `getLogger()`: use the `workingDir` and `driver` arguments. +- `Admin` and `Repository::cloneTo()`: use the static methods of `Repository`. +- `Repository::getDiff()` with one argument and `Log::getDiff()`: use + `Repository::getDiff($from, $to)` or `WorkingCopy::getDiff()`. +- `Diff::setRepository()`, `File::setRepository()`, `File::addChange()`, + `File::isChangeMode()`, `File::isDelete()`, `File::getAnchor()` and `FileChange::getCount()`. +- `FileChange::getLines()`, which returns the lines as arrays, the `FileChange::LINE_*` + constants, and passing the lines to the constructor of `FileChange` as arrays: use + `getDiffLines()`, `DiffLineType` and `DiffLine` objects. +- A range of lines as a string in `Repository::getBlame()` and `Blame`, + `Blame::getGroupedLines()` and `Blame\Line::getLine()`. +- `Log::setOffset()`, `setLimit()`, a negative offset or limit, an option of `git log` but + `--all` as revision, and `count()` of a log whose offset or limit leaves commits out: it + counts them all, where 2.0 counts the commits the log iterates. Use `countCommits()`. +- `ReferenceBag::update()`, `Branch::isMergedTo()`, arguments of + `Commit::getIncludingBranches()`, creating a branch or a tag that exists already, deleting + a reference by another name than its full name under `refs/`. +- Passing an argument to `Repository::getReferences()`, to read the references again: open + a new `Repository`. +- `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. +- `CommitReference`. `Tree::getEntries()`, `getBlobEntries()`, `getTreeEntries()` and + `getCommitReferenceEntries()`, which return the entries as arrays: iterate the tree, or use + `getBlobs()`, `getTrees()` and `getSubmodules()`. +- The second argument of `WorkingCopy::checkout()`: use `checkoutNewBranch()`. A local + `Branch` object, which `checkout()` detaches HEAD on, where 2.0 attaches HEAD to the branch: + pass its name, or its commit. +- Passing a revision name to `Repository::getTree()` or `getBlob()` instead of a hash, and + passing data to the constructor of `Commit`, internal in 2.0. +- `Repository::getSize()`, `Repository::shell()`, `Commit::getShortMessage()`, + `Commit::getFixedShortHash()` and `Util\StringHelper`. +- The parsers that 2.0 moves to its `CliDriver`, all but `DiffParser` and `ParserBase`. +- The classes `final` in 2.0 are `@final since gitonomy/gitlib 1.7`. The constructors + internal in 2.0 (`Commit`, `Tree`, `Blob`, `Revision`, `Reference`, `ReferenceBag`) say so. + +## [v1.6.0] - 2025-12-09 + +_Earlier versions are not documented here. See the +[GitHub releases](https://github.com/gitonomy/gitlib/releases) and +[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ + +[Unreleased]: https://github.com/gitonomy/gitlib/compare/v1.6.0...1.x +[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 diff --git a/composer.json b/composer.json index 6979e26f..4b9a2fca 100644 --- a/composer.json +++ b/composer.json @@ -35,16 +35,18 @@ } }, "require": { - "php": "^8.0", + "php": "^8.1", "ext-pcre": "*", + "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/polyfill-mbstring": "^1.7", "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "ext-fileinfo": "*", - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.1", "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", - "psr/log": "^1.0" + "psr/log": "^1.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0" }, "config": { "preferred-install": "dist", diff --git a/doc/admin.md b/doc/admin.md index d4776804..bd774e5a 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -1,24 +1,25 @@ Create and access git repositories ================================== -gitlib provides methods to initialize new repositories. +gitlib provides static methods of `Repository` to initialize and clone +repositories. Each of them takes a `CliDriver` as last argument, to +configure git (see [Repository](repository.md)). + +`Admin` and `Repository::cloneTo()`, which did it before 1.7, are +deprecated. Create a repository ------------------- -To initialize a new repository, use method `Admin::init`. - ```php -// Initialize a bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository'); +use Gitonomy\Git\Repository; -// Initialize a non-bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository', false); -``` +// Initialize a repository with a working directory +$repository = Repository::init('/path/to/repository'); -Default behavior is to create a bare repository. If you want to -initialize a repository with a working copy,pass `false` as third -argument of Repository constructor. +// Initialize a bare repository +$repository = Repository::initBare('/path/to/repository'); +``` Cloning repositories -------------------- @@ -26,49 +27,48 @@ Cloning repositories You can clone a repository from an URL by doing: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); +// Clone to a repository with a working directory +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); +// Clone to a bare repository +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); ``` -Default behavior is to clone in a bare repository. - You can also clone a repository and point it to a specific branch. In a -non-bare repository, this branch will be checked out: +repository with a working directory, this branch is checked out: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch', false); +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); ``` -Clone a Repository object -------------------------- - -If you already have a Repository instance and want to clone it, you can -use this shortcut: +To clone a `Repository` object, clone its git directory: ```php -$new = $repository->cloneTo('/tmp/clone'); +$new = Repository::cloneBareFrom($repository->getGitDir(), '/tmp/clone'); ``` Mirror a repository ------------------- -If you want to mirror fully a repository and all references, use the -`mirrorTo` method. This method takes only two arguments, where to mirror -and what to mirror: +To mirror a repository and all its references into a bare repository, +use the `mirrorFrom` method: + +```php +$mirror = Repository::mirrorFrom('https://github.com/gitonomy/gitlib.git', '/tmp/mirror'); +``` + +Check a remote repository +------------------------- + +To check that git can read a repository, without cloning it: ```php -// Mirror to a bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); +Repository::isValidRepository('https://github.com/gitonomy/gitlib.git'); -// Mirror to a non-bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); +// And that it has the branch "main", with this exact name +Repository::isValidRepositoryAndBranch('https://github.com/gitonomy/gitlib.git', 'main'); ``` ### References diff --git a/doc/blame.md b/doc/blame.md index 80816c9b..bba94cd8 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -26,6 +26,17 @@ If you want to access directly a line: $line = $blame->getLine(32); ``` +To blame some lines only, pass the first and the last ones: + +```php +$blame = $repository->getBlame('master', 'README.md', 10, 20); +``` + +`getLines()` and `getLine()` number these lines from 1, where gitlib 2.0 +numbers them like the file: use `Line::getNumber()`. A range of lines as +a string (`'10,20'`, `'10,+11'`, a regular expression), passed to +`git blame -L`, is deprecated. + The Line object --------------- @@ -33,8 +44,10 @@ LineObject represents an item of the blame file. It is composed of those informations: ```php -$line->getCommit(); // returns a Commit -$line->getContent(); // returns text +$line->getCommit(); // returns a Commit +$line->getContent(); // returns text +$line->getNumber(); // the number of the line in the file +$line->getOriginalNumber(); // its number in the file of the commit // you can access author from commmit: $author = $line->getCommit()->getAuthorName(); @@ -46,13 +59,18 @@ Group reading by commit If you plan to display it, you'll probably need a version where lines from same commit are grouped. -To do so, use the *getGroupedLines* method that will return an array -like this: +To do so, use the *getHunks* method, which returns `Blame\Hunk` objects, +the consecutive lines last changed by the same commit, by number in the +file: ```php -$blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) -) +foreach ($blame->getHunks() as $hunk) { + echo $hunk->getCommit()->getHash().PHP_EOL; + + foreach ($hunk->getLines() as $number => $line) { + echo $number.': '.$line->getContent().PHP_EOL; + } +} ``` + +`getGroupedLines()` and `Line::getLine()` are deprecated. diff --git a/doc/branch.md b/doc/branch.md index d6e0872c..188fc3a8 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -14,3 +14,17 @@ You can check is the branch is a local or remote one: $branch->isLocal(); $branch->isRemote(); ``` + +To check that the history of a revision contains the branch: + +```php +$branch->isMergedInto('master'); +``` + +`isMergedTo()` is deprecated. + +To point the branch to another revision: + +```php +$branch = $branch->moveTo('a8b7e4...'); +``` diff --git a/doc/commit.md b/doc/commit.md index 08e9b1e3..08d546ac 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -31,7 +31,7 @@ For example, if you want to display all parents, starting from a commit: ```php function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage().PHP_EOL; + echo '- '.$commit->getSubjectMessage().PHP_EOL; foreach ($commit->getParents() as $parent) { displayLog($parent); } @@ -94,24 +94,20 @@ To access the message, you can use the *getMessage* method: $commit->getMessage(); ``` -For your convenience, this library provides a shortcut method to keep -only the first line or first 50 characters if the first line is too -long: +To keep only the first line, or its first 50 characters if it is too +long, use [symfony/string](https://symfony.com/doc/current/string.html): ```php -$commit->getShortMessage(); -``` +use function Symfony\Component\String\u; -You can customize it like this: +u($commit->getSubjectMessage())->truncate(50, '...'); -```php -$commit->getShortMessage(45, true, '.'); +// Without cutting the last word +u($commit->getSubjectMessage())->truncate(45, '.', false); ``` -- The first parameter is the max length of the message. -- The second parameter determine if the last word should be cut or - preserved -- The third parameter is the separator +`getShortMessage()`, which did it, is deprecated, as `getFixedShortHash()`: +use `substr($commit->getHash(), 0, 6)`. There are also two other methods for your convenience: @@ -165,7 +161,11 @@ Find every branches containing a commit --------------------------------------- ```php -$branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); -$localBranches = $commit->getIncludingBranches(true, false); -$remoteBranches = $commit->getIncludingBranches(false, true); +use Gitonomy\Git\Reference\Branch; + +$branches = $commit->getIncludingBranches(); +$localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); +$remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); ``` + +Passing arguments to `getIncludingBranches()` is deprecated. diff --git a/doc/diff.md b/doc/diff.md index 2ce7870a..a243d5df 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -4,25 +4,41 @@ Computing diff Even if git is a diff-less storage engine, it's possible to compute them. -To compute a diff in git, you need to specify a *revision*. This -revision can be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). +To compute a diff, pass the two revisions to compare to the *getDiff* +method of the repository: + +```php +$diff = $repository->getDiff('master@{2 days ago}', 'master'); +``` For more informations about git revisions: *man gitrevisions*. -When you have decided the revision you want and have your *Repository* -object, you can call the *getDiff* method on the repository: +To compare with the best common ancestor, like `git diff a...b`: + +```php +$diff = $repository->getDiff($repository->getMergeBase('a', 'b'), 'b'); +``` + +To compare the working tree, staged or not, with a revision: ```php -$diff = $repository->getDiff('master@{2 days ago}..master'); +$diff = $repository->getWorkingCopy()->getDiff('master'); ``` -You can also access it from a *Log* object: +Calling `getDiff()` with one argument, a range or a list of revisions, is +deprecated, as `Log::getDiff()`. + +To parse the output of `git diff --raw --patch` yourself, give the +repository to read the blobs from: ```php -$log = $repository->getLog('master@{2 days ago}..master'); -$diff = $log->getDiff(); +use Gitonomy\Git\Diff\Diff; + +$diff = Diff::parse($output, $repository); ``` +`Diff::setRepository()` and `File::setRepository()` are deprecated. + Iterating a diff ---------------- @@ -56,7 +72,7 @@ $file->isDeletion(); $file->isModification(); $file->isRename(); -$file->isChangeMode(); +$file->isModeChange(); $file->getAdditions(); // Number of added lines $file->getDeletions(); // Number of deleted lines @@ -78,27 +94,35 @@ The FileChange object A *File* object is composed of many changes. For each of those changes, a *FileChange* object is associated. -To access changes from a file, use the *getChanges* method: +To access changes from a file, use the *getChanges* method. The +*getDiffLines* method of a change returns its lines, `DiffLine` objects: ```php -$changes = $file->getChanges(); -foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line.PHP_EOL; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line.PHP_EOL; - } else { - echo '-'.$line.PHP_EOL; - } +use Gitonomy\Git\Diff\DiffLineType; + +foreach ($file->getChanges() as $change) { + foreach ($change->getDiffLines() as $line) { + echo match ($line->getType()) { + DiffLineType::Addition => '+', + DiffLineType::Context => ' ', + DiffLineType::Deletion => '-', + }.$line->getContent().PHP_EOL; } } ``` +`DiffLine::getContent()` returns the line, without its prefix (`+`, `-` +or a space) nor its line break. + +`FileChange::getLines()`, which returns the lines as arrays +(`[$type, $content]`), the `FileChange::LINE_ADD`, `LINE_CONTEXT` and +`LINE_REMOVE` constants, and passing the lines to the constructor of +`FileChange` as arrays are deprecated: use `getDiffLines()`, `DiffLineType` +and `DiffLine` objects. + To get line numbers, use the range methods: ```php -echo sprintf('Previously from line %s to %s%s', $change->getOldRangeStart(), $change->getOldRangeEnd(), PHP_EOL); -echo sprintf('Now from line %s to %s%s', $change->getNewRangeStart(), $change->getNewRangeEnd(), PHP_EOL); +echo sprintf('Previously %s lines from line %s%s', $change->getRangeOldCount(), $change->getRangeOldStart(), PHP_EOL); +echo sprintf('Now %s lines from line %s%s', $change->getRangeNewCount(), $change->getRangeNewStart(), PHP_EOL); ``` diff --git a/doc/log.md b/doc/log.md index 4dc5a465..6ed4d67b 100644 --- a/doc/log.md +++ b/doc/log.md @@ -35,21 +35,24 @@ If you want to count overall commits, without offset or limit, use the ```php echo sprintf('This log contains %s commits%s', $log->countCommits(), PHP_EOL); - -// Countable interface -echo sprintf('This log contains %s commits%s', count($log), PHP_EOL); ``` +`count($log)` counts them too, which is deprecated when the offset or +the limit of the log leaves commits out: gitlib 2.0 counts the commits +the log iterates. + Offset and limit ---------------- -Use those methods: +Use those methods, which return another log: ```php -$log->setOffset(32); -$log->setLimit(40); +$page = $log->withOffset(32)->withLimit(40); // or read it: -$log->getOffset(); -$log->getLimit(); +$page->getOffset(); +$page->getLimit(); ``` + +`setOffset()` and `setLimit()`, which changed the log, are deprecated, as +a negative offset or limit. diff --git a/doc/references.md b/doc/references.md index 64430929..6a9a63b2 100644 --- a/doc/references.md +++ b/doc/references.md @@ -11,6 +11,11 @@ this object from a *Repository*, use the *getReferences* method: $references = $repository->getReferences(); ``` +The bag reads the references once. To see the writes of +`Repository::run()` or of other processes, open a new `Repository`: +passing an argument to `getReferences()`, to read them again, is +deprecated. + First, you can test existence of tags and branches like this: ```php @@ -64,16 +69,24 @@ on ReferenceBag object: ```php // create a branch $references = $repository->getReferences(); -$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference +$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference, or a Revision // create a tag $references = $repository->getReferences(); -$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference +$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference, or a Revision + +// move a branch, to a revision or its name +$branch = $branch->moveTo('master'); // delete a branch or a tag $branch->delete(); ``` +Creating a branch or a tag that exists already, which moves it, is +deprecated, as `ReferenceBag::update()`: use `Branch::moveTo()`. Deleting +a reference by another name than its full name, like `HEAD` or `master`, +is deprecated. + Resolution from a commit ------------------------ diff --git a/doc/repository.md b/doc/repository.md index 05b4a4e7..626a8af2 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -8,32 +8,47 @@ to the constructor: $repository = new Repository('/path/to/repo'); ``` -Repository options ------------------- +When the git directory is kept apart from the working directory, pass the +working directory as second argument: -The constructor of Repository takes an additional parameter: `$options`. -This parameter can be used used to tune behavior of library. +```php +$repository = new Repository('/path/to/repo.git', '/path/to/working-dir'); +``` -Available options are: +Configure git +------------- -- **debug** (default: true): Enables exception when edge cases are met -- **environment\_variables**: (default: none) An array of environment - variables to be set in sub-process -- **logger**: (default: none) Logger to use for reporting of execution - (a `Psr\Log\LoggerInterface`) -- **command**: (default: `git`) Specify command to execute to run git -- **working\_dir**: If you are using multiple working directories, - this option is for you +The last argument of the constructor is a `Gitonomy\Git\Driver\Cli\CliDriver`, +which configures how gitlib runs git: -An example: +- **command** (default: `git`): the git binary; +- **environmentVariables** (default: none): environment variables set on + every git process; +- **processTimeout** (default: 3600): in seconds, `null` for none; +- **logger** (default: none): a `Psr\Log\LoggerInterface` receiving every + git command run. ```php -$repository = new Repository('/path/to/repo', [ - 'debug' => true, - 'logger' => new Monolog\Logger(), -]); +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new CliDriver( + command: '/home/alice/bin/git', + environmentVariables: ['GIT_SSH_COMMAND' => 'ssh -i /path/to/key'], + processTimeout: 60, + logger: new Monolog\Logger('repository'), +)); ``` +`CliDriver` has the arguments of the one of gitlib 2.0, where it runs git +itself: code using it works with both versions. + +An array of options as second argument is deprecated: its `working_dir` +option is the second argument, and its `logger`, `command`, +`environment_variables` and `process_timeout` options configure a +`CliDriver`. Setting its `debug` option to `false` is deprecated: gitlib +2.0 always throws when git fails. + Test if a repository is bare ---------------------------- @@ -47,21 +62,18 @@ $repository->isBare(); Compute size of a repository ---------------------------- -To know how much size a repository is using on your drive, you can use -`getSize` method on a *Repository* object. - -> **warning** -> -> This command was only tested with linux. - -The returned size is in kilobytes: +To know how much size the git directory of a repository is using on your +drive, you can use `getSizeInBytes` method on a *Repository* object. It +doesn't follow the links. ```php -$size = $repository->getSize(); +$size = $repository->getSizeInBytes(); -echo 'Your repository size is '.$size.'KB'; +echo 'Your repository size is '.$size.' bytes'; ``` +`getSize()`, in kilobytes, is deprecated. + Access HEAD ----------- @@ -78,27 +90,14 @@ if ($repository->isHeadDetached()) { } ``` -Options for repository ----------------------- - -### Logger +Logger +------ If you are developing, you may appreciate to have a logger inside -repository, telling you every executed command. - -You call method `setLogger` as an option on repository creation: - -```php -$repository->setLogger(new Monolog\Logger('repository')); - -$repository->run('fetch', ['--all']); -``` - -You can also specify as an option on repository creation: +repository, telling you every executed command: ```php -$logger = new MonologLogger('repository'); -$repository = new Repository('/path/foo', ['logger' => $logger]); +$repository = new Repository('/path/foo', driver: new CliDriver(logger: new Monolog\Logger('repository'))); $repository->run('fetch', ['--all']); ``` @@ -111,37 +110,4 @@ debug last command (fetch) return code: 0 debug last command (fetch) output: Fetching origin ``` -### Disable debug-mode - -Gitlib throws an exception when something seems wrong. If a `git` command exits -with a non-zero code, then execution will be stopped, and a `RuntimeException` -will be thrown. If you want to prevent this, set the `debug` option to` false`. -This will make `Repository` log errors and return empty data instead of -throwing exceptions. - -```php -$repository = new Repository('/tmp/foo', ['debug' => false, 'logger' => $logger]); -``` - -> **note** -> -> If you plan to disable debug, you should rely on the logger to keep a trace -> of the failing cases. - -### Specify git command to use - -You can pass the option `command` to specify which command to use to run git -calls. If you have a git binary located somewhere else, use this option to -specify to gitlib path to your git binary: - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['command' => '/home/alice/bin/git']); -``` - -### Environment variables - -It is possible to send environment variables to the `git` commands. - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['environment_variables' => ['GIT_']]) -``` +`setLogger()` and `getLogger()` are deprecated. diff --git a/doc/tree.md b/doc/tree.md index 0c5c8ef7..d26bce65 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -13,23 +13,21 @@ $tree = $commit->getTree(); This tree is the entry point of all of your files. -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the -entry mode and the entry object. +Iterating a tree gives its entries, `TreeEntry` objects, by name. An +entry has a name, a mode and an object: a `Blob`, a `Tree` or a +`Submodule`. Let's understand how it works with a concrete example: ```php function displayTree(Tree $tree, $indent = 0) { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name.'/'.PHP_EOL; - displayTree($tree, $indent + 1); + foreach ($tree as $name => $entry) { + if ($entry->getObject() instanceof Tree) { + echo str_repeat(' ', $indent).$name.'/'.PHP_EOL; + displayTree($entry->getObject(), $indent + 1); } else { - echo $indent.$name.PHP_EOL; + echo str_repeat(' ', $indent).$name.PHP_EOL; } } } @@ -39,6 +37,17 @@ displayTree($commit->getTree()); This method will recursively display all entries of a tree. +`getBlobs()`, `getTrees()` and `getSubmodules()` return the entries of +one type, by name. A submodule is a `Submodule`, with the hash of the +commit it points to: check it with `instanceof Submodule`. The +`CommitReference` class of its objects, which extends `Submodule`, is +deprecated. + +`getEntries()`, `getBlobEntries()`, `getTreeEntries()` and +`getCommitReferenceEntries()`, which return the entries as arrays +(`[$mode, $object]`), are deprecated: iterate the tree, or use +`getBlobs()`, `getTrees()` and `getSubmodules()`. + Resolve a path -------------- diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 4ecea6c1..8ae81272 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -13,18 +13,21 @@ $wc = $repo->getWorkingCopy(); Checkout a revision ------------------- -You can checkout any revision using *checkout* method. You can also pass -a second argument, which will be passed as argument with `-b`: +You can checkout any revision using *checkout* method, and create a +branch with *checkoutNewBranch*: ```php // git checkout master $wc->checkout('master'); -// git checkout origin/master -b master -$wc->checkout('origin/master', 'master'); +// git checkout -b master origin/master +$wc->checkoutNewBranch('master', 'origin/master'); ``` -You can also pass a *Reference* or a *Commit*. +You can also pass a *Revision*, like a *Reference* or a *Commit*. Passing +the branch to create as second argument of `checkout()` is deprecated, as +a local *Branch* object, which detaches HEAD: pass its name to attach HEAD +to it, like gitlib 2.0 does with the object. Staged modifications -------------------- @@ -45,3 +48,14 @@ You can get pending modifications on tracked files by calling method ```php $diff = $wc->getDiffPending(); ``` + +All modifications +----------------- + +You can get the modifications of the working tree, staged or not, since +a revision, `HEAD` by default: + +```php +$diff = $wc->getDiff(); +$diff = $wc->getDiff('master'); +``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 77fe6238..afa6bbc8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,9 @@ ./tests/Gitonomy/Git/Tests + + + ./src/Gitonomy/Git diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 7b32794c..9d561284 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -12,13 +12,18 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\RuntimeException; -use Symfony\Component\Process\Process; +use Gitonomy\Git\Util\LegacyOverride; /** * Administration class for Git repositories. * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 1.7, use the static methods of Repository instead + * + * @final since gitonomy/gitlib 1.7 */ class Admin { @@ -32,15 +37,18 @@ class Admin * @throws RuntimeException Directory exists or not writable (only if debug=true) * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::initBare() or Repository::init() instead */ public static function init($path, $bare = true, array $options = []) { - $process = static::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + $driver = CliDriver::fromOptions($options); + if ($bare) { + $driver->initBare($path); + } else { + $driver->init($path); } return new Repository($path, $options); @@ -57,10 +65,15 @@ public static function init($path, $bare = true, array $options = []) * @param array $options options for Repository creation * * @return bool true if url is valid + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead */ public static function isValidRepository($url, array $options = []) { - $process = static::getProcess('ls-remote', [$url], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + + // Unlike Repository::isValidRepository(), in the working directory of PHP, where a relative path may name a remote. + $process = CliDriver::fromOptions($options)->createProcess('ls-remote', [$url]); $process->run(); @@ -80,10 +93,16 @@ public static function isValidRepository($url, array $options = []) * @param array $options options for Repository creation * * @return bool true if url is valid and branch exists + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead */ public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) { - $process = static::getProcess('ls-remote', ['--heads', $url, $branchName], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + + // Unlike Repository::isValidRepositoryAndBranch(), "release" matches "feature/release", and "rel" "release", + // in the working directory of PHP. + $process = CliDriver::fromOptions($options)->createProcess('ls-remote', ['--heads', $url, $branchName]); $process->run(); $processOutput = $process->getOutput(); @@ -100,12 +119,16 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ public static function cloneTo($path, $url, $bare = true, array $options = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + $args = $bare ? ['--bare'] : []; - return static::cloneRepository($path, $url, $args, $options); + return self::cloneWith($path, $url, $args, $options); } /** @@ -118,15 +141,19 @@ public static function cloneTo($path, $url, $bare = true, array $options = []) * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + $args = ['--branch', $branch]; if ($bare) { $args[] = '--bare'; } - return static::cloneRepository($path, $url, $args, $options); + return self::cloneWith($path, $url, $args, $options); } /** @@ -137,10 +164,14 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead */ public static function mirrorTo($path, $url, array $options = []) { - return static::cloneRepository($path, $url, ['--mirror'], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); + + return self::cloneWith($path, $url, ['--mirror'], $options); } /** @@ -152,37 +183,27 @@ public static function mirrorTo($path, $url, array $options = []) * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ public static function cloneRepository($path, $url, array $args = [], array $options = []) { - $process = static::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); - } + CliDriver::fromOptions($options)->cloneRepository($url, $path, $args); return new Repository($path, $options); } - /** - * This internal method is used to create a process object. - */ - private static function getProcess($command, array $args = [], array $options = []) + private static function cloneWith($path, $url, array $args, array $options) { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - $process = new Process(array_merge([$options['command'], $command], $args)); - $process->setEnv($options['environment_variables']); - $process->setTimeout($options['process_timeout']); - $process->setIdleTimeout($options['process_timeout']); - - return $process; + // Like in 1.6, a subclass overriding cloneRepository() clones the repository. + if (LegacyOverride::isOverridden(static::class, self::class, 'cloneRepository')) { + return static::cloneRepository($path, $url, $args, $options); + } + + CliDriver::fromOptions($options)->cloneRepository($url, $path, $args); + + return new Repository($path, $options); } } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index dec0773b..e9d3e407 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -12,12 +12,15 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Blame\Hunk; use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\BlameParser; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Blame implements \Countable { @@ -47,14 +50,33 @@ class Blame implements \Countable protected $lines; /** - * @param string $lineRange Argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') + * The fourth argument is the first line to blame, and a fifth one the last + * line, like in 2.0: like "git blame -L 5,3", a last line before the first + * one blames the lines 3 to 5. + * + * A string as fourth argument, a range of lines passed to "git blame -L", + * like "40,60", "40,+21" or a regexp ("/^function$/"), is deprecated. + * + * @param int|string|null $lineRange The first line to blame, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @throws InvalidArgumentException An invalid range of lines */ - public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null) + public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null, ?int $lastLine = null) { + if (is_string($lineRange)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a range of lines as a string to "%s::getBlame()" or "%s()" is deprecated, pass the first and the last lines instead.', Repository::class, __METHOD__); + } elseif (null !== $lastLine) { + if ((null !== $lineRange && $lineRange < 1) || $lastLine < 1) { + throw new InvalidArgumentException(sprintf('Invalid range of lines "%s,%s": the lines are numbered from 1.', $lineRange, $lastLine)); + } + + $lineRange = ($lineRange ?? 1).','.$lastLine; + } + $this->repository = $repository; $this->revision = $revision; + // A first line alone, "-L 5" like in 1.6, blames from this line to the end of the file. $this->lineRange = $lineRange; $this->file = $file; } @@ -77,13 +99,42 @@ public function getLine($number) return $lines[$number]; } + /** + * @return list The consecutive lines last changed by the same commit + */ + public function getHunks(): array + { + $hunks = []; + $commit = null; + $lines = []; + foreach ($this->getLines() as $line) { + if ($commit !== $line->getCommit() && [] !== $lines) { + $hunks[] = new Hunk($commit, $lines); + $lines = []; + } + $commit = $line->getCommit(); + // By number in the file, like in gitlib 2.0, where getLines() numbers them so too. + $lines[$line->getNumber()] = $line; + } + + if (null !== $commit) { + $hunks[] = new Hunk($commit, $lines); + } + + return $hunks; + } + /** * Returns lines grouped by commit. * * @return array a list of two-elements array (commit, lines) + * + * @deprecated since gitonomy/gitlib 1.7, use getHunks() instead */ public function getGroupedLines() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); + $result = []; $commit = null; $current = []; diff --git a/src/Gitonomy/Git/Blame/Hunk.php b/src/Gitonomy/Git/Blame/Hunk.php new file mode 100644 index 00000000..7919cf25 --- /dev/null +++ b/src/Gitonomy/Git/Blame/Hunk.php @@ -0,0 +1,45 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Blame; + +use Gitonomy\Git\Commit; + +/** + * Consecutive lines of a blame, last changed by the same commit. + * + * @author Grégoire Pineau + */ +final class Hunk +{ + /** + * @param array $lines By number + */ + public function __construct( + private Commit $commit, + private array $lines, + ) { + } + + public function getCommit(): Commit + { + return $this->commit; + } + + /** + * @return array By number + */ + public function getLines(): array + { + return $this->lines; + } +} diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 83a7a912..1b10a2f3 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -16,6 +16,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Line { @@ -45,8 +47,29 @@ public function getContent() return $this->content; } + /** + * @return int The number of the line in the blamed file + */ + public function getNumber(): int + { + return (int) $this->targetLine; + } + + /** + * @return int The number of the line in the file of the commit that last changed it + */ + public function getOriginalNumber(): int + { + return (int) $this->sourceLine; + } + + /** + * @deprecated since gitonomy/gitlib 1.7, use getOriginalNumber() instead + */ public function getLine() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); + return $this->sourceLine; } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index dfe885fe..990773ff 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -16,6 +16,8 @@ * Representation of a Blob commit. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Blob { @@ -52,6 +54,8 @@ class Blob /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob + * + * In gitlib 2.0, the constructor is internal: get it from Repository::getBlob(). */ public function __construct(Repository $repository, $hash) { diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 573d3b52..38859bf9 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -23,6 +23,8 @@ * Representation of a Git commit. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Commit extends Revision { @@ -38,9 +40,17 @@ class Commit extends Revision * * @param Repository $repository Repository of the commit * @param string $hash Hash of the commit + * + * In gitlib 2.0, the constructor is internal, and takes a CommitData + * instead of an array: get it from Repository::getCommit(). Passing data + * is deprecated. */ public function __construct(Repository $repository, $hash, array $data = []) { + if ([] !== $data) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing data to "%s()" is deprecated, the constructor is internal in 2.0, and takes a "CommitData" instead of an array: get the commit from "%s::getCommit()".', __METHOD__, Repository::class); + } + if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { throw new ReferenceNotFoundException($hash); } @@ -50,6 +60,9 @@ public function __construct(Repository $repository, $hash, array $data = []) $this->setData($data); } + /** + * In gitlib 2.0, it is internal, and takes a CommitData instead of an array. + */ public function setData(array $data) { foreach ($data as $name => $value) { @@ -64,10 +77,7 @@ public function getDiff() { $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - $diff = Diff::parse($this->repository->run('diff-tree', $args)); - $diff->setRepository($this->repository); - - return $diff; + return Diff::parse($this->repository->run('diff-tree', $args), $this->repository); } /** @@ -94,10 +104,14 @@ public function getShortHash() * Returns a fixed-with short hash. * * @return string Short hash + * + * @deprecated since gitonomy/gitlib 1.7, use substr($commit->getHash(), 0, $length) instead */ public function getFixedShortHash($length = 6) { - return StringHelper::substr($this->revision, 0, $length); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); + + return substr($this->revision, 0, $length); } /** @@ -149,7 +163,7 @@ public function getTree() public function getLastModification($path = null) { if (0 === strpos($path, '/')) { - $path = StringHelper::substr($path, 1); + $path = substr($path, 1); } if ($getWorkingDir = $this->repository->getWorkingDir()) { @@ -171,17 +185,23 @@ public function getLastModification($path = null) * @param string $separator * * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) */ public function getShortMessage($length = 50, $preserve = false, $separator = '...') { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); + $message = $this->getData('subjectMessage'); + // The encoding StringHelper::setEncoding() set, without loading this deprecated class. + $encoding = class_exists(StringHelper::class, false) ? StringHelper::getEncoding() : 'utf-8'; - if (StringHelper::strlen($message) > $length) { - if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { + if (mb_strlen($message, $encoding) > $length) { + if ($preserve && false !== ($breakpoint = mb_strpos($message, ' ', $length, $encoding))) { $length = $breakpoint; } - return rtrim(StringHelper::substr($message, 0, $length)).$separator; + return rtrim(mb_substr($message, 0, $length, $encoding)).$separator; } return $message; @@ -200,6 +220,8 @@ public function resolveReferences() /** * Find branch containing the commit. * + * Passing arguments, to keep local or remote branches only, is deprecated. + * * @param bool $local set true to try to locate a commit on local repository * @param bool $remote set true to try to locate a commit on remote repository * @@ -207,6 +229,10 @@ public function resolveReferences() */ public function getIncludingBranches($local = true, $remote = true) { + if (func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); + } + $arguments = ['--contains', $this->revision]; if ($local && $remote) { @@ -229,7 +255,7 @@ public function getIncludingBranches($local = true, $remote = true) $branchesName = explode("\n", trim(str_replace('*', '', $result))); $branchesName = array_filter($branchesName, function ($v) { - return false === StringHelper::strpos($v, '->'); + return false === strpos($v, '->'); }); $branchesName = array_map('trim', $branchesName); @@ -239,7 +265,7 @@ public function getIncludingBranches($local = true, $remote = true) foreach ($branchesName as $branchName) { if (false === $local) { $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === StringHelper::strrpos($branchName, 'remotes/')) { + } elseif (0 === strrpos($branchName, 'remotes/')) { $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); } else { $branches[] = $references->getBranch($branchName); diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 7be3d89c..7afbebd4 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -12,7 +12,13 @@ namespace Gitonomy\Git; -class CommitReference +/** + * The submodules of trees, a Submodule: 2.0 returns Submodule objects, and + * makes this class its alias. + * + * @deprecated since gitonomy/gitlib 1.7, use Submodule instead + */ +class CommitReference extends Submodule { /** * @var string @@ -23,4 +29,12 @@ public function __construct($hash) { $this->hash = $hash; } + + /** + * The hash of this class, which the objects 1.6 serialized have too, unlike the one of Submodule. + */ + public function getHash(): string + { + return $this->hash; + } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 7737ee8f..87d3a521 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -14,11 +14,14 @@ use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\LegacyOverride; /** * Representation of a diff. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Diff { @@ -45,21 +48,38 @@ public function __construct(array $files, $rawDiff) } /** + * Parses the output of ``git diff --raw --patch``. + * + * @param Repository|null $repository The repository to read the blobs of the files from + * * @return Diff + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function parse($rawDiff) + public static function parse($rawDiff /* , ?Repository $repository = null */) { + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; + $parser = new DiffParser(); $parser->parse($rawDiff); - return new self($parser->files, $rawDiff); + $diff = new self($parser->files, $rawDiff); + if (null !== $repository) { + $diff->attachRepository($repository); + } + + return $diff; } + /** + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository) { - foreach ($this->files as $file) { - $file->setRepository($repository); - } + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + + $this->attachRepository($repository); } /** @@ -103,20 +123,43 @@ function (File $file) { /** * Create a new instance of Diff from an array. * - * @param array $array The array + * @param array $array The array + * @param Repository|null $repository The repository to read the blobs of the files from * * @return Diff The new instance + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function fromArray(array $array) + public static function fromArray(array $array /* , ?Repository $repository = null */) { + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; + return new static( array_map( - function ($array) { - return File::fromArray($array); + function ($array) use ($repository) { + return File::fromArray($array, $repository); }, $array['files'] ), $array['rawDiff'] ); } + + private function attachRepository(Repository $repository): void + { + foreach ($this->files as $file) { + // Like in 1.6, a subclass overriding File::setRepository() gets the repository. + if (LegacyOverride::isOverridden($file, File::class, 'setRepository')) { + $file->setRepository($repository); + + continue; + } + + // Without the deprecation of File::setRepository(). + \Closure::bind(static function () use ($file, $repository) { + $file->repository = $repository; + }, null, File::class)(); + } + } } diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php new file mode 100644 index 00000000..cabd2429 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -0,0 +1,40 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * A line of a hunk. + * + * @author Grégoire Pineau + */ +final class DiffLine +{ + public function __construct( + private readonly DiffLineType $type, + private readonly string $content, + ) { + } + + public function getType(): DiffLineType + { + return $this->type; + } + + /** + * @return string The line, without its "+", "-" or " " prefix, nor its line break + */ + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php new file mode 100644 index 00000000..a00cc0f8 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * The kind of a line of a hunk, its values those of the deprecated + * FileChange::LINE_* constants. + * + * @author Grégoire Pineau + */ +enum DiffLineType: int +{ + case Addition = 1; + case Context = 0; + case Deletion = -1; +} diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index bfca155c..1b72d86f 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -13,9 +13,12 @@ namespace Gitonomy\Git\Diff; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\LegacyOverride; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class File { @@ -54,6 +57,11 @@ class File */ protected $isBinary; + /** + * @var bool + */ + private $isCopy; + /** * @var FileChange[] An array of FileChange objects */ @@ -66,8 +74,11 @@ class File /** * Instanciates a new File object. + * + * @param FileChange[] $changes + * @param Repository|null $repository The repository to read the blobs from */ - public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary) + public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy = false, array $changes = [], ?Repository $repository = null) { $this->oldName = $oldName; $this->newName = $newName; @@ -76,12 +87,18 @@ public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $ $this->oldIndex = $oldIndex; $this->newIndex = $newIndex; $this->isBinary = $isBinary; - - $this->changes = []; + $this->isCopy = $isCopy; + $this->changes = $changes; + $this->repository = $repository; } + /** + * @deprecated since gitonomy/gitlib 1.7, give the changes to the constructor instead + */ public function addChange(FileChange $change) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); + $this->changes[] = $change; } @@ -108,23 +125,46 @@ public function isModification() /** * Indicates if it's a rename. * - * A rename can only occurs if it's a modification (not a creation or a deletion). + * A rename can only occurs if it's a modification (not a creation or a deletion), and is not a copy. * * @return bool */ public function isRename() { - return $this->isModification() && $this->oldName !== $this->newName; + return $this->isModification() && $this->oldName !== $this->newName && !$this->isCopy; + } + + /** + * Indicates if it's a copy. + * + * Unlike a rename, the source file of a copy still exists after the change. + * The diffs gitlib reads have no copies: git detects them with -C only. + */ + public function isCopy(): bool + { + return $this->isCopy; } /** * Indicates if the file mode has changed. */ - public function isChangeMode() + public function isModeChange(): bool { return $this->isModification() && $this->oldMode !== $this->newMode; } + /** + * Indicates if the file mode has changed. + * + * @deprecated since gitonomy/gitlib 1.7, use isModeChange() instead + */ + public function isChangeMode() + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); + + return $this->isModeChange(); + } + /** * Indicates if this diff file is a deletion. * @@ -139,10 +179,14 @@ public function isDeletion() * Indicates if this diff file is a deletion. * * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete() { - return null === $this->newName; + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -152,7 +196,8 @@ public function getAdditions() { $result = 0; foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_ADD); + // Like in 1.6, a subclass overriding FileChange::getCount() counts the lines. + $result += LegacyOverride::isOverridden($change, FileChange::class, 'getCount') ? $change->getCount(DiffLineType::Addition->value) : $change->getAdditions(); } return $result; @@ -165,7 +210,8 @@ public function getDeletions() { $result = 0; foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_REMOVE); + // Like in 1.6, a subclass overriding FileChange::getCount() counts the lines. + $result += LegacyOverride::isOverridden($change, FileChange::class, 'getCount') ? $change->getCount(DiffLineType::Deletion->value) : $change->getDeletions(); } return $result; @@ -240,21 +286,31 @@ public function toArray() } /** + * @param Repository|null $repository The repository to read the blobs from + * * @return File + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function fromArray(array $array) + public static function fromArray(array $array /* , ?Repository $repository = null */) { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; - foreach ($array['changes'] as $change) { - $file->addChange(FileChange::fromArray($change)); - } + $changes = array_map(function (array $change) { + return FileChange::fromArray($change); + }, $array['changes']); - return $file; + return new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary'], $array['is_copy'] ?? false, $changes, $repository); } + /** + * @deprecated since gitonomy/gitlib 1.7, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead + */ public function getAnchor() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); + return substr($this->newIndex, 0, 12); } @@ -263,8 +319,13 @@ public function getRepository() return $this->repository; } + /** + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + $this->repository = $repository; } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index cca18dc6..2da0f01a 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -12,10 +12,26 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * @final since gitonomy/gitlib 1.7 + */ class FileChange { + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Context instead + */ const LINE_CONTEXT = 0; + + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Deletion instead + */ const LINE_REMOVE = -1; + + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Addition instead + */ const LINE_ADD = 1; protected $rangeOldStart; @@ -25,36 +41,52 @@ class FileChange protected $lines; /** - * @param int $rangeOldStart - * @param int $rangeOldCount - * @param int $rangeNewStart - * @param int $rangeNewCount - * @param array $lines + * @param int $rangeOldStart + * @param int $rangeOldCount + * @param int $rangeNewStart + * @param int $rangeNewCount + * @param list $lines The lines as arrays, [$type, $content], are deprecated * * @return void */ public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines) { + foreach ($lines as $line) { + if (!$line instanceof DiffLine) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing the lines to "%s()" as arrays is deprecated, pass "%s" objects instead.', __METHOD__, DiffLine::class); + + break; + } + } + $this->rangeOldStart = $rangeOldStart; $this->rangeOldCount = $rangeOldCount; $this->rangeNewStart = $rangeNewStart; $this->rangeNewCount = $rangeNewCount; - $this->lines = $lines; + // As arrays, like in 1.6: the subclasses may read them. + $this->lines = array_map(static fn ($line) => $line instanceof DiffLine ? [$line->getType()->value, $line->getContent()] : $line, $lines); } /** * @return int + * + * @deprecated since gitonomy/gitlib 1.7, use getAdditions() or getDeletions() instead */ public function getCount($type) { - $result = 0; - foreach ($this->lines as $line) { - if ($line[0] === $type) { - $result++; - } - } + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); - return $result; + return $this->countLines($type); + } + + public function getAdditions(): int + { + return $this->countLines(DiffLineType::Addition->value); + } + + public function getDeletions(): int + { + return $this->countLines(DiffLineType::Deletion->value); } /** @@ -90,10 +122,22 @@ public function getRangeNewCount() } /** - * @return array + * @return list + */ + public function getDiffLines(): array + { + return array_map(static fn (array $line): DiffLine => new DiffLine(self::lineType($line[0]), $line[1]), array_values($this->lines)); + } + + /** + * @return array The lines, as arrays: [$type, $content] + * + * @deprecated since gitonomy/gitlib 1.7, use getDiffLines() instead */ public function getLines() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getDiffLines()" instead.', __METHOD__); + return $this->lines; } @@ -118,6 +162,30 @@ public function toArray() */ public static function fromArray(array $array) { - return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); + $change = new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], []); + // The lines as they are, like in 1.6. + $change->lines = $array['lines']; + + return $change; + } + + /** + * @throws InvalidArgumentException $type is none of the values of DiffLineType + */ + private static function lineType(int $type): DiffLineType + { + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown type of line %d: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).', $type)); + } + + private function countLines($type): int + { + $result = 0; + foreach ($this->lines as $line) { + if ($line[0] === $type) { + $result++; + } + } + + return $result; } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php new file mode 100644 index 00000000..c999bd2e --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -0,0 +1,238 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\RuntimeException; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; + +/** + * Configures how gitlib runs git: pass it as the "driver" argument of + * Repository, and of its static methods creating a repository. + * + * Its constructor is the one of the CliDriver of gitlib 2.0, to write code + * running with both versions. + * + * @author Grégoire Pineau + */ +final class CliDriver +{ + /** + * @param string $command The git binary + * @param array $environmentVariables Set on every git process, false removing an inherited one + * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + * @param LoggerInterface|null $logger Receives every git command a repository runs, with its output + */ + public function __construct( + private string $command = 'git', + private array $environmentVariables = [], + private ?float $processTimeout = 3600, + private ?LoggerInterface $logger = null, + ) { + } + + /** + * @internal For the deprecated options of Admin and Repository::cloneTo(), to be removed in 2.0 + */ + public static function fromOptions(array $options): self + { + $options = array_merge([ + 'environment_variables' => defined('PHP_WINDOWS_VERSION_BUILD') ? ['PATH' => getenv('PATH')] : [], + 'command' => 'git', + 'process_timeout' => 3600, + ], $options); + + return new self($options['command'], $options['environment_variables'], $options['process_timeout']); + } + + /** + * @internal For Repository, which takes options in 1.x + * + * @return array{command: string, environment_variables: array, process_timeout: float|null, logger: LoggerInterface|null} + */ + public function getOptions(): array + { + return [ + 'command' => $this->command, + 'environment_variables' => $this->environmentVariables, + 'process_timeout' => $this->processTimeout, + 'logger' => $this->logger, + ]; + } + + /** + * @internal For Repository::init() + * + * @throws RuntimeException + */ + public function init(string $path): void + { + $this->initRepository(['-q', $path]); + } + + /** + * @internal For Repository::initBare() + * + * @throws RuntimeException + */ + public function initBare(string $path): void + { + $this->initRepository(['-q', '--bare', $path]); + } + + /** + * @internal For Repository::cloneFrom() + * + * @throws RuntimeException + */ + public function cloneFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($url, $path, null === $branch ? [] : ['--branch', $branch]); + } + + /** + * @internal For Repository::cloneBareFrom() + * + * @throws RuntimeException + */ + public function cloneBareFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($url, $path, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); + } + + /** + * @internal For Repository::mirrorFrom() + * + * @throws RuntimeException + */ + public function mirrorFrom(string $url, string $path): void + { + $this->cloneRepository($url, $path, ['--mirror']); + } + + /** + * @internal For Repository::isValidRepository() + */ + public function isValidRepository(string $url): bool + { + $process = $this->createLsRemoteProcess([], $url, []); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * @internal For Repository::isValidRepositoryAndBranch() + */ + public function isValidRepositoryAndBranch(string $url, string $branch): bool + { + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". + $process = $this->createLsRemoteProcess(['--heads'], $url, ['refs/heads/'.$branch]); + $process->run(); + if (!$process->isSuccessful()) { + return false; + } + + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_ends_with($line, "\trefs/heads/".$branch)) { + return true; + } + } + + return false; + } + + /** + * @internal For the deprecated Admin, to be removed in 2.0 + * + * @param string[] $args Arguments of ``git clone`` + * + * @throws RuntimeException + */ + public function cloneRepository(string $url, string $path, array $args): void + { + $process = $this->createProcess('clone', array_merge(['-q'], $args, [$url, $path])); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); + } + } + + /** + * @internal For the deprecated Admin, to be removed in 2.0 + * + * @param string[] $args + */ + public function createProcess(string $command, array $args): Process + { + $process = new Process(array_merge([$this->command, $command], $args)); + $process->setEnv($this->environmentVariables); + $process->setTimeout($this->processTimeout); + $process->setIdleTimeout($this->processTimeout); + + return $process; + } + + /** + * Like in 2.0, out of the working directory of PHP, which may be a + * repository, like the checkout of an application: ls-remote would read + * its configuration, and a relative path as the name of one of its remotes. + * + * @param string[] $options + * @param string[] $patterns + */ + private function createLsRemoteProcess(array $options, string $url, array $patterns): Process + { + $process = $this->createProcess('ls-remote', array_merge($options, [self::absoluteUrl($url)], $patterns)); + $process->setWorkingDirectory(sys_get_temp_dir()); + + return $process; + } + + /** + * A relative path, relative to the working directory of PHP. + */ + private static function absoluteUrl(string $url): string + { + if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\\/]#', $url)) { + return $url; + } + + // Like git: a URL, or an address like "host:path", has a colon before any slash. + $colon = strpos($url, ':'); + $slash = strpos($url, '/'); + if (false !== $colon && (false === $slash || $colon < $slash)) { + return $url; + } + + $cwd = getcwd(); + + return false === $cwd ? $url : $cwd.'/'.$url; + } + + /** + * @param string[] $args + * + * @throws RuntimeException + */ + private function initRepository(array $args): void + { + $process = $this->createProcess('init', $args); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + } + } +} diff --git a/src/Gitonomy/Git/Exception/LogicException.php b/src/Gitonomy/Git/Exception/LogicException.php index 8d299890..3d5866fe 100644 --- a/src/Gitonomy/Git/Exception/LogicException.php +++ b/src/Gitonomy/Git/Exception/LogicException.php @@ -2,6 +2,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class LogicException extends \LogicException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 6d6b6912..c4e20028 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -4,6 +4,9 @@ use Symfony\Component\Process\Process; +/** + * @final since gitonomy/gitlib 1.7 + */ class ProcessException extends RuntimeException implements GitExceptionInterface { protected $process; diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index dd9ae98d..1de3908c 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface { public function __construct($reference) diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index 83b3bebb..00d26bf5 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -2,6 +2,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index dd0adbe8..d5fdd0a6 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -20,6 +20,8 @@ * Hooks collection, aggregated by repository. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Hooks { diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 47665359..fae16366 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -13,12 +13,16 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LegacyOverride; use Gitonomy\Git\Util\StringHelper; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Log implements \Countable, \IteratorAggregate { @@ -62,6 +66,17 @@ public function __construct(Repository $repository, $revisions = null, $paths = $revisions = new RevisionList($repository, $revisions); } + foreach (null === $revisions ? [] : $revisions->getAll() as $revision) { + if ($revision instanceof Commit || $revision instanceof Reference) { + continue; + } + + $name = $revision->getRevision(); + if (str_starts_with($name, '-') && '--all' !== $name) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", an option of "git log", as revision to "%s()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".', $name, __METHOD__); + } + } + if (null === $paths) { $paths = []; } elseif (is_string($paths)) { @@ -70,6 +85,14 @@ public function __construct(Repository $repository, $revisions = null, $paths = throw new \InvalidArgumentException(sprintf('Expected a string or an array, got a "%s".', is_object($paths) ? get_class($paths) : gettype($paths))); } + if (null !== $offset && $offset < 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a negative offset to "%s()" is deprecated, it throws in 2.0.', __METHOD__); + } + + if (null !== $limit && $limit < 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a negative limit to "%s()" is deprecated, it throws in 2.0: pass null for no limit.', __METHOD__); + } + $this->repository = $repository; $this->revisions = $revisions; $this->paths = $paths; @@ -79,10 +102,14 @@ public function __construct(Repository $repository, $revisions = null, $paths = /** * @return Diff + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::getDiff() instead: it ignored the paths of the log */ public function getDiff() { - return $this->repository->getDiff($this->revisions); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); + + return $this->repository->getLegacyDiff($this->revisions); } /** @@ -109,11 +136,32 @@ public function getOffset() return $this->offset; } + /** + * @return self The log, starting from the commit $offset + * + * @throws InvalidArgumentException $offset is negative + */ + public function withOffset(?int $offset): self + { + if (null !== $offset && $offset < 0) { + throw new InvalidArgumentException(sprintf('The offset of a log can\'t be negative, %d given.', $offset)); + } + + $log = clone $this; + $log->offset = $offset; + + return $log; + } + /** * @param int $offset + * + * @deprecated since gitonomy/gitlib 1.7, use withOffset() instead */ public function setOffset($offset) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); + $this->offset = $offset; return $this; @@ -127,11 +175,32 @@ public function getLimit() return $this->limit; } + /** + * @return self The log, of $limit commits at most + * + * @throws InvalidArgumentException $limit is negative + */ + public function withLimit(?int $limit): self + { + if (null !== $limit && $limit < 0) { + throw new InvalidArgumentException(sprintf('The limit of a log can\'t be negative, %d given.', $limit)); + } + + $log = clone $this; + $log->limit = $limit; + + return $log; + } + /** * @param int $limit + * + * @deprecated since gitonomy/gitlib 1.7, use withLimit() instead */ public function setLimit($limit) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); + $this->limit = $limit; return $this; @@ -145,7 +214,12 @@ public function getSingleCommit() $limit = $this->limit; $this->limit = 1; $commits = $this->getCommits(); - $this->setLimit($limit); + // Like in 1.6, a subclass overriding setLimit() sets the limit back. + if (LegacyOverride::isOverridden($this, self::class, 'setLimit')) { + $this->setLimit($limit); + } else { + $this->limit = $limit; + } if (count($commits) === 0) { throw new ReferenceNotFoundException('The log is empty'); @@ -159,7 +233,9 @@ public function getSingleCommit() */ public function getCommits() { - $args = ['--encoding='.StringHelper::getEncoding(), '--format=raw']; + // The encoding StringHelper::setEncoding() set, without loading this deprecated class. + $encoding = class_exists(StringHelper::class, false) ? StringHelper::getEncoding() : 'utf-8'; + $args = ['--encoding='.$encoding, '--format=raw']; if (null !== $this->offset) { $args[] = '--skip='.((int) $this->offset); @@ -204,12 +280,26 @@ public function getCommits() } /** + * Counting a log whose offset or limit leaves commits out is deprecated: + * this count includes them, where 2.0 counts the commits the log iterates. + * * @see Countable */ #[\ReturnTypeWillChange] public function count() { - return $this->countCommits(); + $count = $this->countCommits(); + + $iterated = max(0, $count - (int) $this->offset); + if (null !== $this->limit) { + $iterated = min($iterated, (int) $this->limit); + } + + if ($iterated !== $count) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Counting a log whose offset or limit leaves commits out with "%s()" is deprecated, it counts only the commits the log iterates in 2.0: use "countCommits()" to count them all.', __METHOD__); + } + + return $count; } /** diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Parser/BlameParser.php index 8dd6c570..377cc6c0 100644 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Parser/BlameParser.php @@ -15,6 +15,9 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Repository; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class BlameParser extends ParserBase { public $lines; diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php index 6fff1ea4..bfcb64ed 100644 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ b/src/Gitonomy/Git/Parser/CommitParser.php @@ -14,6 +14,9 @@ use Gitonomy\Git\Exception\RuntimeException; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 merges it into the internal LogParser of its CliDriver + */ class CommitParser extends ParserBase { public $tree; diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index b6386ee9..cd741015 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -12,9 +12,14 @@ namespace Gitonomy\Git\Parser; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; +/** + * @final since gitonomy/gitlib 1.7 + */ class DiffParser extends ParserBase { public $files; @@ -117,9 +122,8 @@ protected function doParse() $newIndex = $newIndex === null ? '' : $newIndex; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); - // 5. Diff + $changes = []; while ($this->expects('@@ ')) { $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); $rangeOldStart = (int) $vars[1]; @@ -134,11 +138,11 @@ protected function doParse() $lines = []; while (true) { if ($this->expects(' ')) { - $lines[] = [FileChange::LINE_CONTEXT, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Context, $this->consumeTo("\n")); } elseif ($this->expects('+')) { - $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Addition, $this->consumeTo("\n")); } elseif ($this->expects('-')) { - $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Deletion, $this->consumeTo("\n")); } elseif ($this->expects("\ No newline at end of file")) { // Ignore this case... } else { @@ -148,12 +152,10 @@ protected function doParse() $this->consumeNewLine(); } - $change = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); - - $file->addChange($change); + $changes[] = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); } - $this->files[] = $file; + $this->files[] = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, false, $changes); } } } diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 67b86687..c92df2f0 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class LogParser extends CommitParser { public $log = []; diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Parser/ReferenceParser.php index 82e3a8b5..4091bade 100644 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Parser/ReferenceParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class ReferenceParser extends ParserBase { public $references; diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php index 1a1c084c..bfe8b6bc 100644 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -14,6 +14,9 @@ use Gitonomy\Git\Exception\RuntimeException; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class TagParser extends ParserBase { public $object; diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Parser/TreeParser.php index d01f4b28..2636a565 100644 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Parser/TreeParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class TreeParser extends ParserBase { public $entries = []; diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index dea10823..ce851642 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -13,12 +13,15 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Util\LegacyOverride; /** * Push reference contains a commit interval. This object aggregates methods * for this interval. * * @author Julien DIDIER + * + * @final since gitonomy/gitlib 1.7 */ class PushReference { @@ -65,11 +68,23 @@ public function getRepository() return $this->repository; } + /** + * @return string The full name of the pushed reference + */ + public function getFullname(): string + { + return $this->reference; + } + /** * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use getFullname() instead */ public function getReference() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); + return $this->reference; } @@ -95,7 +110,8 @@ public function getAfter() public function getLog($excludes = []) { return $this->repository->getLog(array_merge( - [$this->getRevision()], + // Like in 1.6, a subclass overriding getRevision() gives the range. + [LegacyOverride::isOverridden($this, self::class, 'getRevision') ? $this->getRevision() : $this->getRange()], array_map(function ($e) { return '^'.$e; }, $excludes) @@ -103,35 +119,73 @@ public function getLog($excludes = []) } /** - * @return string + * @return string The pushed commits: "before..after", or "after" for a creation + * + * @throws LogicException The push deletes the reference */ - public function getRevision() + public function getRange(): string { - if ($this->isDelete()) { + if ($this->deletes()) { throw new LogicException('No revision for deletion'); } - if ($this->isCreate()) { + if ($this->creates()) { return $this->getAfter(); } return $this->getBefore().'..'.$this->getAfter(); } + /** + * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use getRange() instead + */ + public function getRevision() + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); + + return $this->getRange(); + } + + /** + * @return bool Whether the push creates the reference, like File::isCreation() for a file + */ + public function isCreation(): bool + { + return $this->isZero($this->before); + } + /** * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isCreation() instead */ public function isCreate() { - return $this->isZero($this->before); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); + + return $this->isCreation(); + } + + /** + * @return bool Whether the push deletes the reference, like File::isDeletion() for a file + */ + public function isDeletion(): bool + { + return $this->isZero($this->after); } /** * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete() { - return $this->isZero($this->after); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -147,7 +201,7 @@ public function isForce() */ public function isFastForward() { - return !$this->isDelete() && !$this->isCreate() && !$this->isForce(); + return !$this->deletes() && !$this->creates() && !$this->isForce(); } /** @@ -163,7 +217,7 @@ protected function isZero($reference) */ protected function getForce() { - if ($this->isDelete() || $this->isCreate()) { + if ($this->deletes() || $this->creates()) { return false; } @@ -174,4 +228,20 @@ protected function getForce() return $this->before !== trim($result); } + + /** + * Like in 1.6, a subclass overriding isCreate() tells whether the push creates the reference. + */ + private function creates(): bool + { + return LegacyOverride::isOverridden($this, self::class, 'isCreate') ? $this->isCreate() : $this->isCreation(); + } + + /** + * Like in 1.6, a subclass overriding isDelete() tells whether the push deletes the reference. + */ + private function deletes(): bool + { + return LegacyOverride::isOverridden($this, self::class, 'isDelete') ? $this->isDelete() : $this->isDeletion(); + } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 96b8fa12..f5f430cb 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -25,6 +25,9 @@ abstract class Reference extends Revision { protected $commitHash; + /** + * In gitlib 2.0, the constructor is internal, and requires the commit hash: get it from Repository::getReferences(). + */ public function __construct(Repository $repository, $revision, $commitHash = null) { $this->repository = $repository; diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 0d27fb88..ab5799df 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -15,12 +15,14 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; -use Gitonomy\Git\Util\StringHelper; +use Gitonomy\Git\Revision; /** * Representation of a branch reference. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Branch extends Reference { @@ -55,6 +57,18 @@ public function isLocal() return $this->local; } + /** + * Checks if the history of $revision contains this branch. + */ + public function isMergedInto(Revision|string $revision): bool + { + // ReferenceBag::createBranch() keeps the name it got instead of a hash, like in 1.6. + $commit = $this->repository->getRevision($this->getCommitHash())->getCommit(); + $mergeBase = $this->repository->getMergeBase($commit, $revision); + + return null !== $mergeBase && $mergeBase->getHash() === $commit->getHash(); + } + /** * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. @@ -63,9 +77,13 @@ public function isLocal() * @param bool $compareOnlyWithRemote * * @return null|bool + * + * @deprecated since gitonomy/gitlib 1.7, use isMergedInto() instead */ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRemote = false) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); + $arguments = ['-a']; if ($compareOnlyWithRemote) { @@ -91,13 +109,29 @@ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRe $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); $filtered_output = array_filter($output, static function ($v) { - return false === StringHelper::strpos($v, '->'); + return false === strpos($v, '->'); }); $trimmed_output = array_map('trim', $filtered_output); return in_array($this->getName(), $trimmed_output, true); } + /** + * Points the branch to $revision. + * + * @return self The branch, pointing to $revision + */ + public function moveTo(Revision|string $revision): self + { + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->run('update-ref', [$this->getFullname(), $hash]); + + $references = $this->repository->getReferences(); + $references->invalidate(); + + return $this->isLocal() ? $references->getBranch($this->getName()) : $references->getRemoteBranch($this->getName()); + } + private function detectBranchType() { if (null === $this->local) { diff --git a/src/Gitonomy/Git/Reference/Stash.php b/src/Gitonomy/Git/Reference/Stash.php index 9f26802f..fb8ae331 100644 --- a/src/Gitonomy/Git/Reference/Stash.php +++ b/src/Gitonomy/Git/Reference/Stash.php @@ -16,6 +16,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Stash extends Reference { diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 78c43b25..cae854ce 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -24,6 +24,8 @@ * * @author Alexandre Salomé * @author Bruce Wells + * + * @final since gitonomy/gitlib 1.7 */ class Tag extends Reference { diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 71ef9ae4..2358a46b 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -17,12 +17,15 @@ use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Util\LegacyOverride; /** * Reference set associated to a repository. * * @author Alexandre Salomé * @author Julien DIDIER + * + * @final since gitonomy/gitlib 1.7 */ class ReferenceBag implements \Countable, \IteratorAggregate { @@ -65,6 +68,8 @@ class ReferenceBag implements \Countable, \IteratorAggregate * Constructor. * * @param Repository $repository The repository + * + * In gitlib 2.0, the constructor is internal: get it from Repository::getReferences(). */ public function __construct($repository) { @@ -104,44 +109,63 @@ public function has($fullname) /** * @return Reference + * + * @deprecated since gitonomy/gitlib 1.7, use Branch::moveTo() instead */ public function update(Reference $reference) { - $fullname = $reference->getFullname(); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); - $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); - - $this->references[$fullname] = $reference; - - return $reference; + return $this->updateReference($reference); } /** - * @return Reference + * Creating a branch that exists already, which moves it, is deprecated. + * + * @param Revision|string $commitHash The hash of a commit, or a Revision + * + * @return Branch */ public function createBranch($name, $commitHash) { - $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); + if ($this->has('refs/heads/'.$name)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Creating the branch "%s", which exists already, is deprecated, it throws in 2.0: use "%s::moveTo()" instead.', $name, Branch::class); + } - return $this->update($branch); + $branch = new Branch($this->repository, 'refs/heads/'.$name, self::commitHash($commitHash)); + + return $this->write($branch); } /** - * @return Reference + * Creating a tag that exists already, which moves it, is deprecated. + * + * @param Revision|string $commitHash The hash of a commit, or a Revision + * + * @return Tag */ public function createTag($name, $commitHash) { - $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); + if ($this->has('refs/tags/'.$name)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Creating the tag "%s", which exists already, is deprecated, it throws in 2.0: delete it first.', $name); + } - return $this->update($tag); + $tag = new Tag($this->repository, 'refs/tags/'.$name, self::commitHash($commitHash)); + + return $this->write($tag); } /** + * Deleting a reference by another name than its full name under "refs/", like "HEAD" or "master", is deprecated. + * * @return void */ public function delete($fullname) { + if (!str_starts_with($fullname, 'refs/')) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Deleting "%s", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.', $fullname); + } + $this->repository->run('update-ref', ['-d', $fullname]); unset($this->references[$fullname]); @@ -379,6 +403,19 @@ protected function initialize() } } + /** + * Reads the references again when next needed. + * + * @internal + */ + public function invalidate(): void + { + $this->initialized = false; + $this->references = []; + $this->tags = []; + $this->branches = []; + } + /** * @return int * @@ -402,4 +439,37 @@ public function getIterator() return new \ArrayIterator($this->references); } + + /** + * @return Reference + */ + private function updateReference(Reference $reference) + { + $fullname = $reference->getFullname(); + + $this->initialize(); + $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + + $this->references[$fullname] = $reference; + + return $reference; + } + + /** + * @return Reference + */ + private function write(Reference $reference) + { + // Like in 1.6, a subclass overriding update() writes the reference. + return LegacyOverride::isOverridden($this, self::class, 'update') ? $this->update($reference) : $this->updateReference($reference); + } + + /** + * @param Revision|string $commitHash + */ + private static function commitHash($commitHash) + { + // A string as it is, like in 1.6: git resolves a name when writing the reference. + return $commitHash instanceof Revision ? $commitHash->getCommit()->getHash() : $commitHash; + } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 296496ae..fc78d09e 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -13,8 +13,10 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -25,6 +27,8 @@ * Main entry point for browsing a Git repository. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Repository { @@ -101,7 +105,11 @@ class Repository /** * Constructs a new repository. * - * Available options are: + * The second argument is the working directory, when $dir is a git + * directory kept apart from it. An array of options there is deprecated: + * its "working_dir" option is this argument, and its "logger", "command", + * "environment_variables" and "process_timeout" options configure a + * CliDriver. Available options are: * * * working_dir : specify where working copy is located (option --work-tree) * @@ -111,13 +119,32 @@ class Repository * * * environment_variables : define environment variables for every ran process * - * @param string $dir path to git repository - * @param array $options array of options values + * @param string $dir path to git repository + * @param string|array|null $options The working tree, or an array of options, deprecated + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null * * @throws InvalidArgumentException The folder does not exists */ - public function __construct($dir, $options = []) + public function __construct($dir, $options = [], ?CliDriver $driver = null) { + if (!is_array($options)) { + $options = ['working_dir' => $options]; + } elseif ([] !== $options) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an array of options to "%s()" is deprecated, pass the working directory as second argument, and configure the logger and git through the "driver" argument.', __METHOD__); + + if (null !== $driver && [] !== array_intersect(array_keys($options), ['command', 'environment_variables', 'inherit_environment_variables', 'logger', 'process_timeout'])) { + throw new InvalidArgumentException('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + } + + if (array_key_exists('debug', $options) && !$options['debug']) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Setting the "debug" option of "%s()" to false is deprecated, a failing git operation always throws in 2.0: catch its exception instead.', __METHOD__); + } + } + + if (null !== $driver) { + $options = array_merge($options, $driver->getOptions()); + } + $options = array_merge([ 'working_dir' => null, 'debug' => true, @@ -152,6 +179,106 @@ public function __construct($dir, $options = []) } } + /** + * Initializes a repository with a working directory in $path. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException The directory can't be written, or is already a file + */ + public static function init(string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->init($path); + + return new self($path, null, $driver); + } + + /** + * Initializes a bare repository in $path. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException The directory can't be written, or is already a file + */ + public static function initBare(string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->initBare($path); + + return new self($path, null, $driver); + } + + /** + * Clones $url with a working directory in $path. + * + * @param string|null $branch The branch to check out, the default branch of $url when null + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function cloneFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->cloneFrom($url, $path, $branch); + + return new self($path, null, $driver); + } + + /** + * Clones the branches and the tags of $url into a bare repository in $path. + * + * @param string|null $branch The branch HEAD points to, the default branch of $url when null + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->cloneBareFrom($url, $path, $branch); + + return new self($path, null, $driver); + } + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function mirrorFrom(string $url, string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->mirrorFrom($url, $path); + + return new self($path, null, $driver); + } + + /** + * Checks, without cloning it, that $url is a repository git can read: + * false when it doesn't exist, can't be reached, or refuses access. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + */ + public static function isValidRepository(string $url, ?CliDriver $driver = null): bool + { + return ($driver ?? new CliDriver())->isValidRepository($url); + } + + /** + * Checks, without cloning it, that $url is a repository git can read, + * and that it has the $branch branch. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + */ + public static function isValidRepositoryAndBranch(string $url, string $branch, ?CliDriver $driver = null): bool + { + return ($driver ?? new CliDriver())->isValidRepositoryAndBranch($url, $branch); + } + /** * Initializes directory attributes on repository:. * @@ -315,12 +442,16 @@ public function getWorkingCopy() /** * Returns the reference list associated to the repository. * - * @param bool $reload Reload references from the filesystem + * @param bool $reload Reload references from the filesystem, deprecated: open a new Repository instead * * @return ReferenceBag */ public function getReferences($reload = false) { + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an argument to "%s()" is deprecated, open a new Repository to read the references again.', __METHOD__); + } + if (null === $this->referenceBag || $reload) { $this->referenceBag = new ReferenceBag($this); } @@ -347,12 +478,18 @@ public function getCommit($hash) /** * Instanciates a tree object or fetches one from the cache. * + * Passing anything but a hash, like a revision name, is deprecated. + * * @param string $hash A tree hash, with a length of 40 * * @return Tree */ public function getTree($hash) { + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.', $hash, __METHOD__); + } + if (!isset($this->objects[$hash])) { $this->objects[$hash] = new Tree($this, $hash); } @@ -363,12 +500,18 @@ public function getTree($hash) /** * Instanciates a blob object or fetches one from the cache. * + * Passing anything but a hash, like "HEAD:README.md", is deprecated. + * * @param string $hash A blob hash, with a length of 40 * * @return Blob */ public function getBlob($hash) { + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.', $hash, __METHOD__); + } + if (!isset($this->objects[$hash])) { $this->objects[$hash] = new Blob($this, $hash); } @@ -377,15 +520,29 @@ public function getBlob($hash) } /** + * Returns the commits that last changed the lines of $file. + * + * The third argument is the first line to blame, and a fourth one the last + * line, like in 2.0. A string as third argument, a range of lines like + * "git blame -L", is deprecated. + * + * @param int|string|null $lineRange The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * * @return Blame + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public function getBlame($revision, $file, $lineRange = null) + public function getBlame($revision, $file, $lineRange = null /* , ?int $lastLine = null */) { + // A fourth argument, without breaking the classes overriding this method. + $lastLine = 3 < \func_num_args() ? func_get_arg(3) : null; + if (is_string($revision)) { $revision = $this->getRevision($revision); } - return new Blame($this, $revision, $file, $lineRange); + return new Blame($this, $revision, $file, $lineRange, $lastLine); } /** @@ -408,29 +565,104 @@ public function getLog($revisions = null, $paths = null, $offset = null, $limit } /** + * Returns the best common ancestor of two commits, the one "git diff a...b" compares to. + * + * @return Commit|null Null when they have no common history + * + * @throws ReferenceNotFoundException A revision names no commit + */ + public function getMergeBase(Revision|string $one, Revision|string $two): ?Commit + { + $process = $this->execute('merge-base', [$this->commitHash($one), $this->commitHash($two)]); + + // git exits with 1, writing nothing, when the commits have no common ancestor. + if (1 === $process->getExitCode() && '' === $process->getErrorOutput()) { + return null; + } + + if (!$process->isSuccessful()) { + // Returns with the "debug" option set to false. + $this->fail('merge-base', $process); + + return null; + } + + return $this->getCommit(trim($process->getOutput())); + } + + /** + * Returns the changes from the commit of the first argument to the commit $to. + * + * Passing one argument is deprecated: an "a..b" or "a...b" range, two + * revisions in an array or a RevisionList, or one revision to compare + * with the working tree, like WorkingCopy::getDiff() does. + * + * @param RevisionList|Revision|string|array $revisions The revision to compare from, when a second argument is given + * @param Revision|string|null $to + * * @return Diff + * + * @throws ReferenceNotFoundException A revision names no commit + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public function getDiff($revisions) + public function getDiff($revisions /* , $to = null */) + { + // A second argument, without breaking the classes overriding this method. + $to = 1 < \func_num_args() ? func_get_arg(1) : null; + + if (null === $to) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as first and second arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); + + return $this->getLegacyDiff($revisions); + } + + if (!$revisions instanceof Revision && !is_string($revisions)) { + throw new InvalidArgumentException('The first argument of "Repository::getDiff()" must be a revision, when a second one is given.'); + } + + return $this->runDiff([$this->commitHash($revisions), $this->commitHash($to)]); + } + + /** + * For the deprecated forms of getDiff() and Log::getDiff(), to be removed in 2.0. + * + * @param RevisionList|Revision|string|array $revisions + * + * @internal + */ + public function getLegacyDiff($revisions): Diff { if (null !== $revisions && !$revisions instanceof RevisionList) { $revisions = new RevisionList($this, $revisions); } - $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); - - $diff = Diff::parse($this->run('diff', $args)); - $diff->setRepository($this); + return $this->runDiff($revisions->getAsTextArray()); + } - return $diff; + /** + * @return int The size of the files of the git directory, working tree excluded, in bytes + * + * @throws RuntimeException A file or a directory of the git directory can't be read + */ + public function getSizeInBytes(): int + { + return self::directorySize($this->gitDir); } /** * Returns the size of repository, in kilobytes. * + * Unlike getSizeInBytes(), it counts the files the links point to. + * * @return int A sum, in kilobytes + * + * @deprecated since gitonomy/gitlib 1.7, use getSizeInBytes() instead */ public function getSize() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); + $totalBytes = 0; $path = realpath($this->gitDir); if ($path && file_exists($path)) { @@ -446,9 +678,13 @@ public function getSize() * Executes a shell command on the repository, using PHP pipes. * * @param string $command The command to execute + * + * @deprecated since gitonomy/gitlib 1.7, run "git shell" with Symfony Process instead */ public function shell($command, array $env = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); + $argument = sprintf('%s \'%s\'', $command, $this->gitDir); $prefix = ''; @@ -534,39 +770,15 @@ public function setDescription($description) */ public function run($command, $args = []) { - $process = $this->getProcess($command, $args); - - if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); - } - - $process->run(); - - $output = $process->getOutput(); - - if ($this->logger && $this->debug) { - $duration = microtime(true) - $before; - $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); - $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $output)); - } + $process = $this->execute($command, $args); if (!$process->isSuccessful()) { - $error = sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); - - if ($this->logger) { - $this->logger->error($error); - } - - if ($this->debug) { - throw new ProcessException($process); - } + $this->fail($command, $process); return; } - return $output; + return $process->getOutput(); } /** @@ -575,9 +787,13 @@ public function run($command, $args = []) * @param LoggerInterface $logger A logger * * @return Repository The current repository + * + * @deprecated since gitonomy/gitlib 1.7, pass a CliDriver configured with the logger as driver argument instead */ public function setLogger(LoggerInterface $logger) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); + $this->logger = $logger; return $this; @@ -587,9 +803,13 @@ public function setLogger(LoggerInterface $logger) * Returns repository logger. * * @return LoggerInterface the logger or null + * + * @deprecated since gitonomy/gitlib 1.7, the logger is an argument of CliDriver */ public function getLogger() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); + return $this->logger; } @@ -600,10 +820,74 @@ public function getLogger() * @param bool $bare flag indicating if repository is bare or has a working-copy * * @return Repository the newly created repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead */ public function cloneTo($path, $bare = true, array $options = []) { - return Admin::cloneTo($path, $this->gitDir, $bare, $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + CliDriver::fromOptions($options)->cloneRepository($this->gitDir, $path, $bare ? ['--bare'] : []); + + return new self($path, $options); + } + + /** + * @param string[] $revisions + */ + private function runDiff(array $revisions): Diff + { + $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions); + + return Diff::parse($this->run('diff', $args), $this); + } + + /** + * @throws ReferenceNotFoundException $revision names no commit + */ + private function commitHash(Revision|string $revision): string + { + return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); + } + + /** + * Runs a git command, and logs it. + */ + private function execute($command, $args = []): Process + { + $process = $this->getProcess($command, $args); + + if ($this->logger) { + $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $before = microtime(true); + } + + $process->run(); + + if ($this->logger && $this->debug) { + $duration = microtime(true) - $before; + $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); + $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(sprintf('last command (%s) output: %s', $command, $process->getOutput())); + } + + return $process; + } + + /** + * Logs the failure of a git command, and throws unless the "debug" option is false. + * + * @throws ProcessException + */ + private function fail($command, Process $process): void + { + if ($this->logger) { + $this->logger->error(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput())); + } + + if ($this->debug) { + throw new ProcessException($process); + } } /** @@ -637,4 +921,49 @@ private function getProcess($command, $args = []) return $process; } + + /** + * git may delete a file or a directory while it is counted, during a gc + * or a push: a lock, a temporary pack, the quarantine of a push... It no + * longer counts. + * + * @throws RuntimeException A file or a directory of $dir can't be read + */ + private static function directorySize(string $dir): int + { + try { + $files = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS); + } catch (\UnexpectedValueException $e) { + if (!file_exists($dir)) { + return 0; + } + + throw new RuntimeException(sprintf('Unable to read the directory "%s".', $dir), 0, $e); + } + + $size = 0; + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + if ($file->isDir()) { + $size += self::directorySize($file->getPathname()); + + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + if (file_exists($file->getPathname())) { + throw new RuntimeException(sprintf('Unable to read the size of "%s".', $file->getPathname()), 0, $e); + } + } + } + + return $size; + } } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 9e1921fb..146c1ad1 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -27,6 +27,9 @@ class Revision */ protected $revision; + /** + * In gitlib 2.0, the constructor is internal: get it from Repository::getRevision(). + */ public function __construct(Repository $repository, $revision) { $this->repository = $repository; diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 1c1a1dcd..471b1574 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -14,6 +14,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class RevisionList implements \IteratorAggregate, \Countable { diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php new file mode 100644 index 00000000..95326af6 --- /dev/null +++ b/src/Gitonomy/Git/Submodule.php @@ -0,0 +1,41 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * A submodule in a tree: the commit of another repository it points to. + * + * @author Grégoire Pineau + * + * @final + */ +class Submodule +{ + /** + * @var string + */ + private $hash; + + public function __construct(string $hash) + { + $this->hash = $hash; + } + + /** + * @return string The commit of the submodule, in its own repository + */ + public function getHash(): string + { + return $this->hash; + } +} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 7830cfa4..87c2d867 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -17,8 +17,12 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 + * + * @implements \IteratorAggregate */ -class Tree +class Tree implements \IteratorAggregate { protected $repository; protected $hash; @@ -26,6 +30,9 @@ class Tree protected $entries; protected $entriesByType; + /** + * In gitlib 2.0, the constructor is internal: get it from Repository::getTree() or Commit::getTree(). + */ public function __construct(Repository $repository, $hash) { $this->repository = $repository; @@ -71,10 +78,54 @@ protected function initialize() } /** - * @return array An associative array name => $object + * @return \ArrayIterator The entries, by name + */ + public function getIterator(): \ArrayIterator + { + $this->initialize(); + + return new \ArrayIterator(self::treeEntries($this->entries)); + } + + /** + * @return array The blobs, by name + */ + public function getBlobs(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['blob']); + } + + /** + * @return array The submodules, by name + */ + public function getSubmodules(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['commit']); + } + + /** + * @return array The trees, by name + */ + public function getTrees(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['tree']); + } + + /** + * @return array An associative array name => [mode, object], a CommitReference being a Submodule + * + * @deprecated since gitonomy/gitlib 1.7, iterate the tree instead */ public function getEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, iterate the tree instead.', __METHOD__); + $this->initialize(); return $this->entries; @@ -82,9 +133,13 @@ public function getEntries(): array /** * @return array An associative array of name => [mode, commit reference] + * + * @deprecated since gitonomy/gitlib 1.7, use getSubmodules() instead */ public function getCommitReferenceEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSubmodules()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['commit']; @@ -92,9 +147,13 @@ public function getCommitReferenceEntries(): array /** * @return array An associative array of name => [mode, tree] + * + * @deprecated since gitonomy/gitlib 1.7, use getTrees() instead */ public function getTreeEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getTrees()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['tree']; @@ -102,9 +161,13 @@ public function getTreeEntries(): array /** * @return array An associative array of name => [mode, blob] + * + * @deprecated since gitonomy/gitlib 1.7, use getBlobs() instead */ public function getBlobEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getBlobs()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['blob']; @@ -143,4 +206,19 @@ public function resolvePath($path) return $element; } + + /** + * @param array $entries + * + * @return array + */ + private static function treeEntries(array $entries): array + { + $treeEntries = []; + foreach ($entries as $name => [$mode, $object]) { + $treeEntries[$name] = new TreeEntry((string) $name, $mode, $object); + } + + return $treeEntries; + } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php new file mode 100644 index 00000000..0c183a34 --- /dev/null +++ b/src/Gitonomy/Git/TreeEntry.php @@ -0,0 +1,46 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * An entry of a tree: a blob, a tree or a submodule, with its name and mode. + * + * @author Grégoire Pineau + */ +final class TreeEntry +{ + public function __construct( + private readonly string $name, + private readonly string $mode, + private readonly Blob|Tree|Submodule $object, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string The mode git stores, like "100644" for a file or "040000" for a tree + */ + public function getMode(): string + { + return $this->mode; + } + + public function getObject(): Blob|Tree|Submodule + { + return $this->object; + } +} diff --git a/src/Gitonomy/Git/Util/LegacyOverride.php b/src/Gitonomy/Git/Util/LegacyOverride.php new file mode 100644 index 00000000..0eb406d2 --- /dev/null +++ b/src/Gitonomy/Git/Util/LegacyOverride.php @@ -0,0 +1,33 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Tells whether a subclass overrides a method that gitlib 1.x called itself, + * deprecated in 1.7: gitlib keeps calling the override, like before. + * + * @author Grégoire Pineau + * + * @internal To be removed in 2.0, with the deprecated methods + */ +final class LegacyOverride +{ + /** + * @param object|class-string $objectOrClass The instance, or the class of a static call + * @param class-string $class The class of gitlib declaring $method + */ + public static function isOverridden(object|string $objectOrClass, string $class, string $method): bool + { + return $class !== (new \ReflectionMethod($objectOrClass, $method))->getDeclaringClass()->getName(); + } +} diff --git a/src/Gitonomy/Git/Util/StringHelper.php b/src/Gitonomy/Git/Util/StringHelper.php index d784b66b..5e652699 100644 --- a/src/Gitonomy/Git/Util/StringHelper.php +++ b/src/Gitonomy/Git/Util/StringHelper.php @@ -12,10 +12,14 @@ namespace Gitonomy\Git\Util; +trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s" class is deprecated, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only.', StringHelper::class); + /** * Helper class to support language particularity. * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only */ class StringHelper { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index a94fbbb9..faf209ee 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,9 +15,13 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class WorkingCopy { @@ -48,32 +52,49 @@ public function getUntrackedFiles() return $lines; } - public function getDiffPending() + /** + * Returns the changes of the working tree, staged or not, since $revision. + * + * @throws ReferenceNotFoundException $revision names no commit + */ + public function getDiff(Revision|string $revision = 'HEAD'): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); - $diff->setRepository($this->repository); + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return $diff; + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', $hash]), $this->repository); } - public function getDiffStaged() + public function getDiffPending() { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); - $diff->setRepository($this->repository); + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']), $this->repository); + } - return $diff; + public function getDiffStaged() + { + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']), $this->repository); } /** + * Passing a branch to create as second argument is deprecated, as a local + * Branch object, which detaches HEAD, where 2.0 attaches HEAD to it. + * * @return WorkingCopy */ public function checkout($revision, $branch = null) { + if (null !== $branch) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); + } elseif ($revision instanceof Branch && $revision->isLocal()) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a local "%s" to "%s()", which detaches HEAD, is deprecated, 2.0 attaches HEAD to the branch: pass its name, or its commit to detach HEAD.', Branch::class, __METHOD__); + } + $args = []; if ($revision instanceof Commit) { $args[] = $revision->getHash(); } elseif ($revision instanceof Reference) { $args[] = $revision->getFullname(); + } elseif ($revision instanceof Revision) { + $args[] = $revision->getRevision(); } elseif (is_string($revision)) { $args[] = $revision; } else { @@ -89,6 +110,24 @@ public function checkout($revision, $branch = null) return $this; } + /** + * Creates the branch $branch at $startPoint, and checks it out, like + * `git checkout -b`. + * + * @return $this + * + * @throws ReferenceNotFoundException $startPoint names no commit + */ + public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): self + { + // Like in 2.0. git gets the name, which tells a remote branch, to track it. + ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); + + $this->run('checkout', ['-b', $branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint]); + + return $this; + } + protected function run($command, array $args = []) { return $this->repository->run($command, $args); diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php index 0b9a51b6..31379fd0 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTest.php @@ -12,7 +12,7 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Repository; use PHPUnit\Framework\TestCase; @@ -42,7 +42,7 @@ abstract class AbstractTest extends TestCase public static function createEmptyRepository($bare = true) { $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, self::getOptions()); + $repository = $bare ? Repository::initBare($dir, self::getDriver()) : Repository::init($dir, self::getDriver()); self::registerDeletion($repository); return $repository; @@ -78,10 +78,11 @@ public static function provideEmpty() public static function createFoobarRepository($bare = true) { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); + self::$localRepository = Repository::cloneBareFrom(self::REPOSITORY_URL, self::createTempDir(), null, self::getDriver()); } - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare, self::getOptions()); + $url = self::$localRepository->getGitDir(); + $repository = $bare ? Repository::cloneBareFrom($url, self::createTempDir(), null, self::getDriver()) : Repository::cloneFrom($url, self::createTempDir(), null, self::getDriver()); self::registerDeletion($repository); return $repository; @@ -100,7 +101,7 @@ public static function registerDeletion(Repository $repository) } /** - * Created an empty directory and return path to it. + * Created an empty directory, deleted at the end of the tests, and return path to it. * * @return string a fullpath */ @@ -110,6 +111,10 @@ public static function createTempDir() unlink($tmpDir); mkdir($tmpDir); + register_shutdown_function(function () use ($tmpDir) { + self::deleteDir($tmpDir); + }); + return $tmpDir; } @@ -120,6 +125,11 @@ public static function createTempDir() */ protected static function deleteDir($dir) { + // Deleted already, by another shutdown function. + if (!file_exists($dir)) { + return; + } + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($iterator as $file) { @@ -148,4 +158,11 @@ protected static function getOptions() 'process_timeout' => 60, ]; } + + protected static function getDriver(): CliDriver + { + $options = self::getOptions(); + + return new CliDriver($options['command'], $options['environment_variables'], $options['process_timeout']); + } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 0f7460fa..d0c1be34 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -16,9 +16,15 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +/** + * @group legacy + */ class AdminTest extends AbstractTest { + use ExpectDeprecationTrait; + private $tmpDir; /** @@ -39,6 +45,8 @@ public function tearDownTmpDir() public function testBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + $repository = Admin::init($this->tmpDir, true, self::getOptions()); $objectDir = $this->tmpDir.'/objects'; @@ -52,6 +60,8 @@ public function testBare() public function testNotBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); + $repository = Admin::init($this->tmpDir, false, self::getOptions()); $objectDir = $this->tmpDir.'/.git/objects'; @@ -68,6 +78,8 @@ public function testNotBare() */ public function testClone($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::'.($repository->isBare() ? 'cloneBareFrom' : 'cloneFrom').'()" with "getGitDir()" as URL instead.'); + $newDir = self::createTempDir(); $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); self::registerDeletion($new); @@ -88,6 +100,8 @@ public function testClone($repository) public function testCloneBranchBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); + //we can't use AbstractText::createFoobarRepository() //because it does not clone other branches than "master" //so we test it directly against the remote repository @@ -103,6 +117,8 @@ public function testCloneBranchBare() public function testCloneBranchNotBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); + //we can't use AbstractText::createFoobarRepository() //because it does not clone other branches than "master" //so we test it directly against remote repository @@ -121,6 +137,8 @@ public function testCloneBranchNotBare() */ public function testMirror($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); + $newDir = self::createTempDir(); $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); self::registerDeletion($new); @@ -143,12 +161,16 @@ public function testMirror($repository) */ public function testCheckValidRepository($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepository($url)); } public function testCheckInvalidRepository() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + $url = $this->tmpDir.'/invalid.git'; mkdir($url); @@ -160,6 +182,8 @@ public function testCheckInvalidRepository() */ public function testCheckValidRepositoryAndBranch($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); + $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); } @@ -169,12 +193,16 @@ public function testCheckValidRepositoryAndBranch($repository) */ public function testCheckInvalidRepositoryAndBranch($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); + $url = $repository->getGitDir(); $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); } public function testExistingFile() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + $this->expectException(RuntimeException::class); $file = $this->tmpDir.'/test'; @@ -185,6 +213,8 @@ public function testExistingFile() public function testCloneRepository() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); + $newDir = self::createTempDir(); $args = []; diff --git a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php new file mode 100644 index 00000000..6c4434bd --- /dev/null +++ b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php @@ -0,0 +1,259 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Admin; +use Gitonomy\Git\Blame; +use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Log; +use Gitonomy\Git\PushReference; +use Gitonomy\Git\Reference; +use Gitonomy\Git\ReferenceBag; +use Gitonomy\Git\Repository; + +/** + * 1.7 keeps working what worked with 1.6: the named arguments, the classes + * extending the ones of gitlib, and the methods they override. + */ +class BackwardCompatibilityTest extends AbstractTest +{ + public function testEmptyOptions() + { + $source = self::createFoobarRepository(false); + + $repository = new Repository($source->getWorkingDir(), []); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + /** + * @group legacy + */ + public function testNamedArguments() + { + $source = self::createFoobarRepository(false); + $repository = new Repository($source->getGitDir(), options: ['working_dir' => $source->getWorkingDir()]); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + $this->assertCount(1, $repository->getDiff(revisions: self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getFiles()); + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', lineRange: 5)); + $this->assertCount(3, new Blame($repository, $repository->getRevision(self::LONGFILE_COMMIT), 'README.md', lineRange: 5)); + + $references = $repository->getReferences(); + $references->createBranch('foobar', commitHash: self::INITIAL_COMMIT); + $references->createTag('foobar', commitHash: self::INITIAL_COMMIT); + $this->assertCount(2, $references->resolve(hash: self::INITIAL_COMMIT)); + $this->assertCount(1, $references->resolveBranches(hash: self::INITIAL_COMMIT)); + $this->assertCount(1, $references->resolveTags(hash: self::INITIAL_COMMIT)); + + $this->assertSame('refs/heads/foobar', (new PushReference($repository, reference: 'refs/heads/foobar', before: self::INITIAL_COMMIT, after: self::LONGFILE_COMMIT))->getFullname()); + + $data = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray(); + $this->assertCount(1, Diff::fromArray(array: $data)->getFiles()); + $this->assertSame('long.php', File::fromArray(array: $data['files'][0])->getName()); + $this->assertCount(1, Diff::parse(rawDiff: $data['rawDiff'])->getFiles()); + } + + /** + * @group legacy + */ + public function testSubclassesOverridingMethodsWithTheSignatureOf16() + { + $repository = self::createFoobarRepository(); + $legacy = new LegacyRepository($repository->getGitDir()); + + $this->assertCount(1, $legacy->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getFiles()); + $this->assertCount(3, $legacy->getBlame(self::LONGFILE_COMMIT, 'README.md', 5)); + + $data = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray(); + $this->assertCount(1, LegacyDiff::parse($data['rawDiff'])->getFiles()); + $this->assertCount(1, LegacyDiff::fromArray($data)->getFiles()); + $this->assertSame('long.php', LegacyFile::fromArray($data['files'][0])->getName()); + } + + /** + * @group legacy + */ + public function testOverriddenAdminCloneRepository() + { + LegacyAdmin::$args = []; + + $repository = LegacyAdmin::cloneTo(self::createTempDir(), self::createFoobarRepository()->getGitDir(), false); + + $this->assertSame([[]], LegacyAdmin::$args); + $this->assertFalse($repository->isBare()); + } + + /** + * @group legacy + */ + public function testOverriddenReferenceBagUpdate() + { + $references = new LegacyReferenceBag(self::createFoobarRepository()); + + $references->createBranch('foobar', self::INITIAL_COMMIT); + $references->createTag('foobar', self::INITIAL_COMMIT); + + $this->assertSame(['refs/heads/foobar', 'refs/tags/foobar'], $references->updated); + } + + /** + * @group legacy + */ + public function testOverriddenLogSetLimit() + { + $log = new LegacyLog(self::createFoobarRepository(), self::LONGFILE_COMMIT, null, null, 5); + + $this->assertSame(self::LONGFILE_COMMIT, $log->getSingleCommit()->getHash()); + $this->assertSame([5], $log->limits); + $this->assertSame(5, $log->getLimit()); + } + + public function testOverriddenPushReferenceMethods() + { + $reference = new LegacyPushReference(self::createFoobarRepository(), 'refs/heads/foobar', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT); + + // The overrides say it creates the reference: neither forced, nor fast-forward. + $this->assertFalse($reference->isForce()); + $this->assertFalse($reference->isFastForward()); + $this->assertSame(self::LONGFILE_COMMIT, $reference->getRange()); + // getRevision() gives the commits of the log, from the first one. + $this->assertCount(8, $reference->getLog()->getCommits()); + } + + public function testOverriddenFileChangeGetCount() + { + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false, false, [new LegacyFileChange(1, 1, 1, 1, [])]); + + $this->assertSame(42, $file->getAdditions()); + $this->assertSame(42, $file->getDeletions()); + } + + /** + * @group legacy + */ + public function testOverriddenFileSetRepository() + { + $repository = self::createFoobarRepository(); + $file = new LegacyFile('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + + (new Diff([$file], ''))->setRepository($repository); + + $this->assertSame($repository, $file->repositorySet); + } +} + +class LegacyRepository extends Repository +{ + public function getDiff($revisions) + { + return parent::getDiff($revisions); + } + + public function getBlame($revision, $file, $lineRange = null) + { + return parent::getBlame($revision, $file, $lineRange); + } +} + +class LegacyDiff extends Diff +{ + public static function parse($rawDiff) + { + return parent::parse($rawDiff); + } + + public static function fromArray(array $array) + { + return parent::fromArray($array); + } +} + +class LegacyFile extends File +{ + public $repositorySet; + + public static function fromArray(array $array) + { + return parent::fromArray($array); + } + + public function setRepository(Repository $repository) + { + $this->repositorySet = $repository; + } +} + +class LegacyFileChange extends FileChange +{ + public function getCount($type) + { + return 42; + } +} + +class LegacyAdmin extends Admin +{ + public static $args = []; + + public static function cloneRepository($path, $url, array $args = [], array $options = []) + { + self::$args[] = $args; + + return parent::cloneRepository($path, $url, $args, $options); + } +} + +class LegacyReferenceBag extends ReferenceBag +{ + public $updated = []; + + public function update(Reference $reference) + { + $this->updated[] = $reference->getFullname(); + + return parent::update($reference); + } +} + +class LegacyLog extends Log +{ + public $limits = []; + + public function setLimit($limit) + { + $this->limits[] = $limit; + + return parent::setLimit($limit); + } +} + +class LegacyPushReference extends PushReference +{ + public function isCreate() + { + return true; + } + + public function isDelete() + { + return false; + } + + public function getRevision() + { + return $this->getAfter(); + } +} diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 6d92547d..d4735e8d 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,8 +12,16 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blame\Line; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; + class BlameTest extends AbstractTest { + use ExpectDeprecationTrait; + + const README_COMMIT = 'cfba9c69819a96df0635e2e7ddc86da20f954db6'; + /** * @dataProvider provideFoobar */ @@ -33,8 +41,102 @@ public function testBlame($repository) /** * @dataProvider provideFoobar */ + public function testHunks($repository) + { + $hunks = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getHunks(); + + $this->assertCount(3, $hunks); + $this->assertSame(self::INITIAL_COMMIT, $hunks[0]->getCommit()->getHash()); + $this->assertSame([1, 2, 3], array_keys($hunks[0]->getLines())); + $this->assertSame(self::README_COMMIT, $hunks[1]->getCommit()->getHash()); + $this->assertSame([4, 5, 6], array_keys($hunks[1]->getLines())); + $this->assertSame([7], array_keys($hunks[2]->getLines())); + + $line = $hunks[2]->getLines()[7]; + $this->assertSame(7, $line->getNumber()); + // The line 5 of the file in the initial commit. + $this->assertSame(5, $line->getOriginalNumber()); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLines($repository) + { + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 4, 6); + + $this->assertCount(3, $blame); + $this->assertSame([4, 5, 6], array_map(function (Line $line) { + return $line->getNumber(); + }, array_values($blame->getLines()))); + + $hunks = $blame->getHunks(); + $this->assertCount(1, $hunks); + // Numbered like the file, unlike getLines(), which numbers them from 1. + $this->assertSame([4, 5, 6], array_keys($hunks[0]->getLines())); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesWithoutLastLine($repository) + { + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 5)); + $this->assertCount(2, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', null, 2)); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesInReverse($repository) + { + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 6, 4)); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesFromZero($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range of lines "0,3": the lines are numbered from 1.'); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 0, 3); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testRangeOfLinesAsAString($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" or "Gitonomy\Git\Blame::__construct()" is deprecated, pass the first and the last lines instead.'); + + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', '4,+3')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testLineGetLine($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Blame\Line::getLine()" method is deprecated, use "getOriginalNumber()" instead.'); + + $this->assertSame('5', $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getLine(7)->getLine()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ public function testGroupedBlame($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Blame::getGroupedLines()" method is deprecated, use "getHunks()" instead.'); + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); $this->assertCount(3, $blame); diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 431a0a46..5cb51a74 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -49,7 +49,7 @@ public function testNotExisting($repository) { $this->expectException(RuntimeException::class); - $blob = $repository->getBlob('foobar'); + $blob = $repository->getBlob('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); $blob->getContent(); } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 7d094648..ba74c7ac 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -16,11 +16,16 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use Gitonomy\Git\Tree; +use Gitonomy\Git\Util\StringHelper; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class CommitTest extends AbstractTest { + use ExpectDeprecationTrait; + /** * @dataProvider provideFoobar */ @@ -229,9 +234,13 @@ public function testGetSignedMessage($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetShortMessage($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); + // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); @@ -264,9 +273,12 @@ public function testGetBodyMessage($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetIncludingBranchesException($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); $this->expectException(InvalidArgumentException::class); $commit = $repository->getCommit(self::INITIAL_COMMIT); @@ -281,6 +293,24 @@ public function testGetIncludingBranches($repository) { $commit = $repository->getCommit(self::INITIAL_COMMIT); + $branches = $commit->getIncludingBranches(); + $this->assertCount(count($repository->getReferences()->getBranches()), $branches); + $this->assertCount(count($repository->getReferences()->getLocalBranches()), array_filter($branches, function (Branch $branch) { + return $branch->isLocal(); + })); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetIncludingBranchesWithArguments($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); + + $commit = $repository->getCommit(self::INITIAL_COMMIT); + $branches = $commit->getIncludingBranches(true, false); $this->assertCount(count($repository->getReferences()->getLocalBranches()), $branches); @@ -323,4 +353,42 @@ public function testEncoding($repository) $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetFixedShortHash($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); + + $this->assertSame('74acd0', $repository->getCommit(self::INITIAL_COMMIT)->getFixedShortHash()); + } + + /** + * @runInSeparateProcess + * + * @group legacy + */ + public function testStringHelperIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Util\StringHelper" class is deprecated, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only.'); + + $this->assertSame(3, StringHelper::strlen('été')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testConstructorWithData($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing data to "Gitonomy\\Git\\Commit::__construct()" is deprecated, the constructor is internal in 2.0, and takes a "CommitData" instead of an array: get the commit from "Gitonomy\\Git\\Repository::getCommit()".'); + + $commit = new Commit($repository, self::INITIAL_COMMIT, ['subjectMessage' => 'Hello']); + + $this->assertSame('Hello', $commit->getSubjectMessage()); + } } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 9703bdde..2b9aac7b 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -13,11 +13,19 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class DiffTest extends AbstractTest { + use ExpectDeprecationTrait; + const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; @@ -109,7 +117,7 @@ public function testGetFiles_Rename($repository) $this->assertTrue($files[0]->isRename()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isChangeMode()); + $this->assertFalse($files[0]->isModeChange()); } /** @@ -122,7 +130,7 @@ public function testGetFiles_Changemode($repository) $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isChangeMode()); + $this->assertTrue($files[0]->isModeChange()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); $this->assertFalse($files[0]->isRename()); @@ -153,14 +161,13 @@ public function testWorksWithUmlauts($repository) $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); } + /** + * @group legacy + */ public function testDeleteFileWithoutRaw() { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' diff --git a/test b/test @@ -170,24 +177,20 @@ public function testDeleteFileWithoutRaw() DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertTrue($firstFile->isDeletion()); - $this->assertFalse($firstFile->isChangeMode()); + $this->assertFalse($firstFile->isModeChange()); $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); $this->assertNull($firstFile->getNewIndex()); } + /** + * @group legacy + */ public function testModeChangeFileWithoutRaw() { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' diff --git a/a.out b/a.out @@ -197,23 +200,15 @@ public function testModeChangeFileWithoutRaw() DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('', $firstFile->getOldIndex()); $this->assertSame('', $firstFile->getNewIndex()); } public function testModeChangeFileWithRaw() { - $deprecationCalled = false; - set_error_handler(function (int $errno, string $errstr) use (&$deprecationCalled): void { - $deprecationCalled = true; - }, E_USER_DEPRECATED); - $diff = Diff::parse(<<<'DIFF' :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 @@ -230,19 +225,15 @@ public function testModeChangeFileWithRaw() $firstFile = $files[0]; $secondFile = $files[1]; - restore_exception_handler(); - - $this->assertFalse($deprecationCalled); - $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); $this->assertFalse($secondFile->isCreation()); $this->assertFalse($secondFile->isDeletion()); - $this->assertTrue($secondFile->isChangeMode()); + $this->assertTrue($secondFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } @@ -250,8 +241,7 @@ public function testModeChangeFileWithRaw() public function testThrowErrorOnBlobGetWithoutIndex() { $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); - $file = new File('oldName', 'newName', '100755', '100644', '', '', false); - $file->setRepository($repository); + $file = new File('oldName', 'newName', '100755', '100644', '', '', false, false, [], $repository); try { $file->getOldBlob(); @@ -267,13 +257,19 @@ public function testThrowErrorOnBlobGetWithoutIndex() $this->assertFalse($file->isCreation()); $this->assertFalse($file->isDeletion()); - $this->assertTrue($file->isChangeMode()); + $this->assertTrue($file->isModeChange()); $this->assertSame('', $file->getOldIndex()); $this->assertSame('', $file->getNewIndex()); } + /** + * @group legacy + */ public function testEmptyNewFile() { + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); $firstFile = $diff->getFiles()[0]; @@ -283,8 +279,14 @@ public function testEmptyNewFile() $this->assertNull($firstFile->getOldName()); } + /** + * @group legacy + */ public function testEmptyOldFile() { + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); $firstFile = $diff->getFiles()[0]; @@ -293,4 +295,226 @@ public function testEmptyOldFile() $this->assertNull($firstFile->getNewName()); $this->assertSame('test', $firstFile->getOldName()); } + + /** + * @dataProvider provideFoobar + */ + public function testParseWithTheRepository($repository) + { + $raw = $repository->run('diff-tree', ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', self::CREATE_COMMIT]); + + $file = Diff::parse($raw, $repository)->getFiles()[0]; + + $this->assertSame($repository, $file->getRepository()); + $this->assertSame("getNewBlob()->getContent()); + } + + /** + * @dataProvider provideFoobar + */ + public function testFromArrayWithTheRepository($repository) + { + $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); + + $diff = Diff::fromArray($data, $repository); + + $this->verifyCreateCommitDiff($diff); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + $this->assertSame($repository, File::fromArray($data['files'][0], $repository)->getRepository()); + } + + public function testConstructorTakesTheChangesAndTheRepository() + { + $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'a'), new DiffLine(DiffLineType::Addition, 'b')]); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false, false, [$change], $repository); + + $this->assertSame([$change], $file->getChanges()); + $this->assertSame(1, $file->getAdditions()); + $this->assertSame(0, $file->getDeletions()); + $this->assertSame($repository, $file->getRepository()); + $this->assertFalse($file->isCopy()); + } + + public function testCopy() + { + $file = new File('a.txt', 'b.txt', '100644', '100644', 'abc', 'abc', false, true); + + $this->assertTrue($file->isCopy()); + $this->assertFalse($file->isRename()); + // Like in 1.6, toArray() has no "is_copy" key, which 2.0 adds: fromArray() reads it. + $this->assertArrayNotHasKey('is_copy', $file->toArray()); + $this->assertTrue(File::fromArray(['is_copy' => true] + $file->toArray())->isCopy()); + } + + public function testFileChangeCountsTheLines() + { + $change = new FileChange(1, 2, 1, 2, [new DiffLine(DiffLineType::Deletion, 'a'), new DiffLine(DiffLineType::Addition, 'b'), new DiffLine(DiffLineType::Addition, 'c'), new DiffLine(DiffLineType::Context, 'd')]); + + $this->assertSame(2, $change->getAdditions()); + $this->assertSame(1, $change->getDeletions()); + } + + public function testFileChangeTakesDiffLines() + { + $lines = [new DiffLine(DiffLineType::Deletion, 'a'), new DiffLine(DiffLineType::Addition, 'b'), new DiffLine(DiffLineType::Context, 'c')]; + + $change = new FileChange(1, 2, 1, 2, $lines); + + $this->assertEquals($lines, $change->getDiffLines()); + $this->assertSame([[-1, 'a'], [1, 'b'], [0, 'c']], $change->toArray()['lines']); + $this->assertEquals($lines, FileChange::fromArray($change->toArray())->getDiffLines()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffLines($repository) + { + $change = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->getFiles()[0]->getChanges()[0]; + + $lines = $change->getDiffLines(); + + $this->assertCount($change->getRangeNewCount(), $lines); + $this->assertSame(DiffLineType::Addition, $lines[0]->getType()); + $this->assertSame(array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $lines), $change->toArray()['lines']); + } + + public function testGetDiffLinesThrowsForAnUnknownType() + { + $change = FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[5, 'a']]]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 5: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); + + $change->getDiffLines(); + } + + /** + * @group legacy + */ + public function testFileChangeLinesAsArrays() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getLines()" method is deprecated, use "getDiffLines()" instead.'); + + $change = new FileChange(1, 1, 1, 2, [[FileChange::LINE_CONTEXT, 'a'], [FileChange::LINE_ADD, 'b']]); + + $this->assertSame([[0, 'a'], [1, 'b']], $change->getLines()); + $this->assertEquals([new DiffLine(DiffLineType::Context, 'a'), new DiffLine(DiffLineType::Addition, 'b')], $change->getDiffLines()); + $this->assertSame(1, $change->getAdditions()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffBetweenTwoRevisions($repository) + { + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)); + + $this->assertSame($repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray()['files'], $diff->toArray()['files']); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffRefusesAListOfRevisionsWithTo($repository) + { + $this->expectException(InvalidArgumentException::class); + + $repository->getDiff([self::BEFORE_LONGFILE_COMMIT], self::LONGFILE_COMMIT); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetDiffWithOneArgument($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as first and second arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); + + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $diff->toArray()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testLogGetDiff($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); + + $diff = $repository->getLog(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getDiff(); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $diff->toArray()); + } + + /** + * @group legacy + */ + public function testSetRepository() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + + $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $diff = new Diff([new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false)], ''); + $diff->setRepository($repository); + + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + $file->setRepository($repository); + + $this->assertSame($repository, $file->getRepository()); + } + + /** + * @group legacy + */ + public function testAddChange() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + $change = new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'a')]); + $file->addChange($change); + + $this->assertSame([$change], $file->getChanges()); + } + + /** + * @group legacy + */ + public function testDeprecatedNames() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); + + $file = new File('a.txt', null, '100755', null, 'abcdef0123456789', null, false); + + $this->assertFalse($file->isChangeMode()); + $this->assertTrue($file->isDelete()); + $this->assertSame('', $file->getAnchor()); + $this->assertSame('0123456789ab', (new File('a.txt', 'a.txt', '100644', '100644', 'abc', '0123456789abcdef', false))->getAnchor()); + $this->assertSame(1, (new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'a')]))->getCount(DiffLineType::Addition->value)); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffThrowsForAnUnknownRevision($repository) + { + $this->expectException(ReferenceNotFoundException::class); + + $repository->getDiff(self::LONGFILE_COMMIT, 'unknown'); + } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index a02184dd..38ac22cf 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -12,10 +12,84 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\LogParser; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class LogTest extends AbstractTest { + use ExpectDeprecationTrait; + + /** + * @dataProvider provideFoobar + */ + public function testWithOffsetAndLimit($repository) + { + $log = $repository->getLog(self::LONGFILE_COMMIT); + + $page = $log->withOffset(1)->withLimit(1); + + $this->assertSame([self::BEFORE_LONGFILE_COMMIT], array_map(function ($commit) { + return $commit->getHash(); + }, $page->getCommits())); + $this->assertSame(1, $page->getOffset()); + $this->assertSame(1, $page->getLimit()); + $this->assertNull($log->getOffset()); + $this->assertNull($log->getLimit()); + } + + /** + * @dataProvider provideFoobar + */ + public function testWithNegativeOffset($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The offset of a log can\'t be negative, -1 given.'); + + $repository->getLog()->withOffset(-1); + } + + /** + * @dataProvider provideFoobar + */ + public function testWithNegativeLimit($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The limit of a log can\'t be negative, -1 given.'); + + $repository->getLog()->withLimit(-1); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testSetOffsetAndLimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT); + $log->setOffset(1)->setLimit(1); + + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $log->getSingleCommit()->getHash()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testNegativeOffsetAndLimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a negative offset to "Gitonomy\Git\Log::__construct()" is deprecated, it throws in 2.0.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a negative limit to "Gitonomy\Git\Log::__construct()" is deprecated, it throws in 2.0: pass null for no limit.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT, null, -1, -1); + + $this->assertCount(8, $log->getCommits()); + } /** * @dataProvider provideFoobar */ @@ -133,4 +207,38 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $this->assertEquals("Commit with GitButler headers\n", $log[1]['message']); $this->assertEquals("Another commit without GitButler headers\n", $log[2]['message']); } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testCountWithALimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Counting a log whose offset or limit leaves commits out with "Gitonomy\\Git\\Log::count()" is deprecated, it counts only the commits the log iterates in 2.0: use "countCommits()" to count them all.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT)->withLimit(2); + + $this->assertCount(8, $log); + } + + /** + * @dataProvider provideFoobar + */ + public function testCountWithALimitLeavingNoCommitOut($repository) + { + $this->assertCount(8, $repository->getLog(self::LONGFILE_COMMIT)->withOffset(0)->withLimit(10)); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testOptionAsRevision($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "--no-merges", an option of "git log", as revision to "Gitonomy\\Git\\Log::__construct()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".'); + + $this->assertCount(1, $repository->getLog(['--no-merges', self::MERGE_COMMIT])->withLimit(1)->getCommits()); + } } diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 4490a3f9..8c638313 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -12,10 +12,14 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\PushReference; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class PushReferenceTest extends AbstractTest { + use ExpectDeprecationTrait; + const CREATE = 1; const DELETE = 2; const FORCE = 4; @@ -38,8 +42,8 @@ public function provideIsers() public function testIsers($reference, $before, $after, $mask) { $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); + $this->assertEquals($mask & self::CREATE, $reference->isCreation(), 'Create value is correct.'); + $this->assertEquals($mask & self::DELETE, $reference->isDeletion(), 'Delete value is correct.'); $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); } @@ -53,7 +57,7 @@ public function testLog($repository) $log = $ref->getLog()->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertEquals('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } /** @@ -66,7 +70,7 @@ public function testSignedLog($repository) $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); $this->assertCount(16, $log, '16 commits in log'); - $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); + $this->assertEquals('signed commit', $log[0]->getSubjectMessage(), 'Last commit is correct'); } /** @@ -78,6 +82,45 @@ public function testLogWithExclude($repository) $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertEquals('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); + } + + public function testFullnameAndRange() + { + $repository = self::createFoobarRepository(); + + $reference = new PushReference($repository, 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT); + $this->assertSame('refs/heads/foo', $reference->getFullname()); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, $reference->getRange()); + + $creation = new PushReference($repository, 'refs/heads/foo', PushReference::ZERO, self::LONGFILE_COMMIT); + $this->assertSame(self::LONGFILE_COMMIT, $creation->getRange()); + } + + public function testRangeOfADeletion() + { + $reference = new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::LONGFILE_COMMIT, PushReference::ZERO); + + $this->expectException(LogicException::class); + + $reference->getRange(); + } + + /** + * @group legacy + */ + public function testDeprecatedNames() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); + + $reference = new PushReference(self::createFoobarRepository(), 'refs/heads/foo', PushReference::ZERO, self::LONGFILE_COMMIT); + + $this->assertSame('refs/heads/foo', $reference->getReference()); + $this->assertSame(self::LONGFILE_COMMIT, $reference->getRevision()); + $this->assertTrue($reference->isCreate()); + $this->assertFalse($reference->isDelete()); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 0f975577..158b518c 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,12 +12,17 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class ReferenceTest extends AbstractTest { + use ExpectDeprecationTrait; + /** * @dataProvider provideEmpty */ @@ -212,9 +217,13 @@ public function testCreateAndDeleteBranch($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testIsBranchMergedToMaster() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); + $repository = self::createFoobarRepository(false); $repository->run('config', ['--local', 'user.name', '"Unit Test"']); @@ -237,4 +246,124 @@ public function testIsBranchMergedToMaster() $this->assertFalse($branch->isMergedTo('master')); } + + public function testIsMergedInto() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + $this->assertTrue($references->getBranch('master')->isMergedInto('master')); + $this->assertFalse($references->getBranch('new-feature')->isMergedInto('master')); + $this->assertTrue($references->createBranch('initial', self::INITIAL_COMMIT)->isMergedInto($references->getBranch('new-feature'))); + } + + /** + * @dataProvider provideFoobar + */ + public function testMoveTo($repository) + { + $references = $repository->getReferences(); + $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); + + $moved = $branch->moveTo(self::LONGFILE_COMMIT); + + $this->assertSame(self::LONGFILE_COMMIT, $moved->getCommitHash()); + // The bag read before sees the move, like in 2.0. + $this->assertSame(self::LONGFILE_COMMIT, $references->getBranch('foobar')->getCommitHash()); + $this->assertContains($moved, $references->getLocalBranches()); + $this->assertSame(self::LONGFILE_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + // The branch read before keeps its commit. + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + */ + public function testMoveToAnAnnotatedTag($repository) + { + $branch = $repository->getReferences()->createBranch('foobar', self::INITIAL_COMMIT); + + $moved = $branch->moveTo($repository->getReferences()->getTag('annotated')); + + // The commit of the tag, not the tag object. + $this->assertSame($repository->getRevision('annotated^{commit}')->getCommit()->getHash(), $moved->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + */ + public function testCreateBranchFromARevision($repository) + { + $branch = $repository->getReferences()->createBranch('foobar', $repository->getRevision('master')); + + $this->assertSame($repository->getReferences()->getBranch('master')->getCommitHash(), $branch->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testCreateExistingReferences($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Creating the branch "master", which exists already, is deprecated, it throws in 2.0: use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Creating the tag "0.1", which exists already, is deprecated, it throws in 2.0: delete it first.'); + + $references = $repository->getReferences(); + $references->createBranch('master', self::INITIAL_COMMIT); + $references->createTag('0.1', self::INITIAL_COMMIT); + + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/master'])); + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/tags/0.1'])); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testDeleteOutsideRefs($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Deleting "HEAD", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.'); + + $repository->getReferences()->delete('HEAD'); + + // git deletes the branch HEAD points to. + $this->assertFalse((new Repository($repository->getGitDir(), null, self::getDriver()))->getReferences()->hasBranch('master')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testUpdate($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + + $repository->getReferences()->update(new Branch($repository, 'refs/heads/foobar', self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + } + + public function testCreateBranchFromAName() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + $master = $references->getBranch('master')->getCommitHash(); + + $branch = $references->createBranch('foobar', 'master'); + + // The name, like in 1.6, where 2.0 resolves it. + $this->assertSame('master', $branch->getCommitHash()); + $this->assertSame($master."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + $this->assertTrue($branch->isMergedInto('master')); + } + + public function testCreateBranchFromAnUnknownName() + { + $this->expectException(ProcessException::class); + + self::createFoobarRepository()->getReferences()->createBranch('foobar', 'unknown'); + } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index fc5c7deb..39285451 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -13,14 +13,239 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class RepositoryTest extends AbstractTest { + use ExpectDeprecationTrait; use ProphecyTrait; + public function testInit() + { + $dir = self::createTempDir(); + $repository = Repository::init($dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertFalse($repository->isBare()); + $this->assertSame($dir.'/.git', $repository->getGitDir()); + $this->assertSame($dir, $repository->getWorkingDir()); + } + + public function testInitBare() + { + $dir = self::createTempDir(); + $repository = Repository::initBare($dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + $this->assertDirectoryExists($dir.'/objects'); + } + + public function testInitThrowsForAFile() + { + $file = self::createTempDir().'/file'; + touch($file); + + $this->expectException(RuntimeException::class); + + Repository::initBare($file, self::getDriver()); + } + + public function testCloneFrom() + { + $dir = self::createTempDir(); + $repository = Repository::cloneFrom(self::createFoobarRepository()->getGitDir(), $dir, null, self::getDriver()); + self::registerDeletion($repository); + + $this->assertFalse($repository->isBare()); + $this->assertSame($dir, $repository->getWorkingDir()); + $this->assertSame('master', $repository->getHead()->getName()); + $this->assertTrue($repository->getReferences()->hasRemoteBranch('origin/new-feature')); + } + + public function testCloneFromABranch() + { + $repository = Repository::cloneFrom(self::createFoobarRepository()->getGitDir(), self::createTempDir(), 'new-feature', self::getDriver()); + self::registerDeletion($repository); + + $this->assertSame('new-feature', $repository->getHead()->getName()); + } + + public function testCloneBareFrom() + { + $dir = self::createTempDir(); + $repository = Repository::cloneBareFrom(self::createFoobarRepository()->getGitDir(), $dir, null, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + $this->assertSame('master', $repository->getHead()->getName()); + $this->assertTrue($repository->getReferences()->hasBranch('new-feature')); + } + + public function testCloneBareFromABranch() + { + $repository = Repository::cloneBareFrom(self::createFoobarRepository()->getGitDir(), self::createTempDir(), 'new-feature', self::getDriver()); + self::registerDeletion($repository); + + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); + } + + public function testCloneFromThrowsForAMissingRepository() + { + $this->expectException(RuntimeException::class); + + Repository::cloneFrom(self::createTempDir().'/missing', self::createTempDir(), null, self::getDriver()); + } + + public function testMirrorFrom() + { + $source = self::createFoobarRepository(false); + $dir = self::createTempDir(); + $repository = Repository::mirrorFrom($source->getGitDir(), $dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + // A mirror keeps the remote branches of its source, which a bare clone leaves out. + $this->assertTrue($repository->getReferences()->has('refs/remotes/origin/new-feature')); + } + + public function testIsValidRepository() + { + $this->assertTrue(Repository::isValidRepository(self::createFoobarRepository()->getGitDir(), self::getDriver())); + $this->assertFalse(Repository::isValidRepository(self::createTempDir(), self::getDriver())); + } + + public function testIsValidRepositoryAndBranch() + { + $url = self::createFoobarRepository()->getGitDir(); + + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'new-feature', self::getDriver())); + // The exact name, where Admin::isValidRepositoryAndBranch() matches "new-feature" too. + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'feature', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'unknown', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch(self::createTempDir(), 'master', self::getDriver())); + } + + public function testWorkingDirArgument() + { + $source = self::createFoobarRepository(false); + + $repository = new Repository($source->getGitDir(), $source->getWorkingDir(), self::getDriver()); + + $this->assertFalse($repository->isBare()); + $this->assertSame($source->getGitDir(), $repository->getGitDir()); + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + public function testDriverConfiguresGit() + { + $driver = new CliDriver('git', ['GIT_CONFIG_PARAMETERS' => "'gitlib.test=yes'"]); + $repository = new Repository(self::createFoobarRepository()->getGitDir(), null, $driver); + + $this->assertSame("yes\n", $repository->run('config', ['gitlib.test'])); + } + + public function testDriverCommand() + { + $repository = new Repository(self::createFoobarRepository()->getGitDir(), null, new CliDriver('not-git')); + + $this->expectException(ProcessException::class); + + $repository->run('status'); + } + + /** + * @group legacy + */ + public function testOptionsAreDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as second argument, and configure the logger and git through the "driver" argument.'); + + $source = self::createFoobarRepository(false); + $repository = new Repository($source->getGitDir(), ['working_dir' => $source->getWorkingDir()] + self::getOptions()); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + /** + * @group legacy + */ + public function testDebugOptionSetToFalseIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Setting the "debug" option of "Gitonomy\Git\Repository::__construct()" to false is deprecated, a failing git operation always throws in 2.0: catch its exception instead.'); + + $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => false] + self::getOptions()); + + $this->assertNull($repository->run('not-a-command')); + } + + /** + * @group legacy + */ + public function testGitOptionsCannotBeCombinedWithTheDriver() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + + new Repository(self::createFoobarRepository()->getGitDir(), ['process_timeout' => 60], self::getDriver()); + } + + /** + * @group legacy + */ + public function testOtherOptionsCombineWithTheDriver() + { + $source = self::createFoobarRepository(false); + $driver = new CliDriver('git', ['GIT_CONFIG_PARAMETERS' => "'gitlib.test=yes'"]); + + $repository = new Repository($source->getGitDir(), ['working_dir' => $source->getWorkingDir()], $driver); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + $this->assertSame("yes\n", $repository->run('config', ['gitlib.test'])); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetMergeBase($repository) + { + $mergeBase = $repository->getMergeBase('master', $repository->getRevision(self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT, $mergeBase->getHash()); + } + + public function testGetMergeBaseOfUnrelatedHistories() + { + $repository = self::createFoobarRepository(); + $driver = new CliDriver('git', ['GIT_AUTHOR_NAME' => 'Unit Test', 'GIT_AUTHOR_EMAIL' => 'unit@example.com', 'GIT_COMMITTER_NAME' => 'Unit Test', 'GIT_COMMITTER_EMAIL' => 'unit@example.com']); + // A root commit of the empty tree, which every repository has. + $unrelated = trim((new Repository($repository->getGitDir(), null, $driver))->run('commit-tree', ['4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'Unrelated'])); + + $this->assertNull($repository->getMergeBase('master', $unrelated)); + } + + public function testGetMergeBaseThrowsForAnUnknownRevision() + { + $this->expectException(ReferenceNotFoundException::class); + + self::createFoobarRepository()->getMergeBase('master', 'unknown'); + } + /** * @dataProvider provideFoobar */ @@ -39,12 +264,30 @@ public function testGetBlobWithExistingWorks($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetSize($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); + $size = $repository->getSize(); $this->assertGreaterThanOrEqual(57, $size, 'Repository is at least 57KB'); $this->assertLessThan(84, $size, 'Repository is less than 84KB'); + $this->assertSame((int) round($repository->getSizeInBytes() / 1000), $size); + } + + public function testGetSizeInBytesDoesNotFollowLinks() + { + $repository = self::createEmptyRepository(); + $size = $repository->getSizeInBytes(); + + $this->assertGreaterThan(0, $size); + + symlink($repository->getGitDir().'/missing', $repository->getGitDir().'/hooks/pre-commit'); + symlink($repository->getGitDir().'/config', $repository->getGitDir().'/hooks/post-commit'); + + $this->assertSame($size, $repository->getSizeInBytes()); } public function testIsBare() @@ -78,10 +321,10 @@ public function testLoggerOk($repository) ->info('run command: remote "" ') ->shouldBeCalledTimes(1); $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); + ->debug(Argument::type('string')) // creation of the repository, duration, return code and output + ->shouldBeCalledTimes(4); - $repository->setLogger($loggerProphecy->reveal()); + $repository = new Repository($repository->getGitDir(), $repository->getWorkingDir(), new CliDriver('git', [], 60, $loggerProphecy->reveal())); $repository->run('remote'); } @@ -102,14 +345,93 @@ public function testLoggerNOk($repository) ->info(Argument::type('string')) ->shouldBeCalledTimes(1); $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); + ->debug(Argument::type('string')) // creation of the repository, duration, return code and output + ->shouldBeCalledTimes(4); $loggerProphecy ->error(Argument::type('string')) ->shouldBeCalledTimes(1); - $repository->setLogger($loggerProphecy->reveal()); + $repository = new Repository($repository->getGitDir(), $repository->getWorkingDir(), new CliDriver('git', [], 60, $loggerProphecy->reveal())); $repository->run('not-work'); } + + /** + * @group legacy + */ + public function testSetLoggerIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getLogger()" method is deprecated, the logger is an argument of "Gitonomy\Git\Driver\Cli\CliDriver".'); + + $repository = self::createFoobarRepository(); + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $repository->setLogger($logger); + + $this->assertSame($logger, $repository->getLogger()); + } + + public function testGetMergeBaseOfUnrelatedHistoriesLogsNoError() + { + $repository = self::createFoobarRepository(); + $driver = new CliDriver('git', ['GIT_AUTHOR_NAME' => 'Unit Test', 'GIT_AUTHOR_EMAIL' => 'unit@example.com', 'GIT_COMMITTER_NAME' => 'Unit Test', 'GIT_COMMITTER_EMAIL' => 'unit@example.com']); + $unrelated = trim((new Repository($repository->getGitDir(), null, $driver))->run('commit-tree', ['4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'Unrelated'])); + + $loggerProphecy = $this->prophesize(LoggerInterface::class); + $loggerProphecy->error(Argument::any())->shouldNotBeCalled(); + $loggerProphecy->info(Argument::any())->shouldBeCalled(); + $loggerProphecy->debug(Argument::any())->shouldBeCalled(); + + $repository = new Repository($repository->getGitDir(), null, new CliDriver('git', [], 60, $loggerProphecy->reveal())); + + $this->assertNull($repository->getMergeBase('master', $unrelated)); + } + + public function testIsValidRepositoryReadsARelativePathAsAPath() + { + $repository = self::createFoobarRepository(false); + $cwd = getcwd(); + + try { + // A repository, whose remote "origin" git would read in its working directory. + chdir($repository->getWorkingDir()); + $this->assertFalse(Repository::isValidRepository('origin', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch('origin', 'master', self::getDriver())); + + chdir(dirname($repository->getWorkingDir())); + $this->assertTrue(Repository::isValidRepository(basename($repository->getWorkingDir()), self::getDriver())); + } finally { + chdir($cwd); + } + } + + /** + * @group legacy + */ + public function testGetObjectsByName() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "master^{tree}", which is not a hash, to "Gitonomy\\Git\\Repository::getTree()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "master:README.md", which is not a hash, to "Gitonomy\\Git\\Repository::getBlob()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.'); + + $repository = self::createFoobarRepository(); + + $this->assertArrayHasKey('README.md', iterator_to_array($repository->getTree('master^{tree}'))); + $this->assertStringContainsString('Foo Bar project', $repository->getBlob('master:README.md')->getContent()); + } + + /** + * @group legacy + */ + public function testGetReferencesReadsThemAgain() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing an argument to "Gitonomy\\Git\\Repository::getReferences()" is deprecated, open a new Repository to read the references again.'); + + $repository = self::createFoobarRepository(); + $this->assertFalse($repository->getReferences()->hasBranch('foobar')); + + $repository->run('branch', ['foobar', self::INITIAL_COMMIT]); + + $this->assertFalse($repository->getReferences()->hasBranch('foobar')); + $this->assertTrue($repository->getReferences(true)->hasBranch('foobar')); + } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index b7ba7d32..d45c20e5 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -14,63 +14,76 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; +use Gitonomy\Git\TreeEntry; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class TreeTest extends AbstractTest { + use ExpectDeprecationTrait; + const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; /** * @dataProvider provideFooBar */ - public function testGetEntries($repository) + public function testIterate($repository) { $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); - $entries = $tree->getEntries(); - - $this->assertNotEmpty($entries['long.php'], 'long.php is present'); - $this->assertTrue($entries['long.php'][1] instanceof Blob, 'long.php is a Blob'); + $entries = iterator_to_array($tree); - $this->assertNotEmpty($entries['README.md'], 'README.md is present'); - $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); + $this->assertInstanceOf(TreeEntry::class, $entries['long.php']); + $this->assertSame('long.php', $entries['long.php']->getName()); + $this->assertSame('100644', $entries['long.php']->getMode()); + $this->assertInstanceOf(Blob::class, $entries['long.php']->getObject()); + $this->assertInstanceOf(Blob::class, $entries['README.md']->getObject()); } /** * @dataProvider provideFooBar */ - public function testGetCommitReferenceEntries($repository) + public function testGetSubmodules($repository) { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $commits = $tree->getCommitReferenceEntries(); + $submodules = $tree->getSubmodules(); - $this->assertNotEmpty($commits['barbaz'], 'barbaz is present'); - $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); + $this->assertSame(['barbaz'], array_keys($submodules)); + $this->assertSame('barbaz', $submodules['barbaz']->getName()); + $this->assertSame('160000', $submodules['barbaz']->getMode()); + $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); + // A CommitReference, like in 1.6, which 2.0 makes an alias of Submodule. + $this->assertSame(CommitReference::class, get_class($submodules['barbaz']->getObject())); + $this->assertSame('5a519c47166f333e32091e166eb55f729106c964', $submodules['barbaz']->getObject()->getHash()); } /** * @dataProvider provideFooBar */ - public function testGetTreeEntries($repository) + public function testGetTrees($repository) { - $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + $this->assertSame([], $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree()->getTrees()); - $trees = $tree->getTreeEntries(); + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTrees(); - $this->assertEmpty($trees); + $this->assertSame('040000', $trees['test']->getMode()); + $this->assertInstanceOf(Tree::class, $trees['test']->getObject()); } /** * @dataProvider provideFooBar */ - public function testGetBlobEntries($repository) + public function testGetBlobs($repository) { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $blobs = $tree->getBlobEntries(); + $blobs = $tree->getBlobs(); - $this->assertNotEmpty($blobs['README.md'], 'README.md is present'); - $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); + $this->assertSame('README.md', $blobs['README.md']->getName()); + $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); + $this->assertArrayNotHasKey('barbaz', $blobs); } /** @@ -82,8 +95,73 @@ public function testResolvePath($repository) $path = 'test/a/b/c'; $resolved = $tree->resolvePath($path); - $entries = $resolved->getEntries(); - $this->assertNotEmpty($entries['d'], 'Successfully resolved source folder'); + $this->assertArrayHasKey('d', iterator_to_array($resolved), 'Successfully resolved source folder'); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getEntries()" method is deprecated, iterate the tree instead.'); + + $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); + + $entries = $tree->getEntries(); + + $this->assertSame('100644', $entries['long.php'][0]); + $this->assertInstanceOf(Blob::class, $entries['long.php'][1]); + $this->assertInstanceOf(Blob::class, $entries['README.md'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetCommitReferenceEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmodules()" instead.'); + + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $commits = $tree->getCommitReferenceEntries(); + + $this->assertSame('160000', $commits['barbaz'][0]); + $this->assertInstanceOf(CommitReference::class, $commits['barbaz'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetTreeEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getTreeEntries()" method is deprecated, use "getTrees()" instead.'); + + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTreeEntries(); + + $this->assertSame('040000', $trees['test'][0]); + $this->assertInstanceOf(Tree::class, $trees['test'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetBlobEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getBlobEntries()" method is deprecated, use "getBlobs()" instead.'); + + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blobs = $tree->getBlobEntries(); + + $this->assertInstanceOf(Blob::class, $blobs['README.md'][1]); } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index dfa048f9..cdb1a4d3 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,25 +12,65 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Repository; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class WorkingCopyTest extends AbstractTest { + use ExpectDeprecationTrait; + public function testNoWorkingCopyInBare() { $this->expectException(LogicException::class); $path = self::createTempDir(); - $repo = Admin::init($path, true, self::getOptions()); + $repo = Repository::initBare($path, self::getDriver()); $repo->getWorkingCopy(); } + public function testCheckoutNewBranch() + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkoutNewBranch('new-feature', 'origin/new-feature'); + + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame($repository->getReferences()->getRemoteBranch('origin/new-feature')->getCommitHash(), $head->getCommitHash()); + } + + public function testCheckoutNewBranchFromHead() + { + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkoutNewBranch('foobar'); + + $head = $repository->getHead(); + $this->assertSame('foobar', $head->getName()); + $this->assertSame($repository->getReferences()->getBranch('master')->getCommitHash(), $head->getCommitHash()); + } + + public function testCheckoutARevision() + { + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout($repository->getRevision('0.1')); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($repository->getRevision('0.1')->getCommit()->getHash(), $repository->getHeadCommit()->getHash()); + } + + /** + * @group legacy + */ public function testCheckout() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); + $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); $wc->checkout('origin/new-feature', 'new-feature'); @@ -40,6 +80,27 @@ public function testCheckout() $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } + public function testDiff() + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + + $this->assertCount(0, $wc->getDiff()->getFiles()); + + file_put_contents($repository->getWorkingDir().'/staged.txt', 'staged'); + $repository->run('add', ['staged.txt']); + file_put_contents($repository->getWorkingDir().'/README.md', 'pending'); + + $diff = $wc->getDiff(); + $this->assertSame(['README.md', 'staged.txt'], array_map(function ($file) { + return $file->getName(); + }, $diff->getFiles())); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + + // Against another revision, the changes of the commits since. + $this->assertGreaterThan(2, count($wc->getDiff($repository->getRevision(self::LONGFILE_COMMIT))->getFiles())); + } + public function testDiffStaged() { $repository = self::createFoobarRepository(false); @@ -110,4 +171,36 @@ public function testGetUntracked() $this->assertContains('untracked.txt', $wc->getUntrackedFiles()); } + + public function testDiffThrowsForAnUnknownRevision() + { + $this->expectException(ReferenceNotFoundException::class); + + self::createFoobarRepository(false)->getWorkingCopy()->getDiff('unknown'); + } + + public function testCheckoutNewBranchThrowsForAnUnknownStartPoint() + { + $repository = self::createFoobarRepository(false); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('foobar', 'unknown'); + $this->fail('An unknown start point throws.'); + } catch (ReferenceNotFoundException $e) { + $this->assertFalse((new Repository($repository->getGitDir(), null, self::getDriver()))->getReferences()->hasBranch('foobar')); + } + } + + /** + * @group legacy + */ + public function testCheckoutALocalBranchObject() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a local "Gitonomy\\Git\\Reference\\Branch" to "Gitonomy\\Git\\WorkingCopy::checkout()", which detaches HEAD, is deprecated, 2.0 attaches HEAD to the branch: pass its name, or its commit to detach HEAD.'); + + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout($repository->getReferences()->getBranch('master')); + + $this->assertTrue($repository->isHeadDetached()); + } }