diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ed5d57f29 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Git operations support with console commands and service layer +- `api:git:clone` command for cloning repositories +- `api:git:fetch` command for fetching updates +- `api:git:pull` command for pulling changes +- `api:git:commit` command for committing changes +- `api:git:push` command for pushing commits +- `Dingo\Api\Contract\Git\Service` interface for git operations +- `Dingo\Api\Git\GitService` implementation with full git support +- Comprehensive git commands documentation in `docs/GIT_COMMANDS.md` +- Complete test suite for git operations in `tests/Git/GitServiceTest.php` + +### Features +- Clone repositories with options: branch, depth, single-branch, recursive +- Fetch from repositories with options: remote, branch, prune, all +- Pull with options: remote, branch, rebase, no-commit, ff-only +- Commit with options: all, amend, no-verify, custom author +- Push with options: remote, branch, force, set-upstream, tags, all +- Repository validation and status checking +- Configurable timeout for git operations (default: 300 seconds) +- Structured return values with success status, output, errors, and exit codes + +## [2.0.0] - Previous Release + +See previous releases for historical changes. diff --git a/docs/GIT_COMMANDS.md b/docs/GIT_COMMANDS.md new file mode 100644 index 000000000..fa48a0b19 --- /dev/null +++ b/docs/GIT_COMMANDS.md @@ -0,0 +1,592 @@ +# Git Commands + +Dingo API now includes built-in git operations support through console commands and a service layer. + +## Table of Contents + +- [Installation](#installation) +- [Console Commands](#console-commands) + - [Clone Command](#clone-command) + - [Fetch Command](#fetch-command) + - [Pull Command](#pull-command) + - [Commit Command](#commit-command) + - [Push Command](#push-command) +- [Using the Git Service](#using-the-git-service) +- [API Reference](#api-reference) + +## Installation + +The git functionality is automatically available when you install Dingo API. Make sure git is installed on your system: + +```bash +git --version +``` + +## Console Commands + +### Clone Command + +Clone a git repository to a specified destination. + +**Signature:** +```bash +php artisan api:git:clone {repository} {destination} [options] +``` + +**Arguments:** +- `repository` - The repository URL to clone (HTTP, HTTPS, or SSH) +- `destination` - The destination path where the repository will be cloned + +**Options:** +- `--branch=BRANCH` - Specific branch to clone +- `--depth=NUMBER` - Create a shallow clone with history truncated to specified number of commits +- `--single-branch` - Clone only one branch (the HEAD or specified with --branch) +- `--recursive` - Clone submodules recursively + +**Examples:** + +Clone a repository: +```bash +php artisan api:git:clone https://github.com/user/repo.git /var/www/repo +``` + +Clone a specific branch: +```bash +php artisan api:git:clone https://github.com/user/repo.git /var/www/repo --branch=develop +``` + +Clone with shallow depth (faster for large repositories): +```bash +php artisan api:git:clone https://github.com/user/repo.git /var/www/repo --depth=1 +``` + +Clone a single branch only: +```bash +php artisan api:git:clone https://github.com/user/repo.git /var/www/repo --branch=main --single-branch +``` + +Clone with submodules: +```bash +php artisan api:git:clone https://github.com/user/repo.git /var/www/repo --recursive +``` + +### Fetch Command + +Fetch updates from a git repository. + +**Signature:** +```bash +php artisan api:git:fetch {path} [options] +``` + +**Arguments:** +- `path` - The path to the git repository + +**Options:** +- `--remote=REMOTE` - Specific remote to fetch from (default: origin) +- `--branch=BRANCH` - Specific branch to fetch +- `--prune` - Remove remote-tracking references that no longer exist on remote +- `--all` - Fetch all remotes + +**Examples:** + +Fetch from origin: +```bash +php artisan api:git:fetch /var/www/repo +``` + +Fetch from a specific remote: +```bash +php artisan api:git:fetch /var/www/repo --remote=upstream +``` + +Fetch a specific branch: +```bash +php artisan api:git:fetch /var/www/repo --branch=main +``` + +Fetch and prune deleted branches: +```bash +php artisan api:git:fetch /var/www/repo --prune +``` + +Fetch from all remotes: +```bash +php artisan api:git:fetch /var/www/repo --all +``` + +### Pull Command + +Pull (fetch and merge) updates from a git repository. + +**Signature:** +```bash +php artisan api:git:pull {path} [options] +``` + +**Arguments:** +- `path` - The path to the git repository + +**Options:** +- `--remote=REMOTE` - Remote to pull from (default: origin) +- `--branch=BRANCH` - Branch to pull +- `--rebase` - Rebase the current branch on top of the upstream branch +- `--no-commit` - Perform the merge but do not commit +- `--ff-only` - Only allow fast-forward merges + +**Examples:** + +Pull from origin: +```bash +php artisan api:git:pull /var/www/repo +``` + +Pull from a specific remote and branch: +```bash +php artisan api:git:pull /var/www/repo --remote=upstream --branch=main +``` + +Pull with rebase: +```bash +php artisan api:git:pull /var/www/repo --rebase +``` + +Pull with fast-forward only: +```bash +php artisan api:git:pull /var/www/repo --ff-only +``` + +### Commit Command + +Commit changes in a git repository. + +**Signature:** +```bash +php artisan api:git:commit {path} {message} [options] +``` + +**Arguments:** +- `path` - The path to the git repository +- `message` - Commit message + +**Options:** +- `--all` - Automatically stage all modified and deleted files +- `--amend` - Amend the previous commit +- `--no-verify` - Bypass pre-commit and commit-msg hooks +- `--author=AUTHOR` - Override the commit author + +**Examples:** + +Commit staged changes: +```bash +php artisan api:git:commit /var/www/repo "Fix bug in authentication" +``` + +Commit all changes (staged and unstaged): +```bash +php artisan api:git:commit /var/www/repo "Update dependencies" --all +``` + +Amend the previous commit: +```bash +php artisan api:git:commit /var/www/repo "Updated message" --amend +``` + +Commit with custom author: +```bash +php artisan api:git:commit /var/www/repo "Deploy to production" --author="Deploy Bot " +``` + +### Push Command + +Push commits to a remote git repository. + +**Signature:** +```bash +php artisan api:git:push {path} [options] +``` + +**Arguments:** +- `path` - The path to the git repository + +**Options:** +- `--remote=REMOTE` - Remote to push to (default: origin) +- `--branch=BRANCH` - Branch to push +- `--force` - Force push (use with caution!) +- `--set-upstream` - Set upstream tracking for the branch +- `--tags` - Push all tags +- `--all` - Push all branches + +**Examples:** + +Push to origin: +```bash +php artisan api:git:push /var/www/repo +``` + +Push to a specific remote and branch: +```bash +php artisan api:git:push /var/www/repo --remote=origin --branch=main +``` + +Push and set upstream: +```bash +php artisan api:git:push /var/www/repo --set-upstream --remote=origin --branch=feature-branch +``` + +Push all tags: +```bash +php artisan api:git:push /var/www/repo --tags +``` + +## Using the Git Service + +You can also use the Git service directly in your application code. + +### Dependency Injection + +```php +use Dingo\Api\Contract\Git\Service as GitService; + +class DeploymentController extends Controller +{ + protected $git; + + public function __construct(GitService $git) + { + $this->git = $git; + } + + public function deploy() + { + // Clone a repository + $result = $this->git->clone( + 'https://github.com/user/repo.git', + '/var/www/repo', + ['branch' => 'main', 'depth' => 1] + ); + + if ($result['success']) { + return response()->json(['message' => 'Repository cloned successfully']); + } + + return response()->json(['error' => $result['error']], 500); + } +} +``` + +### Service Resolution + +```php +$git = app(\Dingo\Api\Contract\Git\Service::class); + +// Clone a repository +$result = $git->clone( + 'https://github.com/user/repo.git', + '/var/www/repo' +); + +// Fetch updates +$result = $git->fetch('/var/www/repo', ['prune' => true]); + +// Check repository status +$result = $git->status('/var/www/repo'); + +// Verify if path is a git repository +$isRepo = $git->isRepository('/var/www/repo'); +``` + +## API Reference + +### GitService Methods + +#### clone($repository, $destination, array $options = []) + +Clone a git repository. + +**Parameters:** +- `string $repository` - Repository URL +- `string $destination` - Destination path +- `array $options` - Optional parameters: + - `branch` (string) - Branch to clone + - `depth` (int) - Shallow clone depth + - `single-branch` (bool) - Clone single branch only + - `recursive` (bool) - Clone submodules recursively + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, + 'error' => string, + 'exit_code' => int, + 'exception' => string // Only present on failure +] +``` + +#### fetch($path, array $options = []) + +Fetch from a git repository. + +**Parameters:** +- `string $path` - Repository path +- `array $options` - Optional parameters: + - `remote` (string) - Remote to fetch from + - `branch` (string) - Branch to fetch + - `prune` (bool) - Prune deleted references + - `all` (bool) - Fetch all remotes + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, + 'error' => string, + 'exit_code' => int, + 'exception' => string // Only present on failure +] +``` + +**Throws:** +- `RuntimeException` - If path is not a git repository + +#### pull($path, array $options = []) + +Pull (fetch and merge) from a git repository. + +**Parameters:** +- `string $path` - Repository path +- `array $options` - Optional parameters: + - `remote` (string) - Remote to pull from + - `branch` (string) - Branch to pull + - `rebase` (bool) - Rebase instead of merge + - `no-commit` (bool) - Perform merge but do not commit + - `ff-only` (bool) - Only allow fast-forward merges + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, + 'error' => string, + 'exit_code' => int, + 'exception' => string // Only present on failure +] +``` + +**Throws:** +- `RuntimeException` - If path is not a git repository + +#### commit($path, $message, array $options = []) + +Commit changes in a git repository. + +**Parameters:** +- `string $path` - Repository path +- `string $message` - Commit message +- `array $options` - Optional parameters: + - `all` (bool) - Stage all modified and deleted files + - `amend` (bool) - Amend the previous commit + - `no-verify` (bool) - Bypass hooks + - `author` (string) - Override commit author + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, + 'error' => string, + 'exit_code' => int, + 'exception' => string // Only present on failure +] +``` + +**Throws:** +- `RuntimeException` - If path is not a git repository + +#### push($path, array $options = []) + +Push to a remote git repository. + +**Parameters:** +- `string $path` - Repository path +- `array $options` - Optional parameters: + - `remote` (string) - Remote to push to + - `branch` (string) - Branch to push + - `force` (bool) - Force push + - `set-upstream` (bool) - Set upstream tracking + - `tags` (bool) - Push all tags + - `all` (bool) - Push all branches + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, + 'error' => string, + 'exit_code' => int, + 'exception' => string // Only present on failure +] +``` + +**Throws:** +- `RuntimeException` - If path is not a git repository + +#### status($path) + +Get the status of a git repository. + +**Parameters:** +- `string $path` - Repository path + +**Returns:** +```php +[ + 'success' => true|false, + 'output' => string, // Porcelain format output + 'error' => string, + 'exit_code' => int +] +``` + +**Throws:** +- `RuntimeException` - If path is not a git repository + +#### isRepository($path) + +Check if a directory is a git repository. + +**Parameters:** +- `string $path` - Directory path + +**Returns:** +- `bool` - True if the path is a git repository + +#### setTimeout($timeout) + +Set the timeout for git operations (in seconds). + +**Parameters:** +- `int $timeout` - Timeout in seconds + +**Returns:** +- `$this` - For method chaining + +**Example:** +```php +$git->setTimeout(600)->clone($repo, $dest); +``` + +## Error Handling + +All git operations return an array with detailed information. Always check the `success` key: + +```php +$result = $git->clone($repo, $dest); + +if (!$result['success']) { + // Handle error + Log::error('Git clone failed', [ + 'error' => $result['error'], + 'exit_code' => $result['exit_code'], + 'exception' => $result['exception'] ?? null + ]); +} +``` + +For `fetch()` and `status()`, a `RuntimeException` is thrown if the path is not a git repository: + +```php +try { + $result = $git->fetch('/invalid/path'); +} catch (\RuntimeException $e) { + Log::error('Not a git repository: ' . $e->getMessage()); +} +``` + +## Timeout Configuration + +The default timeout for git operations is 300 seconds (5 minutes). For large repositories, you may need to increase this: + +```php +$git->setTimeout(600)->clone($largeRepo, $dest); +``` + +## Security Considerations + +- Always validate and sanitize repository URLs from user input +- Be cautious with file paths to prevent directory traversal attacks +- Consider using SSH keys for authentication instead of embedding credentials in URLs +- Limit the directories where repositories can be cloned +- Use appropriate file permissions for cloned repositories + +## Examples + +### Automated Deployment + +```php +public function deployLatest(GitService $git) +{ + $repoPath = '/var/www/myapp'; + + if (!$git->isRepository($repoPath)) { + // First deployment - clone + $result = $git->clone( + 'git@github.com:company/app.git', + $repoPath, + ['branch' => 'production'] + ); + } else { + // Subsequent deployments - fetch + $result = $git->fetch($repoPath, [ + 'remote' => 'origin', + 'branch' => 'production', + 'prune' => true + ]); + } + + if (!$result['success']) { + throw new DeploymentException($result['error']); + } +} +``` + +### Repository Mirror + +```php +public function mirrorRepository(GitService $git, $source, $destination) +{ + // Clone with all branches + $result = $git->clone($source, $destination); + + if ($result['success']) { + // Fetch from all remotes + $git->fetch($destination, ['all' => true]); + } + + return $result; +} +``` + +## Troubleshooting + +### Command Not Found + +If you get "command not found" errors, ensure git is installed and in the system PATH. + +### Permission Denied + +Ensure the web server user has appropriate permissions to write to the destination directory and read git configuration. + +### Timeout Errors + +For large repositories, increase the timeout: +```php +$git->setTimeout(1800); // 30 minutes +``` + +### Authentication Issues + +For private repositories, ensure proper authentication is configured: +- SSH: Set up SSH keys for the web server user +- HTTPS: Use credential helpers or embed credentials (not recommended for production) diff --git a/readme.md b/readme.md index 7bb74c66d..4c5b0bb04 100644 --- a/readme.md +++ b/readme.md @@ -25,11 +25,14 @@ This package provides tools for the following, and more: - Error and Exception Handling - Internal Requests - API Blueprint Documentation +- **Git Operations** - Clone, fetch, pull, commit, and push repositories programmatically ## Documentation Please refer to our extensive [Wiki documentation](https://github.com/dingo/api/wiki) for more information. +For git operations, see the [Git Commands Documentation](docs/GIT_COMMANDS.md). + ## API Boilerplate If you are looking to start a new project from scratch, consider using the [Laravel API Boilerplate](https://github.com/specialtactics/laravel-api-boilerplate), which builds on top of the dingo-api package, and adds a lot of great features. diff --git a/src/Console/Command/GitClone.php b/src/Console/Command/GitClone.php new file mode 100644 index 000000000..b0fb43269 --- /dev/null +++ b/src/Console/Command/GitClone.php @@ -0,0 +1,99 @@ +git = $git; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $repository = $this->argument('repository'); + $destination = $this->argument('destination'); + + $this->info("Cloning repository: {$repository}"); + $this->info("Destination: {$destination}"); + + $options = array_filter([ + 'branch' => $this->option('branch'), + 'depth' => $this->option('depth'), + 'single-branch' => $this->option('single-branch'), + 'recursive' => $this->option('recursive'), + ]); + + if (!empty($options)) { + $this->line('Options: ' . json_encode($options)); + } + + $result = $this->git->clone($repository, $destination, $options); + + if ($result['success']) { + $this->info('Repository cloned successfully!'); + + if (!empty($result['output'])) { + $this->line($result['output']); + } + + return 0; + } else { + $this->error('Failed to clone repository.'); + + if (!empty($result['error'])) { + $this->error($result['error']); + } + + if (isset($result['exception'])) { + $this->error($result['exception']); + } + + return 1; + } + } +} diff --git a/src/Console/Command/GitCommit.php b/src/Console/Command/GitCommit.php new file mode 100644 index 000000000..37d9dfad9 --- /dev/null +++ b/src/Console/Command/GitCommit.php @@ -0,0 +1,108 @@ +git = $git; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $path = $this->argument('path'); + $message = $this->argument('message'); + + if (!$this->git->isRepository($path)) { + $this->error("The path [{$path}] is not a git repository."); + return 1; + } + + $this->info("Committing changes in repository: {$path}"); + $this->line("Message: {$message}"); + + $options = array_filter([ + 'all' => $this->option('all'), + 'amend' => $this->option('amend'), + 'no-verify' => $this->option('no-verify'), + 'author' => $this->option('author'), + ]); + + if (!empty($options)) { + $this->line('Options: ' . json_encode($options)); + } + + $result = $this->git->commit($path, $message, $options); + + if ($result['success']) { + $this->info('Commit created successfully!'); + + if (!empty($result['output'])) { + $this->line($result['output']); + } + + if (!empty($result['error'])) { + $this->line($result['error']); + } + + return 0; + } else { + $this->error('Failed to create commit.'); + + if (!empty($result['error'])) { + $this->error($result['error']); + } + + if (isset($result['exception'])) { + $this->error($result['exception']); + } + + return 1; + } + } +} diff --git a/src/Console/Command/GitFetch.php b/src/Console/Command/GitFetch.php new file mode 100644 index 000000000..5fe96512a --- /dev/null +++ b/src/Console/Command/GitFetch.php @@ -0,0 +1,105 @@ +git = $git; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $path = $this->argument('path'); + + if (!$this->git->isRepository($path)) { + $this->error("The path [{$path}] is not a git repository."); + return 1; + } + + $this->info("Fetching from repository: {$path}"); + + $options = array_filter([ + 'remote' => $this->option('remote'), + 'branch' => $this->option('branch'), + 'prune' => $this->option('prune'), + 'all' => $this->option('all'), + ]); + + if (!empty($options)) { + $this->line('Options: ' . json_encode($options)); + } + + $result = $this->git->fetch($path, $options); + + if ($result['success']) { + $this->info('Fetch completed successfully!'); + + if (!empty($result['output'])) { + $this->line($result['output']); + } + + if (!empty($result['error'])) { + $this->line($result['error']); + } + + return 0; + } else { + $this->error('Failed to fetch from repository.'); + + if (!empty($result['error'])) { + $this->error($result['error']); + } + + if (isset($result['exception'])) { + $this->error($result['exception']); + } + + return 1; + } + } +} diff --git a/src/Console/Command/GitPull.php b/src/Console/Command/GitPull.php new file mode 100644 index 000000000..a39d8de83 --- /dev/null +++ b/src/Console/Command/GitPull.php @@ -0,0 +1,107 @@ +git = $git; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $path = $this->argument('path'); + + if (!$this->git->isRepository($path)) { + $this->error("The path [{$path}] is not a git repository."); + return 1; + } + + $this->info("Pulling from repository: {$path}"); + + $options = array_filter([ + 'remote' => $this->option('remote'), + 'branch' => $this->option('branch'), + 'rebase' => $this->option('rebase'), + 'no-commit' => $this->option('no-commit'), + 'ff-only' => $this->option('ff-only'), + ]); + + if (!empty($options)) { + $this->line('Options: ' . json_encode($options)); + } + + $result = $this->git->pull($path, $options); + + if ($result['success']) { + $this->info('Pull completed successfully!'); + + if (!empty($result['output'])) { + $this->line($result['output']); + } + + if (!empty($result['error'])) { + $this->line($result['error']); + } + + return 0; + } else { + $this->error('Failed to pull from repository.'); + + if (!empty($result['error'])) { + $this->error($result['error']); + } + + if (isset($result['exception'])) { + $this->error($result['exception']); + } + + return 1; + } + } +} diff --git a/src/Console/Command/GitPush.php b/src/Console/Command/GitPush.php new file mode 100644 index 000000000..5ba5d8354 --- /dev/null +++ b/src/Console/Command/GitPush.php @@ -0,0 +1,109 @@ +git = $git; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $path = $this->argument('path'); + + if (!$this->git->isRepository($path)) { + $this->error("The path [{$path}] is not a git repository."); + return 1; + } + + $this->info("Pushing to repository: {$path}"); + + $options = array_filter([ + 'remote' => $this->option('remote'), + 'branch' => $this->option('branch'), + 'force' => $this->option('force'), + 'set-upstream' => $this->option('set-upstream'), + 'tags' => $this->option('tags'), + 'all' => $this->option('all'), + ]); + + if (!empty($options)) { + $this->line('Options: ' . json_encode($options)); + } + + $result = $this->git->push($path, $options); + + if ($result['success']) { + $this->info('Push completed successfully!'); + + if (!empty($result['output'])) { + $this->line($result['output']); + } + + if (!empty($result['error'])) { + $this->line($result['error']); + } + + return 0; + } else { + $this->error('Failed to push to repository.'); + + if (!empty($result['error'])) { + $this->error($result['error']); + } + + if (isset($result['exception'])) { + $this->error($result['exception']); + } + + return 1; + } + } +} diff --git a/src/Contract/Git/Service.php b/src/Contract/Git/Service.php new file mode 100644 index 000000000..f56af4dec --- /dev/null +++ b/src/Contract/Git/Service.php @@ -0,0 +1,76 @@ +executeCommand($command, dirname($destination)); + } + + /** + * Fetch from a git repository. + * + * @param string $path + * @param array $options + * + * @return array + */ + public function fetch($path, array $options = []) + { + if (!$this->isRepository($path)) { + throw new RuntimeException("The path [{$path}] is not a git repository."); + } + + $command = ['git', 'fetch']; + + if (isset($options['remote'])) { + $command[] = $options['remote']; + } + + if (isset($options['branch'])) { + $command[] = $options['branch']; + } + + if (isset($options['prune']) && $options['prune']) { + $command[] = '--prune'; + } + + if (isset($options['all']) && $options['all']) { + $command[] = '--all'; + } + + return $this->executeCommand($command, $path); + } + + /** + * Pull from a git repository. + * + * @param string $path + * @param array $options + * + * @return array + */ + public function pull($path, array $options = []) + { + if (!$this->isRepository($path)) { + throw new RuntimeException("The path [{$path}] is not a git repository."); + } + + $command = ['git', 'pull']; + + if (isset($options['remote'])) { + $command[] = $options['remote']; + } + + if (isset($options['branch'])) { + $command[] = $options['branch']; + } + + if (isset($options['rebase']) && $options['rebase']) { + $command[] = '--rebase'; + } + + if (isset($options['no-commit']) && $options['no-commit']) { + $command[] = '--no-commit'; + } + + if (isset($options['ff-only']) && $options['ff-only']) { + $command[] = '--ff-only'; + } + + return $this->executeCommand($command, $path); + } + + /** + * Commit changes in a git repository. + * + * @param string $path + * @param string $message + * @param array $options + * + * @return array + */ + public function commit($path, $message, array $options = []) + { + if (!$this->isRepository($path)) { + throw new RuntimeException("The path [{$path}] is not a git repository."); + } + + $command = ['git', 'commit']; + + if (isset($options['all']) && $options['all']) { + $command[] = '--all'; + } + + if (isset($options['amend']) && $options['amend']) { + $command[] = '--amend'; + } + + if (isset($options['no-verify']) && $options['no-verify']) { + $command[] = '--no-verify'; + } + + if (isset($options['author'])) { + $command[] = '--author'; + $command[] = $options['author']; + } + + $command[] = '-m'; + $command[] = $message; + + return $this->executeCommand($command, $path); + } + + /** + * Push to a git repository. + * + * @param string $path + * @param array $options + * + * @return array + */ + public function push($path, array $options = []) + { + if (!$this->isRepository($path)) { + throw new RuntimeException("The path [{$path}] is not a git repository."); + } + + $command = ['git', 'push']; + + if (isset($options['remote'])) { + $command[] = $options['remote']; + } + + if (isset($options['branch'])) { + $command[] = $options['branch']; + } + + if (isset($options['force']) && $options['force']) { + $command[] = '--force'; + } + + if (isset($options['set-upstream']) && $options['set-upstream']) { + $command[] = '--set-upstream'; + } + + if (isset($options['tags']) && $options['tags']) { + $command[] = '--tags'; + } + + if (isset($options['all']) && $options['all']) { + $command[] = '--all'; + } + + return $this->executeCommand($command, $path); + } + + /** + * Get the status of a git repository. + * + * @param string $path + * + * @return array + */ + public function status($path) + { + if (!$this->isRepository($path)) { + throw new RuntimeException("The path [{$path}] is not a git repository."); + } + + return $this->executeCommand(['git', 'status', '--porcelain'], $path); + } + + /** + * Check if a directory is a git repository. + * + * @param string $path + * + * @return bool + */ + public function isRepository($path) + { + if (!is_dir($path)) { + return false; + } + + $process = new Process(['git', 'rev-parse', '--git-dir'], $path); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * Execute a git command. + * + * @param array $command + * @param string $workingDirectory + * + * @return array + */ + protected function executeCommand(array $command, $workingDirectory = null) + { + $process = new Process($command, $workingDirectory, null, null, $this->timeout); + + try { + $process->mustRun(); + + return [ + 'success' => true, + 'output' => $process->getOutput(), + 'error' => $process->getErrorOutput(), + 'exit_code' => $process->getExitCode(), + ]; + } catch (ProcessFailedException $e) { + return [ + 'success' => false, + 'output' => $process->getOutput(), + 'error' => $process->getErrorOutput(), + 'exit_code' => $process->getExitCode(), + 'exception' => $e->getMessage(), + ]; + } + } + + /** + * Set the timeout for git operations. + * + * @param int $timeout + * + * @return $this + */ + public function setTimeout($timeout) + { + $this->timeout = $timeout; + + return $this; + } +} diff --git a/src/Provider/DingoServiceProvider.php b/src/Provider/DingoServiceProvider.php index a86fdd7e5..d7867379d 100644 --- a/src/Provider/DingoServiceProvider.php +++ b/src/Provider/DingoServiceProvider.php @@ -60,12 +60,19 @@ public function register() $this->registerTransformer(); + $this->registerGitService(); + $this->registerDocsCommand(); if (class_exists('Illuminate\Foundation\Application', false)) { $this->commands([ \Dingo\Api\Console\Command\Cache::class, \Dingo\Api\Console\Command\Routes::class, + \Dingo\Api\Console\Command\GitClone::class, + \Dingo\Api\Console\Command\GitFetch::class, + \Dingo\Api\Console\Command\GitPull::class, + \Dingo\Api\Console\Command\GitCommit::class, + \Dingo\Api\Console\Command\GitPush::class, ]); } } @@ -169,6 +176,18 @@ protected function registerTransformer() }); } + /** + * Register the git service. + * + * @return void + */ + protected function registerGitService() + { + $this->app->singleton(\Dingo\Api\Contract\Git\Service::class, function ($app) { + return new \Dingo\Api\Git\GitService(); + }); + } + /** * Register the documentation command. * diff --git a/tests/Git/GitServiceTest.php b/tests/Git/GitServiceTest.php new file mode 100644 index 000000000..9990e62e6 --- /dev/null +++ b/tests/Git/GitServiceTest.php @@ -0,0 +1,153 @@ +gitService = new GitService(); + } + + public function testIsRepositoryReturnsFalseForNonExistentPath() + { + $result = $this->gitService->isRepository('/path/that/does/not/exist'); + $this->assertFalse($result); + } + + public function testIsRepositoryReturnsFalseForNonGitDirectory() + { + $tempDir = sys_get_temp_dir() . '/test_non_git_' . uniqid(); + mkdir($tempDir); + + $result = $this->gitService->isRepository($tempDir); + $this->assertFalse($result); + + rmdir($tempDir); + } + + public function testIsRepositoryReturnsTrueForGitRepository() + { + // Use the current API directory which is a git repository + $result = $this->gitService->isRepository(__DIR__ . '/../..'); + $this->assertTrue($result); + } + + public function testFetchThrowsExceptionForNonRepository() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository'); + + $this->gitService->fetch('/path/that/does/not/exist'); + } + + public function testStatusThrowsExceptionForNonRepository() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository'); + + $this->gitService->status('/path/that/does/not/exist'); + } + + public function testSetTimeoutReturnsInstance() + { + $result = $this->gitService->setTimeout(600); + $this->assertInstanceOf(GitService::class, $result); + } + + public function testCloneWithInvalidRepositoryReturnsFalse() + { + $tempDir = sys_get_temp_dir() . '/test_clone_' . uniqid(); + + $result = $this->gitService->clone('https://invalid-repo-url.example.com/repo.git', $tempDir); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + $this->assertFalse($result['success']); + } + + public function testStatusReturnsArrayWithExpectedKeys() + { + // Test with the current repository + $result = $this->gitService->status(__DIR__ . '/../..'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + } + + public function testPullThrowsExceptionForNonRepository() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository'); + + $this->gitService->pull('/path/that/does/not/exist'); + } + + public function testCommitThrowsExceptionForNonRepository() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository'); + + $this->gitService->commit('/path/that/does/not/exist', 'Test commit'); + } + + public function testPushThrowsExceptionForNonRepository() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository'); + + $this->gitService->push('/path/that/does/not/exist'); + } + + public function testPullReturnsArrayWithExpectedKeys() + { + // Test pull returns proper structure (may fail if no remote configured) + $result = $this->gitService->pull(__DIR__ . '/../..'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + } + + public function testCommitReturnsArrayWithExpectedKeys() + { + // Test commit returns proper structure (will fail if nothing to commit) + $result = $this->gitService->commit(__DIR__ . '/../..', 'Test commit message'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + } + + public function testPushReturnsArrayWithExpectedKeys() + { + // Test push returns proper structure (may fail if no remote configured) + $result = $this->gitService->push(__DIR__ . '/../..'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + } +}