Working across repositories

Not all work fits in one directory. There are two shapes of "somewhere else" that come up constantly, and Projectile has a command for each:

  • The same project, checked out twice - you’re on a feature branch here and want the copy that still has main in it. projectile-switch-worktree (s-p W).

  • Different projects that belong together - a library and the app using it, a tool and its documentation site. projectile-switch-sibling-project (s-p n).

Both are narrowed versions of projectile-switch-project (s-p p). That command offers every project you’ve ever visited; these two offer the handful you’re likely to actually want, which is the difference between typing a few characters and remembering which of a hundred directories is the right one.

Keybinding Command

s-p W

projectile-switch-worktree

s-p n

projectile-switch-sibling-project

Both honour projectile-switch-project-action on switch, and both take a prefix argument to run projectile-dispatch instead, exactly like the other switch commands.

Other checkouts of this project

s-p W lists the other directories holding the same repository, each annotated with whatever tells it apart - the branch for git, the workspace name for Jujutsu:

Switch to worktree:
~/src/myapp-main/     (main)
~/src/myapp-hotfix/   (hotfix/crash-on-open)

Several quite different mechanisms end up on that list, because from where you’re sitting they’re the same thing.

Git worktrees are the built-in mechanism:

git worktree add ../myapp-hotfix -b hotfix/crash-on-open

Projectile asks git for these, so a worktree you have never opened in Emacs still shows up.

Jujutsu workspaces are the same idea under jj:

jj workspace add ../myapp-hotfix

These are enumerated too, so an unvisited one shows up as well, labelled with its workspace name. A colocated repository (jj git init --colocate, and in recent versions plain jj git init too) has both git worktrees and Jujutsu workspaces, and both are offered.

Separate clones of the same upstream are the hand-rolled version of the same workflow, and plenty of people work that way:

git clone git@github.com:me/myapp.git myapp
git clone git@github.com:me/myapp.git myapp-experiment

Projectile can’t enumerate these from the repository - nothing records them anywhere - so it finds them among the known projects. A clone you have never visited won’t be offered until Projectile knows about it.

If your clones aren’t turning up, it’s almost always because they aren’t known projects yet. Add ~/src to projectile-project-search-path and run M-x projectile-discover-projects-in-search-path.

Where checkouts come from is controlled by projectile-worktree-functions, a list of functions each taking a project root and returning what it found. The default set covers git, Jujutsu and the known projects. To add another way of tracking checkouts, write a function returning a list of plists with :path (mandatory), :label and :prunable - see that option’s docstring for the details.

Related projects

s-p n offers the projects related to the one you’re in. Three signals decide what "related" means, consulted in the order listed by projectile-sibling-project-functions - from the one you can trust completely to the one that’s a guess.

Groups you configure

The only signal that is never wrong, so it comes first:

(setq projectile-project-groups
      '(("myapp"  . ("~/src/myapp" "~/src/myapp-docs" "~/src/myapp-cli"))
        ("infra"  . ("~/src/deploy" "~/src/terraform"))))

A project can be in several groups; you get the members of all of them. Configured groups are never subject to the share cap - however many projects you put in a group, you meant to.

To describe one project’s siblings from the project itself, put projectile-project-siblings in its .dir-locals.el instead:

;;; ~/src/myapp/.dir-locals.el
((nil . ((projectile-project-siblings . ("~/src/myapp-docs")))))

The owner of the upstream remote

This is by far the best of the guessed signals, and the only one that can relate projects whose names have nothing in common. Projectile takes the account or organization out of the remote URL, so everything under github.com/clojure-emacs is related to everything else under it: cider, orchard, haystack, sayid and port are one family, which no amount of comparing directory names would ever discover.

It works on the normalized remote, so the spelling doesn’t matter: git@github.com:me/app.git, https://github.com/me/app and ssh://git@github.com:22/me/app/ are all the same repository.

The leading word of the directory name

The signal of last resort, and the only one that works with no version control at all - a plain directory with a .projectile in it still gets siblings. rubocop, rubocop-ast and rubocop-rails are related because they start with the same word.

Only the leading word counts. Matching on any shared word relates every -mode package to every other one and every docs. site to the rest, which reads far more into a name than is actually there. The cost of that choice is that helm-projectile is not offered as a sibling of projectile - put them in a group if you want that.

When a group is too big

Inference that relates most of your projects to each other has found nothing. If every repository you own is under your own account, "same account" doesn’t distinguish anything, and offering that group is projectile-switch-project with extra steps.

So an inferred group covering more than projectile-sibling-max-group-share (a quarter by default) of your known projects is dropped, and the next signal gets its turn. The cap doesn’t apply until you have at least ten known projects, because a share of a handful measures nothing, and a group of two always survives.

The visible consequence: if most of your projects share one account, that signal goes quiet and you fall back to name matching. When a project you care about ends up with no siblings, that’s what projectile-project-groups is for.

What else checkouts share

Knowing that two roots are one repository is useful beyond switching between them: the command history you browse at a compile or test prompt is shared across checkouts, so a worktree made this morning already offers the commands the project is built with. See History across checkouts.

The line elsewhere is drawn at anything that acts without asking, which stays local to the checkout you’re in: the command a prompt is pre-filled with, what projectile-repeat-last-command replays, and the task projectile-repeat-last-task re-runs. A remembered command can carry absolute paths back into the checkout it was typed in, and being handed somebody else’s build unasked is no fun.

Per-project sessions aren’t shared either, for a different reason: a window layout is a set of files in a particular directory, and the whole point of a second checkout is that its files differ.

How Projectile decides two projects are the same repository

Both commands rest on projectile-repo-identity, which describes what repository a project root is a checkout of:

:repo

The directory the checkouts share - git’s common dir, the store an hg share points at, the git directory a Jujutsu repository keeps its commits in. Two roots with the same :repo are worktrees of each other.

:remote

A canonical spelling of the upstream, so the scp-like and URL forms of one remote compare equal. Two roots with the same :remote are separate clones.

:owner

The account, organization or containing directory that upstream hangs off. This is what the sibling owner signal groups on.

It reads the version control system’s own files rather than running it, so comparing every known project costs file reads instead of a process launch apiece.

Limitations

Worth knowing before you file a bug:

  • The guessed signals are guesses. The share cap will sometimes leave a project with no siblings at all - that’s the design working, not a failure. Configure a group for that project.

  • Name matching only looks at the first word. helm-projectile will not be offered as a sibling of projectile.

  • Identity comes from the remote, not the directory name. A directory called emacs-fsharp-ts whose remote is me/fsharp-ts-mode is related to whatever else lives under me, and is not related to other directories starting with emacs.

  • A repository with no remote gets name matching only - there’s no owner to group on.

  • Only a repository’s own top level has an identity. A project defined by a .projectile file inside a larger repository is a directory in a checkout, not a checkout of its own, so it has no worktrees - the enclosing repository’s worktrees are not other copies of it, and aren’t offered as though they were. Projectile cannot yet map such a project to the matching subdirectory of another worktree.

  • Remote (TRAMP) projects are skipped. Every probe would be a network round trip, so a remote project has no identity and no worktrees.

  • Sibling clones must already be known projects. Real git worktrees and Jujutsu workspaces are found without that, since the version control system registers them itself.

  • Only git and Jujutsu checkouts carry a label. A Mercurial share is offered without one, since Projectile would have to run hg to learn its branch and the whole point of the lookup is that it doesn’t.

When s-p W has nothing to offer it distinguishes the two reasons: having looked and found no other checkout, and not being able to look at all because the project isn’t the top level of a git, Mercurial or Jujutsu repository. Only the first means "there aren’t any". s-p n needs none of this, so it’s the one to reach for when Projectile can’t identify the repository.

Version control support

System What works

Git

Everything. Worktrees come from git worktree list; clones are matched on the normalized remote.

Mercurial

A working directory created by hg share is matched against the other known projects via its .hg/sharedpath, and the default path in .hg/hgrc acts as the remote. Mercurial has no way to enumerate shares, so one you’ve never visited won’t be found, and a share is listed without a branch label.

Jujutsu

Workspaces come from jj workspace list, so one you’ve never visited is found too. Jujutsu only started recording a workspace’s path in 0.38: a workspace created by an older version is skipped rather than guessed at, and an older jj binary rejects the query outright, so nothing is enumerated and the known projects take over. A repository is identified through its git backing store, so a workspace and the colocated git checkout agree on being the same thing, and jj repositories get a remote (and therefore siblings) like any other. Enumeration needs jj on PATH; without it the workspaces are still related through the known projects, since that reads files rather than running anything.

Everything else

No worktree support. Fossil check-outs, Subversion, Bazaar, Darcs and Sapling are all treated as ordinary projects.