diff --git a/CHANGELOG.md b/CHANGELOG.md index 161559ba0..5d26281e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### New features +- [#2147](https://github.com/bbatsov/projectile/pull/2147): Add `projectile-switch-worktree` (`s-p W`), which offers the other checkouts of the current project's repository, each annotated with the branch it has checked out. + - Git worktrees and separate clones of the same upstream both count as checkouts, since they're the same workflow with and without the plumbing; `projectile-worktree-functions` is where other ways of finding them plug in. - [#2138](https://github.com/bbatsov/projectile/pull/2138): Better support for OCaml, Erlang and F#. - `projectile-run-test-at-point` learns `erlang-ts-mode` (EUnit's `_test`/`_test_` functions, run as `rebar3 eunit --test=module:name`) and `fsharp-ts-mode` (bindings attributed `[]`, `[]`, `[]`, `[]` or `[]`, run through `dotnet test --filter`). - The `ocaml-dune` and `rebar` project types gained their run, install, package and source/test directory attributes, and a new `erlang-mk` type covers the other common Erlang build tool. diff --git a/projectile.el b/projectile.el index 99ffb4537..d86da58b3 100644 --- a/projectile.el +++ b/projectile.el @@ -4878,7 +4878,7 @@ Never use on many files since it's going to recalculate the project-root for every file." (expand-file-name name (projectile-project-root dir))) -(cl-defun projectile-completing-read (prompt choices &key initial-input action caller sort-function (category 'project-file)) +(cl-defun projectile-completing-read (prompt choices &key initial-input action caller sort-function annotation-function (category 'project-file)) "Present a project tailored PROMPT with CHOICES. Reads with `completing-read', unless `projectile-completion-system' is a @@ -4890,6 +4890,12 @@ when non-nil, is exposed as the completion metadata's `display-sort-function' and `cycle-sort-function', so completion UIs that honor metadata present the candidates in that order. +ANNOTATION-FUNCTION, when non-nil, is exposed as the metadata's +`annotation-function', so UIs that honor metadata show a suffix next to +each candidate. Use it when the candidate string alone doesn't identify +what's being picked - a worktree's path doesn't say which branch it has +checked out, say. + CATEGORY is the completion metadata category advertised to UIs like marginalia and embark so they annotate and act on the candidates appropriately; it defaults to `project-file' (the candidates are project @@ -4908,6 +4914,8 @@ CALLER is accepted for backward compatibility but no longer used." ;; and embark enhance how candidates are presented. (if (eq action 'metadata) `(metadata ,@(when category `((category . ,category))) + ,@(when annotation-function + `((annotation-function . ,annotation-function))) ,@(when sort-function `((display-sort-function . ,sort-function) (cycle-sort-function . ,sort-function)))) @@ -13525,6 +13533,418 @@ overwriting each other's changes." (projectile-save-known-projects))) +;;; Repository identity +;; +;; Projectile treats every checkout as a project of its own: a git worktree +;; and the checkout it was linked from have their own roots, their own file +;; listings and usually their own branches, so that's the right call. It +;; does mean that "take me to my other checkout of this" needs a notion of +;; identity that outlives any single root, which is what +;; `projectile-repo-identity' provides. Its two keys answer progressively +;; weaker questions: +;; +;; :repo the directory the checkouts share - git's common dir, the +;; store an `hg share' points at. Equal `:repo' means one +;; repository checked out more than once, which is what a +;; worktree is. +;; :remote the upstream they were cloned from, normalized so that the +;; scp-like and URL spellings of one remote compare equal. +;; Equal `:remote' means separate clones of one project - the +;; hand-rolled version of worktrees, and just as common. +;; +;; Both are needed: `:repo' alone would miss separate clones entirely, and +;; `:remote' alone would miss the worktrees of a repository that doesn't +;; have a remote at all. + +(projectile-define-project-cache projectile-repo-identity-cache + "Cache of `projectile-repo-identity' results keyed by project root. +Cleared by `projectile-invalidate-cache'.") + +(defconst projectile--repo-url-scheme-regexp + "\\`[a-zA-Z][a-zA-Z0-9+.-]*://\\(?:[^@/]*@\\)?\\([^/:]+\\)\\(?::[0-9]+\\)?/+\\(.+\\)\\'" + "Match a `scheme://[user@]host[:port]/path' remote URL. +Group 1 is the host, group 2 the path.") + +(defconst projectile--repo-url-scp-regexp + "\\`\\(?:[^@/]*@\\)?\\([^/:]\\{2,\\}\\):\\(.+\\)\\'" + "Match git's scp-like `[user@]host:path' remote syntax. +Group 1 is the host, group 2 the path. The host has to be at least two +characters long so that a Windows path like `c:/src/repo' isn't read as +one; that's the same ambiguity, resolved the same way, as in git itself.") + +(defun projectile--normalize-repo-path (path) + "Return PATH without its trailing slashes or its `.git' suffix. +Those are the two ways one repository path gets spelled differently." + (string-remove-suffix ".git" (string-trim-right path "/+"))) + +(defun projectile--normalize-repo-url (url) + "Return a canonical identity for the remote URL, or nil when there's none. + +One repository can be addressed in several ways - `git@host:owner/repo.git', +`https://host/owner/repo', `ssh://git@host:22/owner/repo/' - and all of +them have to compare equal for two clones of it to be recognized as +checkouts of the same thing. The identity is `HOST/PATH' without the +user, port, trailing slashes or `.git' suffix, downcased because hosts +are case-insensitive and so, in practice, are the forges' paths. + +A URL that addresses a local repository (a bare path, or a `file://' +URL) normalizes to that path, left in its original case since local file +systems are not reliably case-insensitive." + (when (and url (not (string-blank-p url))) + (let ((url (string-trim url))) + (cond + ;; A `file://' URL addresses a local repository, same as a bare path. + ((string-prefix-p "file:///" url) + (projectile--normalize-repo-path (string-remove-prefix "file://" url))) + ((or (string-match projectile--repo-url-scheme-regexp url) + ;; The scp-like syntax has no scheme, so anything carrying one + ;; has already had its chance above and isn't a host:path. + (and (not (string-match-p "://" url)) + (string-match projectile--repo-url-scp-regexp url))) + (downcase (concat (match-string 1 url) "/" + (projectile--normalize-repo-path + (replace-regexp-in-string "\\`/+" "" (match-string 2 url)))))) + (t (projectile--normalize-repo-path (expand-file-name url))))))) + +;; Everything below reads git's own files rather than running git. Identity +;; is computed for every known project when looking for the other checkouts +;; of one, and a subprocess apiece would be seconds of latency on a machine +;; with a hundred projects - the same reason `projectile--hg-default-path' +;; parses `.hg/hgrc' directly. The layout being read is stable and +;; documented in gitrepository-layout(5). + +(defun projectile--git-dir (root) + "Return the git directory belonging to the checkout at ROOT, or nil. + +That's `/.git' when it is a directory, and the directory named by +the `gitdir:' line when it is the file a linked worktree gets instead. + +Nil when ROOT holds no `.git' at all, which is what distinguishes a +checkout from a directory inside one: `projectile-project-vcs' +deliberately answers `git' for a project below a repository root too, so +that file listing can still go through git, but the worktrees of the +enclosing repository are not other copies of such a project." + (let ((dot-git (expand-file-name ".git" root))) + (cond + ((file-directory-p dot-git) (file-name-as-directory dot-git)) + ((file-readable-p dot-git) + (with-temp-buffer + (insert-file-contents dot-git) + (goto-char (point-min)) + (when (looking-at "gitdir:[ \t]*\\(.+?\\)[ \t]*$") + (file-name-as-directory + (expand-file-name (match-string 1) root)))))))) + +(defun projectile--git-common-dir (git-dir) + "Return the directory GIT-DIR shares with the repository's other checkouts. + +A linked worktree's git directory carries a `commondir' file naming that +shared directory; the main checkout's git directory is the shared +directory itself." + (let ((commondir (expand-file-name "commondir" git-dir))) + (if (file-readable-p commondir) + (file-name-as-directory + (expand-file-name + (with-temp-buffer + (insert-file-contents commondir) + (string-trim (buffer-string))) + git-dir)) + git-dir))) + +(defun projectile--git-config-remote-url (config-file) + "Return the URL of the upstream remote configured in CONFIG-FILE, or nil. + +That's `origin' when it's there, since it's what cloning sets up and what +two checkouts of one repository will therefore agree on; a repository +wired up by hand may use another name, so fall back to whichever remote +comes first rather than giving up." + (when (file-readable-p config-file) + (with-temp-buffer + (insert-file-contents config-file) + (goto-char (point-min)) + (let (remotes) + (while (re-search-forward "^[ \t]*\\[remote[ \t]+\"\\([^\"]+\\)\"\\]" nil t) + (let ((name (match-string 1)) + (section-end (save-excursion + (if (re-search-forward "^[ \t]*\\[" nil t) + (match-beginning 0) + (point-max))))) + (when (re-search-forward "^[ \t]*url[ \t]*=[ \t]*\\(.+?\\)[ \t]*$" + section-end t) + (push (cons name (match-string 1)) remotes)))) + (setq remotes (nreverse remotes)) + (or (cdr (assoc "origin" remotes)) (cdar remotes)))))) + +(defun projectile--git-head-branch (git-dir) + "Return the branch checked out in GIT-DIR, or nil when HEAD is detached." + (let ((head (expand-file-name "HEAD" git-dir))) + (when (file-readable-p head) + (with-temp-buffer + (insert-file-contents head) + (goto-char (point-min)) + (when (looking-at "ref:[ \t]*refs/heads/\\(.+?\\)[ \t]*$") + (match-string 1)))))) + +(defun projectile--git-repo-identity (root) + "Return the repository identity plist for the git checkout at ROOT." + (when-let* ((git-dir (projectile--git-dir root)) + (common-dir (projectile--git-common-dir git-dir))) + (list :repo (file-truename common-dir) + :remote (projectile--normalize-repo-url + (projectile--git-config-remote-url + (expand-file-name "config" common-dir)))))) + +(defun projectile--hg-repo-identity (root) + "Return the repository identity plist for the Mercurial project at ROOT. + +A working directory created by `hg share' keeps its store elsewhere and +records where in `.hg/sharedpath', which makes that path the Mercurial +equivalent of git\\='s common dir." + (let* ((hg-dir (expand-file-name ".hg" root)) + (sharedpath (expand-file-name "sharedpath" hg-dir)) + (store (if (file-readable-p sharedpath) + (with-temp-buffer + (insert-file-contents sharedpath) + (string-trim (buffer-string))) + hg-dir))) + (list :repo (when (file-exists-p store) (file-truename store)) + :remote (projectile--normalize-repo-url + (projectile--hg-default-path root))))) + +(defun projectile--hg-default-path (root) + "Return the Mercurial `default' path configured for ROOT, or nil. +That's the upstream a repository was cloned from, so it plays the same +role as git\\='s `origin' remote. Read out of `.hg/hgrc' directly rather +than by running hg, which would cost a process launch per project." + (let ((hgrc (expand-file-name ".hg/hgrc" root))) + (when (file-readable-p hgrc) + (with-temp-buffer + (insert-file-contents hgrc) + (goto-char (point-min)) + (when (re-search-forward "^[ \t]*default[ \t]*=[ \t]*\\(.+?\\)[ \t]*$" nil t) + (match-string 1)))))) + +(defun projectile-repo-identity (&optional project-root) + "Return a plist identifying the repository PROJECT-ROOT is a checkout of. + +The plist has two keys, either of which may be nil: `:repo', the +directory every checkout of this very repository shares, and `:remote', +a canonical identity for the upstream it was cloned from. See +`projectile-same-repo-p' for comparing two of these. + +Returns nil for a project that isn't under a version control system +Projectile can answer this for (only git and Mercurial carry the notion), +and for a remote project, where every probe would be a TRAMP round trip. + +Results are cached in `projectile-repo-identity-cache' (cleared by +`projectile-invalidate-cache')." + (let ((root (or project-root (projectile-acquire-root)))) + (unless (file-remote-p root) + (let ((cached (gethash root projectile-repo-identity-cache 'unset))) + (if (not (eq cached 'unset)) + cached + (let ((identity (pcase (projectile-project-vcs root) + ('git (projectile--git-repo-identity root)) + ('hg (projectile--hg-repo-identity root))))) + ;; An identity with nothing in it says as little as no identity + ;; at all, and storing it as nil keeps the callers from having + ;; to test both. + (unless (or (plist-get identity :repo) (plist-get identity :remote)) + (setq identity nil)) + (puthash root identity projectile-repo-identity-cache) + identity)))))) + +(defun projectile-same-repo-p (a b) + "Return non-nil when identities A and B describe one repository. + +Either sharing the repository directory (worktrees of each other) or +sharing an upstream (clones of each other) is enough. Missing keys never +match, so two projects that Projectile knows nothing about aren't +silently declared identical." + (or (when-let* ((repo (plist-get a :repo))) + (equal repo (plist-get b :repo))) + (when-let* ((remote (plist-get a :remote))) + (equal remote (plist-get b :remote))))) + + +;;; Worktrees +;; +;; A worktree, in the sense this section means it, is another directory +;; holding the same repository: a real `git worktree', or simply a second +;; clone, which is how the same workflow gets done without the plumbing. +;; `projectile-switch-worktree' offers both, because from where the user +;; sits they're the same thing - the other place this project is checked +;; out, on another branch. +;; +;; Worktrees are found by `projectile-worktree-functions', which is a list +;; so that a version control system Projectile can enumerate directly +;; doesn't have to go through the generic fallback. Each entry takes a +;; project root and returns a list of plists with `:path' (mandatory), +;; `:branch' and `:prunable'. + +(defcustom projectile-worktree-functions + '(projectile-worktrees-from-git + projectile-worktrees-from-known-projects) + "Functions consulted by `projectile-project-worktrees'. + +Each is called with a project root and should return a list of plists, +one per checkout of that project\\='s repository, with the keys `:path' +(the checkout\\='s directory, mandatory), `:branch' (what it has checked +out, if that\\='s known) and `:prunable' (non-nil when the checkout is +registered but no longer on disk). Results from all the functions are +merged and de-duplicated by path, so a checkout found twice is listed +once, and a function that has nothing to say should return nil. + +The default pair covers git worktrees, which git enumerates itself, and +anything else via the known projects (see +`projectile-worktrees-from-known-projects')." + :group 'projectile + :type '(repeat function) + :package-version '(projectile . "3.4.0")) + +(defun projectile--parse-git-worktree-list (output) + "Parse the `git worktree list --porcelain' OUTPUT into worktree plists. + +Records are separated by blank lines and each opens with a `worktree' +line. Bare repositories are skipped: they have no working tree, so +there\\='s nothing there to switch to." + (delq nil + (mapcar + (lambda (record) + (let ((lines (split-string record "\n" t))) + (unless (member "bare" lines) + (let ((worktree (list :path (file-name-as-directory + (string-remove-prefix + "worktree " (car lines)))))) + ;; `HEAD' and `detached' say nothing a switch needs, and + ;; `locked' doesn't stop one, so they're all skipped here. + (dolist (line (cdr lines) worktree) + (cond + ((string-prefix-p "branch " line) + (plist-put worktree :branch + (string-remove-prefix + "refs/heads/" + (string-remove-prefix "branch " line)))) + ((string-prefix-p "prunable" line) + (plist-put worktree :prunable t)))))))) + (split-string output "\n\n" t)))) + +(defun projectile-worktrees-from-git (root) + "Return the git worktrees of the project at ROOT. + +Git registers them itself, so this finds worktrees that have never been +visited in this Emacs session - which the known projects can't do." + (when-let* (((eq (projectile-project-vcs root) 'git)) + ((not (file-remote-p root))) + (output (projectile--git root "worktree" "list" "--porcelain"))) + (projectile--parse-git-worktree-list output))) + +(defun projectile-worktrees-from-known-projects (root) + "Return the known projects that are checkouts of ROOT\\='s repository. + +This is how everything git can\\='t enumerate gets found: a Mercurial +working directory sharing another\\='s store, and - the case that turns up +far more often than the plumbing suggests - a second clone of the same +upstream, which is the same workflow done by hand. + +Only projects Projectile already knows about can be found this way, since +there\\='s nothing else to enumerate." + (when-let* ((identity (projectile-repo-identity root))) + (delq nil + (mapcar (lambda (project) + (let ((project (file-name-as-directory + (expand-file-name project)))) + (when (and (not (file-remote-p project)) + (file-directory-p project) + (not (projectile-ignored-project-p project)) + (projectile-same-repo-p + identity (projectile-repo-identity project))) + (list :path project + :branch (projectile--checkout-branch project))))) + (projectile-known-projects))))) + +(defun projectile--checkout-branch (root) + "Return the branch checked out at ROOT, or nil when that isn't knowable. + +Read out of the checkout's own files rather than by running the version +control system, since this is asked once per candidate checkout." + (pcase (projectile-project-vcs root) + ('git (when-let* ((git-dir (projectile--git-dir root))) + (projectile--git-head-branch git-dir))))) + +(defun projectile-project-worktrees (&optional project-root) + "Return the checkouts of PROJECT-ROOT\\='s repository, including itself. + +Every function in `projectile-worktree-functions' is consulted in turn and +their results merged, de-duplicated by resolved path so that a worktree +both git and the known projects report is listed once - the first +function to report it wins, which is why the one that knows the most +about a checkout should come first. The plists that come back carry +`:path', and `:branch'/`:prunable' when whoever found them knew." + (let ((root (or project-root (projectile-acquire-root))) + (seen (make-hash-table :test 'equal)) + worktrees) + (dolist (fn projectile-worktree-functions) + (dolist (worktree (condition-case err + (funcall fn root) + (error + (projectile--message "Worktree function %s failed: %s" + fn (error-message-string err)) + nil))) + (when-let* ((path (plist-get worktree :path)) + (key (file-truename path)) + ((not (gethash key seen)))) + (puthash key t seen) + (push worktree worktrees)))) + (nreverse worktrees))) + +(defun projectile--worktree-annotation (worktree) + "Return the completion annotation describing WORKTREE, or nil. +That's the branch it has checked out, which is the thing a path alone +doesn't say and the whole reason for picking one worktree over another." + (when-let* ((branch (plist-get worktree :branch))) + (format " (%s)" branch))) + +;;;###autoload +(defun projectile-switch-worktree (&optional arg) + "Switch to another checkout of the current project\\='s repository. + +That's the project\\='s git worktrees, plus any other clone of the same +upstream that Projectile already knows about - both are the same thing in +practice, the place this project is checked out on another branch. + +Invokes the command referenced by `projectile-switch-project-action' on +switch. With a prefix ARG invokes `projectile-dispatch' instead." + (interactive "P") + (let* ((root (projectile-acquire-root)) + (worktrees (seq-remove + (lambda (worktree) + ;; The checkout we're already in is not somewhere to + ;; switch to, and one that's been deleted from under + ;; its registration can't be switched to at all. + (or (plist-get worktree :prunable) + (file-equal-p (plist-get worktree :path) root))) + (projectile-project-worktrees root))) + ;; Offered in the spelling every other switch command uses, so a + ;; worktree looks the same here as in `projectile-switch-project'. + (by-path (mapcar (lambda (worktree) + (cons (projectile--known-project-root + (plist-get worktree :path)) + worktree)) + worktrees))) + (unless worktrees + (user-error "No other checkout of %s found" + (projectile-project-name root))) + (projectile-completing-read + "Switch to worktree: " (mapcar #'car by-path) + :action (lambda (path) + (projectile-switch-project-by-name path arg)) + :annotation-function (lambda (path) + (projectile--worktree-annotation + (cdr (assoc path by-path)))) + :category 'projectile-worktree))) + + ;;; Project bookmarks ;; ;; Project-scoped bookmarks on top of the built-in `bookmark.el'. There's @@ -15412,6 +15832,8 @@ Magit that don't trigger `find-file-hook'." (define-key map (kbd "w R") #'projectile-session-restore-all) (define-key map (kbd "w f") #'projectile-session-forget) (define-key map (kbd "w b") #'projectile-session-switch-to-buffer) + ;; other checkouts of the current project's repository + (define-key map (kbd "W") #'projectile-switch-worktree) ;; project lifecycle external commands (define-key map (kbd "c o") #'projectile-configure-project) (define-key map (kbd "c c") #'projectile-compile-project) @@ -15738,6 +16160,7 @@ search/replace case-sensitive, `--word' makes it match whole words, [["Project" ("p" "switch project" projectile-dispatch-switch-project) ("q" "switch open project" projectile-switch-open-project) + ("W" "switch worktree" projectile-switch-worktree) ("A" "add known project" projectile-add-known-project) ("v" "vc" projectile-vc) ("P" "dashboard" projectile-dashboard) @@ -16940,6 +17363,11 @@ existing tabs untouched." ;; offers project operations (switch, vc, dired, remove) instead of only ;; generic file actions. Marginalia keeps annotating those candidates via ;; the built-in file annotator (they are directory paths). +;; +;; The `projectile-worktree' category gets the same Embark actions but is +;; deliberately left out of the Marginalia registry: those candidates carry +;; their own `annotation-function' naming the branch each worktree has +;; checked out, and a registered annotator would take precedence over it. (defun projectile--embark-project-file-target (target) "Resolve a `project-file' TARGET to an absolute path under the Projectile root. @@ -17008,7 +17436,11 @@ Projectile again doesn't stack wrappers." (setf (alist-get 'project-file embark-transformer-alist) #'projectile--embark-project-file-transform))) (add-to-list 'embark-keymap-alist - '(projectile-project . projectile-embark-project-map))) + '(projectile-project . projectile-embark-project-map)) + ;; A worktree candidate is a project directory too, so the same actions + ;; apply to it. + (add-to-list 'embark-keymap-alist + '(projectile-worktree . projectile-embark-project-map))) (with-eval-after-load 'embark (projectile--embark-setup)) diff --git a/test/projectile-core-test.el b/test/projectile-core-test.el index 296b4c467..5a897ad73 100644 --- a/test/projectile-core-test.el +++ b/test/projectile-core-test.el @@ -380,6 +380,10 @@ (expect projectile--embark-project-file-prev-transform :to-be prev) (expect (alist-get 'projectile-project embark-keymap-alist) :to-be 'projectile-embark-project-map) + ;; Worktree candidates are project directories too, so they get the + ;; same actions. + (expect (alist-get 'projectile-worktree embark-keymap-alist) + :to-be 'projectile-embark-project-map) (expect (keymap-parent projectile-embark-project-map) :to-be embark-general-map))) @@ -390,9 +394,10 @@ (embark-keymap-alist nil) (projectile--embark-project-file-prev-transform nil)) (projectile--embark-setup) - (projectile--embark-setup) - (expect projectile--embark-project-file-prev-transform :to-be prev) - (expect (length embark-keymap-alist) :to-equal 1)))) + (let ((registered (length embark-keymap-alist))) + (projectile--embark-setup) + (expect projectile--embark-project-file-prev-transform :to-be prev) + (expect (length embark-keymap-alist) :to-equal registered))))) (describe "Marginalia integration" (it "registers the file annotator for project candidates" diff --git a/test/projectile-repo-identity-test.el b/test/projectile-repo-identity-test.el new file mode 100644 index 000000000..774442886 --- /dev/null +++ b/test/projectile-repo-identity-test.el @@ -0,0 +1,295 @@ +;;; projectile-repo-identity-test.el --- Tests for repository identity -*- lexical-binding: t -*- + +;; Copyright © 2011-2026 Bozhidar Batsov + +;; Author: Bozhidar Batsov + +;; This file is NOT part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or +;; modify it under the terms of the GNU General Public License as +;; published by the Free Software Foundation, either version 3 of the +;; License, or (at your option) any later version. +;; +;; This program is distributed in the hope that it will be useful, but +;; WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;; General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see `http://www.gnu.org/licenses/'. + +;;; Commentary: + +;; Tests for `projectile-repo-identity' - the notion that tells two +;; checkouts of one repository apart from two unrelated projects. + +;;; Code: + +(require 'projectile-test-helpers) + +;;; Remote URL normalization + +(describe "projectile--normalize-repo-path" + (it "strips trailing slashes" + (expect (projectile--normalize-repo-path "owner/repo/") :to-equal "owner/repo") + (expect (projectile--normalize-repo-path "owner/repo///") :to-equal "owner/repo")) + + (it "strips a .git suffix" + (expect (projectile--normalize-repo-path "owner/repo.git") :to-equal "owner/repo")) + + (it "strips a .git suffix hidden behind a trailing slash" + (expect (projectile--normalize-repo-path "owner/repo.git/") :to-equal "owner/repo")) + + (it "leaves a plain path alone" + (expect (projectile--normalize-repo-path "owner/repo") :to-equal "owner/repo"))) + +(describe "projectile--normalize-repo-url" + (it "collapses every spelling of one remote to the same identity" + (let ((expected "github.com/bbatsov/projectile")) + (dolist (url '("git@github.com:bbatsov/projectile.git" + "git@github.com:bbatsov/projectile" + "https://github.com/bbatsov/projectile.git" + "https://github.com/bbatsov/projectile" + "https://github.com/bbatsov/projectile/" + "https://user@github.com/bbatsov/projectile.git" + "ssh://git@github.com/bbatsov/projectile.git" + "ssh://git@github.com:22/bbatsov/projectile.git")) + (expect (projectile--normalize-repo-url url) :to-equal expected)))) + + (it "keeps distinct repositories distinct" + (expect (projectile--normalize-repo-url "git@github.com:bbatsov/projectile.git") + :not :to-equal + (projectile--normalize-repo-url "git@github.com:bbatsov/crux.git")) + (expect (projectile--normalize-repo-url "git@github.com:bbatsov/projectile.git") + :not :to-equal + (projectile--normalize-repo-url "git@gitlab.com:bbatsov/projectile.git"))) + + (it "downcases the identity so case differences still match" + (expect (projectile--normalize-repo-url "git@GitHub.com:BBatsov/Projectile.git") + :to-equal "github.com/bbatsov/projectile")) + + (it "handles subgroup paths" + (expect (projectile--normalize-repo-url "git@gitlab.com:group/sub/proj.git") + :to-equal "gitlab.com/group/sub/proj")) + + (it "reads a file:// URL as the local path it is" + (expect (projectile--normalize-repo-url "file:///srv/git/repo.git") + :to-equal "/srv/git/repo")) + + (it "reads a bare path as a local repository" + (expect (projectile--normalize-repo-url "/srv/git/repo.git") + :to-equal "/srv/git/repo")) + + (it "does not mistake a Windows drive letter for a host" + ;; A single-letter "host" is a drive, so this has to come back as a path + ;; rather than as `c/src/repo'. + (expect (projectile--normalize-repo-url "c:/src/repo.git") + :not :to-equal "c/src/repo")) + + (it "returns nil for no URL at all" + (expect (projectile--normalize-repo-url nil) :to-be nil) + (expect (projectile--normalize-repo-url "") :to-be nil) + (expect (projectile--normalize-repo-url " ") :to-be nil))) + + +;;; Reading git's own files + +(describe "projectile--git-dir" + (it "returns the .git directory of an ordinary checkout" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (expect (file-truename (projectile--git-dir repo)) + :to-equal (file-name-as-directory + (file-truename (expand-file-name ".git" repo))))))) + + (it "follows the gitdir: file a linked worktree gets instead" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (worktree (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature"))) + ;; A linked worktree's `.git' is a file, not a directory. + (expect (file-directory-p (expand-file-name ".git" worktree)) :to-be nil) + (expect (projectile--git-dir worktree) :to-be-truthy) + (expect (file-truename (projectile--git-dir worktree)) + :to-match "worktrees/feature/\\'")))) + + (it "returns nil for a directory that holds no .git at all" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (make-directory (expand-file-name "sub" repo) t) + (expect (projectile--git-dir + (file-name-as-directory (expand-file-name "sub" repo))) + :to-be nil))))) + +(describe "projectile--git-common-dir" + (it "resolves a linked worktree's git dir to the shared one" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (worktree (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature"))) + (expect (file-truename + (projectile--git-common-dir (projectile--git-dir worktree))) + :to-equal + (file-truename + (projectile--git-common-dir (projectile--git-dir repo))))))) + + (it "leaves the main checkout's git dir alone" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (git-dir (projectile--git-dir repo))) + (expect (projectile--git-common-dir git-dir) :to-equal git-dir))))) + +(describe "projectile--git-config-remote-url" + (it "prefers origin over any other remote" + (projectile-test-with-temp-files ((config)) + (with-temp-file config + (insert "[core]\n\trepositoryformatversion = 0\n" + "[remote \"upstream\"]\n\turl = git@github.com:up/stream.git\n" + "\tfetch = +refs/heads/*:refs/remotes/upstream/*\n" + "[remote \"origin\"]\n\turl = git@github.com:me/mine.git\n")) + (expect (projectile--git-config-remote-url config) + :to-equal "git@github.com:me/mine.git"))) + + (it "falls back to the first remote when there's no origin" + (projectile-test-with-temp-files ((config)) + (with-temp-file config + (insert "[remote \"upstream\"]\n\turl = git@github.com:up/stream.git\n" + "[remote \"fork\"]\n\turl = git@github.com:me/fork.git\n")) + (expect (projectile--git-config-remote-url config) + :to-equal "git@github.com:up/stream.git"))) + + (it "returns nil when no remote is configured" + (projectile-test-with-temp-files ((config)) + (with-temp-file config (insert "[core]\n\tbare = false\n")) + (expect (projectile--git-config-remote-url config) :to-be nil))) + + (it "returns nil for a file that isn't there" + (expect (projectile--git-config-remote-url "/nope/config") :to-be nil))) + +(describe "projectile--git-head-branch" + (it "reads the branch out of HEAD" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (expect (projectile--git-head-branch + (projectile--git-dir + (file-name-as-directory (expand-file-name "feature")))) + :to-equal "feature")))) + + (it "returns nil for a detached HEAD" + (projectile-test-with-temp-files ((git-dir :dir)) + (with-temp-file (expand-file-name "HEAD" git-dir) + (insert "9cd1a2b0e5f3d4c6a7b8e9f0a1b2c3d4e5f6a7b8\n")) + (expect (projectile--git-head-branch git-dir) :to-be nil)))) + + +;;; Repository identity + +(describe "projectile-repo-identity" + (it "reports the common dir and the normalized remote of a git project" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo + "repo" "git@github.com:bbatsov/projectile.git")) + (identity (projectile-repo-identity repo))) + (expect (plist-get identity :repo) + :to-equal (file-name-as-directory + (file-truename (expand-file-name ".git" repo)))) + (expect (plist-get identity :remote) + :to-equal "github.com/bbatsov/projectile")))) + + (it "leaves the remote nil when the repository has none" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (identity (projectile-repo-identity repo))) + (expect (plist-get identity :remote) :to-be nil) + (expect (plist-get identity :repo) :to-be-truthy)))) + + (it "falls back to the first remote when there's no origin" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (let ((default-directory repo)) + (projectile-test-git + "remote" "add" "upstream" "git@github.com:bbatsov/crux.git")) + (expect (plist-get (projectile-repo-identity repo) :remote) + :to-equal "github.com/bbatsov/crux")))) + + (it "gives a worktree the same repo as the checkout it was linked from" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (expect (plist-get (projectile-repo-identity repo) :repo) + :to-equal + (plist-get (projectile-repo-identity + (file-name-as-directory (expand-file-name "feature"))) + :repo))))) + + (it "gives two clones of one upstream the same remote but different repos" + (projectile-test-with-sandbox + (let* ((remote "git@github.com:bbatsov/projectile.git") + (one (projectile-test-init-git-repo "one" remote)) + (two (projectile-test-init-git-repo "two" remote))) + (expect (plist-get (projectile-repo-identity one) :remote) + :to-equal (plist-get (projectile-repo-identity two) :remote)) + (expect (plist-get (projectile-repo-identity one) :repo) + :not :to-equal (plist-get (projectile-repo-identity two) :repo))))) + + (it "returns nil for a project that isn't under version control" + (spy-on 'projectile-project-vcs :and-return-value 'none) + (expect (projectile-repo-identity "/src/plain/") :to-be nil)) + + (it "returns nil for a project sitting below a repository's root" + ;; `projectile-project-vcs' answers `git' here - the enclosing repository + ;; is found by walking up - but a directory inside a checkout is not a + ;; checkout of its own, so it has no identity and no worktrees. + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (make-directory (expand-file-name "sub" repo) t) + (expect (projectile-repo-identity + (file-name-as-directory (expand-file-name "sub" repo))) + :to-be nil)))) + + (it "returns nil for a remote project rather than reaching over TRAMP" + (spy-on 'projectile-project-vcs) + (expect (projectile-repo-identity "/ssh:host:/src/repo/") :to-be nil) + (expect 'projectile-project-vcs :not :to-have-been-called)) + + (it "caches its answer per root" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-repo-identity repo) + (spy-on 'projectile-project-vcs) + (projectile-repo-identity repo) + (expect 'projectile-project-vcs :not :to-have-been-called))))) + +(describe "projectile-same-repo-p" + (it "matches identities sharing a repo directory" + (expect (projectile-same-repo-p '(:repo "/src/.git" :remote nil) + '(:repo "/src/.git" :remote nil)) + :to-be-truthy)) + + (it "matches identities sharing a remote" + (expect (projectile-same-repo-p '(:repo "/one/.git" :remote "host/o/r") + '(:repo "/two/.git" :remote "host/o/r")) + :to-be-truthy)) + + (it "does not match unrelated identities" + (expect (projectile-same-repo-p '(:repo "/one/.git" :remote "host/o/one") + '(:repo "/two/.git" :remote "host/o/two")) + :to-be nil)) + + (it "does not treat two unknown identities as the same" + (expect (projectile-same-repo-p '(:repo nil :remote nil) + '(:repo nil :remote nil)) + :to-be nil)) + + (it "does not match when either identity is missing" + (expect (projectile-same-repo-p nil '(:repo "/src/.git")) :to-be nil) + (expect (projectile-same-repo-p '(:repo "/src/.git") nil) :to-be nil))) + + +(provide (quote projectile-repo-identity-test)) + +;;; projectile-repo-identity-test.el ends here diff --git a/test/projectile-test-helpers.el b/test/projectile-test-helpers.el index 50acc9584..9d1ccdd10 100644 --- a/test/projectile-test-helpers.el +++ b/test/projectile-test-helpers.el @@ -380,6 +380,32 @@ specs' smart-case settings) can wrap BODY in its own `let'." (progn ,@body) (projectile-test-kill-project-buffers default-directory))))) +;;; Git sandbox helpers + +(defun projectile-test-git (&rest args) + "Run git with ARGS in `default-directory', discarding its output." + (apply #'call-process "git" nil nil nil args)) + +(defun projectile-test-init-git-repo (dir &optional remote) + "Create a git repository in DIR with a single empty commit. +REMOTE, when non-nil, is added as the `origin' remote. Returns DIR as a +directory name." + (make-directory dir t) + (let ((default-directory (file-name-as-directory (expand-file-name dir)))) + (projectile-test-git "init" "-q") + (projectile-test-git "config" "user.email" "test@test.com") + (projectile-test-git "config" "user.name" "Test") + (projectile-test-git "commit" "-q" "--allow-empty" "-m" "init") + (when remote + (projectile-test-git "remote" "add" "origin" remote)) + default-directory)) + +(defun projectile-test-add-git-worktree (repo path branch) + "Add a worktree of REPO at PATH, checked out on a new BRANCH." + (let ((default-directory repo)) + (projectile-test-git "worktree" "add" "-q" path "-b" branch)) + (file-name-as-directory (expand-file-name path))) + (defun file-handler-for-tests (operation &rest args) "Handler for # files. Just delegates OPERATION and ARGS for all operations except for diff --git a/test/projectile-worktree-test.el b/test/projectile-worktree-test.el new file mode 100644 index 000000000..b8137d0dd --- /dev/null +++ b/test/projectile-worktree-test.el @@ -0,0 +1,264 @@ +;;; projectile-worktree-test.el --- Tests for repository identity and worktrees -*- lexical-binding: t -*- + +;; Copyright © 2011-2026 Bozhidar Batsov + +;; Author: Bozhidar Batsov + +;; This file is NOT part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or +;; modify it under the terms of the GNU General Public License as +;; published by the Free Software Foundation, either version 3 of the +;; License, or (at your option) any later version. +;; +;; This program is distributed in the hope that it will be useful, but +;; WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;; General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see `http://www.gnu.org/licenses/'. + +;;; Commentary: + +;; Tests for `projectile-switch-worktree' and the functions that find the +;; other checkouts of a project's repository. + +;;; Code: + +(require 'projectile-test-helpers) + +;;; Parsing git's worktree listing + +(describe "projectile--parse-git-worktree-list" + (it "parses the path and branch of each worktree" + (let ((worktrees (projectile--parse-git-worktree-list + (concat "worktree /src/main\nHEAD abc\nbranch refs/heads/master\n\n" + "worktree /src/feature\nHEAD abc\nbranch refs/heads/feature\n\n")))) + (expect (length worktrees) :to-equal 2) + (expect (plist-get (nth 0 worktrees) :path) :to-equal "/src/main/") + (expect (plist-get (nth 0 worktrees) :branch) :to-equal "master") + (expect (plist-get (nth 1 worktrees) :path) :to-equal "/src/feature/") + (expect (plist-get (nth 1 worktrees) :branch) :to-equal "feature"))) + + (it "leaves a detached worktree without a branch" + (let ((worktrees (projectile--parse-git-worktree-list + "worktree /src/detached\nHEAD abc\ndetached\n\n"))) + (expect (length worktrees) :to-equal 1) + (expect (plist-get (car worktrees) :branch) :to-be nil))) + + (it "skips a bare repository, which has no working tree to switch to" + (let ((worktrees (projectile--parse-git-worktree-list + (concat "worktree /src/bare.git\nbare\n\n" + "worktree /src/feature\nHEAD abc\nbranch refs/heads/feature\n\n")))) + (expect (length worktrees) :to-equal 1) + (expect (plist-get (car worktrees) :path) :to-equal "/src/feature/"))) + + (it "flags a prunable worktree" + (let ((worktrees (projectile--parse-git-worktree-list + "worktree /src/gone\nHEAD abc\nbranch refs/heads/gone\nprunable gitdir file points to non-existent location\n\n"))) + (expect (plist-get (car worktrees) :prunable) :to-be-truthy))) + + (it "is not confused by a locked worktree" + (let ((worktrees (projectile--parse-git-worktree-list + "worktree /src/locked\nHEAD abc\nbranch refs/heads/locked\nlocked\n\n"))) + (expect (length worktrees) :to-equal 1) + (expect (plist-get (car worktrees) :branch) :to-equal "locked"))) + + (it "parses a final record that isn't followed by a blank line" + (let ((worktrees (projectile--parse-git-worktree-list + "worktree /src/main\nHEAD abc\nbranch refs/heads/master"))) + (expect (length worktrees) :to-equal 1) + (expect (plist-get (car worktrees) :branch) :to-equal "master"))) + + (it "returns nothing for empty output" + (expect (projectile--parse-git-worktree-list "") :to-be nil))) + + +;;; Finding worktrees + +(describe "projectile-worktrees-from-git" + (it "lists the main checkout and its linked worktrees" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let ((paths (mapcar (lambda (w) (file-truename (plist-get w :path))) + (projectile-worktrees-from-git repo)))) + (expect paths :to-have-same-items-as + (list (file-truename repo) + (file-truename (file-name-as-directory + (expand-file-name "feature"))))))))) + + (it "reports the branch each worktree has checked out" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let* ((worktrees (projectile-worktrees-from-git repo)) + (feature (seq-find (lambda (w) + (string-match-p "feature" (plist-get w :path))) + worktrees))) + (expect (plist-get feature :branch) :to-equal "feature"))))) + + (it "returns nothing for a project that isn't under git" + (spy-on 'projectile-project-vcs :and-return-value 'hg) + (expect (projectile-worktrees-from-git "/src/repo/") :to-be nil))) + +(describe "projectile-worktrees-from-known-projects" + (it "finds another clone of the same upstream" + (projectile-test-with-sandbox + (let* ((remote "git@github.com:bbatsov/projectile.git") + (one (projectile-test-init-git-repo "one" remote)) + (two (projectile-test-init-git-repo "two" remote)) + (projectile-known-projects (list one two))) + (expect (mapcar (lambda (w) (file-truename (plist-get w :path))) + (projectile-worktrees-from-known-projects one)) + :to-have-same-items-as + (list (file-truename one) (file-truename two)))))) + + (it "reports the branch a sibling clone has checked out" + (projectile-test-with-sandbox + (let* ((remote "git@github.com:bbatsov/projectile.git") + (one (projectile-test-init-git-repo "one" remote)) + (two (projectile-test-init-git-repo "two" remote)) + (projectile-known-projects (list one two))) + (let ((default-directory two)) + (projectile-test-git "checkout" "-q" "-b" "topic")) + (let ((found (seq-find (lambda (w) + (string-match-p "two" (plist-get w :path))) + (projectile-worktrees-from-known-projects one)))) + (expect (plist-get found :branch) :to-equal "topic"))))) + + (it "does not launch a git process per known project" + ;; The scan runs over every known project, so it reads git's own files + ;; rather than shelling out - see `projectile--git-dir'. + (projectile-test-with-sandbox + (let* ((remote "git@github.com:bbatsov/projectile.git") + (one (projectile-test-init-git-repo "one" remote)) + (two (projectile-test-init-git-repo "two" remote)) + (projectile-known-projects (list one two))) + (spy-on 'projectile--git) + (expect (length (projectile-worktrees-from-known-projects one)) + :to-equal 2) + (expect 'projectile--git :not :to-have-been-called)))) + + (it "leaves out a project that is a different repository" + (projectile-test-with-sandbox + (let* ((one (projectile-test-init-git-repo + "one" "git@github.com:bbatsov/projectile.git")) + (other (projectile-test-init-git-repo + "other" "git@github.com:bbatsov/crux.git")) + (projectile-known-projects (list one other))) + (expect (mapcar (lambda (w) (file-truename (plist-get w :path))) + (projectile-worktrees-from-known-projects one)) + :to-equal (list (file-truename one)))))) + + (it "returns nothing when the project has no identity to match on" + (spy-on 'projectile-repo-identity) + (expect (projectile-worktrees-from-known-projects "/src/plain/") :to-be nil))) + +(describe "projectile-project-worktrees" + (it "lists a worktree once even when several functions report it" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let* ((feature (file-name-as-directory (expand-file-name "feature"))) + (projectile-known-projects (list repo feature)) + (worktrees (projectile-project-worktrees repo))) + (expect (length worktrees) :to-equal 2))))) + + (it "keeps the branch git knew about when merging duplicate reports" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let* ((feature (file-name-as-directory (expand-file-name "feature"))) + (projectile-known-projects (list repo feature)) + (found (seq-find (lambda (w) + (string-match-p "feature" (plist-get w :path))) + (projectile-project-worktrees repo)))) + (expect (plist-get found :branch) :to-equal "feature"))))) + + (it "survives a worktree function that throws" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (projectile-worktree-functions + (list (lambda (_root) (error "boom")) + (lambda (_root) (list (list :path "/src/other/")))))) + (expect (mapcar (lambda (w) (plist-get w :path)) + (projectile-project-worktrees repo)) + :to-equal '("/src/other/")))))) + + +;;; Switching + +(describe "projectile-switch-worktree" + (it "errors when the project has no other checkout" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (projectile-known-projects (list repo))) + (spy-on 'projectile-acquire-root :and-return-value repo) + (expect (projectile-switch-worktree) :to-throw 'user-error)))) + + (it "switches to the chosen worktree" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let ((projectile-known-projects (list repo))) + (spy-on 'projectile-acquire-root :and-return-value repo) + (spy-on 'projectile-switch-project-by-name) + (spy-on 'projectile-completing-read :and-call-fake + (lambda (_prompt choices &rest args) + (funcall (plist-get args :action) (car choices)))) + (projectile-switch-worktree) + (expect 'projectile-switch-project-by-name :to-have-been-called) + (expect (file-name-as-directory + (file-truename + (car (spy-calls-args-for 'projectile-switch-project-by-name 0)))) + :to-equal + (file-name-as-directory + (file-truename (expand-file-name "feature")))))))) + + (it "does not offer the checkout we're already in" + (projectile-test-with-sandbox + (let ((repo (projectile-test-init-git-repo "repo"))) + (projectile-test-add-git-worktree + repo (expand-file-name "feature") "feature") + (let ((projectile-known-projects (list repo)) + (offered nil)) + (spy-on 'projectile-acquire-root :and-return-value repo) + (spy-on 'projectile-completing-read :and-call-fake + (lambda (_prompt choices &rest _args) + (setq offered choices) + nil)) + (projectile-switch-worktree) + (expect (mapcar #'file-truename offered) + :to-equal + (list (file-truename (file-name-as-directory + (expand-file-name "feature"))))))))) + + (it "does not offer a worktree that is no longer on disk" + (projectile-test-with-sandbox + (let* ((repo (projectile-test-init-git-repo "repo")) + (projectile-worktree-functions + (list (lambda (_root) + (list (list :path "/src/gone/" :prunable t) + (list :path "/src/here/" :branch "here")))))) + (spy-on 'projectile-acquire-root :and-return-value repo) + (spy-on 'projectile-completing-read :and-call-fake + (lambda (_prompt choices &rest _args) (car choices))) + (projectile-switch-worktree) + (expect (spy-calls-args-for 'projectile-completing-read 0) + :to-contain '("/src/here/"))))) + + (it "annotates candidates with their branch" + (expect (projectile--worktree-annotation '(:path "/src/x/" :branch "feature")) + :to-equal " (feature)") + (expect (projectile--worktree-annotation '(:path "/src/x/")) :to-be nil))) + +(provide 'projectile-worktree-test) + +;;; projectile-worktree-test.el ends here