AbstractGitMojo.java

/*
 *    Copyright 2011-2026 the original author or authors.
 *
 *    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 2
 *    of the License, or (at your option) any later version.
 *
 *    You may obtain a copy of the License at
 *
 *       https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html
 *
 *    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.
 */
package com.hazendaz.maven.makeself;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.List;

import javax.inject.Inject;

import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.ArchiveInputStream;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Parameter;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactRequest;
import org.eclipse.aether.resolution.ArtifactResolutionException;
import org.eclipse.aether.resolution.ArtifactResult;

/**
 * Abstract base class providing portable Git download and installation support for Windows.
 */
public abstract class AbstractGitMojo extends AbstractMojo {

    /** isWindows is detected at start of plugin to ensure windows needs. */
    static final boolean WINDOWS = System.getProperty("os.name").startsWith("Windows");

    /** The Constant GIT_USER_BIN. */
    static final String GIT_USER_BIN = "/usr/bin/";

    /**
     * Returns true if the current platform is Windows. Extracted as a method to allow test subclasses to override the
     * platform detection without modifying production code.
     *
     * @return true if running on Windows
     */
    protected boolean isWindows() {
        return WINDOWS;
    }

    /**
     * The path to existing git install for windows usage. If left blank per default, portable git will be used.
     * Location should be something like 'C:/Program Files/Git'. When set and not windows, it will be treated as blank.
     */
    @Parameter(defaultValue = "", property = "gitPath")
    protected String gitPath;

    /** Maven Artifact Factory. */
    @Inject
    protected RepositorySystem repositorySystem;

    /** Maven Repository System Session. */
    @Parameter(defaultValue = "${repositorySystemSession}", readonly = true, required = true)
    protected RepositorySystemSession repoSession;

    /** Maven Remote Repositories. */
    @Parameter(defaultValue = "${project.remoteProjectRepositories}", readonly = true, required = true)
    protected List<RemoteRepository> remoteRepositories;

    /** Portable Git. */
    protected PortableGit portableGit;

    /**
     * Check Git Setup.
     *
     * @throws MojoFailureException
     *             the mojo failure exception
     */
    protected void checkGitSetup() throws MojoFailureException {
        // Get Portable Git Maven Information
        this.portableGit = new PortableGit(this.getLog());

        // Extract Portable Git
        this.extractPortableGit();
    }

    /**
     * Extract Portable Git.
     *
     * @throws MojoFailureException
     *             failure retrieving portable git
     */
    protected void extractPortableGit() throws MojoFailureException {
        final String location = this.repoSession.getLocalRepository().getBasedir() + File.separator
                + this.portableGit.getName() + File.separator + this.portableGit.getVersion();
        if (Files.exists(Path.of(location))) {
            this.getLog().debug("Existing 'PortableGit' folder found at " + location);
            this.gitPath = location + AbstractGitMojo.GIT_USER_BIN;
            return;
        }

        this.getLog().info("Loading portable git");
        final Artifact artifact = new DefaultArtifact(this.portableGit.getGroupId(), this.portableGit.getArtifactId(),
                this.portableGit.getClassifier(), this.portableGit.getExtension(), this.portableGit.getVersion());
        final ArtifactRequest artifactRequest = new ArtifactRequest().setRepositories(this.remoteRepositories)
                .setArtifact(artifact);
        ArtifactResult resolutionResult = null;
        try {
            resolutionResult = this.repositorySystem.resolveArtifact(this.repoSession, artifactRequest);
            if (!resolutionResult.isResolved()) {
                throw new MojoFailureException("Unable to resolve artifact: " + artifact.getGroupId() + ":"
                        + artifact.getArtifactId() + ":" + artifact.getVersion() + ":" + artifact.getClassifier() + ":"
                        + artifact.getExtension());
            }
        } catch (final ArtifactResolutionException e) {
            throw new MojoFailureException(
                    "Unable to resolve artifact: " + artifact.getGroupId() + ":" + artifact.getArtifactId() + ":"
                            + artifact.getVersion() + ":" + artifact.getClassifier() + ":" + artifact.getExtension());
        }
        this.installGit(resolutionResult.getArtifact(), location);
    }

    /**
     * Install Git extracts git to .m2/repository under PortableGit.
     *
     * @param artifact
     *            the maven artifact representation for git
     * @param location
     *            the location in maven repository to store portable git
     */
    protected void installGit(final Artifact artifact, final String location) {
        Path currentFile = null;

        // Unzip 'git-for-windows-*-portable.tar.gz' from '.m2/repository/com/github/hazendaz/git/git-for-windows'
        // into '.m2/repository/PortableGit'
        try (InputStream inputStream = Files.newInputStream(artifact.getFile().toPath());
                InputStream bufferedStream = new BufferedInputStream(inputStream);
                InputStream gzipStream = new GzipCompressorInputStream(bufferedStream);
                ArchiveInputStream<TarArchiveEntry> tarStream = new TarArchiveInputStream(gzipStream)) {
            ArchiveEntry entry;
            final String directory = this.repoSession.getLocalRepository().getBasedir() + File.separator
                    + this.portableGit.getName();
            while ((entry = tarStream.getNextEntry()) != null) {
                if (entry.isDirectory()) {
                    continue;
                }
                currentFile = Path.of(directory, entry.getName());
                if (!currentFile.normalize().startsWith(directory)) {
                    throw new IOException("Bad zip entry, possible directory traversal");
                }
                final Path parent = currentFile.getParent();
                if (!Files.exists(parent)) {
                    Files.createDirectories(parent);
                }
                this.getLog().debug("Current file: " + currentFile.getFileName());
                Files.copy(tarStream, currentFile, StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (final IOException e) {
            this.getLog().error("", e);
        }

        try {
            if (currentFile != null) {
                // Extract Portable Git
                this.getLog().debug("Extract Portable Git");
                this.runInstaller(Arrays.asList(currentFile.toString(), "-y", "-o", location));
                this.gitPath = location + AbstractGitMojo.GIT_USER_BIN;
            }
        } catch (final IOException e) {
            this.getLog().error("", e);
        } catch (final InterruptedException e) {
            this.getLog().error("", e);
            // restore interruption status of the corresponding thread
            Thread.currentThread().interrupt();
        }
    }

    /**
     * Run installer executes a process and logs its output.
     *
     * @param command
     *            the command to run
     *
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws InterruptedException
     *             the interrupted exception
     */
    protected void runInstaller(final List<String> command) throws IOException, InterruptedException {
        this.getLog().debug("Execution commands: " + command);

        final ProcessBuilder processBuilder = new ProcessBuilder(command);
        processBuilder.redirectErrorStream(true);

        final Process process = processBuilder.start();
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
            String line = "";
            while ((line = reader.readLine()) != null) {
                this.getLog().info(line);
            }
            this.getLog().info("");
        }

        final int status = process.waitFor();
        if (status > 0) {
            this.getLog().error(String.join(" ", "Process failed with error status:", String.valueOf(status)));
        }
    }

}