git.f90 Source File


Source Code

!> Implementation for interacting with git repositories.
module fpm_git
    use fpm_error, only: error_t, fatal_error
    use fpm_filesystem, only : get_temp_filename, getline, join_path, execute_and_read_output, run
    use fpm_toml, only: serializable_t, toml_table, get_value, set_value, toml_stat, set_string
    implicit none

    public :: git_target_t, git_target_default, git_target_branch, git_target_tag, git_target_revision, git_revision, &
            & git_archive, git_matches_manifest, operator(==), compressed_package_name

    !> Name of the compressed package that is generated temporarily.
    character(len=*), parameter :: compressed_package_name = 'compressed_package'

    !> Possible git target
    type :: enum_descriptor

        !> Default target
        integer :: default = 200

        !> Branch in git repository
        integer :: branch = 201

        !> Tag in git repository
        integer :: tag = 202

        !> Commit hash
        integer :: revision = 203

        !> Invalid descriptor
        integer :: error = -999

    end type enum_descriptor

    !> Actual enumerator for descriptors
    type(enum_descriptor), parameter :: git_descriptor = enum_descriptor()


    !> Description of an git target
    type, extends(serializable_t) :: git_target_t

        !> Kind of the git target
        integer :: descriptor = git_descriptor%default

        !> Target URL of the git repository
        character(len=:), allocatable :: url

        !> Additional descriptor of the git object
        character(len=:), allocatable :: object

    contains

        !> Fetch and checkout in local directory
        procedure :: checkout

        !> Show information on instance
        procedure :: info

        !> Serialization interface
        procedure :: serializable_is_same => git_is_same
        procedure :: dump_to_toml
        procedure :: load_from_toml

    end type git_target_t

    !> Common output format for writing to the command line
    character(len=*), parameter :: out_fmt = '("#", *(1x, g0))'

contains


    !> Default target
    function git_target_default(url) result(self)

        !> Target URL of the git repository
        character(len=*), intent(in) :: url

        !> New git target
        type(git_target_t) :: self

        self%descriptor = git_descriptor%default
        self%url = url

    end function git_target_default


    !> Target a branch in the git repository
    function git_target_branch(url, branch) result(self)

        !> Target URL of the git repository
        character(len=*), intent(in) :: url

        !> Name of the branch of interest
        character(len=*), intent(in) :: branch

        !> New git target
        type(git_target_t) :: self

        self%descriptor = git_descriptor%branch
        self%url = url
        self%object = branch

    end function git_target_branch


    !> Target a specific git revision
    function git_target_revision(url, sha1) result(self)

        !> Target URL of the git repository
        character(len=*), intent(in) :: url

        !> Commit hash of interest
        character(len=*), intent(in) :: sha1

        !> New git target
        type(git_target_t) :: self

        self%descriptor = git_descriptor%revision
        self%url = url
        self%object = sha1

    end function git_target_revision


    !> Target a git tag
    function git_target_tag(url, tag) result(self)

        !> Target URL of the git repository
        character(len=*), intent(in) :: url

        !> Tag name of interest
        character(len=*), intent(in) :: tag

        !> New git target
        type(git_target_t) :: self

        self%descriptor = git_descriptor%tag
        self%url = url
        self%object = tag

    end function git_target_tag

    !> Check that two git targets are equal
    logical function git_is_same(this,that)
        class(git_target_t), intent(in) :: this
        class(serializable_t), intent(in) :: that

        git_is_same = .false.

        select type (other=>that)
           type is (git_target_t)

              if (.not.(this%descriptor==other%descriptor)) return
              if (.not.(this%url==other%url)) return
              if (.not.(this%object==other%object)) return

           class default
              ! Not the same type
              return
        end select

        !> All checks passed!
        git_is_same = .true.

    end function git_is_same

    !> Check that a cached dependency matches a manifest request
    logical function git_matches_manifest(cached,manifest,verbosity,iunit)

        !> Two input git targets
        type(git_target_t), intent(in) :: cached,manifest

        integer, intent(in) :: verbosity,iunit

        git_matches_manifest = cached%url == manifest%url
        if (.not.git_matches_manifest) then
            if (verbosity>1) write(iunit,out_fmt) "GIT URL has changed: ",cached%url," vs. ", manifest%url
            return
        endif

        !> The manifest dependency only contains partial information (what's requested),
        !> while the cached dependency always stores a commit hash because it's built
        !> after the repo is available (saved as git_descriptor%revision==revision).
        !> So, comparing against the descriptor is not reliable
        git_matches_manifest = allocated(cached%object) .eqv. allocated(manifest%object)
        if (git_matches_manifest .and. allocated(cached%object)) &
        git_matches_manifest = cached%object == manifest%object
        if (.not.git_matches_manifest) then
            if (verbosity>1) write(iunit,out_fmt) "GIT OBJECT has changed: ",cached%object," vs. ", manifest%object
        end if

    end function git_matches_manifest


    subroutine checkout(self, local_path, error)

        !> Instance of the git target
        class(git_target_t), intent(in) :: self

        !> Local path to checkout in
        character(*), intent(in) :: local_path

        !> Error
        type(error_t), allocatable, intent(out) :: error

        integer :: stat
        character(len=:), allocatable :: object, workdir

        if (allocated(self%object)) then
            object = self%object
        else
            object = 'HEAD'
        end if
        workdir = "--work-tree="//local_path//" --git-dir="//join_path(local_path, ".git")

        call execute_command_line("git init "//local_path, exitstat=stat)

        if (stat /= 0) then
            call fatal_error(error,'Error while initiating git repository for remote dependency')
            return
        end if

        call execute_command_line("git "//workdir//" fetch --depth=1 "// &
                                  self%url//" "//object, exitstat=stat)

        if (stat /= 0) then
            call fatal_error(error,'Error while fetching git repository for remote dependency')
            return
        end if

        call execute_command_line("git "//workdir//" checkout -qf FETCH_HEAD", exitstat=stat)

        if (stat /= 0) then
            call fatal_error(error,'Error while checking out git repository for remote dependency')
            return
        end if

    end subroutine checkout


    subroutine git_revision(local_path, object, error)

        !> Local path to checkout in
        character(*), intent(in) :: local_path

        !> Git object reference
        character(len=:), allocatable, intent(out) :: object

        !> Error
        type(error_t), allocatable, intent(out) :: error

        integer :: stat, unit, istart, iend
        character(len=:), allocatable :: temp_file, line, iomsg, workdir
        character(len=*), parameter :: hexdigits = '0123456789abcdef'

        workdir = "--work-tree="//local_path//" --git-dir="//join_path(local_path, ".git")
        allocate(temp_file, source=get_temp_filename())
        line = "git "//workdir//" log -n 1 > "//temp_file
        call execute_command_line(line, exitstat=stat)

        if (stat /= 0) then
            call fatal_error(error, "Error while retrieving commit information")
            return
        end if

        open(file=temp_file, newunit=unit)
        call getline(unit, line, stat, iomsg)

        if (stat /= 0) then
            call fatal_error(error, iomsg)
            return
        end if
        close(unit, status="delete")

        ! Tokenize:
        ! commit 0123456789abcdef (HEAD, ...)
        istart = scan(line, ' ') + 1
        iend = verify(line(istart:), hexdigits) + istart - 1
        if (iend < istart) iend = len(line)
        object = line(istart:iend)

    end subroutine git_revision


    !> Show information on git target
    subroutine info(self, unit, verbosity)

        !> Instance of the git target
        class(git_target_t), intent(in) :: self

        !> Unit for IO
        integer, intent(in) :: unit

        !> Verbosity of the printout
        integer, intent(in), optional :: verbosity

        integer :: pr
        character(len=*), parameter :: fmt = '("#", 1x, a, t30, a)'

        if (present(verbosity)) then
            pr = verbosity
        else
            pr = 1
        end if

        if (pr < 1) return

        write(unit, fmt) "Git target"
        if (allocated(self%url)) then
            write(unit, fmt) "- URL", self%url
        end if
        if (allocated(self%object)) then
            select case(self%descriptor)
            case default
                write(unit, fmt) "- object", self%object
            case(git_descriptor%tag)
                write(unit, fmt) "- tag", self%object
            case(git_descriptor%branch)
                write(unit, fmt) "- branch", self%object
            case(git_descriptor%revision)
                write(unit, fmt) "- sha1", self%object
            end select
        end if

    end subroutine info

    !> Dump dependency to toml table
    subroutine dump_to_toml(self, table, error)

        !> Instance of the serializable object
        class(git_target_t), intent(inout) :: self

        !> Data structure
        type(toml_table), intent(inout) :: table

        !> Error handling
        type(error_t), allocatable, intent(out) :: error

        integer :: ierr

        call set_string(table, "descriptor", descriptor_name(self%descriptor), error, 'git_target_t')
        if (allocated(error)) return
        call set_string(table, "url", self%url, error, 'git_target_t')
        if (allocated(error)) return
        call set_string(table, "object", self%object, error, 'git_target_t')
        if (allocated(error)) return

    end subroutine dump_to_toml

    !> Read dependency from toml table (no checks made at this stage)
    subroutine load_from_toml(self, table, error)

        !> Instance of the serializable object
        class(git_target_t), intent(inout) :: self

        !> Data structure
        type(toml_table), intent(inout) :: table

        !> Error handling
        type(error_t), allocatable, intent(out) :: error

        !> Local variables
        character(len=:), allocatable :: descriptor_name

        call get_value(table, "descriptor", descriptor_name)
        self%descriptor = parse_descriptor(descriptor_name)

        if (self%descriptor==git_descriptor%error) then
            call fatal_error(error,"invalid descriptor ID <"//descriptor_name//"> in TOML entry")
            return
        end if

        !> Target URL of the git repository
        call get_value(table, "url", self%url)

        !> Additional descriptor of the git object
        call get_value(table,"object", self%object)

    end subroutine load_from_toml

    !> Parse git descriptor identifier from a string
    pure integer function parse_descriptor(name)
        character(len=*), intent(in) :: name

        select case (name)
           case ("default");  parse_descriptor = git_descriptor%default
           case ("branch");   parse_descriptor = git_descriptor%branch
           case ("tag");      parse_descriptor = git_descriptor%tag
           case ("revision"); parse_descriptor = git_descriptor%revision
           case default;      parse_descriptor = git_descriptor%error
        end select

    end function parse_descriptor

    !> Code git descriptor to a string
    pure function descriptor_name(descriptor) result(name)
       integer, intent(in) :: descriptor
       character(len=:), allocatable :: name

       select case (descriptor)
          case (git_descriptor%default);   name = "default"
          case (git_descriptor%branch);    name = "branch"
          case (git_descriptor%tag);       name = "tag"
          case (git_descriptor%revision);  name = "revision"
          case default;                    name = "ERROR"
       end select

    end function descriptor_name

  !> Archive a folder using `git archive`.
  subroutine git_archive(source, destination, ref, additional_files, verbose, error)
    !> Directory to archive.
    character(*), intent(in) :: source
    !> Destination of the archive.
    character(*), intent(in) :: destination
    !> (Symbolic) Reference to be archived.
    character(*), intent(in) :: ref
    !> (Optional) list of additional untracked files to be added to the archive.
    character(*), optional, intent(in) :: additional_files(:)
    !> Print additional information if true.
    logical, intent(in) :: verbose
    !> Error handling.
    type(error_t), allocatable, intent(out) :: error

    integer :: stat,i
    character(len=:), allocatable :: cmd_output, archive_format, add_files

    call execute_and_read_output('git archive -l', cmd_output, error, verbose)
    if (allocated(error)) return

    if (index(cmd_output, 'tar.gz') /= 0) then
      archive_format = 'tar.gz'
    else
      call fatal_error(error, "Cannot find a suitable archive format for 'git archive'."); return
    end if

    allocate(character(len=0) :: add_files)
    if (present(additional_files)) then 
       do i=1,size(additional_files)
          add_files = trim(add_files)//' --add-file='//adjustl(additional_files(i))
       end do
    endif

    call run('git archive '//ref//' &
        --format='//archive_format// &
        add_files//' \
        -o '//destination, \
        echo=verbose, \
        exitstat=stat)
    if (stat /= 0) then
      call fatal_error(error, "Error packing '"//source//"'."); return
    end if
  end

end module fpm_git