;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2013, 2014, 2015, 2017, 2018 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2017 Marius Bakke <mbakke@fastmail.com>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix 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.
;;;
;;; GNU Guix 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 GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (guix scripts pull)
  #:use-module (guix ui)
  #:use-module (guix utils)
  #:use-module (guix scripts)
  #:use-module (guix store)
  #:use-module (guix config)
  #:use-module (guix packages)
  #:use-module (guix derivations)
  #:use-module (guix profiles)
  #:use-module (guix gexp)
  #:use-module (guix grafts)
  #:use-module (guix memoization)
  #:use-module (guix monads)
  #:autoload   (guix inferior) (open-inferior)
  #:use-module (guix scripts build)
  #:autoload   (guix self) (whole-package)
  #:use-module (gnu packages)
  #:autoload   (gnu packages ssh) (guile-ssh)
  #:autoload   (gnu packages tls) (gnutls)
  #:use-module ((guix scripts package) #:select (build-and-use-profile))
  #:use-module ((guix build utils)
                #:select (with-directory-excursion delete-file-recursively))
  #:use-module ((guix build download)
                #:select (%x509-certificate-directory))
  #:use-module (gnu packages base)
  #:use-module (gnu packages guile)
  #:use-module ((gnu packages bootstrap)
                #:select (%bootstrap-guile))
  #:use-module ((gnu packages certs) #:select (le-certs))
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-11)
  #:use-module (srfi srfi-26)
  #:use-module (srfi srfi-35)
  #:use-module (srfi srfi-37)
  #:use-module (ice-9 match)
  #:use-module (ice-9 vlist)
  #:export (guix-pull))

(module-autoload! (resolve-module '(guix scripts pull))
                  '(git) '(git-error? set-tls-certificate-locations!)
                  '(guix git) '(latest-repository-commit))

(define (ensure-guile-git!)
  ;; Previously Guile-Git was not a prerequisite.  Thus, someone running 'guix
  ;; pull' on an old installation may be lacking Guile-Git.  To address this,
  ;; we autoload things that depend on Guile-Git and check in the entry point
  ;; whether Guile-Git is available.
  ;;
  ;; TODO: Remove this hack when Guile-Git is widespread or enforced.

  (unless (false-if-exception (resolve-interface '(git)))
    (leave (G_ "Guile-Git is missing but it is now required by 'guix pull'.
Install it by running:

  guix package -i ~a
  export GUILE_LOAD_PATH=$HOME/.guix-profile/share/guile/site/~a:$GUILE_LOAD_PATH
  export GUILE_LOAD_COMPILED_PATH=$HOME/.guix-profile/lib/guile/~a/site-ccache:$GUILE_LOAD_COMPILED_PATH
\n")
           (match (effective-version)
             ("2.0" "guile2.0-git")
             (_     "guile-git"))
           (effective-version)
           (effective-version)))

  ;; XXX: For unclear reasons this is needed for
  ;; 'set-tls-certificate-locations!'.
  (module-use! (resolve-module '(guix scripts pull))
               (resolve-interface '(git))))

(define %repository-url
  (or (getenv "GUIX_PULL_URL") "https://git.savannah.gnu.org/git/guix.git"))


;;;
;;; Command-line options.
;;;

(define %default-options
  ;; Alist of default option values.
  `((repository-url . ,%repository-url)
    (ref . (branch . "origin/master"))
    (system . ,(%current-system))
    (substitutes? . #t)
    (build-hook? . #t)
    (graft? . #t)
    (verbosity . 0)))

(define (show-help)
  (display (G_ "Usage: guix pull [OPTION]...
Download and deploy the latest version of Guix.\n"))
  (display (G_ "
      --verbose          produce verbose output"))
  (display (G_ "
      --url=URL          download from the Git repository at URL"))
  (display (G_ "
      --commit=COMMIT    download the specified COMMIT"))
  (display (G_ "
      --branch=BRANCH    download the tip of the specified BRANCH"))
  (display (G_ "
  -l, --list-generations[=PATTERN]
                         list generations matching PATTERN"))
  (display (G_ "
      --bootstrap        use the bootstrap Guile to build the new Guix"))
  (newline)
  (show-build-options-help)
  (display (G_ "
  -h, --help             display this help and exit"))
  (display (G_ "
  -V, --version          display version information and exit"))
  (newline)
  (show-bug-report-information))

(define %options
  ;; Specifications of the command-line options.
  (cons* (option '("verbose") #f #f
                 (lambda (opt name arg result)
                   (alist-cons 'verbose? #t result)))
         (option '(#\l "list-generations") #f #t
                 (lambda (opt name arg result)
                   (cons `(query list-generations ,(or arg ""))
                         result)))
         (option '("url") #t #f
                 (lambda (opt name arg result)
                   (alist-cons 'repository-url arg
                               (alist-delete 'repository-url result))))
         (option '("commit") #t #f
                 (lambda (opt name arg result)
                   (alist-cons 'ref `(commit . ,arg) result)))
         (option '("branch") #t #f
                 (lambda (opt name arg result)
                   (alist-cons 'ref `(branch . ,(string-append "origin/" arg))
                               result)))
         (option '(#\n "dry-run") #f #f
                 (lambda (opt name arg result)
                   (alist-cons 'dry-run? #t (alist-cons 'graft? #f result))))
         (option '("bootstrap") #f #f
                 (lambda (opt name arg result)
                   (alist-cons 'bootstrap? #t result)))

         (option '(#\h "help") #f #f
                 (lambda args
                   (show-help)
                   (exit 0)))
         (option '(#\V "version") #f #f
                 (lambda args
                   (show-version-and-exit "guix pull")))

         %standard-build-options))

(define what-to-build
  (store-lift show-what-to-build))
(define indirect-root-added
  (store-lift add-indirect-root))

(define %self-build-file
  ;; The file containing code to build Guix.  This serves the same purpose as
  ;; a makefile, and, similarly, is intended to always keep this name.
  "build-aux/build-self.scm")

(define %pull-version
  ;; This is the version of the 'guix pull' protocol.  It specifies what's
  ;; expected from %SELF-BUILD-FILE.  The initial version ("0") was when we'd
  ;; place a set of compiled Guile modules in ~/.config/guix/latest.
  1)

(define* (build-from-source source
                            #:key verbose? commit)
  "Return a derivation to build Guix from SOURCE, using the self-build script
contained therein.  Use COMMIT as the version string."
  ;; Running the self-build script makes it easier to update the build
  ;; procedure: the self-build script of the Guix-to-be-installed contains the
  ;; right dependencies, build procedure, etc., which the Guix-in-use may not
  ;; be know.
  (let* ((script (string-append source "/" %self-build-file))
         (build  (primitive-load script)))
    ;; BUILD must be a monadic procedure of at least one argument: the source
    ;; tree.
    ;;
    ;; Note: BUILD can return #f if it does not support %PULL-VERSION.  In the
    ;; future we'll fall back to a previous version of the protocol when that
    ;; happens.
    (build source #:verbose? verbose? #:version commit
           #:pull-version %pull-version)))

(define (whole-package-for-legacy name modules)
  "Return a full-blown Guix package for MODULES, a derivation that builds Guix
modules in the old ~/.config/guix/latest style."
  (whole-package name modules

                 ;; In the "old style", %SELF-BUILD-FILE would simply return a
                 ;; derivation that builds modules.  We have to infer what the
                 ;; dependencies of these modules were.
                 (list guile-json guile-git guile-bytestructures
                       guile-ssh gnutls)))

(define* (derivation->manifest-entry drv
                                     #:key url branch commit)
  "Return a manifest entry for DRV, which represents Guix at COMMIT.  Record
URL, BRANCH, and COMMIT as a property in the manifest entry."
  (mbegin %store-monad
    (what-to-build (list drv))
    (built-derivations (list drv))
    (let ((out (derivation->output-path drv)))
      (return (manifest-entry
                (name "guix")
                (version (string-take commit 7))
                (item (if (file-exists? (string-append out "/bin/guix"))
                          drv
                          (whole-package-for-legacy (string-append name "-"
                                                                   version)
                                                    drv)))
                (properties
                 `((source (repository
                            (version 0)
                            (url ,url)
                            (branch ,branch)
                            (commit ,commit))))))))))

(define (display-profile-news profile)
  "Display what's up in PROFILE--new packages, and all that."
  (match (memv (generation-number profile)
               (reverse (profile-generations profile)))
    ((current previous _ ...)
     (newline)
     (let ((old (fold-packages (lambda (package result)
                                 (alist-cons (package-name package)
                                             (package-version package)
                                             result))
                               '()))
           (new (profile-package-alist
                 (generation-file-name profile current))))
       (display-new/upgraded-packages old new
                                      #:heading (G_ "New in this revision:\n"))))
    (_ #t)))

(define* (build-and-install source config-dir
                            #:key verbose? url branch commit)
  "Build the tool from SOURCE, and install it in CONFIG-DIR."
  (define update-profile
    (store-lift build-and-use-profile))

  (define profile
    (string-append config-dir "/current"))

  (mlet* %store-monad ((drv   (build-from-source source
                                                 #:commit commit
                                                 #:verbose? verbose?))
                       (entry (derivation->manifest-entry drv
                                                          #:url url
                                                          #:branch branch
                                                          #:commit commit)))
    (mbegin %store-monad
      (update-profile profile (manifest (list entry)))
      (return (display-profile-news profile)))))

(define (honor-lets-encrypt-certificates! store)
  "Tell Guile-Git to use the Let's Encrypt certificates."
  (let* ((drv   (package-derivation store le-certs))
         (certs (string-append (derivation->output-path drv)
                               "/etc/ssl/certs")))
    (build-derivations store (list drv))

    ;; In the past Guile-Git would not provide this procedure.
    (if (module-defined? (resolve-interface '(git))
                         'set-tls-certificate-locations!)
        (set-tls-certificate-locations! certs)
        (begin
          ;; In this case we end up using whichever certificates OpenSSL
          ;; chooses to use: $SSL_CERT_FILE, $SSL_CERT_DIR, or /etc/ssl/certs.
          (warning (G_ "cannot enforce use of the Let's Encrypt \
certificates~%"))
          (warning (G_ "please upgrade Guile-Git~%"))))))

(define (report-git-error error)
  "Report the given Guile-Git error."
  ;; Prior to Guile-Git commit b6b2760c2fd6dfaa5c0fedb43eeaff06166b3134,
  ;; errors would be represented by integers.
  (match error
    ((? integer? error)                           ;old Guile-Git
     (leave (G_ "Git error ~a~%") error))
    ((? git-error? error)                         ;new Guile-Git
     (leave (G_ "Git error: ~a~%") (git-error-message error)))))

(define-syntax-rule (with-git-error-handling body ...)
  (catch 'git-error
    (lambda ()
      body ...)
    (lambda (key err)
      (report-git-error err))))


;;;
;;; Queries.
;;;

(define (display-profile-content profile number)
  "Display the packages in PROFILE, generation NUMBER, in a human-readable
way and displaying details about the channel's source code."
  (display-generation profile number)
  (for-each (lambda (entry)
              (format #t "  ~a ~a~%"
                      (manifest-entry-name entry)
                      (manifest-entry-version entry))
              (match (assq 'source (manifest-entry-properties entry))
                (('source ('repository ('version 0)
                                       ('url url)
                                       ('branch branch)
                                       ('commit commit)
                                       _ ...))
                 (format #t (G_ "    repository URL: ~a~%") url)
                 (when branch
                   (format #t (G_ "    branch: ~a~%") branch))
                 (format #t (G_ "    commit: ~a~%") commit))
                (_ #f)))

            ;; Show most recently installed packages last.
            (reverse
             (manifest-entries
              (profile-manifest (generation-file-name profile number))))))

(define (indented-string str indent)
  "Return STR with each newline preceded by IDENT spaces."
  (define indent-string
    (make-list indent #\space))

  (list->string
   (string-fold-right (lambda (chr result)
                        (if (eqv? chr #\newline)
                            (cons chr (append indent-string result))
                            (cons chr result)))
                      '()
                      str)))

(define profile-package-alist
  (mlambda (profile)
    "Return a name/version alist representing the packages in PROFILE."
    (fold (lambda (package lst)
            (alist-cons (inferior-package-name package)
                        (inferior-package-version package)
                        lst))
          '()
          (let* ((inferior (open-inferior profile))
                 (packages (inferior-packages inferior)))
            (close-inferior inferior)
            packages))))

(define* (display-new/upgraded-packages alist1 alist2
                                        #:key (heading ""))
  "Given the two package name/version alists ALIST1 and ALIST2, display the
list of new and upgraded packages going from ALIST1 to ALIST2.  When ALIST1
and ALIST2 differ, display HEADING upfront."
  (let* ((old      (fold (match-lambda*
                           (((name . version) table)
                            (vhash-cons name version table)))
                         vlist-null
                         alist1))
         (new      (remove (match-lambda
                             ((name . _)
                              (vhash-assoc name old)))
                           alist2))
         (upgraded (filter-map (match-lambda
                                 ((name . new-version)
                                  (match (vhash-fold* cons '() name old)
                                    (() #f)
                                    ((= (cut sort <> version>?) old-versions)
                                     (and (version>? new-version
                                                     (first old-versions))
                                          (string-append name "@"
                                                         new-version))))))
                               alist2)))
    (unless (and (null? new) (null? upgraded))
      (display heading))

    (match (length new)
      (0 #t)
      (count
       (format #t (N_ "  ~h new package: ~a~%"
                      "  ~h new packages: ~a~%" count)
               count
               (indented-string
                (fill-paragraph (string-join (sort (map first new) string<?)
                                             ", ")
                                (- (%text-width) 4) 30)
                4))))
    (match (length upgraded)
      (0 #t)
      (count
       (format #t (N_ "  ~h package upgraded: ~a~%"
                      "  ~h packages upgraded: ~a~%" count)
               count
               (indented-string
                (fill-paragraph (string-join (sort upgraded string<?) ", ")
                                (- (%text-width) 4) 35)
                4))))))

(define (display-profile-content-diff profile gen1 gen2)
  "Display the changes in PROFILE GEN2 compared to generation GEN1."
  (define (package-alist generation)
    (profile-package-alist (generation-file-name profile generation)))

  (display-profile-content profile gen2)
  (display-new/upgraded-packages (package-alist gen1)
                                 (package-alist gen2)))

(define (process-query opts)
  "Process any query specified by OPTS."
  (define profile
    (string-append (config-directory) "/current"))

  (match (assoc-ref opts 'query)
    (('list-generations pattern)
     (define (list-generations profile numbers)
       (match numbers
         ((first rest ...)
          (display-profile-content profile first)
          (let loop ((numbers numbers))
            (match numbers
              ((first second rest ...)
               (display-profile-content-diff profile
                                             first second)
               (loop (cons second rest)))
              ((_) #t)
              (()  #t))))))

     (leave-on-EPIPE
      (cond ((not (file-exists? profile))         ; XXX: race condition
             (raise (condition (&profile-not-found-error
                                (profile profile)))))
            ((string-null? pattern)
             (list-generations profile (profile-generations profile)))
            ((matching-generations pattern profile)
             =>
             (match-lambda
               (()
                (exit 1))
               ((numbers ...)
                (list-generations profile numbers)))))))))


(define (guix-pull . args)
  (define (use-le-certs? url)
    (string-prefix? "https://git.savannah.gnu.org/" url))

  (with-error-handling
    (with-git-error-handling
     (let* ((opts  (parse-command-line args %options
                                       (list %default-options)))
            (url   (assoc-ref opts 'repository-url))
            (ref   (assoc-ref opts 'ref))
            (cache (string-append (cache-directory) "/pull")))
       (ensure-guile-git!)

       (cond ((assoc-ref opts 'query)
              (process-query opts))
             ((assoc-ref opts 'dry-run?)
              #t)                                 ;XXX: not very useful
             (else
              (with-store store
                (parameterize ((%graft? (assoc-ref opts 'graft?)))
                  (set-build-options-from-command-line store opts)

                  ;; For reproducibility, always refer to the LE certificates
                  ;; when we know we're talking to Savannah.
                  (when (use-le-certs? url)
                    (honor-lets-encrypt-certificates! store))

                  (format (current-error-port)
                          (G_ "Updating from Git repository at '~a'...~%")
                          url)

                  (let-values (((checkout commit)
                                (latest-repository-commit store url
                                                          #:ref ref
                                                          #:cache-directory
                                                          cache)))

                    (format (current-error-port)
                            (G_ "Building from Git commit ~a...~%")
                            commit)
                    (parameterize ((%guile-for-build
                                    (package-derivation
                                     store
                                     (if (assoc-ref opts 'bootstrap?)
                                         %bootstrap-guile
                                         (canonical-package guile-2.2)))))
                      (run-with-store store
                        (build-and-install checkout (config-directory)
                                           #:url url
                                           #:branch (match ref
                                                      (('branch . branch)
                                                       branch)
                                                      (_ #f))
                                           #:commit commit
                                           #:verbose?
                                           (assoc-ref opts 'verbose?)))))))))))))

;;; pull.scm ends here