diff options
Diffstat (limited to 'lisp/km-magit.el')
-rw-r--r-- | lisp/km-magit.el | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/lisp/km-magit.el b/lisp/km-magit.el new file mode 100644 index 0000000..616f975 --- /dev/null +++ b/lisp/km-magit.el @@ -0,0 +1,579 @@ +;;; km-magit.el --- Magit extensions + +;; Copyright (C) 2012-2016 Kyle Meyer <kyle@kyleam.com> + +;; Author: Kyle Meyer <kyle@kyleam.com> +;; URL: https://github.com/kyleam/emacs.d + +;; 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/>. + +;;; Code: + +(require 'avy) +(require 'git-rebase) +(require 'magit) +(require 'projectile) + +(defun km/magit-auto-commit () + "Commit all changes with \"auto\" commit message. +This can be useful for non-source code repos (e.g., Org mode note +files) or commiting incomplete changes that will be extended into +a proper commit." + (interactive) + (magit-run-git "commit" "--all" "--message=auto")) + +;;;###autoload +(defun km/magit-show-commit-at-point (&optional choose-project) + "Show commit point. +If there is no current project or if the prefix argument +CHOOSE-PROJECT is non-nil, prompt for the project name." + (interactive "P") + (if (save-excursion (skip-chars-backward "A-z0-9") + (looking-at "\\b[A-z0-9]\\{4,40\\}\\b")) + (let* ((hash (match-string-no-properties 0)) + (project + (and (or choose-project + (not (projectile-project-p)) + (not (magit-rev-verify (concat hash "^{commit}")))) + (completing-read "Project: " + (projectile-relevant-known-projects)))) + (default-directory (or project default-directory))) + (magit-show-commit hash (car (magit-diff-arguments)))) + (user-error "No hash found at point"))) + +;;;###autoload +(defun km/magit-commit-extend-with-file () + "Extend last commit with changes in the current file." + (interactive) + (let ((file (or (magit-current-file) + (user-error "No current file")))) + (cond ((magit-anything-staged-p) + (user-error "There are already staged changes")) + ((member file (nconc (magit-untracked-files) + (magit-modified-files))) + (magit-with-toplevel (magit-stage-file file)) + (magit-commit-extend)) + (t + (message "No changes to %s" file))))) + +;;;###autoload +(defun km/magit-commit-wip-with-file () + "Make a WIP commit for the current file. +Unlike `magit-wip-*' commands, this commit is made to the current +branch." + (interactive) + (let ((file (or (magit-current-file) + (user-error "No current file")))) + (cond ((magit-anything-staged-p) + (user-error "There are already staged changes")) + ((member file (nconc (magit-untracked-files) + (magit-modified-files))) + (magit-with-toplevel (magit-stage-file file)) + (magit-run-git "commit" (concat "--message=WIP " file))) + (t + (message "No changes to %s" file))))) + +(defun km/magit-ff-merge-upstream () + "Perform fast-forward merge of upstream branch. +\n(git merge --no-edit --ff-only <upstream>)" + (interactive) + (--if-let (magit-get-tracked-branch) + (magit-merge it '("--ff-only")) + (user-error "No upstream branch"))) + +(defun km/magit-stage-file-intent (file) + "Stage FILE but not its content. +With a prefix argument or when there is no file at point, ask for +the file to be staged. Otherwise, stage the file at point +without requiring confirmation. +\n(git add -N FILE)" + ;; Modified from `magit-stage-file'. + (interactive + (let* ((atpoint (magit-section-when (file))) + (current (magit-file-relative-name)) + (choices (magit-untracked-files)) + (default (car (member (or atpoint current) choices)))) + (list (if (or current-prefix-arg (not default)) + (magit-completing-read "Stage file" choices + nil t nil nil default) + default)))) + (magit-run-git "add" "-N" file)) + +(defun km/magit-push-all () + "Push all branches." + (interactive) + (magit-run-git-async "push" "-v" + (magit-read-remote "Remote") + "--all")) + +(defun km/magit-push-head (remote &optional args) + "Push current branch to same name on remote. +\n(git push [ARGS] REMOTE HEAD)" + (interactive (list (magit-read-remote "Remote") (magit-push-arguments))) + (magit-run-git-async "push" "-v" args remote "HEAD")) + +(defun km/magit-checkout-local-tracking (remote-branch) + "Create and checkout a local tracking branch for REMOTE-BRANCH. +\n(git checkout -t REMOTE-BRANCH\)" + (interactive + (list (let ((branches (magit-list-remote-branch-names))) + (magit-completing-read + "Remote branch" branches nil t nil nil + (car (member (magit-branch-or-commit-at-point) + branches)))))) + (magit-run-git "checkout" "-t" remote-branch)) + +(defun km/magit-branch-rename (old new &optional force) + "Like `magit-branch-rename', but use old branch as initial prompt." + (interactive + (let ((branch (magit-read-local-branch "Rename branch"))) + (list branch + (magit-read-string-ns (format "Rename branch '%s' to" branch) + branch) + current-prefix-arg))) + (unless (string= old new) + (magit-run-git-no-revert "branch" (if force "-M" "-m") old new))) + +(defun km/magit-delete-previous-branch (&optional force) + "Delete previous branch. +\n(git branch -d @{-1})" + (interactive "P") + (magit-run-git "branch" (if force "-D" "-d") "@{-1}")) + +(defun km/magit-checkout-previous-branch () + "Checkout previous branch. +\n(git checkout -)" + (interactive) + (magit-run-git "checkout" "-")) + +(defun km/magit-list-recent-refs (n &optional remote) + "List N recent refs. +If REMOTE is non-nil, limit to remote refs." + (magit-git-lines + "for-each-ref" "--sort=-committerdate" "--format=%(refname:short)" + (format "--count=%s" n) + (if remote "refs/remotes" "refs/heads"))) + +(defun km/magit-checkout-recent-ref (n) + "Checkout branch from N recent refs. +Refs are sorted by committer date." + (interactive (list (or (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)) + 5))) + (magit-run-git "checkout" + (magit-completing-read + "Ref" (km/magit-list-recent-refs n)))) + +(defun km/magit-checkout-track-recent-ref (n) + "Create and checkout a local tracking branch. +Listed refs are limited to N most recent, sorted by committer +date." + (interactive (list (or (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)) + 5))) + (magit-run-git "checkout" "-t" + (magit-completing-read + "Ref" (km/magit-list-recent-refs n 'remote)))) + +(defun km/magit-refs-filter-recent (n) + "Limit branch list to N most recent. +Warning: I find this useful, but it's a hack that breaks +magit-section-backward and probably other things. Hit `g` to +refresh the buffer, and all should be right again." + (interactive (list (or (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)) + 5))) + (unless (derived-mode-p 'magit-refs-mode) + (user-error "Not in Magit Refs mode")) + (let ((sec (magit-current-section)) + remote refs line-sec) + (when (eq (magit-section-type sec) 'branch) + (setq sec (magit-section-parent sec))) + (when (eq (magit-section-type sec) 'remote) + (setq remote (magit-section-value sec))) + (setq refs + (magit-git-lines + "for-each-ref" "--sort=-committerdate" "--format=%(refname:short)" + (format "--count=%s" n) + (if remote (format "refs/remotes/%s" remote) "refs/heads"))) + (save-excursion + (save-restriction + (narrow-to-region (magit-section-content sec) (magit-section-end sec)) + (goto-char (point-min)) + (while (and (not (eobp)) + (setq line-sec (magit-current-section)) + (eq (magit-section-type line-sec) 'branch)) + (if (member (magit-section-value line-sec) refs) + (forward-line 1) + (let ((inhibit-read-only t)) + (delete-region (magit-section-start line-sec) + (magit-section-end line-sec))))))))) + +(defun km/magit-checkout-master () + "Checkout master branch. +\n(git checkout master)" + (interactive) + (magit-run-git "checkout" "master")) + +(defun km/magit-branch-and-checkout-from-current (branch) + "Create and checkout BRANCH at current branch. +This is equivalent to running `magit-branch-and-checkout' with +START-POINT set to the current branch. +\n(git checkout -b BRANCH)" + (interactive (list (magit-read-string "Branch name"))) + (magit-run-git "checkout" "-b" branch)) + +(defun km/magit-backup-branch () + "Create a backup branch for the current branch. +\n(git branch b/<current-branch>)" + (interactive) + (--if-let (magit-get-current-branch) + (magit-run-git "branch" (concat "b/" it)) + (user-error "No current branch"))) + +(defun km/magit-mode-bury-all-windows (&optional kill-buffer) + "Run `magit-mode-quit-window' until no longer in Magit buffer." + (interactive "P") + (while (derived-mode-p 'magit-mode) + (magit-mode-bury-buffer kill-buffer))) + +(defun km/magit-log-select-guess-fixup-commit (&optional ntop) + "Guess commit from fixup/squash commmits. +Consider NTOP commits (default is 5) when searching for 'fixup!' +and 'squash!' titles." + (interactive (list (or (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)) + 5))) + (let (ntop-end msgs commit-pts) + (save-excursion + ;; Get limit for fixup/squash search. + (goto-char (point-min)) + (setq ntop-end (line-end-position (1+ ntop))) + ;; Get fixup and squash messages. + (while (re-search-forward "[a-z0-9]+ \\(fixup!\\|squash!\\) \\(.+\\)" + ntop-end t) + (push (match-string-no-properties 2) msgs)) + (when (not msgs) + (user-error "No fixup or squash commits found")) + ;; Find earliest commit. + (goto-char (point-min)) + (dolist (msg msgs) + (when (re-search-forward (concat "[a-z0-9]+ " msg "\n") nil t) + (push (match-beginning 0) commit-pts)))) + (if commit-pts + (goto-char (apply #'max commit-pts)) + (message "No matching commits found")))) + +;;;###autoload +(defun km/magit-reset-file (rev file &optional checkout) + "Reset FILE from revision REV. + +If prefix argument CHECKOUT is non-nil, checkout FILE from REV +instead. + +\(git reset REV -- FILE) +\(git checkout REV -- FILE)" + (interactive + (let ((rev (magit-read-branch-or-commit "Revision" magit-buffer-revision))) + (list rev (magit-read-file-from-rev rev "File") current-prefix-arg))) + (magit-with-toplevel + (magit-run-git (if checkout "checkout" "reset") + rev "--" file))) + +;;;###autoload +(defun km/magit-pin-file (&optional other-rev) + "Pin this file to the current revision. + +Visit the current file and current revision with +`magit-find-file'. Position point as in the original buffer. +This may not correspond to same content if text before point has +changed since the current commit. + +If OTHER-REV is non-nil, prompt for another revision instead of +the current. If buffer is already a revision buffer, then find +the working tree copy instead. In both these cases, point may +not land in a reasonable location depending on how the content of +the file has changed." + (interactive "P") + (magit-with-toplevel + (let* ((line (+ (if (bolp) 1 0) (count-lines 1 (point)))) + (col (current-column)) + (rev (cond (other-rev + (magit-read-branch-or-commit "Find file from revision")) + ((not magit-buffer-file-name) + (or (magit-get-current-branch) + (magit-rev-parse "HEAD"))))) + (fname (file-relative-name + (or buffer-file-name + magit-buffer-file-name + (user-error "Buffer not visiting file"))))) + (if rev (magit-find-file rev fname) (find-file fname)) + (goto-char (point-min)) + (forward-line (1- line)) + (move-to-column col)))) + +;;;###autoload +(defun km/magit-revfile-reset (&optional checkout) + "Reset to revision from current revfile. +If CHECKOUT is non-nil, checkout file instead." + (interactive "P") + (unless (and magit-buffer-refname magit-buffer-file-name) + (user-error "Not in Magit revfile buffer")) + (magit-with-toplevel + (magit-run-git (if checkout "checkout" "reset") + magit-buffer-refname "--" magit-buffer-file-name))) + +;;;###autoload +(defun km/magit-find-recently-changed-file (n) + "Find a file that changed from \"HEAD~N..HEAD\". +N defaults to 20." + (interactive "p") + (unless current-prefix-arg (setq n 10)) + (magit-with-toplevel + (find-file (magit-completing-read + "File" + (magit-changed-files (format "HEAD~%s..HEAD" n)) + nil t)))) + +(defun km/magit-find-commit-file (commit) + "Find file changed in COMMIT." + (interactive (list (or (magit-branch-or-commit-at-point) + (and (derived-mode-p 'magit-revision-mode) + (car magit-refresh-args)) + (magit-read-branch-or-commit "Commit")))) + (let ((files (magit-changed-files (format "%s~..%s" commit commit)))) + (find-file + (cl-case (length files) + (0 (user-error "No changed files in %s" commit)) + (1 (car files)) + (t (magit-completing-read "File" files nil t)))))) + +(defun km/magit-insert-staged-file (&optional no-directory) + "Select staged file to insert. + +This is useful for referring to file names in commit messages. +By default, the path for the file name is relative to the top +directory of the repository. Remove the directory component from +the file name if NO-DIRECTORY is non-nil. + +If there are no staged files, look instead at files that changed +in HEAD. These rules will usually offer the files of interest +while commiting, but this is not the case if you are amending a +commit with the \"--only\" flag and have staged files (i.e., this +command will still offer the staged files)." + (interactive "P") + (magit-with-toplevel + (let* ((files (or (magit-staged-files) + (magit-changed-files "HEAD^..HEAD"))) + (file (cl-case (length files) + (1 (car files)) + (0 (error "No files found")) + (t + (completing-read "Staged file: " files nil t))))) + (insert (if no-directory (file-name-nondirectory file) file))))) + +(defun km/magit-shorten-hash (hash &optional n) + (magit-rev-parse (format "--short=%s" (or n (magit-abbrev-length))) hash)) + +;;;###autoload +(defun km/magit-shorten-hash-at-point (&optional n) + "Shorten hash at point to N characters. + +N defaults to `magit-abbrev-length'. If the commit belongs to +the current repo and the hash is ambiguous, the hash is extended +as needed. + +To explicitly set the hash length, use a numeric prefix +argument." + (interactive (list (or (and current-prefix-arg + (prefix-numeric-value current-prefix-arg)) + (magit-abbrev-length)))) + (cond + ((< n 4) + (user-error "Hash must be at least 4 characters")) + ((>= n 40) + (user-error "Full hashes are 40 characters")) + ((> n 30) + (message "That doesn't seem incredibly useful, but OK"))) + (let ((offset (- (skip-chars-backward "A-z0-9")))) + (if (looking-at "\\b[A-z0-9]\\{5,40\\}\\b") + (let ((hash-len (- (match-end 0) (match-beginning 0))) + (hash (match-string 0))) + (when (< hash-len n) + (user-error "Desired hash length is greater than current")) + (replace-match (or (km/magit-shorten-hash hash n) + ;; We're not in a repo. + (substring hash 0 n)) + 'fixedcase) + (when (< offset n) + (skip-chars-backward "A-z0-9") + (goto-char (+ (point) offset)))) + (goto-char (+ (point) offset)) + (user-error "No hash found at point")))) + +(defvar km/magit-copy-hook + '(km/magit-copy-commit-summary-from-header + km/magit-copy-commit-message + km/magit-copy-region-commits + km/magit-copy-region-hunk + km/magit-copy-hunk) + "Functions tried by `km/magit-copy-as-kill'. +These will be given one argument (the current prefix value) and +should succeed by copying and returning non-nil or fail by +returning nil.") + +;;;###autoload +(defun km/magit-copy-commit-summary (commit) + "Copy a citation for the COMMIT at point. +Format the reference as '<hash>, (<subject>, <date>)'. If there +is no commit at point or with a prefix argument, prompt for +COMMIT." + (interactive + (let ((atpoint (or (and magit-blame-mode (magit-blame-chunk-get :hash)) + (magit-branch-or-commit-at-point) + (magit-tag-at-point)))) + (list (or (and (not current-prefix-arg) atpoint) + (magit-read-branch-or-commit "Commit" atpoint))))) + (if (magit-rev-verify (concat commit "^{commit}")) + (kill-new (message + ;; Using `magit-git-string' instead of + ;; `magit-rev-format' to pass --date flag. + (magit-git-string "show" "-s" "--date=short" + "--format=%h (%s, %ad)" + commit "--"))) + (user-error "%s does not exist" commit))) + +(defun km/magit-copy-commit-summary-from-header (&optional arg) + (magit-section-when headers + (km/magit-copy-commit-summary (car magit-refresh-args)))) + +(defun km/magit-copy-region-commits (&optional arg) + (--when-let (magit-region-values 'commit) + (deactivate-mark) + (kill-new + (mapconcat #'identity it + (if arg (read-string "Separator: ") ", "))))) + +(defun km/magit-copy-commit-message (&optional arg) + (magit-section-when message + (kill-new (replace-regexp-in-string + "^ " "" + (buffer-substring-no-properties (magit-section-start it) + (magit-section-end it)))))) + +(defun km/magit-copy-region-hunk (&optional no-column) + (when (magit-section-internal-region-p) + (magit-section-when hunk + (deactivate-mark) + (let ((text (buffer-substring-no-properties + (region-beginning) (region-end)))) + (kill-new (if no-column + (replace-regexp-in-string "^[ \\+\\-]" "" text) + text)))))) + +(defun km/magit-copy-hunk (&optional arg) + (magit-section-when hunk + (let ((start (save-excursion (goto-char (magit-section-start it)) + (1+ (point-at-eol))))) + (kill-new (buffer-substring-no-properties + start (magit-section-end it)))))) + +(defun km/magit-copy-as-kill () + "Try `km/magit-copy-hook' before calling `magit-copy-as-kill'. +With a prefix argument of -1, always call `magit-copy-section-value' +Otherwise, the current prefix argument is passed to each hook +function." + (interactive) + (or (unless (= (prefix-numeric-value current-prefix-arg) -1) + (run-hook-with-args-until-success + 'km/magit-copy-hook current-prefix-arg)) + (magit-copy-section-value))) + +(defun km/magit-rev-ancestor-p (rev-a rev-b) + "Report whether REV-A is the ancestor of REV-B. +Use the revision at point as REV-B. With prefix argument or if +there is no revision at point, prompt for the revision. Always +prompt for REV-A." + (interactive + (let* ((atpoint (or (and magit-blame-mode (magit-blame-chunk-get :hash)) + (magit-branch-or-commit-at-point) + (magit-tag-at-point))) + (commit (or (and (not current-prefix-arg) atpoint) + (magit-read-branch-or-commit "Descendant" atpoint)))) + (list (magit-read-other-branch-or-commit + (format "Test if ancestor of %s" commit) commit) + commit))) + (message "%s is %san ancestor of %s" rev-a + (if (magit-git-success "merge-base" "--is-ancestor" + rev-a rev-b) + "" "NOT ") + rev-b)) + +(defun km/magit-refs-toggle-tags () + "Toggle showing tags in `magit-refs-mode'. +This only affects the current buffer and is useful if you do not +show tags by default." + (interactive) + (if (memq 'magit-insert-tags magit-refs-sections-hook) + (remove-hook 'magit-refs-sections-hook 'magit-insert-tags t) + (add-hook 'magit-refs-sections-hook 'magit-insert-tags t t)) + (magit-refresh-buffer)) + +(defun km/magit-log-flip-revs () + "Swap revisions in log range." + (interactive) + (let ((range (caar magit-refresh-args))) + (if (and range + (derived-mode-p 'magit-log-mode) + (string-match magit-range-re range)) + (progn + (setf (caar magit-refresh-args) + (concat (match-string 3 range) + (match-string 2 range) + (match-string 1 range))) + (magit-refresh)) + (user-error "No range to swap")))) + +(defun km/magit-flip-revs () + (interactive) + (cond ((derived-mode-p 'magit-diff-mode) + (call-interactively #'magit-diff-flip-revs)) + ((derived-mode-p 'magit-log-mode) + (call-interactively #'km/magit-log-flip-revs)))) + +(defun km/magit-diff-visit-file (&optional prev-rev other-window) + "Like `magit-diff-visit-file', but with the option to visit REV^. + +If prefix argument PREV-REV is non-nil, visit file for REV^ +instead of REV. If not in `magit-revision-mode', the prefix +argument has no effect. + +OTHER-WINDOW corresponds to `magit-diff-visit-file's OTHER-WINDOW +argument. Interactively, this can be accessed using the command +`km/magit-diff-visit-file-other-window'." + (interactive "P") + (let ((magit-refresh-args (if (and prev-rev + (derived-mode-p 'magit-revision-mode)) + (cons (concat (car magit-refresh-args) "^") + (cdr magit-refresh-args)) + magit-refresh-args)) + (current-prefix-arg (and other-window (list 4)))) + (call-interactively #'magit-diff-visit-file))) + +(defun km/magit-diff-visit-file-other-window (&optional prev-rev) + (interactive "P") + (km/magit-diff-visit-file prev-rev t)) + +(provide 'km-magit) +;;; km-magit.el ends here |