org.dellroad.stuff.pobj.distrib.GitRepository Maven / Gradle / Ivy
/*
* Copyright (C) 2012 Archie L. Cobbs. All rights reserved.
*/
package org.dellroad.stuff.pobj.distrib;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.dellroad.stuff.spring.AbstractBean;
/**
* Represents a {@code git(1)} repository and provides a few simple methods that wrap the command-line client
* and provide the functionality needed by {@link Synchronizer}.
* Only normal (i.e., non-bare) repositories are supported.
*
*
* This class assumes the repository already has all branches and remotes already configured, and that no
* other external process is accessing the {@code git(1)} repository without our knowledge.
*
*
* Instances are thread safe and guarantees exclusive access to the working directory
* to each {@link Accessor} while it is in use.
*
*
* Requires git version at least 1.7.4.
*
* @see Git web site
*/
public class GitRepository extends AbstractBean {
/**
* Accepted git branch name pattern.
*/
public static final Pattern BRANCH_NAME_PATTERN = Pattern.compile("[\\w][-:\\w]*");
/**
* Accepted git remote name pattern.
*/
public static final Pattern REMOTE_NAME_PATTERN = Pattern.compile("[\\w][-:\\w]*");
/**
* SHA1 pattern.
*/
public static final Pattern SHA1_PATTERN = Pattern.compile("[0-9a-f]{40}");
/**
* Packed reference pattern.
*/
public static final Pattern PACKED_REF_PATTERN = Pattern.compile("([0-9a-f]{40})\\s+([^\\s]+)");
/**
* Indirect reference pattern.
*/
public static final Pattern INDIRECT_REF_PATTERN = Pattern.compile("ref: (.*)");
private final File dir;
// Constructors
/**
* Constructor.
*
* @param dir repository filesystem location (i.e., the directory containing {@code .git} as a subdirectory)
* @throws IllegalArgumentException if {@code dir} is null
*/
public GitRepository(File dir) {
if (dir == null)
throw new IllegalArgumentException("null dir");
this.dir = dir;
}
// Lifecycle
@Override
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet();
final File gitDir = new File(this.dir, ".git");
if (!gitDir.exists() || !gitDir.isDirectory())
throw new Exception("`" + this.dir + "' is not a git repository");
}
// Methods
/**
* Get the git working directory associated with this instance.
*
* @return repository working directory location
*/
public File getDirectory() {
return this.dir;
}
/**
* Fetch data from all configured remotes to update our local copy of their information.
*
* @throws GitException if an error occurs
*/
public synchronized void fetch() {
new GitCommand(this.dir, "fetch", "--all").run();
}
/**
* Fetch data from the named remotes to update our local copy of their information.
*
* @param remotes list of remotes
* @throws GitException if an error occurs
* @throws IllegalArgumentException if {@code remotes} or any element is null
* @throws IllegalArgumentException if an remote does not match {@link #REMOTE_NAME_PATTERN}
*/
public synchronized void fetch(List remotes) {
// Sanity check
if (remotes == null)
throw new IllegalArgumentException("null remotes");
for (String remote : remotes) {
if (remote == null)
throw new IllegalArgumentException("null remote");
if (!REMOTE_NAME_PATTERN.matcher(remote).matches())
throw new IllegalArgumentException("illegal remote name `" + remote + "'");
}
// Fetch
final ArrayList params = new ArrayList(remotes.size() + 2);
params.add("fetch");
params.add("--multiple");
params.addAll(remotes);
new GitCommand(this.dir, params).run();
}
/**
* Get the author date associated with the given commit.
*
* @param tree commit reference
* @return author date
* @throws GitException if git fails
* @throws IllegalArgumentException if {@code tree} is null
*/
public synchronized Date getAuthorDate(String tree) {
try {
return new Date(Long.parseLong(new GitCommand(this.dir,
"log", "-1", "--format=format:%at", tree).runAndGetOutput(), 10) * 1000L);
} catch (NumberFormatException e) {
throw new GitException("error parsing git output", e);
}
}
/**
* Access the head of the specified branch or the named commit.
* Any changes made by the {@link Accessor} are discarded when this method completes.
*
* @param branch name of the branch or commit we want to access
* @param accessor callback interface to access the working directory contents
* @return commit name (SHA-1 hash) corresponding to what tree was accessed
* @throws GitException if {@code branch} is not a local branch or a commit ID
* @throws IllegalArgumentException if {@code branch} is null
* @throws IllegalArgumentException if {@code branch} does not match {@link #BRANCH_NAME_PATTERN}
* @throws IllegalArgumentException if {@code branch} is null
* @throws IllegalArgumentException if {@code accessor} is null
*/
public synchronized String access(String branch, Accessor accessor) {
// Sanity check
if (branch == null)
throw new IllegalArgumentException("null branch");
if (!BRANCH_NAME_PATTERN.matcher(branch).matches())
throw new IllegalArgumentException("illegal branch name `" + branch + "'");
if (accessor == null)
throw new IllegalArgumentException("null accessor");
// Reset
this.reset();
// Checkout the commit
new GitCommand(this.dir, "checkout", "--force", branch).run();
// Get the commit ID that we have checked out
final String commit = this.getCurrentCommit();
// Grant access
try {
accessor.accessWorkingCopy(this.dir);
} finally {
this.reset();
}
// Done
return commit;
}
/**
* Commit changes onto the specified branch. The branch must already exist.
*
*
* If after the {@code accessor} runs nothing has changed, then no commit is performed
* and the previous commit ID is returned.
*
* @param branch name of the branch we want to commit onto
* @param accessor callback interface to update the working directory contents
* @param message commit message
* @return commit name (SHA-1 hash), or previous existing commit if no changes were made
* @throws GitException if {@code branch} is not an existing local branch
* @throws IllegalArgumentException if {@code branch} is null
* @throws IllegalArgumentException if {@code branch} does not match {@link #BRANCH_NAME_PATTERN}
* @throws IllegalArgumentException if {@code accessor} is null
* @throws IllegalArgumentException if {@code message} is null
*/
public synchronized String commit(String branch, Accessor accessor, String message) {
// Sanity check
if (branch == null)
throw new IllegalArgumentException("null branch");
if (!BRANCH_NAME_PATTERN.matcher(branch).matches())
throw new IllegalArgumentException("illegal branch name `" + branch + "'");
if (accessor == null)
throw new IllegalArgumentException("null accessor");
if (message == null)
throw new IllegalArgumentException("null message");
// Verify branch is really a local branch
try {
if (!this.localBranchExists(branch))
throw new GitException("branch `" + branch + "' does not exist or is not a local branch");
} catch (IOException e) {
throw new GitException("error accessing git directory", e);
}
// Reset
this.reset();
// Perform commit steps
try {
// Check out branch
new GitCommand(this.dir, "checkout", "--force", branch).run();
// Apply changes
accessor.accessWorkingCopy(this.dir);
// Attempt commit only if something has changed
if (new GitCommand(this.dir, "status", "--porcelain").runAndGetOutput().length() > 0) {
// Stage them into the index
new GitCommand(this.dir, "add", "--all").run();
// Commit them
new GitCommand(this.dir, "commit", "--message", message).run();
}
} finally {
this.reset();
}
// Done
return this.getCurrentCommit();
}
/**
* Ensure the specified local branch exists. If it does not, it will be created with an initial empty commit.
*
* @param branch name of the branch we want to ensure exists
* @param message commit message (only used if branch does not yet exist)
* @throws IllegalArgumentException if {@code branch} is null
* @throws IllegalArgumentException if {@code branch} does not match {@link #BRANCH_NAME_PATTERN}
* @throws IllegalArgumentException if {@code message} is null
*/
public synchronized void ensureBranch(String branch, String message) {
// Sanity check
if (branch == null)
throw new IllegalArgumentException("null branch");
if (!BRANCH_NAME_PATTERN.matcher(branch).matches())
throw new IllegalArgumentException("illegal branch name `" + branch + "'");
if (message == null)
throw new IllegalArgumentException("null message");
// Does branch already exist?
try {
if (this.localBranchExists(branch))
return;
} catch (IOException e) {
throw new GitException("error accessing git directory", e);
}
// Create a commit with no parents and an empty tree
this.log.info("creating new local branch `" + branch + "' starting with an empty commit in directory `" + this.dir + "'");
new GitCommand(this.dir, "read-tree", "--empty").run();
final String tree = new GitCommand(this.dir, "write-tree").runAndGetOutput();
if (!SHA1_PATTERN.matcher(tree).matches())
throw new GitException("can't interpret output from `git write-tree': " + tree);
final String commit = new GitCommand(this.dir, "commit-tree", "-m", message, tree).runAndGetOutput();
// Create a branch starting there
new GitCommand(this.dir, "branch", branch, commit).run();
}
/**
* Compare two committed trees for equality.
*
* @param tree1 name of the first tree (branch name, commit, etc.)
* @param tree2 name of the second tree (branch name, commit, etc.)
* @return true if the trees are equal, otherwise false
* @throws GitException if {@code tree1} or {@code tree2} is not a valid branch or commit reference
* @throws IllegalArgumentException if {@code tree1} or {@code tree2} is null
*/
public synchronized boolean equalTrees(String tree1, String tree2) {
final GitCommand diff = new GitCommand(this.dir, "diff", "--quiet", tree1, tree2);
final int exitValue = diff.run(true);
switch (exitValue) {
case 0:
return true;
case 1:
return false;
default:
throw new GitException("command `" + diff + "' in directory `" + this.dir + "' failed with exit value " + exitValue);
}
}
/**
* Merge and commit.
*
*
* Trivial merges, i.e., when {@code other} is already an ancestor of {@code branch}, do not result in a new commit
* and the {@code accessor} is not run.
*
* @param branch name of the branch we want to commit onto
* @param other name of the other commit or branch we want to merge into {@code branch}
* @param strategy merge strategy
* @param accessor callback interface to access the working directory contents after the merge; may be null
* @param message commit message
* @return commit name (SHA-1 hash)
* @throws GitException if {@code branch} is not a local branch
* @throws GitException if {@code other} is not a valid branch or commit reference
* @throws GitMergeConflictException if the merge fails with conflicts
* @throws IllegalArgumentException if {@code branch} or {@code other} is null
* @throws IllegalArgumentException if {@code branch} does not match {@link #BRANCH_NAME_PATTERN}
* @throws IllegalArgumentException if {@code message} is null
*/
public synchronized String merge(String branch, String other, MergeStrategy strategy, Accessor accessor, String message) {
// Sanity check
if (branch == null)
throw new IllegalArgumentException("null branch");
if (!BRANCH_NAME_PATTERN.matcher(branch).matches())
throw new IllegalArgumentException("illegal branch name `" + branch + "'");
if (other == null)
throw new IllegalArgumentException("null other");
if (strategy == null)
throw new IllegalArgumentException("null strategy");
if (message == null)
throw new IllegalArgumentException("null message");
// Verify branch is really a local branch
try {
if (!this.localBranchExists(branch))
throw new GitException("branch `" + branch + "' does not exist or is not a local branch");
} catch (IOException e) {
throw new GitException("error accessing git directory", e);
}
// Reset
this.reset();
// Perform merge steps
try {
// Check out branch
new GitCommand(this.dir, "checkout", "--force", branch).run();
// Perform the merge
strategy.merge(this.dir, other);
// Detect and ignore trivial merge
if (new GitCommand(this.dir, "status", "--porcelain").runAndGetOutput().length() > 0) {
// Grant access
if (accessor != null)
accessor.accessWorkingCopy(this.dir);
// Commit changes
new GitCommand(this.dir, "commit", "--message", message).run();
}
} finally {
// Clean up
this.reset();
}
// Done
return this.getCurrentCommit();
}
/**
* Read file possibly containing a reference and follow it until we get a commit ID.
*
* @param filename filename relative to {@code .git}, e.g., {@code "HEAD"} or {@code "refs/heads/foobar"}
* @return SHA-1 commit ID
* @throws GitException if we can't figure it out
*/
public String followReference(String filename) {
for (int i = 0; i < 10; i++) {
// Read reference
final String ref;
try {
ref = this.readRef(filename);
} catch (IOException e) {
throw new GitException("error reading reference: " + filename, e);
}
if (ref == null)
throw new GitException("reference not found: " + filename);
// Is it a SHA-1?
if (SHA1_PATTERN.matcher(ref).matches())
return ref;
// Recurse and try again
filename = ref;
}
throw new GitException("too many levels indirection in reference: " + filename);
}
/**
* Clean out and reset working directory.
*/
protected void reset() {
new GitCommand(this.dir, "clean", "-xdf").run(); // clean out working directory
new GitCommand(this.dir, "reset", "--hard").run(); // discard uncommitted changes
}
/**
* Get the commit ID of what we have currently checked out (i.e., our HEAD).
*
* @return HEAD commit ID
* @throws GitException if we can't figure it out
*/
protected String getCurrentCommit() {
return this.followReference("HEAD");
}
/**
* Read the first line of a file (trimmed) as a {@link String}.
*
* @param file file to read
* @return first line of {@code file}
* @throws IOException if an I/O error occurs
*/
protected String readFirstLine(File file) throws IOException {
try (final LineNumberReader reader = new LineNumberReader(
new InputStreamReader(new FileInputStream(file), Charset.forName("UTF-8")))) {
final String firstLine = reader.readLine();
if (firstLine == null)
throw new GitException("read empty content from `" + file + "'");
return firstLine.trim();
}
}
/**
* Get the {@link File} corresponding to the given relative path under the {@code .git} directory.
*
* @param path relative path, e.g., {@code HEAD} or {@code refs/heads/foo}
* @return corresponding file
*/
public File getRepoFile(String path) {
return new File(new File(this.dir, ".git"), path);
}
/**
* Determine if the named local branch exists.
*
* @param branch branch name
* @return true if found, otherwise false
* @throws IOException if an I/O error occurs
*/
public boolean localBranchExists(String branch) throws IOException {
return this.refExists("refs/heads/" + branch);
}
/**
* Determine if the named reference exists.
*
* @param ref reference name
* @return true if found, otherwise false
* @throws IOException if an I/O error occurs
*/
public boolean refExists(String ref) throws IOException {
return this.readRef(ref) != null;
}
/**
* Read the given reference.
*
* @param ref reference name
* @return reference (possibly another reference), or null if not found
* @throws IOException if an I/O error occurs
*/
public String readRef(String ref) throws IOException {
// Look for file
final File refFile = this.getRepoFile(ref);
if (refFile.exists()) {
// Read file
final String line;
try {
line = this.readFirstLine(refFile);
} catch (IOException e) {
throw new GitException("error reading `" + refFile + "'", e);
}
// Is it a SHA-1?
if (SHA1_PATTERN.matcher(line).matches())
return line;
// Is it a reference?
final Matcher matcher = INDIRECT_REF_PATTERN.matcher(line);
if (matcher.matches())
return matcher.group(1);
// Unexpected
throw new GitException("can't interpret contents of `" + refFile + "': " + line);
}
// Look in packed-refs
final File packedRefsFile = this.getRepoFile("packed-refs");
if (packedRefsFile.exists()) {
try (final LineNumberReader reader = new LineNumberReader(
new InputStreamReader(new FileInputStream(packedRefsFile), Charset.forName("UTF-8")))) {
for (String line; (line = reader.readLine()) != null; ) {
final Matcher matcher = PACKED_REF_PATTERN.matcher(line);
if (matcher.matches() && matcher.group(2).equals(ref))
return matcher.group(1);
}
}
}
// Not found
return null;
}
// Accessor
/**
* Callback interface used to access or modify working directory contents.
*/
public interface Accessor {
/**
* Access the working copy in the specified working directory.
*
* @param dir working directory root
*/
void accessWorkingCopy(File dir);
}
}