├── .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 |
--------------------------------------------------------------------------------