James Dyer: Git Worktreess Without Leaving Built-in VC
More vc stuff, and this week's rabbit hole was git worktrees (Bozhidar Batsov's Working with Git Worktrees in Magit is what sent me down this particular rabbit hole!). It was an itch that manifested itself while reading this blog and mainly seeing that worktrees seemed to make a good deal of sense to me (especially as I am quite a subversion veteran). I have always felt uncomfortable with the default git in-situ branch switching and prefer to branch develop away from the mainline in a physical separate directory and this is just what worktrees facilitate. So how to do this with built-in vc-mode tools?, well as always this requires a little work and yes, I could use Magit, but I'm seeing how far I can push the built in vc.
The funny thing is that vc already understands worktrees perfectly well: a worktree is just a directory holding a .git pointer file instead of a .git folder, vc-git resolves that transparently, and diffs, commits and vc-dir all quietly do the right thing per worktree. What vc cannot do is actually seemingly manage them. There is no command to create one, switch between them, move one, list them, or throw one away, and the branch side is nearly as thin. Magit does all of this beautifully, of course - but I am stubborn about staying inside core Emacs where I can, so I spent an evening teaching vc-dir some new tricks. And yes, I do understand that vc in Emacs is in fact a generic abstraction and works well across all different types of version control, hence the reduction to a core subset of general source code commands, with a few extra added on of course for those obvious missing pieces, but I want to add the less obvious pieces, and certainly for git, worktrees is probably going to be one of them. Right lets get on with this and write some code!
Everything funnels through one small worker that shells out via vc-git-command and then drops you straight into vc-dir in the new tree:
Here is what I implemented:
;; ;; -> vc-git-worktree-core ;; (defun my/vc-git-branches () "Return a list of local branch names in the current git repository." (let ((root (vc-git-root default-directory))) (when root (with-temp-buffer (let ((default-directory root)) (vc-git-command (current-buffer) 0 nil "branch" "--format=%(refname:short)")) (split-string (buffer-string) "\n" t)))))
(defun my/vc-git--worktree-root () "Return the git worktree root for `default-directory'. Signal a `user-error' when not inside a Git repository." (or (vc-git-root default-directory) (user-error "Not in a Git repository")))
(defun my/vc-git-worktree-entries () "Return the worktrees of the current repository as a list of plists. Each plist has keys :path, :head, :branch, :detached, :bare, :locked and :prunable, parsed from `git worktree list --porcelain'." (let ((root (my/vc-git--worktree-root)) entries current) (with-temp-buffer (let ((default-directory root)) (vc-git-command (current-buffer) 0 nil "worktree" "list" "--porcelain")) (goto-char (point-min)) (while (not (eobp)) (let ((line (buffer-substring-no-properties (line-beginning-position) (line-end-position)))) (cond ((string-prefix-p "worktree " line) (when current (push current entries)) (setq current (list :path (substring line 9)))) ((string-prefix-p "HEAD " line) (setq current (plist-put current :head (substring line 5)))) ((string-prefix-p "branch " line) (let ((ref (substring line 7))) (setq current (plist-put current :branch (if (string-prefix-p "refs/heads/" ref) (substring ref 11) ref))))) ((string= line "detached") (setq current (plist-put current :detached t))) ((string= line "bare") (setq current (plist-put current :bare t))) ((string-prefix-p "locked" line) (setq current (plist-put current :locked (if (> (length line) 6) (substring line 7) t)))) ((string-prefix-p "prunable" line) (setq current (plist-put current :prunable (if (> (length line) 8) (substring line 9) t)))))) (forward-line 1))) (when current (push current entries)) (nreverse entries)))
(defun my/vc-git--default-worktree-path (root branch) "Return a default new-worktree path for BRANCH next to ROOT. The default is a sibling directory of ROOT prefixed with the project name, e.g. <parent>/myproj-feature for branch feature of myproj. Slashes in BRANCH become dashes so hierarchical branches cannot collide; an empty BRANCH falls back to <project>-worktree." (let ((project (file-name-nondirectory (directory-file-name (expand-file-name root))))) (expand-file-name (concat project "-" (if (string-empty-p branch) "worktree" (replace-regexp-in-string "/" "-" branch))) (file-name-directory (directory-file-name (expand-file-name root))))))
(defun my/vc-git-worktree-add (path branch &optional new-branch start-point) "Create a Git worktree at PATH checking out BRANCH, then open `vc-dir'. An empty BRANCH checks out HEAD. With prefix argument NEW-BRANCH, create BRANCH as a new branch from START-POINT (defaulting to HEAD) instead of checking out an existing branch or revision." (interactive (let* ((root (my/vc-git--worktree-root)) (branches (or (my/vc-git-branches) '())) (make-new (if current-prefix-arg t nil)) (branch (if make-new (read-string "New branch name: ") (completing-read "Checkout branch (empty = HEAD): " branches nil nil))) (default-path (my/vc-git--default-worktree-path root branch)) (path (read-file-name "Worktree location: " (file-name-directory default-path) nil nil (file-name-nondirectory default-path))) (start (and make-new (completing-read "Start point: " (delete-dups (append (list "HEAD") branches (my/vc-git-tags))) nil nil nil nil "HEAD")))) (list (expand-file-name path) branch make-new start))) (my/vc-git-worktree--create path branch new-branch start-point))
(defun my/vc-git-worktree--create (path branch new-branch start-point) "Create a Git worktree at PATH and open `vc-dir' there. BRANCH names an existing branch or revision, unless NEW-BRANCH is non-nil, in which case BRANCH is created from START-POINT first." (let ((root (my/vc-git--worktree-root)) (expanded (expand-file-name path))) (let ((default-directory root)) (cond (new-branch (when (string-empty-p branch) (user-error "New branch name must not be empty")) (vc-git-command nil 0 nil "worktree" "add" "-b" branch expanded (if (string-empty-p start-point) "HEAD" start-point))) ((string-empty-p branch) (vc-git-command nil 0 nil "worktree" "add" expanded)) (t (vc-git-command nil 0 nil "worktree" "add" expanded branch)))) (message "Created worktree %s" expanded) (vc-dir expanded)))
(defun my/vc-git-worktree-checkout (path branch) "Check out existing BRANCH in a new worktree at PATH, then open `vc-dir'. The Magit-style `magit-worktree-checkout' first action: the location defaults to a sibling directory named <project>-<branch>." (interactive (let* ((root (my/vc-git--worktree-root)) (branches (or (my/vc-git-branches) '())) (branch (completing-read "Checkout branch in new worktree: " branches nil nil)) (default-path (my/vc-git--default-worktree-path root branch)) (path (read-file-name "Worktree location: " (file-name-directory default-path) nil nil (file-name-nondirectory default-path)))) (list (expand-file-name path) branch))) (when (string-empty-p branch) (user-error "Branch must not be empty")) (my/vc-git-worktree--create path branch nil nil))
(defun my/vc-git-worktree-branch (path branch start-point) "Create new BRANCH from START-POINT in a new worktree at PATH. Then open `vc-dir'. The Magit-style `magit-worktree-branch' first action: the location defaults to a sibling directory named <project>-<branch>." (interactive (let* ((root (my/vc-git--worktree-root)) (branches (or (my/vc-git-branches) '())) (branch (read-string "New branch name: ")) (start (completing-read "Start point: " (delete-dups (append (list "HEAD") branches (my/vc-git-tags))) nil nil nil nil "HEAD")) (default-path (my/vc-git--default-worktree-path root branch)) (path (read-file-name "Worktree location: " (file-name-directory default-path) nil nil (file-name-nondirectory default-path)))) (list (expand-file-name path) branch start))) (my/vc-git-worktree--create path branch t start-point))
(defun my/vc-git-worktree-move (path new-path) "Move the Git worktree at PATH to NEW-PATH (`git worktree move'). Interactively, prompt for a worktree, defaulting to the current one." (interactive (let* ((entries (my/vc-git-worktree-entries)) (paths (mapcar (lambda (e) (plist-get e :path)) entries)) (current (my/vc-git--worktree-root))) (unless paths (user-error "No Git worktrees found")) (let ((path (completing-read "Move worktree: " paths nil t nil nil current))) (list path (read-file-name "Move to: " (file-name-directory (directory-file-name (expand-file-name path))) nil nil))))) (let* ((root (my/vc-git--worktree-root)) (expanded (expand-file-name path)) (target (expand-file-name new-path))) (let ((default-directory root)) (vc-git-command nil 0 nil "worktree" "move" expanded target)) (message "Moved worktree %s to %s" expanded target) (when (derived-mode-p 'vc-dir-mode) (vc-dir-refresh))))
(defun my/vc-git-worktree-switch (path) "Interactively select an existing Git worktree and open `vc-dir' in it." (interactive (let* ((entries (my/vc-git-worktree-entries)) (table (mapcar (lambda (e) (cons (plist-get e :path) e)) entries)) (current (my/vc-git--worktree-root))) (unless table (user-error "No Git worktrees found")) (let ((completion-extra-properties (list :annotation-function (lambda (cand) (let ((e (cdr (assoc cand table)))) (when e (concat " [" (or (plist-get e :branch) (and (plist-get e :detached) "detached") "?") (when (plist-get e :bare) ", bare") "]"))))))) (list (completing-read "Switch to worktree: " table nil t nil nil current))))) (vc-dir path))
(defun my/vc-git-worktree-remove (path &optional force) "Remove the Git worktree at PATH, prompting first for confirmation. With prefix argument FORCE, pass --force to git. Interactively, prompt for a worktree, defaulting to the current one." (interactive (let* ((entries (my/vc-git-worktree-entries)) (paths (mapcar (lambda (e) (plist-get e :path)) entries)) (current (my/vc-git--worktree-root))) (unless paths (user-error "No Git worktrees found")) (list (completing-read "Remove worktree: " paths nil t nil nil current) (if current-prefix-arg t nil)))) (let* ((root (my/vc-git--worktree-root)) (expanded (expand-file-name path)) (main-root (plist-get (car (my/vc-git-worktree-entries)) :path)) (here (file-truename default-directory)) (gone (file-truename expanded))) (when (yes-or-no-p (format "Remove worktree %s%s? " expanded (if force " (forced)" ""))) (let ((default-directory root)) (if force (vc-git-command nil 0 nil "worktree" "remove" "--force" expanded) (vc-git-command nil 0 nil "worktree" "remove" expanded))) (message "Removed worktree %s" expanded) (when (derived-mode-p 'vc-dir-mode) (if (string-prefix-p gone here) (vc-dir (or main-root root)) (vc-dir-refresh))))))
(defun my/vc-git-worktree-prune () "Prune stale Git worktree metadata (`git worktree prune')." (interactive) (let ((root (my/vc-git--worktree-root))) (let ((default-directory root)) (vc-git-command nil 0 nil "worktree" "prune")) (message "Pruned worktrees in %s" root) (when (derived-mode-p 'vc-dir-mode) (vc-dir-refresh))))
(defvar-local my/vc-git-worktree-list--root nil "Repository root shown in the current worktree list buffer.")
(defvar my/vc-git-worktree-list-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "RET") #'my/vc-git-worktree-list-visit) (define-key map (kbd "g") #'my/vc-git-worktree-list) (define-key map (kbd "q") #'quit-window) map) "Keymap for `my/vc-git-worktree-list-mode'.")
(define-derived-mode my/vc-git-worktree-list-mode special-mode "Worktrees" "Major mode for listing Git worktrees. \\{my/vc-git-worktree-list-mode-map}")
(defun my/vc-git-worktree-list-visit () "Open `vc-dir' for the worktree on the current line." (interactive) (let* ((here (line-number-at-pos)) (btn (or (button-at (point)) (save-excursion (beginning-of-line) (next-button (point) t)))) (ok (and btn (= here (line-number-at-pos (button-start btn)))))) (if ok (vc-dir (button-label btn)) (user-error "No worktree on this line"))))
(defun my/vc-git-worktree-list () "List Git worktrees of the current repository in a dedicated buffer. RET on a path opens `vc-dir' there; `g' refreshes the list, `q' quits." (interactive) (let* ((root (if (derived-mode-p 'my/vc-git-worktree-list-mode) (or my/vc-git-worktree-list--root (my/vc-git--worktree-root)) (my/vc-git--worktree-root))) (default-directory root) (entries (my/vc-git-worktree-entries))) (unless entries (user-error "No Git worktrees found")) (with-current-buffer (get-buffer-create "*vc-git worktrees*") (let ((inhibit-read-only t)) (erase-buffer) (my/vc-git-worktree-list-mode) (setq my/vc-git-worktree-list--root root) (setq default-directory root) (insert (format "Worktrees for %s\n\n" root)) (dolist (e entries) (let* ((path (plist-get e :path)) (head (or (plist-get e :head) "")) (short (if (> (length head) 7) (substring head 0 7) head)) (flags (string-join (delq nil (list (when (plist-get e :bare) "bare") (when (plist-get e :detached) "detached") (when (plist-get e :locked) "locked") (when (plist-get e :prunable) "prunable"))) ","))) (insert-button path 'action (lambda (btn) (vc-dir (button-label btn))) 'follow-link t) (insert (format " [%s] %s%s\n" (or (plist-get e :branch) "HEAD") short (if (string-empty-p flags) "" (concat " (" flags ")"))))))) (goto-char (point-min)) (pop-to-buffer (current-buffer)))))
(with-eval-after-load 'vc-dir (define-key vc-dir-mode-map (kbd "Z a") #'my/vc-git-worktree-add) (define-key vc-dir-mode-map (kbd "Z b") #'my/vc-git-worktree-checkout) (define-key vc-dir-mode-map (kbd "Z c") #'my/vc-git-worktree-branch) (define-key vc-dir-mode-map (kbd "Z g") #'my/vc-git-worktree-switch) (define-key vc-dir-mode-map (kbd "Z k") #'my/vc-git-worktree-remove) (define-key vc-dir-mode-map (kbd "Z l") #'my/vc-git-worktree-list) (define-key vc-dir-mode-map (kbd "Z m") #'my/vc-git-worktree-move) (define-key vc-dir-mode-map (kbd "Z p") #'my/vc-git-worktree-prune))
Some interactive commands: Z a for the general case, Z b for checking out an existing branch, Z c for a new branch from a start point, plus Z m to move a tree, Z g to switch between them with branch annotations in the completion, Z k to remove one (prefix forces, and a vc-dir sitting inside the removed tree falls back to the main root rather than dying), Z p to prune stale metadata, and Z l for a listing buffer where RET jumps to vc-dir. The letters deliberately mirror Magit's own worktree map, so Z b, Z c, Z g, Z m and Z k all do what a Magit user's fingers expect; Z a, Z p and Z l are extras with no Magit equivalent. The same set hangs off C-x v z … globally.
So the whole loop, start to finish, never leaves vc: Z c to spin up the worktree, hack away, C-x v v to commit, Z g back to main, merge the branch, and Z k to remove the worktree.
If any of this sounds useful, the pattern to steal is a small one: vc-git-command with default-directory bound to the worktree root does almost all of the heavy lifting, and vc-dir on the resulting path gives you the status buffer for free. The rest is just prompts and keybindings.
-1:-- Git Worktreess Without Leaving Built-in VC (Post James Dyer)--L0--C0--2026-09-08T09:31:34.007Z









































