├── .dir-locals.el ├── .gitignore ├── README.md └── multi-magit.el /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((emacs-lisp-mode 2 | (indent-tabs-mode . nil))) 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.elc 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multi-magit 2 | 3 | A set of extensions to [Magit](https://magit.vc) for handling multiple 4 | repositories simultaneously. This documentation will only make sense 5 | if you're familiar with Magit. 6 | 7 | 8 | ## Browsing Multiple Repositories 9 | 10 | #### [custom variable] multi-magit-selected-repositories 11 | 12 | This list determines which repositories the various multi-magit should 13 | operate on. You can easily select and unselect repositories using 14 | `multi-magit-list-repositories`. 15 | 16 | #### [command] multi-magit-list-repositories 17 | 18 | Similar to `magit-list-repositories` but lets you select/unselect 19 | repositories using RET. 20 | 21 | The format of this listing is controlled via 22 | `multi-magit-repolist-columns` custom variable, which has the same 23 | format as `magit-repolist-columns`. Use `magit-repository-directories` 24 | and `magit-repository-directories-depth` to control which repositories 25 | will be listed. 26 | 27 | #### [command] multi-magit-list-branches 28 | 29 | List all branches in all of your repositories, grouping branches with 30 | the same name. RET will select the applicable repositories 31 | and `multi-magit-checkout` the branch at point. C-k deletes 32 | the branch at point in the applicable repositories. 33 | 34 | Use `magit-repository-directories` and 35 | `magit-repository-directories-depth` to control which repositories 36 | will be listed. 37 | 38 | 39 | #### [command] multi-magit-status 40 | 41 | Like `magit-status` but aggregates all of the 42 | `multi-magit-selected-repositories`. 43 | 44 | We recommend binding it globally to C-x G: 45 | 46 | ```elisp 47 | (global-set-key (kbd "C-x G") 'multi-magit-status) 48 | ``` 49 | 50 | `multi-magit-status-sections-hook` determines which sections will be 51 | inserted for each repo. It accepts the same sections as 52 | `magit-status-sections-hook` but defaults to lightweight sections 53 | focused on giving you a quick overview of each repository. 54 | 55 | #### [custom variable] multi-magit-refresh-status-buffer 56 | 57 | Whether the multi-magit-status buffer is refreshed after running git. 58 | 59 | When this variable and `magit-refresh-status-buffer` are both non-nil, 60 | multi-magit's status buffer is automatically refreshed after running 61 | git for side-effects on a selected repository. 62 | 63 | 64 | ## Multi-repository Commands 65 | 66 | #### [command] multi-magit-checkout 67 | 68 | Checkout a given branch on each of the selected repositories. Lists 69 | suggestions based on branch names that are common across every 70 | repository. 71 | 72 | #### [command] multi-magit-branch-delete 73 | 74 | Delete a given branch on each of the selected repositories. Lists 75 | suggestions based on branch names that are common across every 76 | repository. 77 | 78 | #### [command] multi-magit-git-command 79 | 80 | Execute a git command for each selected repository. 81 | 82 | #### [command] multi-magit-shell-command 83 | 84 | Execute a shell command for each selected repository. 85 | 86 | 87 | ## Repository Overview for `magit-status` 88 | 89 | #### [section] multi-magit-insert-repos-overview 90 | 91 | Add this to `magit-status-sections-hook` to include a one-line 92 | overview for each selected repository showing the repository name, the 93 | current branch and quick status showing an untracked/staged/unstaged 94 | file count. 95 | 96 | ```elisp 97 | (magit-add-section-hook 'magit-status-sections-hook 98 | 'multi-magit-insert-repos-overview 99 | nil t) 100 | ``` 101 | -------------------------------------------------------------------------------- /multi-magit.el: -------------------------------------------------------------------------------- 1 | ;;; multi-magit.el --- multi-repo support for Magit -*- lexical-binding: t -*- 2 | 3 | ;; Author: Luis Oliveira 4 | ;; URL: https://github.com/luismbo/multi-magit 5 | ;; Package-Requires: ((emacs "24.4") (magit "3.3.0")) 6 | ;; Keywords: git tools vc magit 7 | ;; Version: 0.1 8 | 9 | ;;; Commentary: 10 | 11 | ;; multi-magit, Multi-Repository Magit, is a set of Magit extensions for 12 | ;; handling several respositories simultaneously. 13 | 14 | ;;; Code: 15 | 16 | (require 'magit) 17 | (require 'magit-repos) 18 | (require 'magit-status) 19 | (require 'tabulated-list) 20 | (require 'dash) 21 | (require 'cl-lib) 22 | 23 | (defgroup multi-magit nil 24 | "Controlling multiple repositories using Magit." 25 | :link '(url-link "https://github.com/luismbo/multi-magit") 26 | :group 'magit-extensions) 27 | 28 | (defgroup magit-faces nil 29 | "Faces used by Multi-Magit." 30 | :group 'multi-magit) 31 | 32 | (defvar multi-magit-status-mode-map 33 | (let ((map (make-sparse-keymap))) 34 | (define-key map "g" 'multi-magit-status) 35 | (define-key map "q" 'magit-mode-bury-buffer) 36 | map) 37 | "Keymap for `multi-magit-status-mode'.") 38 | 39 | (define-derived-mode multi-magit-status-mode magit-status-mode "Multi-Magit" 40 | "A magit-status for multiple repositories." 41 | :group 'multi-magit) 42 | 43 | (easy-menu-define multi-magit-status-mode-menu multi-magit-status-mode-map 44 | "Multi-Magit menu" 45 | '("Multi-Magit" 46 | ["Checkout" multi-magit-checkout t] 47 | ["Delete branches" multi-magit-branch-delete t] 48 | ["List branches" multi-magit-list-branches t] 49 | "---" 50 | ["List repositories" multi-magit-list-repositories t] 51 | "---" 52 | ["Git command" multi-magit-git-command t] 53 | ["Shell command" multi-magit-shell-command t] 54 | "---" 55 | ["Quit" kill-this-buffer t] ; see note in `multi-magit-status-mode-map' 56 | ["Refresh" multi-magit-status t])) 57 | 58 | (defun multi-magit--find-current-section () 59 | (save-excursion 60 | (while (and (or (null (magit-current-section)) 61 | (eq 'multi-magit-toplevel 62 | (oref (magit-current-section) type))) 63 | (not (eql -1 (forward-line -1))))) 64 | (magit-current-section))) 65 | 66 | (defun multi-magit--current-repo (&optional section) 67 | (setq section (or section (multi-magit--find-current-section))) 68 | (when section 69 | (let ((parent (oref section parent))) 70 | (when parent 71 | (if (null (oref parent parent)) 72 | (oref section value) 73 | (multi-magit--current-repo parent)))))) 74 | 75 | ;;;###autoload 76 | (defun multi-magit-insert-uncommitted-changes () 77 | "Insert a diffstat of changes in worktree relative to HEAD." 78 | (magit-insert-section (diffstat) 79 | (magit-insert-heading "Uncommitted changes") 80 | (magit-git-wash #'magit-diff-wash-diffs 81 | "diff" "HEAD" "--stat" "--numstat" "--no-prefix") 82 | (insert "\n"))) 83 | 84 | ;;;###autoload 85 | (defun multi-magit-insert-committed-changes () 86 | "Insert a diffstat and commit log of commits since the 87 | merge-base betweenn HEAD and @{upstream}." 88 | (let ((merge-base (magit-git-string "merge-base" "HEAD" "@{upstream}"))) 89 | (when merge-base 90 | (magit-insert-section (diffstat) 91 | (magit-insert-heading "Committed changes") 92 | (magit-git-wash #'magit-diff-wash-diffs 93 | "diff" merge-base "HEAD" "--stat" "--numstat" "--no-prefix") 94 | (insert "\n") 95 | (magit-insert-log (format "@{upstream}..")))))) 96 | 97 | (defvar multi-magit-selected-repositories nil 98 | "The list of selected repositories that will be displayed by 99 | `multi-magit-status'.") 100 | 101 | (defun multi-magit--repo-name (repo) 102 | (file-name-nondirectory (directory-file-name repo))) 103 | 104 | (defun multi-magit--all-repositories () 105 | (magit-list-repos-uniquify 106 | (--map (cons (multi-magit--repo-name it) it) 107 | (cl-remove-duplicates (append multi-magit-selected-repositories 108 | (magit-list-repos)) 109 | :test #'string=)))) 110 | 111 | (defun multi-magit--selected-repositories () 112 | (magit-list-repos-uniquify 113 | (--map (cons (multi-magit--repo-name it) 114 | it) 115 | multi-magit-selected-repositories))) 116 | 117 | ;;;###autoload 118 | (defun multi-magit-select-repository (&optional directory) 119 | "Select DIRECTORY's repository." 120 | (interactive) 121 | (let ((repo (magit-toplevel directory))) 122 | (if (null repo) 123 | (user-error "multi-magit: couldn't find a repository here.") 124 | (setq multi-magit-selected-repositories 125 | (cl-delete-duplicates 126 | (cl-merge 'list 127 | (list repo) 128 | (cl-copy-list multi-magit-selected-repositories) 129 | #'string<) 130 | :test #'string=)) 131 | (message "multi-magit: %s selected." repo)))) 132 | 133 | ;;;###autoload 134 | (defun multi-magit-unselect-repository (&optional directory) 135 | "Unselect DIRECTORY's repository." 136 | (interactive) 137 | (let ((repo (magit-toplevel directory))) 138 | (if (null repo) 139 | (user-error "multi-magit: couldn't find a repository here.") 140 | (setq multi-magit-selected-repositories 141 | (remove repo multi-magit-selected-repositories)) 142 | (message "multi-magit: %s unselected." repo)))) 143 | 144 | ;;;; Multi-repository Commands 145 | 146 | (defvar multi-magit-record-process-setup nil) 147 | (defvar multi-magit-pending-process-sections nil) 148 | 149 | (defun multi-magit-process-buffer () 150 | (get-buffer-create "*multi-magit-process*")) 151 | 152 | (defun multi-magit--after-magit-process-finish (arg &optional process-buf 153 | _command-buf _default-dir 154 | section) 155 | (unless (integerp arg) 156 | (setq process-buf (process-buffer arg)) 157 | (setq section (process-get arg 'section)) 158 | (setq arg (process-exit-status arg))) 159 | (when (and section (bufferp process-buf)) 160 | (with-current-buffer (multi-magit-process-buffer) 161 | (let ((inhibit-read-only t) 162 | (target-section (cdr (assq section multi-magit-pending-process-sections)))) 163 | (when target-section 164 | (let ((repo-name (with-current-buffer process-buf 165 | (multi-magit--repo-name default-directory)))) 166 | (setq multi-magit-pending-process-sections 167 | (delq section multi-magit-pending-process-sections)) 168 | (save-excursion 169 | (goto-char (oref target-section start)) 170 | ;; 1+ at the end because we've inserted an extra 171 | ;; newline in `multi-magit--around-magit-process-setup'. 172 | (delete-region (oref target-section start) 173 | (1+ (oref target-section end))) 174 | (insert (propertize (concat "[" repo-name "]") 175 | 'face (if (= arg 0) 176 | 'magit-process-ok 177 | 'magit-process-ng))) 178 | (insert-buffer-substring process-buf 179 | (oref section start) 180 | (oref section end))))))))) 181 | 182 | (advice-add 'magit-process-finish :after 183 | #'multi-magit--after-magit-process-finish) 184 | 185 | (defun multi-magit--around-magit-process-setup (original-function program args) 186 | (let ((result (funcall original-function program args))) 187 | (when multi-magit-record-process-setup 188 | (let ((section (cdr result))) 189 | (cl-assert (magit-section-p section)) 190 | (push (cons section 191 | (with-current-buffer (multi-magit-process-buffer) 192 | (let ((inhibit-read-only t)) 193 | (goto-char (point-max)) 194 | ;; `magit-process-insert-section' jumps to (1- 195 | ;; (point-max)) for some reason and this gives 196 | ;; us trouble. This is probably not the right 197 | ;; fix. 198 | (insert "\n")) 199 | (prog1 (magit-process-insert-section 200 | default-directory program args 201 | nil nil) 202 | (backward-char 1)))) 203 | multi-magit-pending-process-sections))) 204 | result)) 205 | 206 | (advice-add 'magit-process-setup 207 | :around 208 | #'multi-magit--around-magit-process-setup) 209 | 210 | (defun multi-magit--call-with-process (fn) 211 | (let ((buffer (multi-magit-process-buffer))) 212 | (with-current-buffer buffer 213 | (let ((inhibit-read-only t)) 214 | (setq default-directory temporary-file-directory) ; HACK 215 | (erase-buffer) 216 | (magit-process-mode) 217 | ;; HACK: disable hooks that don't appreciate multi-magit's setup due to 218 | ;; markers being in the wrong buffer. 219 | (setq-local magit-section-highlight-hook nil) 220 | (setq-local magit-section-unhighlight-hook nil))) 221 | (let ((multi-magit-record-process-setup t) 222 | (magit-process-popup-time -1)) 223 | (funcall fn)) 224 | (magit-display-buffer buffer))) 225 | 226 | (defmacro multi-magit--with-process (&rest body) 227 | (declare (indent defun) (debug (body))) 228 | `(multi-magit--call-with-process (lambda () ,@body))) 229 | 230 | (defun multi-magit-list-common-branches () 231 | (-reduce '-intersection 232 | (--map (let ((default-directory it)) 233 | (magit-list-refs "refs/heads/" "%(refname:short)")) 234 | multi-magit-selected-repositories))) 235 | 236 | (defun multi-magit--read-branch (prompt) 237 | (magit-completing-read prompt 238 | (multi-magit-list-common-branches) 239 | nil nil nil 240 | 'magit-revision-history)) 241 | 242 | ;;;###autoload 243 | (defun multi-magit-checkout (branch) 244 | "Checkout BRANCH for each selected repository." 245 | (interactive (list (multi-magit--read-branch "Checkout"))) 246 | (multi-magit--with-process 247 | (dolist (repo multi-magit-selected-repositories) 248 | (let ((default-directory repo) 249 | (inhibit-message t)) 250 | (call-interactively #'magit-checkout branch))))) 251 | 252 | ;;;###autoload 253 | (defun multi-magit-branch-delete (branch) 254 | "Delete BRANCH for each selected repository." 255 | (interactive (list (multi-magit--read-branch "Delete"))) 256 | (multi-magit--with-process 257 | (dolist (repo multi-magit-selected-repositories) 258 | (let ((default-directory repo) 259 | (inhibit-message t)) 260 | (call-interactively #'magit-branch-delete (list branch) t))))) 261 | 262 | (defun multi-magit--shell-command (command) 263 | (multi-magit--with-process 264 | (dolist (repo multi-magit-selected-repositories) 265 | (let ((default-directory repo) 266 | (inhibit-message t) 267 | (process-environment process-environment)) 268 | (with-current-buffer (magit-process-buffer t) 269 | (push "GIT_PAGER=cat" process-environment) 270 | (magit-start-process shell-file-name nil 271 | shell-command-switch command)))))) 272 | 273 | ;;;###autoload 274 | (defun multi-magit-git-command (command) 275 | "Execute COMMAND asynchronously for each selected repository. 276 | 277 | Interactively, prompt for COMMAND in the minibuffer. \"git \" is 278 | used as initial input, but can be deleted to run another command. 279 | 280 | COMMAND is run in the top-level directory of each repository." 281 | (interactive (list (read-shell-command "Shell command: " 282 | "git " 283 | 'magit-git-command-history))) 284 | (multi-magit--shell-command command)) 285 | 286 | ;;;###autoload 287 | (defun multi-magit-shell-command (command) 288 | "Execute COMMAND asynchronously for each selected repository. 289 | 290 | Interactively, prompt for COMMAND in the minibuffer. COMMAND is 291 | run in the top-level directory of each repository." 292 | (interactive (list (read-shell-command "Shell command: " 293 | nil 294 | 'magit-git-command-history))) 295 | (multi-magit--shell-command command)) 296 | 297 | ;;;; Select/unselect Repositories 298 | 299 | (defface multi-magit-repolist-repo-face 300 | '((((class color) (background light)) :inherit magit-branch-local) 301 | (((class color) (background dark)) :inherit magit-branch-local)) 302 | "Face for repository names in `multi-magit-list-repositories'." 303 | :group 'multi-magit-faces) 304 | 305 | (defun multi-magit-repolist-column-status (_spec) 306 | "Insert letters if there are uncommitted changes. 307 | 308 | Show N if there is at least one untracked file. 309 | Show U if there is at least one unstaged file. 310 | Show S if there is at least one staged file." 311 | (concat (if (magit-untracked-files) "N" "") 312 | (if (magit-unstaged-files) "U" "") 313 | (if (magit-staged-files) "S" ""))) 314 | 315 | (defun multi-magit-repolist-column-repo (spec) 316 | "Insert the identification of the repository." 317 | (propertize (cadr (assq :id spec)) 'face 'multi-magit-repolist-repo-face)) 318 | 319 | (defcustom multi-magit-repolist-columns 320 | '(("Name" 25 magit-repolist-column-ident nil) 321 | ("Version" 25 magit-repolist-column-version nil) 322 | ("BU" 3 magit-repolist-column-unpushed-to-upstream 326 | ((:right-align t) 327 | (:help-echo "Local changes not in upstream"))) 328 | ("Path" 99 magit-repolist-column-path nil)) 329 | "List of columns displayed by `multi-magit-list-repositories'. 330 | 331 | Each element has the form (HEADER WIDTH FORMAT PROPS). 332 | 333 | HEADER is the string displayed in the header. WIDTH is the width 334 | of the column. FORMAT is a function that is called with one 335 | argument, the repository identification (usually its basename), 336 | and with `default-directory' bound to the toplevel of its working 337 | tree. It has to return a string to be inserted or nil. PROPS is 338 | an alist that supports the keys `:right-align' and `:pad-right'. 339 | Some entries also use `:help-echo', but `tabulated-list' does not 340 | actually support that yet." 341 | :group 'multi-magit 342 | :type `(repeat (list :tag "Column" 343 | (string :tag "Header Label") 344 | (integer :tag "Column Width") 345 | (function :tag "Inserter Function") 346 | (repeat :tag "Properties" 347 | (list (choice :tag "Property" 348 | (const :right-align) 349 | (const :pad-right) 350 | (symbol)) 351 | (sexp :tag "Value")))))) 352 | 353 | (defun multi-magit-repolist-toggle-repository () 354 | "Select or unselect DIRECTORY's repository." 355 | (interactive) 356 | (let* ((item (tabulated-list-get-id)) 357 | (repo (when item (magit-toplevel item)))) 358 | (cond ((null repo) 359 | (user-error "There is no repository at point")) 360 | ((member repo multi-magit-selected-repositories) 361 | (multi-magit-unselect-repository repo) 362 | (tabulated-list-put-tag " ")) 363 | (t 364 | (multi-magit-select-repository repo) 365 | (tabulated-list-put-tag "*"))))) 366 | 367 | (defvar multi-magit-repolist-mode-map 368 | (let ((map (make-sparse-keymap))) 369 | (set-keymap-parent map tabulated-list-mode-map) 370 | (define-key map "g" 'multi-magit-list-repositories) 371 | (define-key map (if (featurep 'jkl) [return] (kbd "C-m")) 372 | 'multi-magit-repolist-toggle-repository) 373 | map) 374 | "Local keymap for Multi-Magit-Repolist mode buffers.") 375 | 376 | (define-derived-mode multi-magit-repolist-mode magit-repolist-mode "Repos" 377 | "Major mode for managing the list of selected Git repositories." 378 | (setq tabulated-list-padding 2) 379 | (tabulated-list-init-header)) 380 | 381 | ;;;###autoload 382 | (defun multi-magit-list-repositories () 383 | "Display a list of repositories for selection. 384 | 385 | Use the options `magit-repository-directories' and 386 | `magit-repository-directories-depth' to control which 387 | repositories are displayed." 388 | (interactive) 389 | (if magit-repository-directories 390 | (with-current-buffer (get-buffer-create "*Multi-Magit Repositories*") 391 | (let ((magit-repolist-columns multi-magit-repolist-columns)) 392 | (multi-magit-repolist-mode)) 393 | (setq tabulated-list-format 394 | (vconcat (mapcar (pcase-lambda (`(,title ,width ,_fn ,props)) 395 | (nconc (list title width t) 396 | (-flatten props))) 397 | multi-magit-repolist-columns))) 398 | (setq tabulated-list-entries 399 | (mapcar (pcase-lambda (`(,id . ,path)) 400 | (let ((default-directory path)) 401 | (list path 402 | (vconcat 403 | (mapcar (pcase-lambda (`(,title ,width ,fn ,props)) 404 | (or (funcall fn `((:id ,id) 405 | (:title ,title) 406 | (:width ,width) 407 | ,@props)) 408 | "")) 409 | multi-magit-repolist-columns))))) 410 | (multi-magit--all-repositories))) 411 | (tabulated-list-init-header) 412 | (tabulated-list-print) 413 | (save-excursion 414 | (goto-char (point-min)) 415 | (while (tabulated-list-get-id) 416 | (when (member (file-name-as-directory (tabulated-list-get-id)) 417 | multi-magit-selected-repositories) 418 | (tabulated-list-put-tag "*")) 419 | (forward-line))) 420 | (switch-to-buffer (current-buffer))) 421 | (message "You need to customize `magit-repository-directories' %s" 422 | "before you can list repositories"))) 423 | 424 | ;;;; List Branches 425 | 426 | (defvar multi-magit-branchlist-mode-map 427 | (let ((map (make-sparse-keymap))) 428 | (set-keymap-parent map tabulated-list-mode-map) 429 | (define-key map "g" 'multi-magit-list-branches) 430 | (define-key map "q" 'magit-mode-bury-buffer) 431 | (define-key map (kbd "C-k") 'multi-magit-branchlist-delete) 432 | (define-key map (if (featurep 'jkl) [return] (kbd "C-m")) 433 | 'multi-magit-branchlist-checkout) 434 | map) 435 | "Local keymap for Multi-Magit-Branchlist mode buffers.") 436 | 437 | (easy-menu-define multi-magit-branchlist-mode-menu multi-magit-branchlist-mode-map 438 | "multi-magit-branchlist-mode menu." 439 | '("Multi-Magit Branches" 440 | ["Checkout" multi-magit-branchlist-checkout t] 441 | ["Delete" multi-magit-branchlist-delete t] 442 | "---" 443 | ["Quit" kill-buffer t] 444 | ["Refresh" multi-magit-list-branches t])) 445 | 446 | (defun multi-magit--human-readable-time-since (seconds) 447 | (let* ((seconds (truncate (float-time (time-since (seconds-to-time seconds))))) 448 | (minutes (truncate seconds 60))) 449 | (if (zerop minutes) 450 | (format "%ss" seconds) 451 | (let ((hours (truncate minutes 60))) 452 | (if (zerop hours) 453 | (format "%sm" minutes) 454 | (let ((days (truncate hours 24))) 455 | (if (zerop days) 456 | (format "%sh" hours) 457 | (let ((weeks (truncate days 7))) 458 | (if (zerop weeks) 459 | (format "%sd" days) 460 | ;; just an approximation 461 | (let ((months (truncate days 30))) 462 | (if (zerop months) 463 | (format "%sw" weeks) 464 | ;; ditto 465 | (let ((years (truncate days 365))) 466 | (if (zerop years) 467 | (format "%smo" months) 468 | (format "%sy" years)))))))))))))) 469 | 470 | (defun multi-magit--tabulated-list-printer (id cols) 471 | (let ((new-cols (vector (elt cols 0) 472 | (multi-magit--human-readable-time-since (elt cols 1)) 473 | (elt cols 2)))) 474 | (tabulated-list-print-entry id new-cols))) 475 | 476 | (define-derived-mode multi-magit-branchlist-mode tabulated-list-mode "Branches" 477 | "Major mode for managing the list of selected Git repositories." 478 | (setq x-stretch-cursor nil) 479 | (setq tabulated-list-padding 0) 480 | (setq tabulated-list-sort-key (cons "Branch" nil)) 481 | (setq tabulated-list-format 482 | [("Branch" 30 t) 483 | ("Chg" 5 (lambda (e1 e2) 484 | (> (elt (cl-second e1) 1) 485 | (elt (cl-second e2) 1)))) 486 | ("Repositories" 99 t)]) 487 | (setq tabulated-list-printer 'multi-magit--tabulated-list-printer) 488 | (setq tabulated-list-padding 2) 489 | (tabulated-list-init-header)) 490 | 491 | (defun multi-magit-branchlist-checkout () 492 | "Checkout branch at point in its respective repositories. Point 493 | `multi-magit-selected-repositories' to these repositories." 494 | (interactive) 495 | (cl-destructuring-bind (&optional branch &rest repos) 496 | (tabulated-list-get-id) 497 | (when (null branch) 498 | (user-error "There is no branch at point")) 499 | (when (yes-or-no-p (format "Select %s and checkout `%s'? " 500 | (mapconcat #'multi-magit--repo-name repos ", ") 501 | branch)) 502 | (setq multi-magit-selected-repositories 503 | (--map (file-name-as-directory it) repos)) 504 | (multi-magit-checkout branch)))) 505 | 506 | (defun multi-magit-branchlist-delete () 507 | "Delete branch at point in its respective repositories." 508 | (interactive) 509 | (cl-destructuring-bind (&optional branch &rest repos) 510 | (tabulated-list-get-id) 511 | (when (null branch) 512 | (user-error "There is no branch at point")) 513 | (let ((repo-names (mapconcat #'multi-magit--repo-name repos ", "))) 514 | (--when-let (--filter (let ((default-directory it)) 515 | (string= (magit-get-current-branch) branch)) 516 | repos) 517 | (user-error "Refusing to delete. `%s' is currently checked out in %s" 518 | branch repo-names)) 519 | (when (yes-or-no-p (format "Delete `%s' in %s? " branch repo-names)) 520 | (tabulated-list-delete-entry) 521 | (let ((multi-magit-selected-repositories repos)) 522 | (multi-magit-branch-delete branch)))))) 523 | 524 | (defun multi-magit--repo-branches+mtime (repo-path) 525 | ;; (magit-list-refs "refs/heads/" "%(refname:short)") would be the proper way 526 | ;; to do this, but it's comparatively very slow (at least on Windows), 527 | ;; particularly if we want to grab the modification time from the commit 528 | ;; metadata too. Also, note that we want to exclude secondary worktrees. 529 | (when (file-directory-p (expand-file-name ".git" repo-path)) 530 | (cl-remove-duplicates ; earlier occurences are removed, i.e., 531 | ; .git/refs/heads takes precedence over 532 | ; .git/packed-refs. 533 | (append (let ((packed-refs (expand-file-name ".git/packed-refs" repo-path))) 534 | ;; We're certainly asking for trouble parsing packed-refs. :-( 535 | (when (file-exists-p packed-refs) 536 | (let* ((mtime (float-time (cl-sixth (file-attributes packed-refs)))) 537 | (lines (with-temp-buffer 538 | (insert-file-contents packed-refs) 539 | (split-string (buffer-string) "\n" t))) 540 | (heads (--filter (when it (string-match-p "^refs/heads/" it)) 541 | (--map (cl-second (split-string it " " t)) 542 | lines)))) 543 | (--map (list (file-name-nondirectory it) mtime) heads)))) 544 | (--map (list (file-name-nondirectory it) 545 | (float-time (cl-sixth (file-attributes it)))) 546 | (with-demoted-errors "multi-magit: error listing repository branches: %S" 547 | (directory-files (expand-file-name ".git/refs/heads/" repo-path) 548 | t "[^.]")))) 549 | :test #'string= 550 | :key #'cl-first))) 551 | 552 | ;;;###autoload 553 | (defun multi-magit-list-branches () 554 | "Display a list of branches in all repositories, selected or unselected. 555 | 556 | Use the options `magit-repository-directories' and 557 | `magit-repository-directories-depth' to control which 558 | repositories are displayed." 559 | (interactive) 560 | (if magit-repository-directories 561 | (with-current-buffer (get-buffer-create "*Multi-Magit Branches*") 562 | (multi-magit-branchlist-mode) 563 | (let ((branch->info (make-hash-table :test 'equal))) 564 | (cl-loop for (repo . path) in (multi-magit--all-repositories) 565 | for default-directory = path 566 | do (-map (-lambda ((branch mtime)) 567 | (let ((info (or (gethash branch branch->info) 568 | (setf (gethash branch branch->info) 569 | (list nil nil nil))))) 570 | (push repo (cl-first info)) 571 | (push path (cl-second info)) 572 | (setf (cl-third info) 573 | (max (or (cl-third info) 0) mtime)))) 574 | (multi-magit--repo-branches+mtime path))) 575 | (setq tabulated-list-entries 576 | (cl-loop for branch being the hash-keys in branch->info 577 | using (hash-value info) 578 | collect (cl-destructuring-bind (repos paths last-changed) info 579 | (list (cons branch paths) 580 | (vector (propertize branch 'face 'magit-branch-local) 581 | last-changed 582 | (mapconcat 'identity repos ", "))))))) 583 | (tabulated-list-print) 584 | (switch-to-buffer (current-buffer))) 585 | (message "You need to customize `magit-repository-directories' %s" 586 | "before you can list branches."))) 587 | 588 | ;;;; Multi-Magit Status 589 | 590 | (defcustom multi-magit-status-sections-hook 591 | '(magit-insert-untracked-files 592 | magit-insert-unstaged-changes 593 | magit-insert-staged-changes 594 | ;; multi-magit-insert-uncommitted-changes 595 | multi-magit-insert-committed-changes) 596 | "Hook run to insert section into a `multi-magit-status' buffer." 597 | :group 'multi-magit 598 | :type 'hook) 599 | 600 | (defface multi-magit-repo-heading 601 | '((((class color) (background light)) :inherit magit-section-heading :box t) 602 | (((class color) (background dark)) :inherit magit-section-heading :box t)) 603 | "Face for repository headings." 604 | :group 'multi-magit-faces) 605 | 606 | (defvar multi-magit-repo-header-section-map 607 | (let ((map (make-sparse-keymap))) 608 | (define-key map [return] 'magit-status) 609 | map) 610 | "Keymap for repository headers.") 611 | 612 | (defun multi-magit--insert-repo-heading (repo-name) 613 | (let* ((repo (propertize (format " %s " repo-name) 614 | 'face 'multi-magit-repo-heading)) 615 | (branch (--if-let (magit-get-current-branch) 616 | (propertize it 'face 'magit-branch-current) 617 | (propertize "(detached)" 'face 'warning)))) 618 | (magit-insert-heading (propertize (concat repo " " branch) 619 | 'keymap 620 | multi-magit-repo-header-section-map)))) 621 | 622 | (defun multi-magit-status-refresh-buffer () 623 | (multi-magit-status)) 624 | 625 | (defvar multi-magit-status-buffer-name "*Multi-Magit Status*") 626 | 627 | (defun multi-magit--around-magit-mode-get-buffers (original-function &rest args) 628 | (let ((buffers (apply original-function args)) 629 | (multi-magit-status-buffer (get-buffer multi-magit-status-buffer-name))) 630 | (if (and multi-magit-status-buffer 631 | (member default-directory multi-magit-selected-repositories)) 632 | (cons multi-magit-status-buffer buffers) 633 | buffers))) 634 | 635 | (advice-add 'magit-mode-get-buffers 636 | :around 637 | 'multi-magit--around-magit-mode-get-buffers) 638 | 639 | (defcustom multi-magit-refresh-status-buffer t 640 | "Whether the multi-magit-status buffer is refreshed after running git. 641 | 642 | When this variable and `magit-refresh-status-buffer' are both 643 | non-nil, multi-magit's status buffer is automatically refreshed 644 | after running git for side-effects on a selected repository." 645 | :group 'multi-magit 646 | :type 'boolean) 647 | 648 | (defun multi-magit--maybe-refresh () 649 | (--when-let (and magit-refresh-status-buffer 650 | multi-magit-refresh-status-buffer 651 | (member default-directory multi-magit-selected-repositories) 652 | (get-buffer multi-magit-status-buffer-name)) 653 | (multi-magit--refresh-status it))) 654 | 655 | (add-hook 'magit-post-refresh-hook 'multi-magit--maybe-refresh) 656 | 657 | (defun multi-magit--set-current-repo () 658 | (let ((repo (or (multi-magit--current-repo) 659 | (cl-first multi-magit-selected-repositories)))) 660 | (setq default-directory repo) 661 | (setq magit--default-directory repo))) 662 | 663 | ;; Similar to `magit-list-repos-uniquify' but the resulting alist maps 664 | ;; repo paths to repo names not the other way around. 665 | (defun multi-magit--uniquify-repo-names (alist) 666 | (let ((result nil) 667 | (name->repos (make-hash-table :test 'equal))) 668 | (cl-loop for (name . repo) in alist 669 | do (puthash name 670 | (cons repo (gethash name name->repos)) 671 | name->repos)) 672 | (maphash 673 | (lambda (key value) 674 | (if (= (length value) 1) 675 | (push (cons (car value) key) result) 676 | (setq result 677 | (append (multi-magit--uniquify-repo-names 678 | (--map (cons (concat 679 | key "\\" 680 | (file-name-nondirectory 681 | (directory-file-name 682 | (substring it 0 (- (1+ (length key))))))) 683 | it) 684 | value)) 685 | result)))) 686 | name->repos) 687 | result)) 688 | 689 | (defun multi-magit--selected-repo-names () 690 | (let ((repo->name (multi-magit--uniquify-repo-names 691 | (--map (cons (file-name-nondirectory (directory-file-name it)) it) 692 | multi-magit-selected-repositories)))) 693 | (--map (cdr (assoc it repo->name)) 694 | multi-magit-selected-repositories))) 695 | 696 | (defun multi-magit--refresh-status (buffer) 697 | (with-current-buffer buffer 698 | (let ((inhibit-read-only t)) 699 | (erase-buffer) 700 | (multi-magit--set-current-repo) 701 | (multi-magit-status-mode) 702 | (save-excursion 703 | (magit-insert-section (multi-magit-toplevel) 704 | (cl-loop for repo in multi-magit-selected-repositories 705 | for repo-name in (multi-magit--selected-repo-names) 706 | do (let ((default-directory repo) 707 | (magit--default-directory repo)) 708 | (magit-insert-section (multi-magit-status repo) 709 | (multi-magit--insert-repo-heading repo-name) 710 | (insert "\n") 711 | (magit-run-section-hook 'multi-magit-status-sections-hook)))))) 712 | (add-hook 'post-command-hook 'multi-magit--set-current-repo nil :local)))) 713 | 714 | ;;;###autoload 715 | (defun multi-magit-status () 716 | (interactive) 717 | (if (null multi-magit-selected-repositories) 718 | (when (y-or-n-p "`multi-magit-selected-repositories' is empty. Would you \ 719 | like to select some using `multi-magit-list-repositories'? ") 720 | (multi-magit-list-repositories)) 721 | (let ((buffer (get-buffer-create multi-magit-status-buffer-name))) 722 | (multi-magit--refresh-status buffer) 723 | (magit-display-buffer buffer)))) 724 | 725 | ;;;; Magit-status Sections 726 | 727 | ;;; Implicitly used by (magit-insert-section (multi-magit-repo ...) ...) 728 | ;;; in `multi-magit-insert-repos-overview'. 729 | (defvar magit-multi-magit-repo-section-map 730 | (let ((map (make-sparse-keymap))) 731 | (unless (featurep 'jkl) 732 | (define-key map "\C-j" 'multi-magit-repo-visit)) 733 | (define-key map [C-return] 'multi-magit-repo-visit) 734 | (define-key map [remap magit-visit-thing] 'multi-magit-repo-visit) 735 | map) 736 | "Keymap for `multi-magit-repo' sections.") 737 | 738 | (defun multi-magit-repo-visit (repo &optional _other-window) 739 | "Visit REPO by calling `magit-status' on it." 740 | (interactive (list (magit-section-value-if 'multi-magit-repo) 741 | current-prefix-arg)) 742 | (when repo 743 | (magit-status-setup-buffer repo))) 744 | 745 | ;;;###autoload 746 | (defun multi-magit-insert-repos-overview () 747 | "Insert sections for all selected repositories." 748 | (when multi-magit-selected-repositories 749 | (let* ((repos multi-magit-selected-repositories) 750 | (repo-names (multi-magit--selected-repo-names)) 751 | (path-format (format "%%-%is " 752 | (min (apply 'max (mapcar 'length repo-names)) 753 | (/ (window-width) 2)))) 754 | (branch-format (format "%%-%is " (min 25 (/ (window-width) 3))))) 755 | (magit-insert-heading (format "%s (%d)" 756 | (propertize "Selected repositories" 757 | 'face 'magit-section-heading) 758 | (length repos))) 759 | (cl-loop for repo in repos 760 | for repo-name in repo-names 761 | do (let ((default-directory repo)) 762 | (magit-insert-section (multi-magit-repo repo t) 763 | (insert (propertize (format path-format repo-name) 764 | 'face 'magit-diff-file-heading)) 765 | (insert (format branch-format 766 | (--if-let (magit-get-current-branch) 767 | (propertize it 'face 'magit-branch-local) 768 | (propertize "(detached)" 'face 'warning)))) 769 | (insert (mapconcat 770 | 'identity 771 | (remove nil 772 | (list (--when-let (magit-untracked-files) 773 | (format "%d untracked" (length it))) 774 | (--when-let (magit-unstaged-files) 775 | (format "%d unstaged" (length it))) 776 | (--when-let (magit-staged-files) 777 | (format "%d staged" (length it))))) 778 | ", ")) 779 | (insert "\n")))))) 780 | (insert "\n")) 781 | 782 | (provide 'multi-magit) 783 | ;;; multi-magit.el ends here 784 | --------------------------------------------------------------------------------