magit-files.el (23150B)
1 ;;; magit-files.el --- Finding files -*- lexical-binding:t -*- 2 3 ;; Copyright (C) 2008-2024 The Magit Project Contributors 4 5 ;; Author: Jonas Bernoulli <emacs.magit@jonas.bernoulli.dev> 6 ;; Maintainer: Jonas Bernoulli <emacs.magit@jonas.bernoulli.dev> 7 8 ;; SPDX-License-Identifier: GPL-3.0-or-later 9 10 ;; Magit is free software: you can redistribute it and/or modify it 11 ;; under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 ;; 15 ;; Magit is distributed in the hope that it will be useful, but WITHOUT 16 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 17 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 18 ;; License for more details. 19 ;; 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with Magit. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; This library implements support for finding blobs, staged files, 26 ;; and Git configuration files. It also implements modes useful in 27 ;; buffers visiting files and blobs, and the commands used by those 28 ;; modes. 29 30 ;;; Code: 31 32 (require 'magit) 33 34 ;;; Find Blob 35 36 (defvar magit-find-file-hook nil) 37 (add-hook 'magit-find-file-hook #'magit-blob-mode) 38 39 ;;;###autoload 40 (defun magit-find-file (rev file) 41 "View FILE from REV. 42 Switch to a buffer visiting blob REV:FILE, creating one if none 43 already exists. If prior to calling this command the current 44 buffer and/or cursor position is about the same file, then go 45 to the line and column corresponding to that location." 46 (interactive (magit-find-file-read-args "Find file")) 47 (magit-find-file--internal rev file #'pop-to-buffer-same-window)) 48 49 ;;;###autoload 50 (defun magit-find-file-other-window (rev file) 51 "View FILE from REV, in another window. 52 Switch to a buffer visiting blob REV:FILE, creating one if none 53 already exists. If prior to calling this command the current 54 buffer and/or cursor position is about the same file, then go to 55 the line and column corresponding to that location." 56 (interactive (magit-find-file-read-args "Find file in other window")) 57 (magit-find-file--internal rev file #'switch-to-buffer-other-window)) 58 59 ;;;###autoload 60 (defun magit-find-file-other-frame (rev file) 61 "View FILE from REV, in another frame. 62 Switch to a buffer visiting blob REV:FILE, creating one if none 63 already exists. If prior to calling this command the current 64 buffer and/or cursor position is about the same file, then go to 65 the line and column corresponding to that location." 66 (interactive (magit-find-file-read-args "Find file in other frame")) 67 (magit-find-file--internal rev file #'switch-to-buffer-other-frame)) 68 69 (defun magit-find-file-read-args (prompt) 70 (let ((pseudo-revs '("{worktree}" "{index}"))) 71 (if-let ((rev (magit-completing-read "Find file from revision" 72 (append pseudo-revs 73 (magit-list-refnames nil t)) 74 nil nil nil 'magit-revision-history 75 (or (magit-branch-or-commit-at-point) 76 (magit-get-current-branch))))) 77 (list rev (magit-read-file-from-rev (if (member rev pseudo-revs) 78 "HEAD" 79 rev) 80 prompt)) 81 (user-error "Nothing selected")))) 82 83 (defun magit-find-file--internal (rev file fn) 84 (let ((buf (magit-find-file-noselect rev file)) 85 line col) 86 (when-let ((visited-file (magit-file-relative-name))) 87 (setq line (line-number-at-pos)) 88 (setq col (current-column)) 89 (cond 90 ((not (equal visited-file file))) 91 ((equal magit-buffer-revision rev)) 92 ((equal rev "{worktree}") 93 (setq line (magit-diff-visit--offset file magit-buffer-revision line))) 94 ((equal rev "{index}") 95 (setq line (magit-diff-visit--offset file nil line))) 96 (magit-buffer-revision 97 (setq line (magit-diff-visit--offset 98 file (concat magit-buffer-revision ".." rev) line))) 99 (t 100 (setq line (magit-diff-visit--offset file (list "-R" rev) line))))) 101 (funcall fn buf) 102 (when line 103 (with-current-buffer buf 104 (widen) 105 (goto-char (point-min)) 106 (forward-line (1- line)) 107 (move-to-column col))) 108 buf)) 109 110 (defun magit-find-file-noselect (rev file) 111 "Read FILE from REV into a buffer and return the buffer. 112 REV is a revision or one of \"{worktree}\" or \"{index}\". 113 FILE must be relative to the top directory of the repository." 114 (magit-find-file-noselect-1 rev file)) 115 116 (defun magit-find-file-noselect-1 (rev file &optional revert) 117 "Read FILE from REV into a buffer and return the buffer. 118 REV is a revision or one of \"{worktree}\" or \"{index}\". 119 FILE must be relative to the top directory of the repository. 120 Non-nil REVERT means to revert the buffer. If `ask-revert', 121 then only after asking. A non-nil value for REVERT is ignored if REV is 122 \"{worktree}\"." 123 (if (equal rev "{worktree}") 124 (find-file-noselect (expand-file-name file (magit-toplevel))) 125 (let ((topdir (magit-toplevel))) 126 (when (file-name-absolute-p file) 127 (setq file (file-relative-name file topdir))) 128 (with-current-buffer (magit-get-revision-buffer-create rev file) 129 (when (or (not magit-buffer-file-name) 130 (if (eq revert 'ask-revert) 131 (y-or-n-p (format "%s already exists; revert it? " 132 (buffer-name)))) 133 revert) 134 (setq magit-buffer-revision 135 (if (equal rev "{index}") 136 "{index}" 137 (magit-rev-format "%H" rev))) 138 (setq magit-buffer-refname rev) 139 (setq magit-buffer-file-name (expand-file-name file topdir)) 140 (setq default-directory 141 (let ((dir (file-name-directory magit-buffer-file-name))) 142 (if (file-exists-p dir) dir topdir))) 143 (setq-local revert-buffer-function #'magit-revert-rev-file-buffer) 144 (revert-buffer t t) 145 (run-hooks (if (equal rev "{index}") 146 'magit-find-index-hook 147 'magit-find-file-hook))) 148 (current-buffer))))) 149 150 (defun magit-get-revision-buffer-create (rev file) 151 (magit-get-revision-buffer rev file t)) 152 153 (defun magit-get-revision-buffer (rev file &optional create) 154 (funcall (if create #'get-buffer-create #'get-buffer) 155 (format "%s.~%s~" file (subst-char-in-string ?/ ?_ rev)))) 156 157 (defun magit-revert-rev-file-buffer (_ignore-auto noconfirm) 158 (when (or noconfirm 159 (and (not (buffer-modified-p)) 160 (catch 'found 161 (dolist (regexp revert-without-query) 162 (when (string-match regexp magit-buffer-file-name) 163 (throw 'found t))))) 164 (yes-or-no-p (format "Revert buffer from Git %s? " 165 (if (equal magit-buffer-refname "{index}") 166 "index" 167 (concat "revision " magit-buffer-refname))))) 168 (let* ((inhibit-read-only t) 169 (default-directory (magit-toplevel)) 170 (file (file-relative-name magit-buffer-file-name)) 171 (coding-system-for-read (or coding-system-for-read 'undecided))) 172 (erase-buffer) 173 (magit-git-insert "cat-file" "-p" 174 (if (equal magit-buffer-refname "{index}") 175 (concat ":" file) 176 (concat magit-buffer-refname ":" file))) 177 (setq buffer-file-coding-system last-coding-system-used)) 178 (let ((buffer-file-name magit-buffer-file-name) 179 (after-change-major-mode-hook 180 (seq-difference after-change-major-mode-hook 181 '(global-diff-hl-mode-enable-in-buffer ; Emacs >= 30 182 global-diff-hl-mode-enable-in-buffers ; Emacs < 30 183 eglot--maybe-activate-editing-mode) 184 #'eq))) 185 (normal-mode t)) 186 (setq buffer-read-only t) 187 (set-buffer-modified-p nil) 188 (goto-char (point-min)))) 189 190 (define-advice lsp (:around (fn &rest args) magit-find-file) 191 "Do nothing when visiting blob using `magit-find-file' and similar. 192 See also https://github.com/doomemacs/doomemacs/pull/6309." 193 (unless magit-buffer-revision 194 (apply fn args))) 195 196 ;;; Find Index 197 198 (defvar magit-find-index-hook nil) 199 200 (defun magit-find-file-index-noselect (file &optional revert) 201 "Read FILE from the index into a buffer and return the buffer. 202 FILE must to be relative to the top directory of the repository." 203 (magit-find-file-noselect-1 "{index}" file (or revert 'ask-revert))) 204 205 (defun magit-update-index () 206 "Update the index with the contents of the current buffer. 207 The current buffer has to be visiting a file in the index, which 208 is done using `magit-find-index-noselect'." 209 (interactive) 210 (let ((file (magit-file-relative-name))) 211 (unless (equal magit-buffer-refname "{index}") 212 (user-error "%s isn't visiting the index" file)) 213 (if (y-or-n-p (format "Update index with contents of %s" (buffer-name))) 214 (let ((index (make-temp-name 215 (expand-file-name "magit-update-index-" (magit-gitdir)))) 216 (buffer (current-buffer))) 217 (when magit-wip-before-change-mode 218 (magit-wip-commit-before-change (list file) " before un-/stage")) 219 (unwind-protect 220 (progn 221 (let ((coding-system-for-write buffer-file-coding-system)) 222 (with-temp-file index 223 (insert-buffer-substring buffer))) 224 (magit-with-toplevel 225 (magit-call-git 226 "update-index" "--cacheinfo" 227 (substring (magit-git-string "ls-files" "-s" file) 228 0 6) 229 (magit-git-string "hash-object" "-t" "blob" "-w" 230 (concat "--path=" file) 231 "--" (magit-convert-filename-for-git index)) 232 file))) 233 (ignore-errors (delete-file index))) 234 (set-buffer-modified-p nil) 235 (when magit-wip-after-apply-mode 236 (magit-wip-commit-after-apply (list file) " after un-/stage"))) 237 (message "Abort"))) 238 (when-let ((buffer (magit-get-mode-buffer 'magit-status-mode))) 239 (with-current-buffer buffer 240 (magit-refresh))) 241 t) 242 243 ;;; Find Config File 244 245 (defun magit-find-git-config-file (filename &optional wildcards) 246 "Edit a file located in the current repository's git directory. 247 248 When \".git\", located at the root of the working tree, is a 249 regular file, then that makes it cumbersome to open a file 250 located in the actual git directory. 251 252 This command is like `find-file', except that it temporarily 253 binds `default-directory' to the actual git directory, while 254 reading the FILENAME." 255 (interactive 256 (let ((default-directory (magit-gitdir))) 257 (find-file-read-args "Find file: " 258 (confirm-nonexistent-file-or-buffer)))) 259 (find-file filename wildcards)) 260 261 (defun magit-find-git-config-file-other-window (filename &optional wildcards) 262 "Edit a file located in the current repo's git directory, in another window. 263 264 When \".git\", located at the root of the working tree, is a 265 regular file, then that makes it cumbersome to open a file 266 located in the actual git directory. 267 268 This command is like `find-file-other-window', except that it 269 temporarily binds `default-directory' to the actual git 270 directory, while reading the FILENAME." 271 (interactive 272 (let ((default-directory (magit-gitdir))) 273 (find-file-read-args "Find file in other window: " 274 (confirm-nonexistent-file-or-buffer)))) 275 (find-file-other-window filename wildcards)) 276 277 (defun magit-find-git-config-file-other-frame (filename &optional wildcards) 278 "Edit a file located in the current repo's git directory, in another frame. 279 280 When \".git\", located at the root of the working tree, is a 281 regular file, then that makes it cumbersome to open a file 282 located in the actual git directory. 283 284 This command is like `find-file-other-frame', except that it 285 temporarily binds `default-directory' to the actual git 286 directory, while reading the FILENAME." 287 (interactive 288 (let ((default-directory (magit-gitdir))) 289 (find-file-read-args "Find file in other frame: " 290 (confirm-nonexistent-file-or-buffer)))) 291 (find-file-other-frame filename wildcards)) 292 293 ;;; File Dispatch 294 295 ;;;###autoload (autoload 'magit-file-dispatch "magit" nil t) 296 (transient-define-prefix magit-file-dispatch () 297 "Invoke a Magit command that acts on the visited file. 298 When invoked outside a file-visiting buffer, then fall back 299 to `magit-dispatch'." 300 :info-manual "(magit) Minor Mode for Buffers Visiting Files" 301 [:if magit-file-relative-name 302 ["File actions" 303 (" s" "Stage" magit-stage-buffer-file) 304 (" u" "Unstage" magit-unstage-buffer-file) 305 (", x" "Untrack" magit-file-untrack) 306 (", r" "Rename" magit-file-rename) 307 (", k" "Delete" magit-file-delete) 308 (", c" "Checkout" magit-file-checkout)] 309 ["Inspect" 310 ("D" "Diff..." magit-diff) 311 ("d" "Diff" magit-diff-buffer-file)] 312 ["" 313 ("L" "Log..." magit-log) 314 ("l" "Log" magit-log-buffer-file) 315 ("t" "Trace" magit-log-trace-definition) 316 (7 "M" "Merged" magit-log-merged)] 317 ["" 318 ("B" "Blame..." magit-blame) 319 ("b" "Blame" magit-blame-addition) 320 ("r" "...removal" magit-blame-removal) 321 ("f" "...reverse" magit-blame-reverse) 322 ("m" "Blame echo" magit-blame-echo) 323 ("q" "Quit blame" magit-blame-quit)] 324 ["Navigate" 325 ("p" "Prev blob" magit-blob-previous) 326 ("n" "Next blob" magit-blob-next) 327 ("v" "Goto blob" magit-find-file) 328 ("V" "Goto file" magit-blob-visit-file) 329 ("g" "Goto status" magit-status-here) 330 ("G" "Goto magit" magit-display-repository-buffer)] 331 ["More actions" 332 ("c" "Commit" magit-commit) 333 ("e" "Edit line" magit-edit-line-commit)]] 334 [:if-not magit-file-relative-name 335 ["File actions" 336 ("s" "Stage" magit-stage-file) 337 ("u" "Unstage" magit-unstage-file) 338 ("x" "Untrack" magit-file-untrack) 339 ("r" "Rename" magit-file-rename) 340 ("k" "Delete" magit-file-delete) 341 ("c" "Checkout" magit-file-checkout)] 342 ["Navigate" 343 ("g" "Goto status" magit-status-here :if-not-mode magit-status-mode) 344 ("G" "Goto magit" magit-display-repository-buffer)]]) 345 346 ;;; Blob Mode 347 348 (defvar-keymap magit-blob-mode-map 349 :doc "Keymap for `magit-blob-mode'." 350 "p" #'magit-blob-previous 351 "n" #'magit-blob-next 352 "b" #'magit-blame-addition 353 "r" #'magit-blame-removal 354 "f" #'magit-blame-reverse 355 "q" #'magit-kill-this-buffer) 356 357 (define-minor-mode magit-blob-mode 358 "Enable some Magit features in blob-visiting buffers. 359 360 Currently this only adds the following key bindings. 361 \n\\{magit-blob-mode-map}" 362 :package-version '(magit . "2.3.0")) 363 364 (defun magit-blob-next () 365 "Visit the next blob which modified the current file." 366 (interactive) 367 (if magit-buffer-file-name 368 (magit-blob-visit (or (magit-blob-successor magit-buffer-revision 369 magit-buffer-file-name) 370 magit-buffer-file-name)) 371 (if (buffer-file-name (buffer-base-buffer)) 372 (user-error "You have reached the end of time") 373 (user-error "Buffer isn't visiting a file or blob")))) 374 375 (defun magit-blob-previous () 376 "Visit the previous blob which modified the current file." 377 (interactive) 378 (if-let ((file (or magit-buffer-file-name 379 (buffer-file-name (buffer-base-buffer))))) 380 (if-let ((ancestor (magit-blob-ancestor magit-buffer-revision file))) 381 (magit-blob-visit ancestor) 382 (user-error "You have reached the beginning of time")) 383 (user-error "Buffer isn't visiting a file or blob"))) 384 385 ;;;###autoload 386 (defun magit-blob-visit-file () 387 "View the file from the worktree corresponding to the current blob. 388 When visiting a blob or the version from the index, then go to 389 the same location in the respective file in the working tree." 390 (interactive) 391 (if-let ((file (magit-file-relative-name))) 392 (magit-find-file--internal "{worktree}" file #'pop-to-buffer-same-window) 393 (user-error "Not visiting a blob"))) 394 395 (defun magit-blob-visit (blob-or-file) 396 (if (stringp blob-or-file) 397 (find-file blob-or-file) 398 (pcase-let ((`(,rev ,file) blob-or-file)) 399 (magit-find-file rev file) 400 (apply #'message "%s (%s %s ago)" 401 (magit-rev-format "%s" rev) 402 (magit--age (magit-rev-format "%ct" rev)))))) 403 404 (defun magit-blob-ancestor (rev file) 405 (let ((lines (magit-with-toplevel 406 (magit-git-lines "log" "-2" "--format=%H" "--name-only" 407 "--follow" (or rev "HEAD") "--" file)))) 408 (if rev (cddr lines) (butlast lines 2)))) 409 410 (defun magit-blob-successor (rev file) 411 (let ((lines (magit-with-toplevel 412 (magit-git-lines "log" "--format=%H" "--name-only" "--follow" 413 "HEAD" "--" file)))) 414 (catch 'found 415 (while lines 416 (if (equal (nth 2 lines) rev) 417 (throw 'found (list (nth 0 lines) (nth 1 lines))) 418 (setq lines (nthcdr 2 lines))))))) 419 420 ;;; File Commands 421 422 (defun magit-file-rename (file newname) 423 "Rename or move FILE to NEWNAME. 424 NEWNAME may be a file or directory name. If FILE isn't tracked in 425 Git, fallback to using `rename-file'." 426 (interactive 427 (let* ((file (magit-read-file "Rename file")) 428 (path (expand-file-name file (magit-toplevel)))) 429 (list path (expand-file-name 430 (read-file-name (format "Move %s to destination: " file) 431 (file-name-directory path)))))) 432 (let ((oldbuf (get-file-buffer file)) 433 (dstdir (file-name-directory newname)) 434 (dstfile (if (directory-name-p newname) 435 (concat newname (file-name-nondirectory file)) 436 newname))) 437 (when (and oldbuf (buffer-modified-p oldbuf)) 438 (user-error "Save %s before moving it" file)) 439 (when (file-exists-p dstfile) 440 (user-error "%s already exists" dstfile)) 441 (unless (file-exists-p dstdir) 442 (user-error "Destination directory %s does not exist" dstdir)) 443 (if (magit-file-tracked-p file) 444 (magit-call-git "mv" 445 (magit-convert-filename-for-git file) 446 (magit-convert-filename-for-git newname)) 447 (rename-file file newname current-prefix-arg)) 448 (when oldbuf 449 (with-current-buffer oldbuf 450 (let ((buffer-read-only buffer-read-only)) 451 (set-visited-file-name dstfile nil t)) 452 (if (fboundp 'vc-refresh-state) 453 (vc-refresh-state) 454 (with-no-warnings 455 (vc-find-file-hook)))))) 456 (magit-refresh)) 457 458 (defun magit-file-untrack (files &optional force) 459 "Untrack the selected FILES or one file read in the minibuffer. 460 461 With a prefix argument FORCE do so even when the files have 462 staged as well as unstaged changes." 463 (interactive (list (or (if-let ((files (magit-region-values 'file t))) 464 (if (magit-file-tracked-p (car files)) 465 (magit-confirm-files 'untrack files "Untrack") 466 (user-error "Already untracked")) 467 (list (magit-read-tracked-file "Untrack file")))) 468 current-prefix-arg)) 469 (magit-with-toplevel 470 (magit-run-git "rm" "--cached" (and force "--force") "--" files))) 471 472 (defun magit-file-delete (files &optional force) 473 "Delete the selected FILES or one file read in the minibuffer. 474 475 With a prefix argument FORCE do so even when the files have 476 uncommitted changes. When the files aren't being tracked in 477 Git, then fallback to using `delete-file'." 478 (interactive (list (if-let ((files (magit-region-values 'file t))) 479 (magit-confirm-files 'delete files "Delete") 480 (list (magit-read-file "Delete file"))) 481 current-prefix-arg)) 482 (if (magit-file-tracked-p (car files)) 483 (magit-call-git "rm" (and force "--force") "--" files) 484 (let ((topdir (magit-toplevel))) 485 (dolist (file files) 486 (delete-file (expand-file-name file topdir) t)))) 487 (magit-refresh)) 488 489 ;;;###autoload 490 (defun magit-file-checkout (rev file) 491 "Checkout FILE from REV." 492 (interactive 493 (let ((rev (magit-read-branch-or-commit 494 "Checkout from revision" magit-buffer-revision))) 495 (list rev (magit-read-file-from-rev rev "Checkout file" nil t)))) 496 (magit-with-toplevel 497 (magit-run-git "checkout" rev "--" file))) 498 499 ;;; Read File 500 501 (defvar magit-read-file-hist nil) 502 503 (defun magit-read-file-from-rev (rev prompt &optional default include-dirs) 504 (let ((files (magit-revision-files rev))) 505 (when include-dirs 506 (setq files (sort (nconc files (magit-revision-directories rev)) 507 #'string<))) 508 (magit-completing-read 509 prompt files nil t nil 'magit-read-file-hist 510 (car (member (or default (magit-current-file)) files))))) 511 512 (defun magit-read-file (prompt &optional tracked-only) 513 (magit-with-toplevel 514 (let ((choices (nconc (magit-list-files) 515 (and (not tracked-only) 516 (magit-untracked-files))))) 517 (magit-completing-read 518 prompt choices nil t nil nil 519 (car (member (or (magit-section-value-if '(file submodule)) 520 (magit-file-relative-name nil tracked-only)) 521 choices)))))) 522 523 (defun magit-read-tracked-file (prompt) 524 (magit-read-file prompt t)) 525 526 (defun magit-read-unmerged-file (&optional prompt) 527 (let ((current (magit-current-file)) 528 (unmerged (magit-unmerged-files))) 529 (unless unmerged 530 (user-error "There are no unresolved conflicts")) 531 (magit-completing-read (or prompt "Resolve file") 532 unmerged nil t nil nil 533 (car (member current unmerged))))) 534 535 (defun magit-read-file-choice (prompt files &optional error default) 536 "Read file from FILES. 537 538 If FILES has only one member, return that instead of prompting. 539 If FILES has no members, give a user error. ERROR can be given 540 to provide a more informative error. 541 542 If DEFAULT is non-nil, use this as the default value instead of 543 `magit-current-file'." 544 (pcase (length files) 545 (0 (user-error (or error "No file choices"))) 546 (1 (car files)) 547 (_ (magit-completing-read 548 prompt files nil t nil 'magit-read-file-hist 549 (car (member (or default (magit-current-file)) files)))))) 550 551 (defun magit-read-changed-file (rev-or-range prompt &optional default) 552 (magit-read-file-choice 553 prompt 554 (magit-changed-files rev-or-range) 555 default 556 (concat "No file changed in " rev-or-range))) 557 558 ;;; _ 559 (provide 'magit-files) 560 ;;; magit-files.el ends here