diff options
Diffstat (limited to 'lisp/emacs-lisp/package-vc.el')
-rw-r--r-- | lisp/emacs-lisp/package-vc.el | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/lisp/emacs-lisp/package-vc.el b/lisp/emacs-lisp/package-vc.el new file mode 100644 index 00000000000..52e7e25e9f1 --- /dev/null +++ b/lisp/emacs-lisp/package-vc.el @@ -0,0 +1,615 @@ +;;; package-vc.el --- Manage packages from VC checkouts -*- lexical-binding: t; -*- + +;; Copyright (C) 2022 Free Software Foundation, Inc. + +;; Author: Philip Kaludercic <philipk@posteo.net> +;; Keywords: tools + +;; 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 <https://www.gnu.org/licenses/>. + +;;; Commentary: + +;; While packages managed by package.el use tarballs for distributing +;; the source code, this extension allows for packages to be fetched +;; and updated directly from a version control system. + +;;; TODO: + +;; - Allow for automatic updating +;; * Detect merge conflicts +;; * Check if there are upstream changes +;; - Allow finding revisions that bump the version tag +;; * Allow for `package-vc-install' to use the version +;; of the package if already installed. +;; - Allow for ELPA specifications to be respected without +;; endangering the user with arbitrary code execution +;; - Allow maintaining patches that are ported back onto regular +;; packages and maintained between versions. +;; - Allow locking the specific revisions of sourced packages +;; (comparable to `package-selected-packages') so that specific +;; revisions can be re-installed. + +;;; Code: + +(eval-when-compile (require 'rx)) +(eval-when-compile (require 'inline)) +(eval-when-compile (require 'map)) +(require 'package) +(require 'lisp-mnt) +(require 'vc) +(require 'seq) +(require 'xdg) + +(defgroup package-vc nil + "Manage packages from VC checkouts." + :group 'package + :version "29.1") + +(defconst package-vc-elpa-packages-version 1 + "Version number of the package specification format understood by package-vc.") + +(defcustom package-vc-heuristic-alist + `((,(rx bos "http" (? "s") "://" + (or (: (? "www.") "github.com" + "/" (+ (or alnum "-" "." "_")) + "/" (+ (or alnum "-" "." "_"))) + (: "codeberg.org" + "/" (+ (or alnum "-" "." "_")) + "/" (+ (or alnum "-" "." "_"))) + (: (? "www.") "gitlab" (+ "." (+ alnum)) + "/" (+ (or alnum "-" "." "_")) + "/" (+ (or alnum "-" "." "_"))) + (: "git.sr.ht" + "/~" (+ (or alnum "-" "." "_")) + "/" (+ (or alnum "-" "." "_"))) + (: "git." (or "savannah" "sv") "." (? "non") "gnu.org/" + (or "r" "git") "/" + (+ (or alnum "-" "." "_")) (? "/"))) + (or (? "/") ".git") eos) + . Git) + (,(rx bos "http" (? "s") "://" + (or (: "hg.sr.ht" + "/~" (+ (or alnum "-" "." "_")) + "/" (+ (or alnum "-" "." "_"))) + (: "hg." (or "savannah" "sv") "." (? "non") "gnu.org/hgweb/" + (+ (or alnum "-" "." "_")) (? "/"))) + eos) + . Hg) + (,(rx bos "http" (? "s") "://" + (or (: "bzr." (or "savannah" "sv") "." (? "non") "gnu.org/r/" + (+ (or alnum "-" "." "_")) (? "/"))) + eos) + . Bzr)) + "Heuristic mapping URL regular expressions to VC backends." + :type `(alist :key-type (regexp :tag "Regular expression matching URLs") + :value-type (choice :tag "VC Backend" + ,@(mapcar (lambda (b) `(const ,b)) + vc-handled-backends))) + :version "29.1") + +(defcustom package-vc-repository-store + (expand-file-name "emacs/vc-packages" (xdg-data-home)) + "Directory used by `package-vc-unpack' to store repositories." + :type 'directory + :version "29.1") + +(defcustom package-vc-default-backend 'Git + "Default VC backend used when cloning a package repository. +If no repository type was specified or could be guessed by +`package-vc-heuristic-alist', the VC backend denoted by this +symbol is used. The value must be a member of +`vc-handled-backends' that implements the `clone' function." + :type `(choice ,@(mapcar (lambda (b) (list 'const b)) + vc-handled-backends)) + :version "29.1") + +(defvar package-vc-archive-spec-alist nil + "List of package specifications for each archive. +The list maps package names as string to plist. Valid keys +include + + `:url' (string) + +The URL of the repository used to fetch the package source. + + `:branch' (string) + +If given, the branch to check out after cloning the directory. + + `:lisp-dir' (string) + +The repository-relative directory to use for loading the Lisp +sources. If not given, the value defaults to the root directory +of the repository. + + `:main-file' (string) + +The main file of the project, relevant to gather package +metadata. If not given, the assumed default is the package named +with \".el\" concatenated to the end. + + `:vc-backend' (symbol) + +A symbol indicating what the VC backend to use for cloning a +package. The value ought to be a member of +`vc-handled-backends'. If missing, `vc-clone' will fall back +onto the archive default or `package-vc-default-backend'. + +All other values are ignored.") + +(defvar package-vc-archive-data-alist nil + "List of package specification archive metadata. +Each element of the list has the form (ARCHIVE . PLIST), where +PLIST keys are one of: + + `:version' (integer) + +Indicating the version of the file formatting, to be compared +with `package-vc-elpa-packages-version'. + + `:vc-backend' (symbol) + +A symbol indicating what the default VC backend to use if a +package specification does not indicate anything. The value +ought to be a member of `vc-handled-backends'. If missing, +`vc-clone' will fall back onto `package-vc-default-backend'. + +All other values are ignored.") + +(defun package-vc-desc->spec (pkg-desc &optional name) + "Retrieve the package specification for PKG-DESC. +The optional argument NAME can be used to override the default +name for PKG-DESC." + (alist-get + (or name (package-desc-name pkg-desc)) + (if (package-desc-archive pkg-desc) + (alist-get (intern (package-desc-archive pkg-desc)) + package-vc-archive-spec-alist) + (mapcan #'append (mapcar #'cdr package-vc-archive-spec-alist))) + nil nil #'string=)) + +(define-inline package-vc-query-spec (pkg-desc prop) + "Query the property PROP for the package specification for PKG-DESC. +If no package specification can be determined, the function will +return nil." + (inline-letevals (pkg-desc prop) + (inline-quote (plist-get (package-vc-desc->spec ,pkg-desc) ,prop)))) + +(defun package-vc--read-archive-data (archive) + "Update `package-vc-archive-spec-alist' with the contents of ARCHIVE. +This function is meant to be used as a hook for +`package--read-archive-hook'." + (let* ((contents-file (expand-file-name + (format "archives/%s/elpa-packages.eld" archive) + package-user-dir))) + (when (file-exists-p contents-file) + (with-temp-buffer + (let ((coding-system-for-read 'utf-8)) + (insert-file-contents contents-file) + ;; The response from the server is expected to have the form + ;; + ;; ((("foo" :url "..." ...) ...) + ;; :version 1 + ;; :default-vc Git) + (let ((spec (read (current-buffer)))) + (when (eq package-vc-elpa-packages-version + (plist-get (cdr spec) :version)) + (setf (alist-get (intern archive) package-vc-archive-spec-alist) + (car spec))) + (setf (alist-get (intern archive) package-vc-archive-data-alist) + (cdr spec)) + (when-let ((default-vc (plist-get (cdr spec) :default-vc)) + ((not (memq default-vc vc-handled-backends)))) + (warn "Archive `%S' expects missing VC backend %S" + archive (plist-get (cdr spec) :default-vc))))))))) + +(defun package-vc--download-and-read-archives (&optional async) + "Download specifications of all `package-archives' and read them. +Populate `package-vc-archive-spec-alist' with the result. + +If optional argument ASYNC is non-nil, perform the downloads +asynchronously." + (dolist (archive package-archives) + (condition-case-unless-debug nil + (package--download-one-archive archive "elpa-packages.eld" async) + (error (message "Failed to download `%s' archive." (car archive)))))) + +(add-hook 'package-read-archive-hook #'package-vc--read-archive-data 20) +(add-hook 'package-refresh-contents-hook #'package-vc--download-and-read-archives 20) + +(defun package-vc-commit (pkg) + "Extract the commit of a development package PKG." + (cl-assert (package-vc-p pkg)) + ;; FIXME: vc should be extended to allow querying the commit of a + ;; directory (as is possible when dealing with git repositores). + ;; This should be a fallback option. + (cl-loop with dir = (package-desc-dir pkg) + for file in (directory-files dir t "\\.el\\'" t) + when (vc-working-revision file) return it + finally return "unknown")) + +(defun package-vc-version (pkg) + "Extract the commit of a development package PKG." + (cl-assert (package-vc-p pkg)) + (cl-loop with dir = (package-desc-dir pkg) ;FIXME: dir is nil + for file in (sort (directory-files dir t "\\.el\\'") + (lambda (s1 s2) + (< (length s1) (length s2)))) + when (with-temp-buffer + (insert-file-contents file) + (package-strip-rcs-id + (or (lm-header "package-version") + (lm-header "version")))) + return it + finally return "0")) + +(defun package-vc-main-file (pkg-desc) + "Return the main file for PKG-DESC." + (cl-assert (package-vc-p pkg-desc)) + (let ((pkg-spec (package-vc-desc->spec pkg-desc))) + (or (plist-get pkg-spec :main-file) + (expand-file-name + (format "%s.el" (package-desc-name pkg-desc)) + (file-name-concat + (or (package-desc-dir pkg-desc) + (expand-file-name + (package-desc-name pkg-desc) + package-user-dir)) + (plist-get pkg-spec :lisp-dir)))))) + +(defun package-vc-generate-description-file (pkg-desc pkg-file) + "Generate a package description file for PKG-DESC. +The output is written out into PKG-FILE." + (let ((name (package-desc-name pkg-desc))) + ;; Infer the subject if missing. + (unless (package-desc-summary pkg-desc) + (setf (package-desc-summary pkg-desc) + (let ((main-file (package-vc-main-file pkg-desc))) + (or (package-desc-summary pkg-desc) + (and-let* ((pkg (cadr (assq name package-archive-contents)))) + (package-desc-summary pkg)) + (and main-file (file-exists-p main-file) + (lm-summary main-file)) + package--default-summary)))) + (let ((print-level nil) + (print-quoted t) + (print-length nil)) + (write-region + (concat + ";;; Generated package description from " + (replace-regexp-in-string + "-pkg\\.el\\'" ".el" + (file-name-nondirectory pkg-file)) + " -*- no-byte-compile: t -*-\n" + (prin1-to-string + (nconc + (list 'define-package + (symbol-name name) + (cons 'vc (package-vc-version pkg-desc)) + (package-desc-summary pkg-desc) + (let ((requires (package-desc-reqs pkg-desc))) + (list 'quote + ;; Turn version lists into string form. + (mapcar + (lambda (elt) + (list (car elt) + (package-version-join (cadr elt)))) + requires)))) + (package--alist-to-plist-args + (package-desc-extras pkg-desc)))) + "\n") + nil pkg-file nil 'silent)))) + +(declare-function org-export-to-file "ox" (backend file)) + +(defun package-vc-build-documentation (pkg-desc file) + "Build documentation FILE for PKG-DESC." + (let ((pkg-dir (package-desc-dir pkg-desc))) + (when (string-match-p "\\.org\\'" file) + (require 'ox) + (require 'ox-texinfo) + (with-temp-buffer + (insert-file-contents file) + (setq file (make-temp-file "ox-texinfo-")) + (org-export-to-file 'texinfo file))) + (call-process "install-info" nil nil nil + file pkg-dir))) + +(defun package-vc-unpack-1 (pkg-desc pkg-dir) + "Install PKG-DESC that is already located in PKG-DIR." + ;; In case the package was installed directly from source, the + ;; dependency list wasn't know beforehand, and they might have + ;; to be installed explicitly. + (let (deps) + (dolist (file (directory-files pkg-dir t "\\.el\\'" t)) + (with-temp-buffer + (insert-file-contents file) + (when-let* ((require-lines (lm-header-multiline "package-requires"))) + (thread-last + (mapconcat #'identity require-lines " ") + package-read-from-string + package--prepare-dependencies + (nconc deps) + (setq deps))))) + (dolist (dep deps) + (cl-callf version-to-list (cadr dep))) + (package-download-transaction + (package-compute-transaction nil (delete-dups deps)))) + + (let ((default-directory (file-name-as-directory pkg-dir)) + (name (package-desc-name pkg-desc)) + (pkg-file (expand-file-name (package--description-file pkg-dir) pkg-dir))) + ;; Generate autoloads + (package-generate-autoloads name pkg-dir) + + ;; Generate package file + (package-vc-generate-description-file pkg-desc pkg-file) + + ;; Detect a manual + (when-let ((pkg-spec (package-vc-desc->spec pkg-desc)) + ((executable-find "install-info"))) + (dolist (doc-file (ensure-list (plist-get pkg-spec :doc))) + (package-vc-build-documentation pkg-desc doc-file)))) + + ;; Update package-alist. + (let ((new-desc (package-load-descriptor pkg-dir))) + ;; Activation has to be done before compilation, so that if we're + ;; upgrading and macros have changed we load the new definitions + ;; before compiling. + (when (package-activate-1 new-desc :reload :deps) + ;; FIXME: Compilation should be done as a separate, optional, step. + ;; E.g. for multi-package installs, we should first install all packages + ;; and then compile them. + (package--compile new-desc) + (when package-native-compile + (package--native-compile-async new-desc)) + ;; After compilation, load again any files loaded by + ;; `activate-1', so that we use the byte-compiled definitions. + (package--reload-previously-loaded new-desc))) + + ;; Mark package as selected + (package--save-selected-packages + (cons (package-desc-name pkg-desc) + package-selected-packages)) + + ;; Confirm that the installation was successful + (let ((main-file (package-vc-main-file pkg-desc))) + (message "Source package `%s' installed (Version %s, Revision %S)." + (package-desc-name pkg-desc) + (lm-with-file main-file + (package-strip-rcs-id + (or (lm-header "package-version") + (lm-header "version")))) + (vc-working-revision main-file))) + t) + +(defun package-vc-guess-backend (url) + "Guess the VC backend for URL. +This function will internally query `package-vc-heuristic-alist' +and return nil if no reasonable guess can be made." + (and url (alist-get url package-vc-heuristic-alist + nil nil #'string-match-p))) + +(defun package-vc-unpack (pkg-desc pkg-spec &optional rev) + "Install the package described by PKG-DESC. +PKG-SPEC is a package specification is a property list describing +how to fetch and build the package PKG-DESC. See +`package-vc-archive-spec-alist' for details. The optional argument +REV specifies a specific revision to checkout. This overrides +the `:brach' attribute in PKG-SPEC." + (let* ((name (package-desc-name pkg-desc)) + (dirname (package-desc-full-name pkg-desc)) + (pkg-dir (expand-file-name dirname package-user-dir))) + (setf (package-desc-dir pkg-desc) pkg-dir) + (when (file-exists-p pkg-dir) + (if (yes-or-no-p "Overwrite previous checkout?") + (package--delete-directory pkg-dir pkg-desc) + (error "There already exists a checkout for %s" name))) + (pcase-let* (((map :url :branch :lisp-dir) pkg-spec) + (repo-dir + (if (null lisp-dir) + pkg-dir + (unless (file-exists-p package-vc-repository-store) + (make-directory package-vc-repository-store t)) + (file-name-concat + package-vc-repository-store + ;; FIXME: We aren't sure this directory + ;; will be unique, but we can try other + ;; names to avoid an unnecessary error. + (file-name-base url))))) + + ;; Clone the repository into `repo-dir' if necessary + (unless (file-exists-p repo-dir) + (make-directory (file-name-directory repo-dir) t) + (let ((backend (or (plist-get pkg-spec :vc-backend) + (package-vc-query-spec pkg-desc :vc-backend) + (package-vc-guess-backend url) + (plist-get (alist-get (package-desc-archive pkg-desc) + package-vc-archive-data-alist + nil nil #'string=) + :vc-backend) + package-vc-default-backend))) + (unless (vc-clone url backend repo-dir + (or (and (not (eq rev :last-release)) rev) branch)) + (error "Failed to clone %s from %s" name url)))) + + ;; Check out the latest release if requested + (when (eq rev :last-release) + (if-let ((release-rev (package-vc-release-rev pkg-desc))) + (vc-retrieve-tag pkg-dir release-rev) + (message "No release revision was found, continuing..."))) + + (unless (eq pkg-dir repo-dir) + ;; Link from the right position in `repo-dir' to the package + ;; directory in the ELPA store. + (make-symbolic-link (file-name-concat repo-dir lisp-dir) pkg-dir))) + (package-vc-unpack-1 pkg-desc pkg-dir))) + +(defun package-vc-sourced-packages-list () + "Generate a list of packages with VC data." + (seq-filter + (lambda (pkg) + (or (package-vc-desc->spec (cadr pkg)) + ;; If we have no explicit VC data, we can try a kind of + ;; heuristic and use the URL header, that might already be + ;; pointing towards a repository, and use that as a backup + (and-let* ((extras (package-desc-extras (cadr pkg))) + (url (alist-get :url extras)) + ((package-vc-guess-backend url)))))) + package-archive-contents)) + +(defun package-vc-update (pkg-desc) + "Attempt to update the packager PKG-DESC." + (let* ((default-directory (package-desc-dir pkg-desc)) + (ret (with-demoted-errors "Error during package update: %S" + (vc-pull))) + (buf (cond + ((processp ret) (process-buffer ret)) + ((bufferp ret) ret)))) + (if buf + (with-current-buffer buf + (vc-run-delayed + (package-vc-unpack-1 pkg-desc default-directory))) + (package-vc-unpack-1 pkg-desc default-directory)))) + +(defun package-vc--archives-initialize () + "Initialise package.el and fetch package specifications." + (package--archives-initialize) + (unless package-vc-archive-data-alist + (package-vc--download-and-read-archives))) + +(defun package-vc-release-rev (pkg-desc) + "Find the latest revision that bumps the \"Version\" tag for PKG-DESC. +If no such revision can be found, return nil." + (with-current-buffer (find-file-noselect (package-vc-main-file pkg-desc)) + (vc-buffer-sync) + (save-excursion + (goto-char (point-min)) + (let ((case-fold-search t)) + (when (cond + ((re-search-forward + (concat (lm-get-header-re "package-version") ".*$") + (lm-code-start) t)) + ((re-search-forward + (concat (lm-get-header-re "version") ".*$") + (lm-code-start) t))) + (ignore-error vc-not-supported + (vc-call-backend (vc-backend (buffer-file-name)) + 'last-change + (buffer-file-name) + (line-number-at-pos nil t)))))))) + +;;;###autoload +(defun package-vc-install (name-or-url &optional name rev backend) + "Fetch the source of NAME-OR-URL. +If NAME-OR-URL is a URL, then the package will be downloaded from +the repository indicated by the URL. The function will try to +guess the name of the package using `file-name-base'. This can +be overridden by manually passing the optional NAME. Otherwise +NAME-OR-URL is taken to be a package name, and the package +metadata will be consulted for the URL. An explicit revision can +be requested using REV. If the command is invoked with a prefix +argument, the revision used for the last release in the package +archive is used. This can also be reproduced by passing the +special value `:last-release' as REV. If a NAME-OR-URL is a URL, +that is to say a string, the VC backend used to clone the +repository can be set by BACKEND. If missing, +`package-vc-guess-backend' will be used." + (interactive + (progn + ;; Initialize the package system to get the list of package + ;; symbols for completion. + (package-vc--archives-initialize) + (let* ((packages (package-vc-sourced-packages-list)) + (input (completing-read + "Fetch package source (name or URL): " packages)) + (name (file-name-base input))) + (list input (intern (string-remove-prefix "emacs-" name)) + (and current-prefix-arg :last-release))))) + (package-vc--archives-initialize) + (cond + ((and-let* ((stringp name-or-url) + (backend (or backend (package-vc-guess-backend name-or-url)))) + (package-vc-unpack + (package-desc-create + :name (or name (intern (file-name-base name-or-url))) + :kind 'vc) + (list :vc-backend backend :url name-or-url) + rev))) + ((and-let* ((desc (assoc name-or-url package-archive-contents #'string=))) + (package-vc-unpack + (let ((copy (copy-package-desc (cadr desc)))) + (setf (package-desc-kind copy) 'vc) + copy) + (or (package-vc-desc->spec (cadr desc)) + (and-let* ((extras (package-desc-extras (cadr desc))) + (url (alist-get :url extras)) + (backend (package-vc-guess-backend url))) + (list :vc-backend backend :url url)) + (user-error "Package has no VC data")) + rev))) + ((user-error "Unknown package to fetch: %s" name-or-url)))) + +(defun package-vc-link-directory (dir name) + "Install the package NAME in DIR by linking it into the ELPA directory. +If invoked interactively with a prefix argument, the user will be +prompted for the package NAME. Otherwise it will be inferred +from the base name of DIR." + (interactive (let ((dir (read-directory-name "Directory: "))) + (list dir + (if current-prefix-arg + (read-string "Package name: ") + (file-name-base (directory-file-name dir)))))) + (unless (vc-responsible-backend dir) + (user-error "Directory %S is not under version control" dir)) + (package-vc--archives-initialize) + (let* ((name (or name (file-name-base (directory-file-name dir)))) + (pkg-dir (expand-file-name name package-user-dir))) + (make-symbolic-link dir pkg-dir) + (package-vc-unpack-1 (package-desc-create + :name (intern name) + :kind 'vc) + pkg-dir))) + +(defun package-vc-refresh (pkg-desc) + "Refresh the installation for PKG-DESC." + (interactive (package-vc-read-pkg "Refresh package: ")) + (package-vc-unpack-1 pkg-desc (package-desc-dir pkg-desc))) + +(defun package-vc-read-pkg (prompt) + "Query for a source package description with PROMPT." + (cadr (assoc (completing-read + prompt + package-alist + (lambda (pkg) (package-vc-p (cadr pkg))) + t) + package-alist + #'string=))) + +;;;###autoload +(defun package-vc-prepare-patch (pkg subject revisions) + "Send a patch to the maintainer of a package PKG. +SUBJECT and REVISIONS are used passed on to `vc-prepare-patch'. +PKG must be a package description." + (interactive + (list (package-vc-read-pkg "Package to prepare a patch for: ") + (and (not vc-prepare-patches-separately) + (read-string "Subject: " "[PATCH] " nil nil t)) + (or (log-view-get-marked) + (vc-read-multiple-revisions "Revisions: ")))) + (vc-prepare-patch (package-maintainers pkg t) + subject revisions)) + +(provide 'package-vc) +;;; package-vc.el ends here |