Git Usage Guide

We essentially follow the GitHub Flow branching strategy. See also trunk-based development for background.

In short:

  • a single, main branch, at present named master, always provides a buildable, usable version of the software passing all checks.

  • changes should be made on a separate branch split from master and merged back into master.

  • short-lived branches proposing small changes are preferred to long-lived branches

  • careful staging and communication is encouraged for work that must be broad-reaching or highly interdependent.

  • versions are marked with tagged commits on master.

We collaborate on Git projects through https://gitlab.com/TargetRWE/.

Branching and merging

Branching guidelines are

  • All proposed changes should be made in a branch separate from master, sometimes called feature branches.

  • Feature branches should originate from master.

  • Branches should strive to be short-lived and propose small changes, if possible.

  • In cases where a large number of changes must be made in one branch (e.g. for code correctness), extra coordination among team members would be a good idea to avoid painful conflicts.

  • Merges back to master are handled via merge requests (MRs) on GitLab.

  • MRs are reviewed and merged in quickly, in the ideal.

  • Branch commits are squashed on merge.[1]

Example

In this example, you

  • Fetch updates from the remote.

  • Create a new local branch, e.g. new-do, from the HEAD commit of origin/master.

  • Check out new-do and add some commits.

  • Push the local branch to the remote, origin/new-do.

git fetch
git switch -c new-do origin/master
git push -u origin new-do

Note: The git switch statement above will automatically set new-do to track origin/master. You do not want that. The subsequent line corrects that by creating origin/new-do and setting the local new-do to track it.

Best practices for feature branches

Note: These are subject to change as we learn what works best for us.

  • Only one developer commits to a feature branch.

  • Always branch from an up-to-date origin/master.

  • If you must update your feature for relevant changes, take them from origin/master not other feature branches.

  • Strive for "short-lived" branches with smallish changes where possible:

  • "Short-lived" means a branch that has existed for a week or less, though clearly that is more of a guideline than a strict standard. "Long-lived" therefore means a branch created more than a week ago. The window here is somewhat arbitrary based on our recent pace of development. Some recommend striving to keep feature branches for only a day’s work.

  • If your feature branch lags behind origin/master by more than a couple of commits, particularly version-tagged or relevant commits, consider splitting up the work into multiple branches so that your work-to-date can be merged more quickly into origin/master. "Relevant" means work that might conflict with yours.

  • If your branch is more than a couple of days old because you are stuck, reach out for help on Slack, in a meeting, or on GitLab in the appropriate MR.

  • Communicate with others whose branches have inter-related work.

  • Consider adding a sequencing order to the associated version milestone description, if both features are to be part of the same version’s work. See Managing versions.

  • Reference dependent MRs or issues in your MR’s description.

Collaboration in more complex work

Some extra care is needed when your feature branch includes changes highly dependent or disruptive on others' work. That is more likely to occur in long-running branches.

Some tips for how to handle those situations:

  • Break up the work into smaller MRs, with one feature branch for each. If the disruptive component can be isolated, it will be easier to complete quickly and to resolve merge conflicts if needed.

  • If your work is part of a version-release milestone, which it likely will be, write up a workflow plan for whose work should be merged before the other. See Milestones.

  • For changes that are likely to be highly disruptive to other people’s work, such as a large filesystem reorganization, you could suggest all other work stop until your changes are merged.

  • In some cases, it might be reasonable for one person to work on a group of interelated changes, meaning that person is assigned to all of the related issues and corresponding feature-branch MRs.

Conflicts in merge requests

The branching flow above should reduce the chances of merge conflicts, or at least reduce their complexity, by

  • relying on origin/master as the common branch point and source of up-to-date code,

  • creating short-lived and focused feature branches, and

  • merging in MRs quickly.

Together these produce a fairly linear Git history that is easier for Git and GitLab to merge without conflict.

Conflicts can be reduced further by

  • communicating with colleagues when doing inter-dependent work on feature branches, e.g. staging sequences of feature branches with dependent changes as part of a release milestone,

  • taking extra care to ensure changes that necessarily are broad-reaching, such as filesystem reorganizations, are done in isolation and with the knowledge that doing so will produce nasty conflicts for any unmerged feature branches.

Best practices for resolving merge conflicts

Conflicts do happen, and here are some best practices for managing them in a way that should work reasonably well with GitHub Flow:

  • git rebase origin/master on your local feature branch should be the cleanest way to update your work.

  • git fetch before you rebase.

Software versions

Versions are marked as specific states of the origin/master branch, using annotated tagged commits. These typically are tracked in milestones on GitLab and the version tag added to origin/master once all work is complete.

Versioning guidelines are

  • Use annotated tags in Git using the -a flag, as in git tag -a v1.[2]

  • Tag labels should be of the form v*, and the associated tag message need not have any particular content.

Managing versions

Since versions are simply snapshots of origin/master at a given commit, we need a process to decide who adds the version tag and when.

Note: To manage versions you will either need to have a local master tracking origin/master or will need to add tags through the GitLab user interface.

The workflow for managing versions is as follows:

  1. Create a GitLab milestone for vX, version X of a given repo’s software.

  2. Associate MRs and their respective feature branches with the milestone.

  3. Once all feature branches are merged for a given milestone, create a bump-vX branch to increase the version number. Changes required there will differ by project.

  4. After bump-vX has been merged into origin/master, add a tag pointing to the resulting commit. In other words, the bump-vX merge commit marks the state of origin/master giving version vX.

Note that you can add a tag to any past commit, so if we forget to add a version tag immediately after the milestone is complete we can add it later. See Creating tags.

Milestones

We track work on the next version of our software with GitLab milestones.

  • Milestones should be named for the version.

  • A version tag, say for version "vX", should be assigned as soon as the bump-vX branch of a given milestone is merged into origin/master.

  • MRs and issues that are not to be completed as part of this version should be removed from the milestone.

  • It might be helpful to assign a person in the milestone description to make the final bump-vX branch merge and subsequent tag associated with a milestone and version.

  • In versions with inter-dependent feature branches, it might make sense to suggest a work flow in the milestone description for the affected branches, e.g. FeatB → FeatC → FeatF.

Creating tags

All version tags must be annotated tags with name of the form vX, where X is the version number. They need not have any particular tag message.

You can create annotated tags locally then push them to GitLab like so

git tag -a "vX" <commit>
git push --tags

Leaving <commit> blank tags the current branch HEAD. git push --tags pushes only tags, not commits. You can push tags to protected branches on GitLab, unlike for commits.[3]

Additional tips and comments:

  • git tag --list or view tags in the project’s Repository menu in GitLab.

  • Tag names must be unique among tags. Branches and tags can share names, but beware: Both serve as references to a commit, and doing so will create an ambiguous reference.

  • You can add multiple annotated tags to a single commit.

Creating tags with GitLab

Create them through the GitLab web UI for a project at Repository → Tags → New tag.

  • If you do not wish to tag the HEAD commit of a branch, you can copy-paste a commit hash. The UI here is awkward though.

  • You must include a tag message to create an annotated commit.

Deleting and modifying tags

To delete a tag named "vX" from your local branch, then delete it from the remote, do

git tag -d vX
git push origin --delete vX

The simplest way to rename / replace a tag is to delete it and add it again with the new tag name.

You can add multiple tags to the same commit, as well. To add a second tag called versionX referencing the same commit as vX, do [4]

git tag -a versionX vX^{}

Projects referencing our software

If software is published to an external package registry, downstream projects should prefer to get the software from there. interval-algebra, for example, is hosted on https://hackage.haskell.org, and the version of that package used would be handled by the cabal package manager.

Sometimes, projects using the software will need to reference a Git repository rather than some other source.

In that case, projects should reference a particular version-tagged commit of the repository. Doing so avoids instability that comes with always pulling in unwanted changes from the HEAD commit, which might include merge commits from feature branches for the next version in progress.

Hot fixes

Conceptually, a hot fix is a change to software already in use that must be pushed immediately, e.g. a critical bug fix.

In some workflows, feature branches do not target master but instead target version branches or a development branch. There, a hotfix branch would directly target master and is intended to address some problem in production immediately.

In this workflow, hot fixes are simply branches targeted for merge into master that retroactively are included in the previous version (as defined by a tagged commit) and are not part of a version milestone. Unlike feature branches, which are incorporated via MR, hot fixes must be incorporated into master in a particular way.

Note on public software

This procedure is not appropriate for software published to hackage or any other public package registry, e.g. crates.io. Downstream projects using such software should already be using the versions provided by the registry, and any bug fixes would be incorporated in some versioned release in the usual manner.

This procedure only applies to software, such as asclepias, for which some internal GitLab-hosted repository is the source.

Procedure for incorporating hot fixes

In this workflow, hot fixes require special care because commits from a version in progress might already have been merged into master. Projects using our software via Git repository must reference a particular version-tagged commit, to avoid such unwanted changes prior to the next version-tagged commit.[5]

Therefore, a hot fix is implemented by rewriting the history of master to insert the change directly after the version-tagged commit it modifies. You must then and update the version tag to point to the commit in which the hot fix is included. Those changes are made locally, and the history of origin/master must be updated with a forced push.

Note only maintainers will be allowed to force push to a protected origin/master on GitLab, and force pushes must be allowed in the repo settings.

The following commands implement this procedure for a hot fix to version "vX", as though the same person is both creating the hot fix and pushing it to GitLab. See Example of hot fix for clarity.

# Checkout version that needs fixing
git checkout vX
git switch -c hotfix-vX
git push -u origin hotfix-vX

# Make changes, commit them, share with remote etc. Note that you'll need to
# squash manually if you wish to do so, since we are not merging in the usual
# way.
<changes made to fix bug>

# Rebase master to hotfix-vX so that hotfix commits are made immediately after vX commit
git switch master
git pull
git rebase --rebase-merges hotfix-vX master

# Update the version tag. Note the new merge-base is the last commit from hotfix-vX now.
git tag -d vX
git tag -a vX $(git merge-base hotfix-vX master)

# Update the remote. You will need special privileges to force-push to protected master.
# The forced tag push is needed to overwrite the existing tag vX in the remote.
# Alternatively, you could delete the vX tag in the remote in the previous
# step. See the "Deleting and modifying tags" section.
git push --force
git push --force --tags
Example of hot fix

For clarity, the following is a more complete example of implementing a hot fix.

In this example, we suppose some project is targeting the v1.0 version of master. That version includes a bug that must be fixed immediately. master, though, already includes two commits (hashes efgh and ijkl) from features to be included in v1.1.

Two people will be involved: The first, "Brownan Brend," will make the change in branch hotfix-v1. The second is a privileged user, "Saulley Brad," who will update the version tag and the master branch in the remote.

The relevant history of origin/master before including the hot fix is

merge commit abcd (tag: v1.0) --> merge commit efgh --> merge commit ijkl (HEAD)

Note efgh and ijkl are merge commits, since they are incorporated into master via MRs. That will not be the case for the hot-fix-related commit.

When we’re done, the history will be

merge commit abcd  --> commit mnop (tag: v1.0, hotfix-v1) --> merge commit efgh --> merge commit ijkl (HEAD)
  1. Brownan Brend creates hotfix-v1 from the v1.0 commit of master, sharing it with the remote.

git switch master
git pull
git checkout "v1.0"
git switch -c hotfix-v1
git push -u origin hotfix-v1
  1. Brownan Brend fixes the bug in commit with hash mnop, and pushes the changes to the remote.

< fix changes >
git commit -am "fixed bug xxxx"
git push

The relevant history for hotfix-v1 now is

merge commit abcd (tag: v1.0) --> commit mnop (HEAD)
  1. Saulley Brad updates their local master to include the fix, via rebase.

git switch master
# Note this should implicitly fetch first, so you have the new origin/hotfix-v1
git pull
# git merge-base origin/hotfix-v1 master is the tagged commit v1.0
git rebase --rebase-merges origin/hotfix-v1 master

It is possible to have merge conflicts from work in commits efgh, ijkl related to v1.1 in progress. In cases where merge conflicts are expected, it might make sense for either Saulley or Brownan to do all of these tasks themselves, rather than splitting the load.

The history for master in Saulley’s local repo now looks like this,

merge commit abcd (tag: v1.0) --> commit mnop (hotfix-v1) --> merge commit efgh --> merge commit ijkl (HEAD)
  1. Saulley Brad updates the version tag, then updates master in the remote

This step redefines v1.0 of the software to include the hot fix.

git tag -d "v1.0"
# merge-base now is commit mnop
git tag -a "v1.0" $(git merge-base hotfix-v1 master)

git push --force
git push --force --tags

The relevant history for origin/master is now

merge commit abcd  --> commit mnop (tag: v1.0, hotfix-v1) --> merge commit efgh --> merge commit ijkl (HEAD)

Now, any project referring to commit with tag v1.0 will be using a version in which the fix is applied.

Development on v1.1 proceeds as normal, though any unmerged feature branches will need to rebase to origin/master to get the updated history.

Example workflows

Simple linear workflow

In this case, we have two feature branches that are sequenced one after another. Both are part of the v0.2 milestone. Once merged we branch bump-v0.2, make all version-bumping changes, and tag the merge commit of bump-v0.2 on origin/master.

simplest workflow

Overlapping branches

Here, we’ve complicated the workflow slightly by creating a long-running branch (feature-two), which originally was intended to be completed in milestone v0.2 but ends up being delayed to milestone v0.3.

overlapped feature workflow

Possible conflicts

Though it is not ideal to have longer-running branches, they often happen. If feature-two is not dependent on the other features, there likely will be no conflicts when the former attempts to merge.

If feature-two 's attempt to merge creates conflicts, the branch’s author should be able to resolve them with a rebase to origin/master, since the latter’s history is simple and linear. Otherwise, the author can use whatever methods they deem appropriate, e.g. merge origin/master into feature-two, cherry-pick certain commits, etc.

Note: Whatever commits there might be associated with updating feature-two for the work in v0.2 are not shown.

To avoid such difficulties, the author could delay branching feature-two until feature-three is merged, or otherwise could better coordinate their work with the author of feature-three.

Managing versions

The use of bump-vX branches helps to organize which commits are marked for a given version. Features to be included in a given version are managed using the GitLab milestones feature. The version tag must manually be added to the merge commit for bump-vX. See Creating tags.

In the pictured example,

  • Initially, feature-one, feature-two and feature-three are all part of the v0.2 milestone.

  • feature-two wasn’t ready or perhaps was dependent on feature-three, say, and was delayed to v0.3.

  • feature-one and feature-three were included in the milestone associated with v0.2.

  • feature-two is the sole feature branch included in v0.3.

Tags can be added retroactively, should we forget to do so before beginning work on v0.4, and they can be deleted and remade altogether in case of error.

Note the author of feature-two could consider simply ditching the branch, re-branching and re-doing the work if the changes from v0.2 would make updating the history difficult.

What you do not see here

The workflow above is still perfectly consistent with these guidelines. With that in mind, it’s worth noting what the diagram does not show:

  • There are no direct commits on origin/master outside of feature branches. All commits on origin/master should be via merge requests on GitLab from feature branches.

  • All feature branches originate from origin/master and not other feature branches.

General best practices

TODO

Tips & Tricks

Tags

You can checkout a tag like you would a branch. This creates a detached head, from which you can make commits or branch. With version tags, you can therefore access the state of master for a particular version and create a branch directly from that state.

Here’s an example of using a version tag to create an example branch v1-example from version v1 of master.

git checkout v1
git switch -c v1-example
# do some work now
git add .
git commit -m "added an example about v1"

Finding common ancestors

git merge-base <commit refs> displays the commit that is the common ancestor of all <commit refs>. For example, you can find the commit at which feature-branch-one was branched from master using git merge-base master feature-branch-one.

Visualizing branch structure

GitLab has some tools for visualizing branch structure, but the commandline tool has more flexibility and features.

To create a graph of the history for <commit refs> commit references in tree-like order, with some nice annotations, you can do

git log <commit refs> --graph --pretty --topo-order --decorate

Branch names in <commit refs> will default to the HEAD commit as usual. References can include tags or any other Git commit reference syntax.

Git allows you to alias more complex commands by adding them to your ~/.gitconfigure file. That is helpful in particular with git log commands that use the more advanced formatting features. Adding the following example text to your ~/.gitconfigure file creates several aliases for git log commands with the given specifications. These commands would be invoked with git lg, git lg1 etc.

[alias]
    lg = !"git lg1"
    lg1 = !"git lg1-specific --all"
    lg2 = !"git lg2-specific --all"
    lg3 = !"git lg3-specific --all"
    lg1-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(auto)%d%C(reset)'
    lg2-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset)%C(auto)%d%C(reset)%n''          %C(white)%s%C(reset) %C(dim white)- %an%C(reset)'
    lg3-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset) %C(bold cyan)(committed: %cD)%C(reset) %C(auto)%d%C(reset)%n''          %C(white)%s%C(reset)%n''          %C(dim white)- %an <%ae> %C(reset) %C(dim white)(committer: %cn <%ce>)%C(reset)'
        change-commits = "!f() { VAR=$1; OLD=$2; NEW=$3; shift 3; git filter-branch --env-filter \"if [[ \\\"$`echo $VAR`\\\" = '$OLD' ]]; then export $VAR='$NEW'; fi\" $@; }; f"

Running GitLab jobs locally

The gitlab-runner application can be used to run jobs locally. Use the exec command.

gitlab-runner exec can (currently) only run one job at a time.
See here for instructions on installing gitlab-runner on your machine.

Here is an example of using the shell executor to run the check-source job in asclepias.

% gitlab-runner exec shell check-source
Runtime platform                                    arch=amd64 os=darwin pid=21602 revision=f0a95a76 version=14.5.0
WARNING: You most probably have uncommitted changes.
WARNING: These changes will not be tested.
Running with gitlab-runner 14.5.0 (f0a95a76)
Preparing the "shell" executor
Using Shell executor...
executor not supported                              job=1 project=0 referee=metrics
Preparing environment
Running on TGTRWE-LT-0081...
Getting source from Git repository
Fetching changes...
Initialized empty Git repository in /Users/bsaul/Documents/novisci/software/asclepias/builds/0/project-0/.git/
Created fresh repository.
Checking out 775b60a6 as 146-docs...
Skipping Git submodules setup
Executing "step_script" stage of the job script
$ ./scripts/lint.sh
No hints
$ ./ci/ci-check-format.sh
Job succeeded

Metrics tracking workflow

We hope to generate a few metrics to track:

  • How well we are following the workflow described above.

  • To what extent we have avoided "problematic" merges and other Git-related problems.

Exactly how best to do so is not clear and is subject to ongoing debate. This section will include a description of such metrics and means to generate them once we have settled on some.


1. GitLab lets you configure this to be required, should we wish to do so.
2. These are properly signed and incorporated into the Git history. See the https://git-scm.com/book/en/v2/Git-Basics-Tagging for details.
3. These statements are based on trial-and-error. I couldn’t find documentation on pushing tags to protected branches.
4. The ^{} reference syntax is to ensure the tag references the commit and not the tag itself. From git help revisions "A suffix ^ followed by an empty brace pair means the object could be a tag, and dereference the tag recursively until a non-tag object is found."
5. If hot fixes become common, we might want to revisit this and consider including a development branch in the workflow.