ArchiveCommand.java
/*
* Copyright (C) 2012 Google Inc. and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.api;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.MutableObjectId;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
/**
* Create an archive of files from a named tree.
* <p>
* Examples (<code>git</code> is a {@link org.eclipse.jgit.api.Git} instance):
* <p>
* Create a tarball from HEAD:
*
* <pre>
* ArchiveCommand.registerFormat("tar", new TarFormat());
* try {
* git.archive().setTree(db.resolve("HEAD")).setOutputStream(out).call();
* } finally {
* ArchiveCommand.unregisterFormat("tar");
* }
* </pre>
* <p>
* Create a ZIP file from master:
*
* <pre>
* ArchiveCommand.registerFormat("zip", new ZipFormat());
* try {
* git.archive().
* .setTree(db.resolve("master"))
* .setFormat("zip")
* .setOutputStream(out)
* .call();
* } finally {
* ArchiveCommand.unregisterFormat("zip");
* }
* </pre>
*
* @see <a href="http://git-htmldocs.googlecode.com/git/git-archive.html" >Git
* documentation about archive</a>
* @since 3.1
*/
public class ArchiveCommand extends GitCommand<OutputStream> {
/**
* Archival format.
*
* Usage:
* Repository repo = git.getRepository();
* T out = format.createArchiveOutputStream(System.out);
* try {
* for (...) {
* format.putEntry(out, path, mode, repo.open(objectId));
* }
* out.close();
* }
*
* @param <T>
* type representing an archive being created.
*/
public static interface Format<T extends Closeable> {
/**
* Start a new archive. Entries can be included in the archive using the
* putEntry method, and then the archive should be closed using its
* close method.
*
* @param s
* underlying output stream to which to write the archive.
* @return new archive object for use in putEntry
* @throws IOException
* thrown by the underlying output stream for I/O errors
*/
T createArchiveOutputStream(OutputStream s) throws IOException;
/**
* Start a new archive. Entries can be included in the archive using the
* putEntry method, and then the archive should be closed using its
* close method. In addition options can be applied to the underlying
* stream. E.g. compression level.
*
* @param s
* underlying output stream to which to write the archive.
* @param o
* options to apply to the underlying output stream. Keys are
* option names and values are option values.
* @return new archive object for use in putEntry
* @throws IOException
* thrown by the underlying output stream for I/O errors
* @since 4.0
*/
T createArchiveOutputStream(OutputStream s, Map<String, Object> o)
throws IOException;
/**
* Write an entry to an archive.
*
* @param out
* archive object from createArchiveOutputStream
* @param tree
* the tag, commit, or tree object to produce an archive for
* @param path
* full filename relative to the root of the archive (with
* trailing '/' for directories)
* @param mode
* mode (for example FileMode.REGULAR_FILE or
* FileMode.SYMLINK)
* @param loader
* blob object with data for this entry (null for
* directories)
* @throws IOException
* thrown by the underlying output stream for I/O errors
* @since 4.7
*/
void putEntry(T out, ObjectId tree, String path, FileMode mode,
ObjectLoader loader) throws IOException;
/**
* Filename suffixes representing this format (e.g.,
* { ".tar.gz", ".tgz" }).
*
* The behavior is undefined when suffixes overlap (if
* one format claims suffix ".7z", no other format should
* take ".tar.7z").
*
* @return this format's suffixes
*/
Iterable<String> suffixes();
}
/**
* Signals an attempt to use an archival format that ArchiveCommand
* doesn't know about (for example due to a typo).
*/
public static class UnsupportedFormatException extends GitAPIException {
private static final long serialVersionUID = 1L;
private final String format;
/**
* @param format the problematic format name
*/
public UnsupportedFormatException(String format) {
super(MessageFormat.format(JGitText.get().unsupportedArchiveFormat, format));
this.format = format;
}
/**
* @return the problematic format name
*/
public String getFormat() {
return format;
}
}
private static class FormatEntry {
final Format<?> format;
/** Number of times this format has been registered. */
final int refcnt;
public FormatEntry(Format<?> format, int refcnt) {
if (format == null)
throw new NullPointerException();
this.format = format;
this.refcnt = refcnt;
}
}
/**
* Available archival formats (corresponding to values for
* the --format= option)
*/
private static final Map<String, FormatEntry> formats =
new ConcurrentHashMap<>();
/**
* Replaces the entry for a key only if currently mapped to a given
* value.
*
* @param map a map
* @param key key with which the specified value is associated
* @param oldValue expected value for the key (null if should be absent).
* @param newValue value to be associated with the key (null to remove).
* @return true if the value was replaced
*/
private static <K, V> boolean replace(Map<K, V> map,
K key, V oldValue, V newValue) {
if (oldValue == null && newValue == null) // Nothing to do.
return true;
if (oldValue == null)
return map.putIfAbsent(key, newValue) == null;
else if (newValue == null)
return map.remove(key, oldValue);
else
return map.replace(key, oldValue, newValue);
}
/**
* Adds support for an additional archival format. To avoid
* unnecessary dependencies, ArchiveCommand does not have support
* for any formats built in; use this function to add them.
* <p>
* OSGi plugins providing formats should call this function at
* bundle activation time.
* <p>
* It is okay to register the same archive format with the same
* name multiple times, but don't forget to unregister it that
* same number of times, too.
* <p>
* Registering multiple formats with different names and the
* same or overlapping suffixes results in undefined behavior.
* TODO: check that suffixes don't overlap.
*
* @param name name of a format (e.g., "tar" or "zip").
* @param fmt archiver for that format
* @throws JGitInternalException
* A different archival format with that name was
* already registered.
*/
public static void registerFormat(String name, Format<?> fmt) {
if (fmt == null)
throw new NullPointerException();
FormatEntry old, entry;
do {
old = formats.get(name);
if (old == null) {
entry = new FormatEntry(fmt, 1);
continue;
}
if (!old.format.equals(fmt))
throw new JGitInternalException(MessageFormat.format(
JGitText.get().archiveFormatAlreadyRegistered,
name));
entry = new FormatEntry(old.format, old.refcnt + 1);
} while (!replace(formats, name, old, entry));
}
/**
* Marks support for an archival format as no longer needed so its
* Format can be garbage collected if no one else is using it either.
* <p>
* In other words, this decrements the reference count for an
* archival format. If the reference count becomes zero, removes
* support for that format.
*
* @param name name of format (e.g., "tar" or "zip").
* @throws JGitInternalException
* No such archival format was registered.
*/
public static void unregisterFormat(String name) {
FormatEntry old, entry;
do {
old = formats.get(name);
if (old == null)
throw new JGitInternalException(MessageFormat.format(
JGitText.get().archiveFormatAlreadyAbsent,
name));
if (old.refcnt == 1) {
entry = null;
continue;
}
entry = new FormatEntry(old.format, old.refcnt - 1);
} while (!replace(formats, name, old, entry));
}
private static Format<?> formatBySuffix(String filenameSuffix)
throws UnsupportedFormatException {
if (filenameSuffix != null)
for (FormatEntry entry : formats.values()) {
Format<?> fmt = entry.format;
for (String sfx : fmt.suffixes())
if (filenameSuffix.endsWith(sfx))
return fmt;
}
return lookupFormat("tar"); //$NON-NLS-1$
}
private static Format<?> lookupFormat(String formatName) throws UnsupportedFormatException {
FormatEntry entry = formats.get(formatName);
if (entry == null)
throw new UnsupportedFormatException(formatName);
return entry.format;
}
private OutputStream out;
private ObjectId tree;
private String prefix;
private String format;
private Map<String, Object> formatOptions = new HashMap<>();
private List<String> paths = new ArrayList<>();
/** Filename suffix, for automatically choosing a format. */
private String suffix;
/**
* Constructor for ArchiveCommand
*
* @param repo
* the {@link org.eclipse.jgit.lib.Repository}
*/
public ArchiveCommand(Repository repo) {
super(repo);
setCallable(false);
}
private <T extends Closeable> OutputStream writeArchive(Format<T> fmt) {
try {
try (TreeWalk walk = new TreeWalk(repo);
RevWalk rw = new RevWalk(walk.getObjectReader());
T outa = fmt.createArchiveOutputStream(out,
formatOptions)) {
String pfx = prefix == null ? "" : prefix; //$NON-NLS-1$
MutableObjectId idBuf = new MutableObjectId();
ObjectReader reader = walk.getObjectReader();
RevObject o = rw.peel(rw.parseAny(tree));
walk.reset(getTree(o));
if (!paths.isEmpty()) {
walk.setFilter(PathFilterGroup.createFromStrings(paths));
}
// Put base directory into archive
if (pfx.endsWith("/")) { //$NON-NLS-1$
fmt.putEntry(outa, o, pfx.replaceAll("[/]+$", "/"), //$NON-NLS-1$ //$NON-NLS-2$
FileMode.TREE, null);
}
while (walk.next()) {
String name = pfx + walk.getPathString();
FileMode mode = walk.getFileMode(0);
if (walk.isSubtree())
walk.enterSubtree();
if (mode == FileMode.GITLINK) {
// TODO(jrn): Take a callback to recurse
// into submodules.
mode = FileMode.TREE;
}
if (mode == FileMode.TREE) {
fmt.putEntry(outa, o, name + "/", mode, null); //$NON-NLS-1$
continue;
}
walk.getObjectId(idBuf, 0);
fmt.putEntry(outa, o, name, mode, reader.open(idBuf));
}
return out;
} finally {
out.close();
}
} catch (IOException e) {
// TODO(jrn): Throw finer-grained errors.
throw new JGitInternalException(
JGitText.get().exceptionCaughtDuringExecutionOfArchiveCommand, e);
}
}
/** {@inheritDoc} */
@Override
public OutputStream call() throws GitAPIException {
checkCallable();
Format<?> fmt;
if (format == null)
fmt = formatBySuffix(suffix);
else
fmt = lookupFormat(format);
return writeArchive(fmt);
}
/**
* Set the tag, commit, or tree object to produce an archive for
*
* @param tree
* the tag, commit, or tree object to produce an archive for
* @return this
*/
public ArchiveCommand setTree(ObjectId tree) {
if (tree == null)
throw new IllegalArgumentException();
this.tree = tree;
setCallable(true);
return this;
}
/**
* Set string prefixed to filenames in archive
*
* @param prefix
* string prefixed to filenames in archive (e.g., "master/").
* null means to not use any leading prefix.
* @return this
* @since 3.3
*/
public ArchiveCommand setPrefix(String prefix) {
this.prefix = prefix;
return this;
}
/**
* Set the intended filename for the produced archive. Currently the only
* effect is to determine the default archive format when none is specified
* with {@link #setFormat(String)}.
*
* @param filename
* intended filename for the archive
* @return this
*/
public ArchiveCommand setFilename(String filename) {
int slash = filename.lastIndexOf('/');
int dot = filename.indexOf('.', slash + 1);
if (dot == -1)
this.suffix = ""; //$NON-NLS-1$
else
this.suffix = filename.substring(dot);
return this;
}
/**
* Set output stream
*
* @param out
* the stream to which to write the archive
* @return this
*/
public ArchiveCommand setOutputStream(OutputStream out) {
this.out = out;
return this;
}
/**
* Set archive format
*
* @param fmt
* archive format (e.g., "tar" or "zip"). null means to choose
* automatically based on the archive filename.
* @return this
*/
public ArchiveCommand setFormat(String fmt) {
this.format = fmt;
return this;
}
/**
* Set archive format options
*
* @param options
* archive format options (e.g., level=9 for zip compression).
* @return this
* @since 4.0
*/
public ArchiveCommand setFormatOptions(Map<String, Object> options) {
this.formatOptions = options;
return this;
}
/**
* Set an optional parameter path. without an optional path parameter, all
* files and subdirectories of the current working directory are included in
* the archive. If one or more paths are specified, only these are included.
*
* @param paths
* file names (e.g <code>file1.c</code>) or directory names (e.g.
* <code>dir</code> to add <code>dir/file1</code> and
* <code>dir/file2</code>) can also be given to add all files in
* the directory, recursively. Fileglobs (e.g. *.c) are not yet
* supported.
* @return this
* @since 3.4
*/
public ArchiveCommand setPaths(String... paths) {
this.paths = Arrays.asList(paths);
return this;
}
private RevTree getTree(RevObject o)
throws IncorrectObjectTypeException {
final RevTree t;
if (o instanceof RevCommit) {
t = ((RevCommit) o).getTree();
} else if (!(o instanceof RevTree)) {
throw new IncorrectObjectTypeException(tree.toObjectId(),
Constants.TYPE_TREE);
} else {
t = (RevTree) o;
}
return t;
}
}