├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── conductor ├── conductor-logo.png ├── examples └── todo │ ├── .gitignore │ ├── README.md │ ├── app │ └── cli │ │ ├── .gitignore │ │ ├── README.md │ │ ├── composer.json │ │ ├── phpunit.xml.dist │ │ ├── src │ │ ├── App.php │ │ └── JsonFileTaskRepository.php │ │ ├── tests │ │ └── JsonFileTaskRepositoryTest.php │ │ └── todo │ ├── artifact │ └── mybuilder_todo-package_0.1.zip │ ├── composer.json │ ├── conductor.yml │ └── package │ └── todo │ ├── .gitignore │ ├── README.md │ ├── composer.json │ ├── phpunit.xml.dist │ ├── src │ ├── Task.php │ ├── TaskRepository.php │ └── TaskService.php │ └── tests │ ├── TaskServiceTest.php │ └── TaskTest.php ├── phpunit.xml.dist ├── src ├── Command │ ├── BaseCommand.php │ ├── LockFixerCommand.php │ ├── SymlinkCommand.php │ └── UpdateCommand.php ├── Conductor.php ├── Exception │ └── ChecksumMismatchException.php └── PackageZipper.php └── tests ├── ConductorTest.php ├── PackageZipperTest.php ├── bootstrap.php └── fixtures ├── packages ├── package-a-changed │ └── composer.json └── package-a │ └── composer.json └── symlink ├── package-a └── content.txt └── package-b └── package-a └── .gitkeep /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.3 5 | - 5.4 6 | - 5.5 7 | - 5.6 8 | - hhvm 9 | 10 | before_script: 11 | - composer install 12 | 13 | script: vendor/bin/phpunit 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016 MyBuilder Limited 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/mybuilder/conductor.svg?branch=master)](http://travis-ci.org/mybuilder/conductor) 2 | 3 | ![Conductor](conductor-logo.png) 4 | 5 | 6 | **We no longer use Conductor at MyBuilder and instead now use the [Composer path](https://getcomposer.org/doc/05-repositories.md#path) but if you want to take over development of Conductor let us know.** 7 | 8 | --- 9 | 10 | This tool allows you to manage isolated, internal [Composer](https://getcomposer.org/) packages within a single, monolithic repository. 11 | Separating units of code based on directory structure, as opposed to at the repository level, maintains a single source of truth whilst providing the benefits of clearly defined component boundaries. 12 | 13 | When would you use it? 14 | ---------------------- 15 | 16 | You would use this tool in a project setting where multiple separate applications co-exist (i.e. admin, frontend and mobile-api). 17 | Within this context each application will share code, such as business logic, to provide the end solution. 18 | 19 | An example project repository structure that we use in-kind is shown below: 20 | 21 | ```bash 22 | ├── app/ 23 | │   ├── admin 24 | │   │   ├── src/ 25 | │   │   ├── tests/ 26 | │   │   └── composer.json 27 | │   ├── frontend 28 | │   │   ├── src/ 29 | │   │   ├── tests/ 30 | │   │   └── composer.json 31 | │   └── mobile-api 32 | │   ├── src/ 33 | │   ├── tests/ 34 | │   └── composer.json 35 | ├── artifact/ 36 | ├── bin 37 | │   └── conductor 38 | ├── package 39 | │   ├── bar 40 | │   │   ├── src/ 41 | │   │   ├── tests/ 42 | │   │   └── composer.json 43 | │   └── foo 44 | │   ├── src/ 45 | │   ├── tests/ 46 | │   └── composer.json 47 | ├── composer.json 48 | └── conductor.yml 49 | 50 | ``` 51 | 52 | As you can see the root-level composer.json file is only used for uniform tooling - so no project specific code should be stored at this level. 53 | The business logic is contained within each of the isolated packages, with the delivery supplied via the 'app' directory. 54 | 55 | Compatibility 56 | ------------- 57 | 58 | - ✔ Mac OSX 59 | - ✔ Unix-derived systems (CentOS, Debian etc.) 60 | - ? Windows - Not tested at this time 61 | 62 | Examples 63 | -------- 64 | 65 | At this time the project comes with a simple [todo example](examples/todo/) which illustrates how to use Conductor in it's entirety. 66 | 67 | Further Reading 68 | --------------- 69 | 70 | - [UK Symfony Meetup - Composer in monolithic repositories](http://www.meetup.com/symfony/events/192889222/) 71 | 72 | --- 73 | 74 | Created by [MyBuilder](http://www.mybuilder.com/) - Check out our [blog](http://tech.mybuilder.com/) for more insight into this and other open-source projects we release. 75 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mybuilder/conductor", 3 | "description": "Tools for managing multiple packages in one source repository", 4 | "license": "MIT", 5 | "authors": [{ 6 | "name": "Sten Hiedel", 7 | "email": "r.sten@hiedel.ee" 8 | }], 9 | "require": { 10 | "symfony/console": "~2.6", 11 | "symfony/finder": "~2.6", 12 | "symfony/yaml": "~2.6", 13 | "symfony/filesystem": "~2.6" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "~4.5" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "MyBuilder\\Conductor\\": "src/" 21 | } 22 | }, 23 | "bin": ["conductor"], 24 | "extra": { 25 | "branch-alias": { 26 | "dev-master": "0.9-dev" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /conductor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new Commands\UpdateCommand($conductor)); 19 | $app->add(new Commands\SymlinkCommand($conductor)); 20 | $app->add(new Commands\LockFixerCommand($conductor)); 21 | $app->run(); 22 | -------------------------------------------------------------------------------- /conductor-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mybuilder/conductor/c9c7127dd0c251c3687a1dfcc137defaa0ce8b19/conductor-logo.png -------------------------------------------------------------------------------- /examples/todo/.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | bin/ -------------------------------------------------------------------------------- /examples/todo/README.md: -------------------------------------------------------------------------------- 1 | To-do Listing Example 2 | ===================== 3 | 4 | Like many developers, I find that a good example is sometimes the best means of grasping a new concept or tool. 5 | This example highlights a solution to the contrived example of displaying task title listings on the CLI. 6 | 7 | Setup 8 | ----- 9 | 10 | The initial step to solving this problem is to first gain access to the Conductor console application within our repository. 11 | Below is the sample 'composer.json' file used to define the root repositories tool dependencies. 12 | 13 | ```json 14 | { 15 | "name": "mybuilder/todo", 16 | "require": { 17 | "mybuilder/conductor": "*@dev" 18 | }, 19 | "config": { 20 | "bin-dir": "bin" 21 | } 22 | } 23 | ``` 24 | 25 | We are then required to specify within 'conductor.yml', the zipped archive and internal packages directories present. 26 | You will notice by looking at the following YAML definition that we have decided to place our packages into a single directory called 'package'. 27 | In the case where more fine-grained separation is required, simply adding further directories to this list will suffice. 28 | 29 | ```yaml 30 | artifacts_repository: ./artifact 31 | packages: 32 | - ./package/* 33 | ``` 34 | 35 | With these two files now in-place we are able to 'composer install' and download Conductor, along with all it's required dependencies. 36 | 37 | The Package 38 | ----------- 39 | 40 | The model in which we internally represent a task, along with the title listing projection service is core domain logic. 41 | It is also agnostic and does not concern itself with the delivery and persistent methods it could be used in. 42 | As a result of this, the task model, service and repository interface contract can all be packaged up in isolation without any external dependencies. 43 | 44 | ```bash 45 | package 46 | └── todo 47 | ├── src 48 | │   ├── Task.php 49 | │   ├── TaskRepository.php 50 | │   └── TaskService.php 51 | ├── tests 52 | │   ├── TaskServiceTest.php 53 | │   └── TaskTest.php 54 | ├── README.md 55 | ├── composer.json 56 | └── phpunit.xml.dist 57 | ``` 58 | 59 | As you will notice above, the package's structure follows a similar pattern found in most typical composer dependent packages. 60 | As the package does not depend on anything from the outside world, no Conductor dependent additions are required to the 'composer.json' file. 61 | 62 | ```json 63 | { 64 | "name": "mybuilder/todo-package", 65 | "version": "0.1", 66 | "autoload": { 67 | "psr-4": { 68 | "MyBuilder\\Package\\ToDo\\": "src/" 69 | } 70 | }, 71 | "require-dev": { 72 | "phpunit/phpunit": "~4.0" 73 | }, 74 | "config": { 75 | "bin-dir": "bin" 76 | } 77 | } 78 | ``` 79 | 80 | The Application 81 | --------------- 82 | 83 | With the core domain-logic now in place we are able to move on to implementing the CLI application. 84 | Following a similar pattern to how the package is structured we will isolate the application into a single directory - allowing us to easily add other delivery mechanisms (such as 'web') at a later date. 85 | 86 | ```bash 87 | app/ 88 | └── cli 89 | ├── src 90 | │   ├── App.php 91 | │   └── JsonFileTaskRepository.php 92 | ├── tests 93 | │   └── JsonFileTaskRepositoryTest.php 94 | ├── README.md 95 | ├── composer.json 96 | ├── phpunit.xml.dist 97 | └── todo 98 | ``` 99 | 100 | Looking at the file-based JSON repositories implementation you will notice that we depend upon several domain concepts. 101 | This is the use-case for the Conductor tool. 102 | Not only does it allow us to locally depend on other packages within the same repository, but also symbolically links the dependencies to the working directories current state. 103 | 104 | ```json 105 | { 106 | "name": "mybuilder/todo-cli", 107 | "version": "0.1", 108 | "autoload": { 109 | "psr-4": { 110 | "MyBuilder\\App\\ToDo\\Cli\\": "src/" 111 | } 112 | }, 113 | "require": { 114 | "mybuilder/todo-package": "*@dev" 115 | }, 116 | "require-dev": { 117 | "phpunit/phpunit": "~4.0" 118 | }, 119 | "config": { 120 | "bin-dir": "bin" 121 | }, 122 | "scripts": { 123 | "pre-install-cmd": [ 124 | "../../bin/conductor update -c ../.." 125 | ], 126 | "pre-update-cmd": [ 127 | "../../bin/conductor update -c ../.." 128 | ], 129 | "post-update-cmd": [ 130 | "../../bin/conductor fix-composer-lock -c ../.." 131 | ], 132 | "pre-autoload-dump": [ 133 | "../../bin/conductor symlink -c ../.." 134 | ] 135 | }, 136 | "repositories": [ 137 | { 138 | "type": "artifact", 139 | "url": "../../artifact/" 140 | } 141 | ] 142 | } 143 | ``` 144 | 145 | As you can see from the above composer definition, there is a little boilerplate that is required to correctly configure and invoke Conductor, highlighted within 'scripts'. 146 | When an install or update composer action is invoked, all packages found within the defined directories are zipped and archived. 147 | In the case of a successful update, we also address an issue in-regard to how Composer always stores absolute paths in lock files, even though relative paths have been supplied. 148 | So as to allow for the lock files to be committed and developers freely specify where they wish to place the repository, we replace these absolute paths with their relative counterparts. 149 | Finally, before dumping the autoloader we symbolically link the 'vendor' directories internal dependences used within each package to the current working directory version. 150 | This is required as internally the archived directories do not contain any source code, only the directory paths to symbolically link. 151 | 152 | With the command line application now having access to the 'todo-package', we are able to bootstrap the application and solve the problem laid out in its entirety. 153 | 154 | ```php 155 | service = new TaskService( 169 | new JsonFileTaskRepository($this->filePath = $filePath) 170 | ); 171 | } 172 | 173 | public function run() 174 | { 175 | $this->fillWithDummyTasks(); 176 | 177 | foreach ($this->service->fetchAllTitles() as $title) { 178 | echo "* $title\n"; 179 | } 180 | 181 | $this->removeAllTasks(); 182 | } 183 | 184 | private function fillWithDummyTasks() 185 | { 186 | foreach (range(1, 3) as $n) { 187 | $this->service->addTask("Task $n", "Task $n Description"); 188 | } 189 | } 190 | 191 | private function removeAllTasks() 192 | { 193 | unlink($this->filePath); 194 | } 195 | } 196 | ``` 197 | 198 | You will notice above that we provide a dummy set of tasks for the service to consume. 199 | This works in this use-case as we have not been tasked with adding the ability to persist user inputted items at this time. 200 | Finally, you are able to 'composer install' this package, bringing in the symbolically linked internal dependencies, and execute the solution. 201 | 202 | ```php 203 | #!/usr/bin/env php 204 | run(); 210 | ``` 211 | 212 | ```bash 213 | $ ./todo 214 | * Title 1 215 | * Title 2 216 | * Title 3 217 | ``` 218 | -------------------------------------------------------------------------------- /examples/todo/app/cli/.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | bin/ -------------------------------------------------------------------------------- /examples/todo/app/cli/README.md: -------------------------------------------------------------------------------- 1 | To-do Cli App 2 | ============= 3 | 4 | This application allows you to access the 'mybuilder/todo-package' title listing 5 | service from the CLI. 6 | Tasks are persisted using a file-based JSON representation, and the application 7 | prefills this store with dummy data so as to provide the example with meaning. 8 | 9 | Demo 10 | ---- 11 | 12 | To run the example, the following commands must be executed from within this 13 | directory: 14 | 15 | ``` bash 16 | $ composer install 17 | $ ./todo 18 | * Task 1 19 | * Task 2 20 | * Task 3 21 | ``` 22 | -------------------------------------------------------------------------------- /examples/todo/app/cli/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mybuilder/todo-cli", 3 | "version": "0.1", 4 | "autoload": { 5 | "psr-4": { 6 | "MyBuilder\\App\\ToDo\\Cli\\": "src/" 7 | } 8 | }, 9 | "require": { 10 | "mybuilder/todo-package": "*@dev" 11 | }, 12 | "require-dev": { 13 | "phpunit/phpunit": "~4.0" 14 | }, 15 | "config": { 16 | "bin-dir": "bin" 17 | }, 18 | "scripts": { 19 | "pre-install-cmd": [ 20 | "../../bin/conductor update -c ../.." 21 | ], 22 | "pre-update-cmd": [ 23 | "../../bin/conductor update -c ../.." 24 | ], 25 | "post-update-cmd": [ 26 | "../../bin/conductor fix-composer-lock -c ../.." 27 | ], 28 | "pre-autoload-dump": [ 29 | "../../bin/conductor symlink -c ../.." 30 | ] 31 | }, 32 | "repositories": [ 33 | { 34 | "type": "artifact", 35 | "url": "../../artifact/" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /examples/todo/app/cli/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | ./tests 25 | ./vendor 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/todo/app/cli/src/App.php: -------------------------------------------------------------------------------- 1 | service = new TaskService( 15 | new JsonFileTaskRepository($this->filePath = $filePath) 16 | ); 17 | } 18 | 19 | public function run() 20 | { 21 | $this->fillWithDummyTasks(); 22 | 23 | foreach ($this->service->fetchAllTitles() as $title) { 24 | echo "* $title\n"; 25 | } 26 | 27 | $this->removeAllTasks(); 28 | } 29 | 30 | private function fillWithDummyTasks() 31 | { 32 | foreach (range(1, 3) as $n) { 33 | $this->service->addTask("Task $n", "Task $n Description"); 34 | } 35 | } 36 | 37 | private function removeAllTasks() 38 | { 39 | unlink($this->filePath); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/todo/app/cli/src/JsonFileTaskRepository.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 15 | } 16 | 17 | public function store(Task $task) 18 | { 19 | $tasks = $this->fetchTaskJson(); 20 | 21 | $tasks[] = array( 22 | 'title' => $task->getTitle(), 23 | 'description' => $task->getDescription(), 24 | ); 25 | 26 | file_put_contents($this->filePath, json_encode($tasks)); 27 | } 28 | 29 | public function fetchAll() 30 | { 31 | return array_map(function ($task) { 32 | return new Task($task['title'], $task['description']); 33 | }, $this->fetchTaskJson()); 34 | } 35 | 36 | private function fetchTaskJson() 37 | { 38 | if (file_exists($this->filePath)) { 39 | return json_decode(file_get_contents($this->filePath), true); 40 | } 41 | 42 | return array(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/todo/app/cli/tests/JsonFileTaskRepositoryTest.php: -------------------------------------------------------------------------------- 1 | filePath = __DIR__ . "/" . md5(time()) . ".json"; 15 | $this->tasks = new JsonFileTaskRepository($this->filePath); 16 | } 17 | 18 | public function tearDown() 19 | { 20 | unlink($this->filePath); 21 | } 22 | 23 | /** 24 | * @test 25 | */ 26 | public function shouldStoreTask() 27 | { 28 | $this->tasks->store(new Task('Sample Title', 'Sample Description')); 29 | 30 | $this->assertContains('Sample Title', file_get_contents($this->filePath)); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function shouldFetchAllTasks() 37 | { 38 | $this->storeTasks($tasks = array( 39 | new Task('Title 1', 'Description 1'), 40 | new Task('Title 2', 'Description 2'), 41 | )); 42 | 43 | $this->assertEquals($tasks, $this->tasks->fetchAll()); 44 | } 45 | 46 | private function storeTasks(array $tasks) 47 | { 48 | foreach ($tasks as $task) { 49 | $this->tasks->store($task); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /examples/todo/app/cli/todo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 8 | -------------------------------------------------------------------------------- /examples/todo/artifact/mybuilder_todo-package_0.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mybuilder/conductor/c9c7127dd0c251c3687a1dfcc137defaa0ce8b19/examples/todo/artifact/mybuilder_todo-package_0.1.zip -------------------------------------------------------------------------------- /examples/todo/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mybuilder/todo", 3 | "require": { 4 | "mybuilder/conductor": "*@dev" 5 | }, 6 | "config": { 7 | "bin-dir": "bin" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/todo/conductor.yml: -------------------------------------------------------------------------------- 1 | artifacts_repository: ./artifact 2 | packages: 3 | - ./package/* 4 | -------------------------------------------------------------------------------- /examples/todo/package/todo/.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | bin/ -------------------------------------------------------------------------------- /examples/todo/package/todo/README.md: -------------------------------------------------------------------------------- 1 | To-do Package 2 | ============= 3 | 4 | Contains the core domain logic required to represent and list tasks stored 5 | within an implemented repository. 6 | 7 | Service 8 | ------- 9 | 10 | The supplied service provides functionality to add new tasks, along with a 11 | projection of all present task titles. 12 | 13 | Repository 14 | ---------- 15 | 16 | This package is not concerned with how the collection of tasks is stored within 17 | the particular use-case. 18 | All that is required is that their be an implementation abiding by the 19 | 'TaskRepository' interface when instantiating the service. 20 | -------------------------------------------------------------------------------- /examples/todo/package/todo/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mybuilder/todo-package", 3 | "version": "0.1", 4 | "autoload": { 5 | "psr-4": { 6 | "MyBuilder\\Package\\ToDo\\": "src/" 7 | } 8 | }, 9 | "require-dev": { 10 | "phpunit/phpunit": "~4.0" 11 | }, 12 | "config": { 13 | "bin-dir": "bin" 14 | } 15 | } -------------------------------------------------------------------------------- /examples/todo/package/todo/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | ./tests 25 | ./vendor 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/todo/package/todo/src/Task.php: -------------------------------------------------------------------------------- 1 | setTitle($title); 13 | $this->setDescription($description); 14 | } 15 | 16 | private function setTitle($title) 17 | { 18 | if ($title === '') { 19 | throw new \RuntimeException('Task title must be supplied.'); 20 | } 21 | 22 | $this->title = $title; 23 | } 24 | 25 | private function setDescription($description) 26 | { 27 | if (strlen($description) < 5) { 28 | throw new \RuntimeException('Description must be greater than 4 chars long.'); 29 | } 30 | 31 | $this->description = $description; 32 | } 33 | 34 | public function getTitle() 35 | { 36 | return $this->title; 37 | } 38 | 39 | public function getDescription() 40 | { 41 | return $this->description; 42 | } 43 | 44 | public function equals(Task $that) 45 | { 46 | return 47 | $this->getTitle() === $that->getTitle() && 48 | $this->getDescription() === $that->getDescription(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/todo/package/todo/src/TaskRepository.php: -------------------------------------------------------------------------------- 1 | tasks = $tasks; 12 | } 13 | 14 | public function fetchAllTitles() 15 | { 16 | return array_map(function (Task $task) { 17 | return $task->getTitle(); 18 | }, $this->tasks->fetchAll()); 19 | } 20 | 21 | public function addTask($title, $description) 22 | { 23 | $this->tasks->store(new Task($title, $description)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/todo/package/todo/tests/TaskServiceTest.php: -------------------------------------------------------------------------------- 1 | service = new TaskService( 13 | $this->repository = new InMemoryTaskRepository 14 | ); 15 | } 16 | 17 | /** 18 | * @test 19 | */ 20 | public function shouldReturnTaskTitles() 21 | { 22 | $titles = $this->service->fetchAllTitles(); 23 | 24 | $this->assertEquals(array('Task 1', 'Task 2', 'Task 3'), $titles); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | public function shouldAddNewTask() 31 | { 32 | $this->service->addTask('Task 4', 'Task 4 Description'); 33 | 34 | $this->assertContainsTask( 35 | new Task('Task 4', 'Task 4 Description'), 36 | $this->repository->fetchAll() 37 | ); 38 | } 39 | 40 | private function assertContainsTask(Task $task, array $tasks) 41 | { 42 | foreach ($tasks as $t) { 43 | if ($t->equals($task)) { 44 | return true; 45 | } 46 | } 47 | 48 | $this->fail('Collection does not contain task'); 49 | } 50 | } 51 | 52 | class InMemoryTaskRepository implements TaskRepository 53 | { 54 | private $tasks; 55 | 56 | public function __construct() 57 | { 58 | $this->tasks = array( 59 | new Task('Task 1', 'Task 1 Description'), 60 | new Task('Task 2', 'Task 2 Description'), 61 | new Task('Task 3', 'Task 3 Description'), 62 | ); 63 | } 64 | 65 | public function store(Task $task) 66 | { 67 | $this->tasks[] = $task; 68 | } 69 | 70 | public function fetchAll() 71 | { 72 | return $this->tasks; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /examples/todo/package/todo/tests/TaskTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('Sample Title', $task->getTitle()); 15 | $this->assertEquals('Sample Description', $task->getDescription()); 16 | } 17 | 18 | /** 19 | * @test 20 | * @expectedException \RuntimeException 21 | */ 22 | public function shouldThrowForInvalidTitle() 23 | { 24 | new Task('', 'Sample Description'); 25 | } 26 | 27 | /** 28 | * @test 29 | * @expectedException \RuntimeException 30 | */ 31 | public function shouldThrowForInvalidDescription() 32 | { 33 | new Task('Sample Title', 'Foo'); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function shouldBeEqual() 40 | { 41 | $a = new Task('Task 1', 'Task 1 Description'); 42 | $b = new Task('Task 1', 'Task 1 Description'); 43 | 44 | $this->assertTrue($a->equals($b)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | ./tests 25 | ./vendor 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Command/BaseCommand.php: -------------------------------------------------------------------------------- 1 | conductor = $conductor; 28 | } 29 | 30 | protected function configure() 31 | { 32 | $this->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Configuration file', ''); 33 | } 34 | 35 | protected function initialize(InputInterface $input, OutputInterface $output) 36 | { 37 | $this->configurationFile = $this->locateConfigurationFile($input->getOption('config')); 38 | $this->changeWorkingDir( 39 | $this->workingDir = dirname($this->configurationFile) 40 | ); 41 | 42 | if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { 43 | $output->writeln('Using configuration: ' . $this->configurationFile); 44 | $output->writeln('Changed working dir: ' . $this->workingDir); 45 | } 46 | } 47 | 48 | /** 49 | * @param string $directory 50 | * 51 | * @return string 52 | * 53 | * @throws \RuntimeException When conductor.yml is not found 54 | */ 55 | private function locateConfigurationFile($directory) 56 | { 57 | $directory = realpath($directory); 58 | 59 | if (false !== $directory) { 60 | if (file_exists($directory . '/conductor.yml')) { 61 | return $directory . '/conductor.yml'; 62 | } 63 | } 64 | 65 | throw new \RuntimeException('Cannot find the conductor.yml configuration file'); 66 | } 67 | 68 | /** 69 | * @return mixed 70 | */ 71 | protected function getConfiguration() 72 | { 73 | $yaml = new Parser(); 74 | 75 | return $yaml->parse(file_get_contents($this->configurationFile)); 76 | } 77 | 78 | /** 79 | * @param string $workingDir 80 | * 81 | * @return string 82 | */ 83 | protected function changeWorkingDir($workingDir) 84 | { 85 | chdir($workingDir); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Command/LockFixerCommand.php: -------------------------------------------------------------------------------- 1 | setName("fix-composer-lock") 18 | ->setDescription('Fixes composer lock real-paths with relative-paths'); 19 | } 20 | 21 | protected function execute(InputInterface $input, OutputInterface $output) 22 | { 23 | $fileSystem = new Filesystem(); 24 | $configuration = $this->getConfiguration(); 25 | $conductorRealPaths = $this->getConductorRealPaths($configuration['artifacts_repository']); 26 | 27 | foreach ($this->createFinder()->in(getcwd()) as $lockFile) { 28 | $relativePaths = $this->getRelativePaths($fileSystem, $lockFile, $conductorRealPaths); 29 | 30 | if ($this->fixLockFileRealPathsWithRelativePaths($fileSystem, $lockFile, $conductorRealPaths, $relativePaths)) { 31 | $output->writeln('Fixed lock file "' . $lockFile . '"'); 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * @return Finder 38 | */ 39 | private function createFinder() 40 | { 41 | return Finder::create() 42 | ->files() 43 | ->exclude('vendor') 44 | ->name('composer.lock'); 45 | } 46 | 47 | private function getConductorRealPaths($zipsDir) 48 | { 49 | $finder = Finder::create()->files()->name('*.zip')->in($zipsDir); 50 | 51 | return array_map(function (\SplFileInfo $file) { 52 | return $file->getRealPath(); 53 | }, iterator_to_array($finder)); 54 | } 55 | 56 | private function getRelativePaths(Filesystem $fileSystem, \SplFileInfo $lockFile, array $conductorRealPaths) 57 | { 58 | return array_map(function ($conductorPath) use ($fileSystem, $lockFile) { 59 | return $fileSystem->makePathRelative(dirname($conductorPath), dirname($lockFile->getRealPath())) . basename($conductorPath); 60 | }, $conductorRealPaths); 61 | } 62 | 63 | private function fixLockFileRealPathsWithRelativePaths(Filesystem $fileSystem, $lockFile, $conductorRealPaths, $relativePaths) 64 | { 65 | $initialContent = file_get_contents($lockFile); 66 | $fixedContent = str_replace($conductorRealPaths, $relativePaths, $initialContent); 67 | 68 | if ($initialContent === $fixedContent) { 69 | return false; 70 | } 71 | $fileSystem->dumpFile($lockFile, $fixedContent); 72 | 73 | return true; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Command/SymlinkCommand.php: -------------------------------------------------------------------------------- 1 | setName("symlink") 16 | ->setDescription('Symlinks internal packages to vendors'); 17 | } 18 | 19 | protected function execute(InputInterface $input, OutputInterface $output) 20 | { 21 | $output->writeln('Symlink packages'); 22 | 23 | $this->conductor->symlinkPackages(getcwd()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Command/UpdateCommand.php: -------------------------------------------------------------------------------- 1 | setName("update") 18 | ->setDescription('Updates the artifacts within the artifacts repository'); 19 | } 20 | 21 | protected function execute(InputInterface $input, OutputInterface $output) 22 | { 23 | $configuration = $this->getConfiguration(); 24 | 25 | if (false === isset($configuration['artifacts_repository'])) { 26 | throw new \RuntimeException('Missing "conductor.artifacts_repository" configuration'); 27 | } 28 | 29 | if (false === isset($configuration['packages'])) { 30 | throw new \RuntimeException('Missing "conductor.packages" configuration'); 31 | } 32 | 33 | $output->writeln('Zipping packages'); 34 | 35 | $this->ensureRepositoryDirExists($configuration['artifacts_repository']); 36 | 37 | $this->conductor->updatePackages($configuration['packages'], new PackageZipper($configuration['artifacts_repository'])); 38 | } 39 | 40 | private function ensureRepositoryDirExists($path) 41 | { 42 | $fileSystem = new Filesystem(); 43 | if (false === $fileSystem->exists($path)) { 44 | $fileSystem->mkdir($path); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Conductor.php: -------------------------------------------------------------------------------- 1 | fileSystem = $fileSystem; 18 | } 19 | 20 | public function updatePackages($paths, PackageZipper $packageZipper) 21 | { 22 | $finder = new Finder(); 23 | $finder->files()->exclude('vendor')->name('composer.json')->depth(0); 24 | 25 | $results = array(); 26 | foreach ($finder->in($paths) as $file) { 27 | $results[] = $packageZipper->zip($file); 28 | } 29 | 30 | return $results; 31 | } 32 | 33 | public function symlinkPackages($rootPath) 34 | { 35 | $finder = new Finder(); 36 | $finder->files()->name('replace_with_symlink.path'); 37 | 38 | foreach ($finder->in($rootPath) as $file) { 39 | $this->symlinkPackageToVendor(file_get_contents($file), dirname($file)); 40 | } 41 | } 42 | 43 | private function symlinkPackageToVendor($packagePath, $vendorPath) 44 | { 45 | $relative = $this->fileSystem->makePathRelative(realpath($packagePath), realpath($vendorPath . '/../')); 46 | 47 | $this->fileSystem->rename($vendorPath, $vendorPath . '_linked', true); 48 | $this->fileSystem->symlink($relative, $vendorPath); 49 | $this->fileSystem->remove($vendorPath . '_linked'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Exception/ChecksumMismatchException.php: -------------------------------------------------------------------------------- 1 | zipsPath = $zipsPath; 12 | } 13 | 14 | /** 15 | * @param \SplFileInfo $composerFile the package composer.json file path 16 | * 17 | * @return string The package conductor file path 18 | */ 19 | public function zip(\SplFileInfo $composerFile) 20 | { 21 | $json = json_decode(file_get_contents($composerFile), true); 22 | 23 | if (false === isset($json['version'])) { 24 | throw new \RuntimeException('Package "' . $json['name'] . '" has no version defined in file: ' . $composerFile->getPathname() . '.'); 25 | } 26 | 27 | $packageZipPath = $this->getZipPath($json['name'], $json['version']); 28 | 29 | if (false === file_exists($packageZipPath)) { 30 | $this->createZip($composerFile, $packageZipPath); 31 | } 32 | 33 | $this->verifyZip($composerFile, $packageZipPath, $json); 34 | 35 | return $packageZipPath; 36 | } 37 | 38 | private function getZipPath($packageName, $version) 39 | { 40 | return $this->zipsPath . DIRECTORY_SEPARATOR . str_replace("/", '_', $packageName) . '_' . $version . '.zip'; 41 | } 42 | 43 | private function createZip($composerFile, $zipPath) 44 | { 45 | $zip = new \ZipArchive(); 46 | $zip->open($zipPath, \ZipArchive::CREATE); 47 | $zip->addFile($composerFile, 'Package/composer.json'); 48 | $zip->addFromString('Package/replace_with_symlink.path', dirname($composerFile)); 49 | $zip->close(); 50 | } 51 | 52 | private function verifyZip($composerFile, $zipPath, $info) 53 | { 54 | $zip = new \ZipArchive(); 55 | $zip->open($zipPath); 56 | $content = $zip->getFromName('Package/composer.json'); 57 | $zip->close(); 58 | 59 | if (sha1_file($composerFile) !== sha1($content)) { 60 | throw new Exception\ChecksumMismatchException(' 61 | Package ' . $info['name'] . '@' . $info['version'] . ' 62 | is already zipped with the given version but 63 | the zip composer.json checksum does not match the package/composer.json checksum 64 | maybe you forgot to increment the package/composer.json version?'); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/ConductorTest.php: -------------------------------------------------------------------------------- 1 | fs = new Filesystem(); 21 | $this->conductor = new Conductor($this->fs); 22 | } 23 | 24 | public function test_it_should_update_all_packages() 25 | { 26 | $zipperMock = $this->getMock('MyBuilder\Conductor\PackageZipper', array(), array(array())); 27 | $zipperMock 28 | ->expects($this->any()) 29 | ->method('zip') 30 | ->will($this->returnCallback(function($a) { 31 | return $a; 32 | })); 33 | 34 | $files = $this->conductor->updatePackages( 35 | array(__DIR__ . '/fixtures/packages/*'), 36 | $zipperMock); 37 | 38 | $this->assertEquals( 39 | array( 40 | __DIR__ . '/fixtures/packages/package-a/composer.json', 41 | __DIR__ . '/fixtures/packages/package-a-changed/composer.json', 42 | ), 43 | array_map(null, $files)); 44 | } 45 | 46 | public function test_it_should_symlink_packages() 47 | { 48 | $tempDir = $this->createTempDir(); 49 | $this->fs->mirror(__DIR__ . '/fixtures/symlink', $tempDir); 50 | $this->fs->dumpFile($tempDir . '/package-b/package-a/replace_with_symlink.path', $tempDir . '/package-a/'); 51 | 52 | $this->conductor->symlinkPackages($tempDir); 53 | 54 | $link = $tempDir . '/package-b/package-a'; 55 | $this->assertTrue(is_link($link), $link . ' should be a symlink'); 56 | $this->assertEquals('../package-a/', readlink($link), 'It should have a relative symlink'); 57 | } 58 | 59 | public function test_it_should_fix_composer_lock_absolute_paths() 60 | { 61 | $this->markTestIncomplete(); 62 | 63 | // real paths to the conductor zips 64 | // will be made relative from the point of view of the composer.lock file 65 | } 66 | 67 | private function createTempDir() 68 | { 69 | $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(); 70 | $this->fs->mkdir($tempDir, 0777); 71 | 72 | return $tempDir; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/PackageZipperTest.php: -------------------------------------------------------------------------------- 1 | packageZipper = new PackageZipper($tempZipsDir); 20 | } 21 | 22 | public function test_it_should_zip_the_package() 23 | { 24 | $file = __DIR__ . '/fixtures/packages/package-a/composer.json'; 25 | 26 | $zipPath = $this->packageZipper->zip(new \SplFileInfo($file)); 27 | 28 | $this->assertZipContains(array( 29 | 'Package/composer.json' => file_get_contents($file), 30 | 'Package/replace_with_symlink.path' => __DIR__ . '/fixtures/packages/package-a', 31 | ), $zipPath); 32 | } 33 | 34 | public function test_it_should_throw_an_mismatch_exception_when_zip_and_package_checksum_does_not_match() 35 | { 36 | $zipPath = $this->packageZipper->zip(new \SplFileInfo( 37 | __DIR__ . '/fixtures/packages/package-a/composer.json')); 38 | 39 | $this->setExpectedException('MyBuilder\Conductor\Exception\ChecksumMismatchException'); 40 | 41 | $zipPath = $this->packageZipper->zip(new \SplFileInfo( 42 | __DIR__ . '/fixtures/packages/package-a-changed/composer.json')); 43 | } 44 | 45 | private function assertZipContains(array $contents, $file) 46 | { 47 | $zip = new \ZipArchive(); 48 | $zip->open($file); 49 | 50 | $actual = array(); 51 | for ($i = 0; $i < $zip->numFiles; $i++) { 52 | $actual[$zip->getNameIndex($i)] = $zip->getFromIndex($i); 53 | } 54 | 55 | $zip->close(); 56 | 57 | $this->assertEquals($contents, $actual); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |