See projects codebase organization for the latest documentation on organizing your codebase.
Though the Unison namespace tree can be organized however you like, this document suggests handy conventions to keep things tidy even with multiple concurrent workstreams, pull requests, library releases, and external dependencies being added and upgraded. We like conventions that can be followed without much thought and which make things easy, so you can direct your brainpower to actually writing Unison code, not figuring out how to organize it. 😀
We recommend using this exact same set of conventions for library maintainers, library contributors, and application developers. It makes it easy to follow the conventions regardless of which of these roles you are working in, and doesn't require reorganizing anything if you decide you want to publish your personal Unison repository as a library for others to build on.
Here's what a namespace tree will look like that follows these conventions. This will be explained more below, and we'll also show how common workflows (like installing and upgrading libraries and opening pull requests) can be handled with a few UCM commands:
myProject
main/
Boolean/
Nat/
...
README : Doc
releaseNotes : Doc
lib/
dependency1.v7/
alice.mylib.v94/
alice.mylib.v87/
bob.somelib.main/
latest/
Boolean/
Nat/
README : Doc
releaseNotes : Doc
/lib
dependency1.v7/
...
releases/
v1/
v2/
...
series/
v1/
v2/
prs/
myGreatPR
anotherNewFeature
...
...
Directly under the namespace root, we have a project called myProject
with a main
namespace for the project maintainers add their latest feature work in, a latest
(sub-)namespace (for the latest stable version), a releases
namespace containing released versions of main, a series
namespace (for releases branches, forked off main
), and a prs
namespace, containing work that will eventually get merged to main
. Additionally, if you contribute PRs to projects that aren't primarily your own, you might have a top-level .prs
namespace which contains copies of the libraries and namespaces for new features.
Its common to have multiple projects under the root of your codebase, representing the various libraries or applications you're writing in Unison.
Upgrading libraries to the latest version
There's no problem with having multiple versions of a library installed at once, though sometimes you want to upgrade to the latest version of a library and delete any references to the old version. There are a couple cases you can encounter here. If alice.mylib.v6
is a superset of alice.mylib.v5
, this is as simple as just deleting the v5
after v6
is installed.
😎 Since references to definitions are by hash, not by name, any renaming of definitions done between v5
and v6
of a library won't break any of your code. Though we recommend reading the release notes to discover these sorts of changes, or using diff.namespace somelib.v5 somelib.v6
for an overview.
If v6
replaced any definitions with new ones (using the update
command or replace.term
or replace.type
), these are recorded in a patch which can be applied locally.
As a norm, alice.mylib.v6.releaseNotes
will cover how to apply patches to your local namespace. It will typically be commands like:
myProject/main> branch main upgradeAliceLib
myProject/upgradeAliceLib> patch external.mylib.v6.patches.v5_v6 upgradeAliceLib
myProject/upgradeAliceLib> merge /upgradeAliceLib /main
Day-to-day development: creating and merging pull requests
Here's the basic workflow for drafting changes to main
in your own project. It's not much different than a typical Git workflow, where you create branches, hack on them, then merge them to main
when done:
myProject/main> branch main myCoolPR
myProject/myCoolPR>
Now hack away. Usemerge.preview main myCoolPR
at any time to see what you've changed.myProject/myCoolPR> merge /myCoolPR /main
when you're done
To propose changes to another Unison codebase works similarly. We'll use the Unison base library as an example:
scratch/main> pull unison.public.base.main _base
you can do this both for initial fetch and for subsequent updatesscratch/main> fork _base .prs.base._mything
to create a copy of_base
. You can create multiple forks in.prs.base
, if you have multiple things you're working on.- If you haven't already done so, set your default license for
.prs.base
to match the license of the codebase you're contributing to. For base it's the MIT license. - Now
cd .prs.base._mything
and hack away as before. At any time review what's changed between your namespace and_base
withdiff.namespace ._base .prs. base._mything
. - Push your forked version somewhere public with your Unison Share account.
prs.base._mything> push myUser.public.prs.base._mything
. No need to maintain a separate Git repo for every Unison library you want to contribute to. .prs.base._mything> pull-request.create unison.public.base.latest myUser.public.prs.base._mything
and this will create some output. Copy that output to your clipboard. We don't literally use the GitHub pull request mechanism for Unison repositories, we use GitHub issues instead.- Use the GitHub issue comments for coordinating the review. Once merged, the maintainer will close the issue.
- Next, create a GitHub issue in the repo you're submitting the code to (that's right, an issue, not a GitHub PR). Many Unison repositories will have a GitHub issue template for this purpose. Make the issue title something descriptive, and for the issue body, paste in the output generated by pull-request.load'' command to review the PR locally. We'll run that command in any empty namespace:
pull-request.create` as well as some text describing the change, just like you would for any other pull request. This workflow also works fine even if the source and destination repository are the same, so you might use the above PR process when suggesting changes to a library that you maintain with other people. ### Reviewing pull requests We'll use [this issue as an example](https://github.com/unisonweb/base/issues/12). The issue created for the PR will have a
.review.pr12> pull-request.load unison.public.base.main pchiusano.public.unisoncode.prs.random2
If you .review.pr12> ls
you'll see three or four sub-namespaces: base
(the original namespace), head
(the new namespace), merged
(the result of merge head base
) and potentially squashed
(the same content as merged
but without intermediate history). The following commands can be performed against either the merged
or squashed
namespace, depending on if preserving history is important to you:
.review.pr12> diff.namespace base merged
to see what's changed. The numbered entries in the diff can be referenced by subsequent commands, sodiff.namespace base merged
might be followed byview 1-7
to view the first 7 definitions listed there.- You can use comments on the GitHub issue to coordinate if there's any changes you'd like the contributor to make before accepting the PR. You can also make changes directly in
merged
. .review.pr12> push unison.public.base.main merged
to pushmerged
tomain
.review.pr12> history merged
and copy the top namespace hash. That's the Unison namespace hash as of the merged PR. Then close the GitHub issue with a comment like "Merged to main in hash #pjdnqduc38" and thank the contributor. 😀 If you ever want to go back in time, you can sayscratch/main> fork #pjdnqduc38 .pr12
to give the#pjdnqduc38
namespace hash the name.pr12
in your tree.- If you like,
scratch/main> delete.namespace .review.pr12
to tidy up afterwards.
Keeping in sync with main
Periodically, you can pull
the latest main
using:
scratch/main> pull git(https://gitub.com/unisonorama/myproject) main
If you have in-progress PRs that you want to sync with the latest, you can bring them up to date using .prs._myPR> merge .main
.
Using unreleased library versions
Sometimes, you want to use some code that's only in main
of a library but hasn't made its way into a release. No problem. The install process looks the same, just do a pull
:
scratch/main> pull https://github.com/bob/mylib:.main .external.bob.mylib.main
As often as you like, you can re-issue the above command to fetch the latest version of the library. After doing so, you should then apply patches from the library to your local namespace. Check the project's README for information on how to do this, but typically, for a namespace, bob.mylib.main
, there will just be a patch called bob.mylib.main.patch
which can be applied with:
scratch/main> fork main prs.upgradeBob
scratch/main> patch external.bob.mylib.main.patch prs.upgradeAliceLib
Assuming all is well after that patch
, you can scratch/main> merge prs.upgradeBob main
(and then sync that with any other PRs being drafted, as discussed in the previous section).
If you are feeling adventurous it's also possible to directly apply the patch to your in-progress PRs or even main
.
How to create a release
Suppose you are creating v12
of a library. The process is basically to fork
a copy of main
:
- Before getting started, we suggest reviewing the current patch in
main
withscratch/main> view.patch main.patch
. The term and type replacements listed here should generally be bugfixes or critical upgrades that you expect users of your library to make as well. You can usedelete.term-replacement
anddelete.type-replacement
to remove any entries you don't want to force on library users. See below for more. - Fork a copy of
main
:scratch/main> fork main series._v12
- The current dependencies in the release should be included in the library's
lib
namespace. This convention ensures that anyone who obtains the library also receives its dependencies and the naming for those definitions at the time. - Create or update
series._v12.releaseNotes : Doc
. You can include the current namespace hash ofseries._v12
, a link to previous release notes, likereleases. _v11 releaseNotes
, and if the release has a non-emptypatch
, give some guidance on upgrading. Are all the edits type-preserving? If no, what sort of refactoring will users have to do? squash series._v12 releases._v12
to create the release. This squashedreleases._v12
will have no history and is more efficient for users topull
into their codebase.- Reset the patch and release notes in
main
:scratch/main> delete.patch patch
andscratch/main> delete.term releaseNotes
. Anyone can upgrade from a past releasev3
, by applying the patchesreleases._v4.patch
,releases._v5.patch
up throughreleases._v12.patch
. - If desired, you can also produce cumulative patches for skipping multiple versions. These can be published on an ad hoc basis. If publishing these, just include them in
releases._v12.patches.v4_to_v12
.
- If desired, you can also produce cumulative patches for skipping multiple versions. These can be published on an ad hoc basis. If publishing these, just include them in
- Reset the patch and release notes in
- Optional: you can add updated instructions for fetching the release to the the README on the repo's Unison Share page. You can also let folks know about the release via any other communication channels (Twitter, Slack, etc).
We don't recommend any fancy numbering scheme for versioning, just increment the version number. Use the releaseNotes
to convey metadata and additional nuance about each release.
Backporting fixes
Creating a bugfix release works the same way. Suppose the v12
release has a bug. The bug has been fixed in the latest trunk
and you'd like to backport it. Just backport the fix to the series._v12
namespace and continue with the release steps as before, but this time create releases._v12a
then _v12b
, _v12c
, etc.
Patch management
Patches are collections of mappings from old hash to new hash (these entries are called "replacements"). We've seen above how these patches can be applied to upgrade a namespace using these replacements. The patches are built up via update
or replace.term
or replace.type
commands which add replacements to a default patch (called "patch") that exists under each namespace in the tree. You can view this or any other patch using view.patch
:
mylib/trunk> view.patch patch
Edited Terms: List.frobnicate#92jajfh197 -> List.frobnicate
Edited Terms: CinnamonRoll#93jg10ba -> SugarCookie
Tip: To remove entries from a patch, use delete.term-replacement or
delete.type-replacement, as appropriate.
🤓 The update
and replace.term
and replace.type
commands also take an optional patch name, if you want to build up a patch somewhere other than the default patch. This is handy for keeping logically unrelated patches separate. You can also move.patch
and delete.patch
.
Release patches
There are a lot of reasons you might add replacements to a patch during the course of development (see the development patches section below), but for patches published with a release, it's recommended to limit the replacements to cases where the old definition is invalid or would never be preferred the new version (generally just bugfixes for previously released definitions, performance improvements, and critical upgrades). Why do this? Because it makes things a lot easier for your users and avoids needless churn.
In other languages, where definitions are identified by name and the codebase is just mutated in-place, every change to the codebase amounts to a forced upgrade for users. We don't often think of it this way, but by updating List.frobnicate
, the old version of List.frobnicate
is effectively deleted and no one gets to reference the old definition of List.frobnicate
(unless they literally go through the Git history and bring it back somehow). Very often this is done not because the old version is wrong or should never be used, but because we don't feel like coming up with another name for the new definition and decide to just repurpose an existing name.
When this name repurposing is done for definitions that have already been released, it generates work that often isn't necessary.
In Unison, the decision to repurpose a name is completely separate from the decision to force an upgrade on users. If you want to repurpose a name like List.frobnicate
but the old definition is still valid (ask yourself "could someone reasonably still build on the old definition, or is the new definition always preferable?"), you can first delete the old definition (via delete.term
), or archive it by moving it to _archive. y2020_m03_d24_frobnicate
, timestamped with the current date. Then create the new definition for List.frobnicate
.
If you've already done an update
, no problem. Just use delete.term-replacement
or delete.type-replacement
to remove replacements from the patch before release.
Here are a few common types of updates that won't usually be part of a release patch, but will instead be part of a development patch, covered next:
- Adding a parameter to a function to make it more generic: typically the less generic version is still perfectly valid
- Changing the order of parameters: typically the previous argument order is still perfectly valid
- Changing the type of the function: generally, when this is done, the function is actually doing something different
Release patches typically contain bugfixes, performance improvements, or critical updates (say some code depends on an external service, and that external service has a different interface now, invalidating the old code).
Development patches
During development, new, unreleased definitions may get updated or refactored multiple times before making it into a release, for instance:
- An unreleased definition may have a bug that gets fixed before release. A patch records this bugfix.
- An unreleased definition may be stubbed out (using the
todo
function) then the stubbed definition later filled in. A patch records this replacement. - Definitions may be refactored multiple times before being released, and these refactorings are recorded in a patch so all the developers on the release can easily stay up-to-date.
With some simple steps, you can make it easy to keep your release patch clean so when it comes time to make a release, the default patch in .main. patch
is totally clean and just includes the replacements you want all users to make in their code:
- When developing new definitions to be added to
main
, do it in a separate namespace underprs
, sayprs._myNewStuff
. Within this namespace, useupdate
,replace.term
andreplace.type
as much as you like. When you're ready to merge, justdelete.patch prs._myNewStuff.patch
. - For other development patches, like bugfixing an unreleased definition in
main
, or replacing a stub, use a separate named development patch for it rather than the default patch that becomes the release patch during the release process. We recommendupdate .main.patches.dev
for the default development patch. Anyone with in-progress pull requests can apply this patch to their work. It can be archived and reset periodically. - When repurposing the name of a released definition, use the repurposing names steps covered above rather than using
update
on it. - For updates to released code that all users should make (like bugfixes or performance improvements), go ahead and do an
update
of the default patch. Note that if youfork trunk prs._somePR
, then do your updates in_somePR
, when that namespace is merged back into trunk, the updates you made to the default patch will arrive there as well.