Transform any git repository into a monorepo.
monorail
transforms any git repo into a trunk-based development monorepo. It uses a file describing the various directory paths and relationship between those paths, extracts changes from git since the last tag, and derives what has changed. These changes can then be fed to other programs (e.g. monorail-bash
) that can act on those changes. While monorail
currently supports only git
as the VCS backend, support for others could be added.
monorail
boils down to:
Monorail.toml
file that describes your repository layoutmonorail inspect change
command, which reads Monorail.toml
, analyzes git
state between refs (usually between the most recent git annotated tag and HEAD), and returns what has changedmonorail-bash
program, which executes user-defined bash
scripts either directly, or informed by monorail inspect change
outputmonorail release
command, which creates annotated tags (essentially the "checkpoint" for monorail
change detection)See the tutorial below for a practical walkthrough of how monorail
and monorail-bash
work.
Ensure the following are installed and available on your system path:
* Rust
* bash
* jq
, used by the monorail-bash
extension for the default --output-format
of json
monorail
and all extensions can be installed from source by cloning the repository and executing the following, from the root of the repo:
./install.sh
By default, it will place these in /usr/local/bin
, if another location is preferred, use ./install.sh <destination>
.
Note that while monorail
can be installed from crates.io via cargo install
, cargo
does not support installing additional resources such as the script entrypoint for monorail-bash
. The install.sh
script handles both the binary and extension installation.
Use monorail help
Create a Monorail.toml
configuration file in the root of the repository you want monorail
and extensions to use, referring to the Monorail.reference.toml
file for an annotated example.
bash
NOTE: this tutorial assumes a UNIX-like environment.
In this tutorial, you'll learn:
First, create a fresh git
repository, and another to act as a remote:
NOTE: This assumes a init.defaultBranch
of master
or empty string, the default for git
. If yours is something else, change the master
in the git push
command to that.
sh
git init monorail-tutorial
git init monorail-tutorial-remote
REMOTE_TRUNK=$(git -C monorail-tutorial-remote branch --show-current)
git -C monorail-tutorial-remote checkout -b x
pushd monorail-tutorial
git remote add origin ../monorail-tutorial-remote
git commit --allow-empty -m "HEAD"
git push --set-upstream origin $REMOTE_TRUNK
popd
NOTE: the commit is to create a valid HEAD reference, and the branch checkout in the remote is to avoid receive.denyCurrentBranch
errors from git
when we push during the tutorial.
To get started, generate a directory structure with the following shell commands:
sh
cd monorail-tutorial
mkdir -p group1/project1
touch Monorail.toml
... which yields the following directory structure:
├── Monorail.toml
└── group1
└── project1
NOTE: the remainder of this tutorial will apply updates to the Monorail.toml
file with heredoc strings, for convenience.
Execute the following to specify the new group and project in Monorail.toml
, as well as an extension
to be used later in the tutorial:
```sh
cat < [vcs.git]
trunk = "$(git branch --show-current)" [extension]
use = "bash" [[group]]
path = "group1" [[group.project]]
path = "project1" EOF
``` Your Finally, many of Begin by viewing the output of As expected there are no changes, and To show what an actual change looks like in View changes again: The The This understanding of what has changed persists between commits and pushes. Commit your changes: Then, view changes again: Push, and view changes again: The output remains the same. Begin by creating a directory to be used as a dependency, and a directory to hold a new project, Execute the following to adjust the ```sh
cat < [vcs.git]
trunk = "$(git branch --show-current)" [extension]
use = "bash" [[group]]
path = "group1"
depend = [
"common/library1"
] [[group.project]]
path = "project1" [[group.project]]
path = "project2" EOF
``` The To trigger change detection, create a file in library1: and then Our original file entry is still there, but another for the newly-created file has appeared. It has a An entry of Furthermore, an entry has appeared in A Execute the following to adjust the ```sh
cat < [vcs.git]
trunk = "$(git branch --show-current)" [extension]
use = "bash" [[group]]
path = "group1"
depend = [
"common/library1"
]
link = [
"Lockfile"
] [[group.project]]
path = "project1" [[group.project]]
path = "project2" [[group.project]]
path = "project3" EOF
``` Executing Again, our original changes to Note that Commands are run by extensions, which are "runners" for user-defined code. We have already specified the following in Commands are stored in a file on a per-target basis, the path to which is defined in Create the path to this file with: In the ```sh
cat <<"EOF" > group1/project1/support/script/monorail-exec.sh function command1() {
echo "Hello, from command1"
echo "The calling environment is inherited: ${SOMEEXTRAVAR}"
echo "some data" > side_effects.txt
} function command2() {
echo "Hello, from command2"
cat side_effects.txt
} function setup() {
echo "Installing everything you need"
}
EOF
``` Command names can be named any valid UTF-8 string, and are free to do anything a normal With the command script defined, it can be called with When done implicitly, The majority of this output is workflow and debugging information, but it's worth noting a few key pieces. Executing arbitrary bash functions against the changes detected by Manually selecting targets gives one the ability to execute commands independent of VCS change detection. Applications include: To illustrate manually selecting targets, we will run the For more information, execute When a release is performed, it applies to all targets that were changed since the previous release (or the first commit of the repository, if no releases yet exist). First, let's commit and push our current changes: Assuming that we have committed all that we intend to, and target commands have been run to our satisfaction (e.g. CI has passed for the merge of our branch), we can dry-run a patch release with: Now, run a real release: To show that the release cleared out Finally, our newly-pushed tag is now in the remote. To see this, execute: ... which outputs ```
tag v0.0.1
Tagger: you email@domain.com group1/Lockfile
group1/common/library1
group1/project1
group1/project2
group1/project3
``` This concludes the tutorial. Now that you have seen how the core concepts of Refer to In order to work, If any of these are violated, the behavior of This will build the project and run the tests: You can use Recap
monorail
config file (default: Monorail.toml
) describes your existing repository layout in terms of monorail
concepts. A project
is a path to be developed/deployed/tested as a unit, e.g. a backend service, web app, etc. A group
is a set of related projects, and defines what can be shared amongst projects (more on sharing later).monorail
s capabilities are path-based. Our definition of group.path
(relative to the repository root) and project.path
(relative to the specified group.path
) declare where these objects live in our repository.Inspecting changes
monorail
will detect changes since the last release; see: Releasing. For git
, this means uncommitted, committed, and pushed files since the last annotated tag created by monorail release
.Inspect showing no changes
inspect change
:monorail inspect change | jq .
json
{
"group": {
"group1": {
"change": {
"file": [],
"project": [],
"link": [],
"depend": []
}
}
}
}
monorail
is able to interrogate git
and use the Monorail.toml
config successfully. The meaning of the file
, project
, link
, and depend
fields will be explained as the tutorial progresses.Inspect showing a change
monorail
output, create a new file in project1
:touch group1/project1/foo.txt
monorail inspect change | jq .
json
{
"group": {
"group1": {
"change": {
"file": [
{
"name": "group1/project1/foo.txt",
"project": "group1/project1",
"action": "use",
"reason": "project_match"
}
],
"project": [
"group1/project1"
],
"link": [],
"depend": []
}
}
}
}
monorail
has identified that the newly added file represents a meaningful change, based on our configuration in Monorail.toml
.change.file
array contains a list of objects containing metadata about the change detected. It contains the name
of the file (a path relative to the repository root), the project
the file belongs to, the action
taken by monorail
during change detection (e.g. it was use
d), and the reason
that action
was taken (e.g. it matched a declared project).change.project
array contains a list of paths relative to the repo root for projects detected as changed. This list is de-duped across all change.file
entries; a project will appear at most once in this list.Inspect showing a change, after commit or push
git add * && git commit -am "x"
monorail inspect change | jq .
json
{
"group": {
"group1": {
"change": {
"file": [
{
"name": "group1/project1/foo.txt",
"project": "group1/project1",
"action": "use",
"reason": "project_match"
}
],
"project": [
"group1/project1"
],
"link": [],
"depend": []
}
}
}
}
monorail
still knows about the change to the project group1/project1
.git push && monorail inspect change | jq .
Declaring dependencies and links
monorail
allows for projects to depend on paths outside of the path
each project has declared. This allows for reference paths containing utility code, serialization files (e.g. protobuf definitions), configuration, etc. When these paths have changes, it triggers projects that depend on them to be changed.Dependencies
project2
:sh
mkdir -p group1/common/library1
mkdir group1/project2
[[group]]
section of Monorail.toml
to add library1
as a depend-able path, specify project2
, and make project2
depend on library1
:depend = [
"common/library1"
]
depend
declaration in the group
section indicates that this path can be depended on. The project.depend
is where you specify zero or more of these paths your project does depend on.touch group1/common/library1/foo.proto
monorail inspect change | jq .
:json
{
"group": {
"group1": {
"change": {
"file": [
{
"name": "group1/common/library1/foo.proto",
"project": null,
"action": "use",
"reason": "project_depend_effect"
},
{
"name": "group1/project1/foo.txt",
"project": "group1/project1",
"action": "use",
"reason": "project_match"
}
],
"project": [
"group1/project2",
"group1/project1"
],
"link": [],
"depend": [
"group1/common/library1"
]
}
}
}
}
project
of null
because it does not lie in the path of a project, and a reason
that indicates it is being used due to a project depending on a path containing the file (project_depend_effect
).group1/project2
has appeared in group.project
, indicating that this project is now part of the set of projects that have changed. We didn't change any files in project2
(indeed, none exist!), but did modify a path that project2
depends on.group.depend
for our library path.Links
link
works similarly to a depend
, but applies to all projects in a group without them opting-in. To demonstrate, we will create a third project and a contrived Lockfile
to link all projects to:mkdir group1/project3
touch group1/Lockfile
[[group]]
section of Monorail.toml
to specify this new project, as well as a group link
:depend = [
"common/library1"
]
monocle inspect change | jq .
yields:json
{
"group": {
"group1": {
"change": {
"file": [
{
"name": "group1/Lockfile",
"project": null,
"action": "use",
"reason": "group_link_effect"
},
{
"name": "group1/common/library1/foo.proto",
"project": null,
"action": "use",
"reason": "project_depend_effect"
},
{
"name": "group1/project1/foo.txt",
"project": "group1/project1",
"action": "use",
"reason": "project_match"
}
],
"project": [
"group1/project3",
"group1/project2",
"group1/project1"
],
"link": [
"group1/Lockfile"
],
"depend": [
"group1/common/library1"
]
}
}
}
}
project1
and library1
remain. A new change.file
for Lockfile
has appeared, a new change.project
for the project3
has been added, and change.link
now has a path to the Lockfile
we changed.project3
did not need to explicitly depend on Lockfile
; simply being a member of group1
does this.Defining commands
Monorail.toml
, so we will proceed with monorail-bash
:
[extension]
use = "bash"
Monorail.toml
. In our case, that path will be support/script/monorail-exec.sh
(the default) relative to group1/project1
.mkdir -p group1/project1/support/script
group1/project1/support/script/monorail-exec.sh
file, we will define a script containing three commands:!/usr/bin/env bash
bash
script can do: source other scripts, call external build tools, perform network requests, etc. One of the benefits of monorail
is that it does not limit the build tooling you can use.Executing commands
monorail-bash exec
. This can be done in one of two ways:
monorail
Implicit
monorail-bash exec
uses the same processes that power monorail inspect change
to derive changed targets and execute commands against them. To illustrate this use the following (SOME_EXTRA_VAR
just shows that parent shell values can be passed to commands): SOME_EXTRA_VAR=foo monorail-bash -v exec -c command1 -c command2
Sep 10 07:34:07 monorail-bash : 'monorail' path: monorail
Sep 10 07:34:07 monorail-bash : 'jq' path: jq
Sep 10 07:34:07 monorail-bash : 'git' path: git
Sep 10 07:34:07 monorail-bash : use libgit2 status: false
Sep 10 07:34:07 monorail-bash : 'monorail' config: Monorail.toml
Sep 10 07:34:07 monorail-bash : working directory: /Users/patrick/lab/github.com/pnordahl/monorail-tutorial
Sep 10 07:34:07 monorail-bash : command: command1
Sep 10 07:34:07 monorail-bash : command: command2
Sep 10 07:34:07 monorail-bash : start:
Sep 10 07:34:07 monorail-bash : end:
Sep 10 07:34:07 monorail-bash : target (inferred): group1/Lockfile
Sep 10 07:34:07 monorail-bash : target (inferred): group1/common/library1
Sep 10 07:34:07 monorail-bash : target (inferred): group1/project2
Sep 10 07:34:07 monorail-bash : target (inferred): group1/project3
Sep 10 07:34:07 monorail-bash : target (inferred): group1/project1
Sep 10 07:34:07 monorail-bash : NOTE: Ignoring command for non-directory target; command: command1, target: group1/Lockfile
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command1, target: group1/common/library1
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command1, target: group1/project2
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command1, target: group1/project3
Sep 10 07:34:07 monorail-bash : Executing command; command: command1, target: group1/project1
Hello, from command1
The calling environment is inherited: foo
Sep 10 07:34:07 monorail-bash : NOTE: Ignoring command for non-directory target; command: command2, target: group1/Lockfile
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command2, target: group1/common/library1
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command2, target: group1/project2
Sep 10 07:34:07 monorail-bash : NOTE: Script not found; command: command2, target: group1/project3
Sep 10 07:34:07 monorail-bash : Executing command; command: command2, target: group1/project1
Hello, from command2
some data
-c
options.depend
and link
entries could have specified their own implementations of the command1
and command2
commandscommand1
and/or command2
were noted and ignoredmonorail
has a number of applications, including:
monorail-bash
ensures that for each changed target, the requested commands are executed sequentiallycheck
, build
, test
, deploy
, etc.)Explicit
bootstrap
commandsetup
command we defined but did not execute previously. Execute the following (removing the -v
to cut down on the visual noise):monorail-bash exec -t group1/project1 -c setup
Installing everything you need
monorail-bash -h
, and monorail-bash exec -h
.Releasing
monorail
uses the backend VCS native mechanisms, e.g. tags in git
as "release" markers. This creates a "checkpoint" for change detection. Without release tags, monorail
is forced to search git
history back to the first commit. This would be ineffecient and make change detection useless as all targets would be considered changed over a long enough timeline.git add * && git commit -am "update commands" && git push
monorail release --dry-run -t patch | jq .
json
{
"id": "v0.0.1",
"targets": [
"group1/Lockfile",
"group1/common/library1",
"group1/project1",
"group1/project2",
"group1/project3"
],
"dry_run": true
}
monorail
creates releases with an id
appropriate to the conventions of the chosen VCS; in this case, that is the git
semver tagging format. It also embeds the list of targets included as part of this release in the targets
array; in the case of git
, it will embed this list of targets in the release message.monorail release -t patch | jq .
json
{
"id": "v0.0.1",
"targets": [
"group1/Lockfile",
"group1/common/library1",
"group1/project1",
"group1/project2",
"group1/project3"
],
"dry_run": false
}
monorail
s view of changes, execute: monorail inspect change | jq .
json
{
"group": {
"group1": {
"change": {
"file": [],
"project": [],
"link": [],
"depend": []
}
}
}
}
git -C ../monorail-tutorial-remote show -s --format=%B v0.0.1
monorail
and extensions work, you're ready to use it in real projects. Experiment with repository layouts, commands, CI, and working on a trunk-based development workflow that works for your teams.Monorail.reference.toml
for more specific information about the various configuration available for monorail
and extensions.Invariants
monorail
requires the following invariants be satisfied:
link
, or depend
configuration may be shared between projects of that groupmonorail
commands that analyze changes to the repository is undefined.Development setup
sh
cargo build
cargo test -- --nocapture
install.sh
to build a release binary of monorail
and copy it, along with extensions, into your PATH.