/**
* This file is part of git-as-svn. It is subject to the license terms
* in the LICENSE file found in the top-level directory of this distribution
* and at http://www.gnu.org/licenses/gpl-2.0.html. No part of git-as-svn,
* including this file, may be copied, modified, propagated, or distributed
* except according to the terms contained in the LICENSE file.
*/
package svnserver.repository.git;
import org.eclipse.jgit.lib.*;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tmatesoft.svn.core.SVNErrorCode;
import org.tmatesoft.svn.core.SVNErrorMessage;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNProperty;
import svnserver.ReferenceLink;
import svnserver.StringHelper;
import svnserver.auth.User;
import svnserver.repository.*;
import svnserver.repository.git.prop.PropertyMapping;
import svnserver.repository.git.push.GitPusher;
import svnserver.repository.locks.LockDesc;
import svnserver.repository.locks.LockManagerWrite;
import java.io.IOException;
import java.util.*;
/**
* Git commit writer.
*
* @author Artem V. Navrotskiy <bozaro@users.noreply.github.com>
*/
public class GitWriter implements VcsWriter {
private static final int MAX_PROPERTY_ERRROS = 50;
@NotNull
private static final Logger log = LoggerFactory.getLogger(GitWriter.class);
@NotNull
private final GitRepository repo;
@NotNull
private final ObjectInserter inserter;
@NotNull
private final GitPusher pusher;
@NotNull
private final Object pushLock;
@NotNull
private final String gitBranch;
@NotNull
private final User user;
public GitWriter(@NotNull GitRepository repo, @NotNull GitPusher pusher, @NotNull Object pushLock, @NotNull String gitBranch, @NotNull User user) {
this.repo = repo;
this.pusher = pusher;
this.pushLock = pushLock;
this.gitBranch = gitBranch;
this.inserter = repo.getRepository().newObjectInserter();
this.user = user;
}
@NotNull
@Override
public VcsDeltaConsumer createFile(@NotNull VcsEntry parent, @NotNull String name) throws IOException, SVNException {
return new GitDeltaConsumer(this, ((GitEntry) parent).createChild(name, false), null, user);
}
@NotNull
@Override
public VcsDeltaConsumer modifyFile(@NotNull VcsEntry parent, @NotNull String name, @NotNull VcsFile file) throws IOException, SVNException {
return new GitDeltaConsumer(this, ((GitEntry) parent).createChild(name, false), (GitFile) file, user);
}
@NotNull
@Override
public VcsCommitBuilder createCommitBuilder(@NotNull LockManagerWrite lockManager, @NotNull Map<String, String> locks) throws IOException, SVNException {
return new GitCommitBuilder(lockManager, locks, gitBranch);
}
@NotNull
public GitRepository getRepository() {
return repo;
}
@NotNull
public ObjectInserter getInserter() {
return inserter;
}
private class GitCommitBuilder implements VcsCommitBuilder {
@NotNull
private final Deque<GitTreeUpdate> treeStack;
@NotNull
private final GitRevision revision;
@NotNull
private final String branch;
@NotNull
private final LockManagerWrite lockManager;
@NotNull
private final Map<String, String> locks;
@NotNull
private final List<VcsConsumer<CommitAction>> commitActions = new ArrayList<>();
public GitCommitBuilder(@NotNull LockManagerWrite lockManager, @NotNull Map<String, String> locks, @NotNull String branch) throws IOException, SVNException {
this.branch = branch;
this.lockManager = lockManager;
this.locks = locks;
this.revision = repo.getLatestRevision();
this.treeStack = new ArrayDeque<>();
this.treeStack.push(new GitTreeUpdate("", getOriginalTree()));
}
private Iterable<GitTreeEntry> getOriginalTree() throws IOException {
final RevCommit commit = revision.getGitNewCommit();
if (commit == null) {
return Collections.emptyList();
}
return repo.loadTree(new GitTreeEntry(repo.getRepository(), FileMode.TREE, commit.getTree(), ""));
}
@Override
public void checkUpToDate(@NotNull String path, int rev, boolean checkLock) throws SVNException, IOException {
final GitFile file = revision.getFile(path);
if (file == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, path));
} else if (file.getLastChange().getId() > rev) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.WC_NOT_UP_TO_DATE, "Working copy is not up-to-date: " + path));
}
if (checkLock) {
checkLockFile(file);
}
}
private void checkLockFile(@NotNull GitFile file) throws SVNException, IOException {
final String fullPath = file.getFullPath();
if (file.isDirectory()) {
final Iterator<LockDesc> iter = lockManager.getLocks(fullPath, Depth.Infinity);
while (iter.hasNext()) {
checkLockDesc(iter.next());
}
} else {
checkLockDesc(lockManager.getLock(fullPath));
}
}
private void checkLockDesc(@Nullable LockDesc lockDesc) throws SVNException {
if (lockDesc != null) {
final String token = locks.get(lockDesc.getPath());
if (token == null || !lockDesc.getToken().equals(token)) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.FS_BAD_LOCK_TOKEN, lockDesc.getPath()));
}
}
}
@Override
public void addDir(@NotNull String name, @Nullable VcsFile sourceDir) throws SVNException, IOException {
final GitTreeUpdate current = treeStack.element();
if (current.getEntries().containsKey(name)) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.FS_ALREADY_EXISTS, getFullPath(name)));
}
final GitFile source = (GitFile) sourceDir;
commitActions.add(action -> action.openDir(name));
treeStack.push(new GitTreeUpdate(name, repo.loadTree(source == null ? null : source.getTreeEntry())));
}
@Override
public void openDir(@NotNull String name) throws SVNException, IOException {
final GitTreeUpdate current = treeStack.element();
final GitTreeEntry originalDir = current.getEntries().remove(name);
if ((originalDir == null) || (!originalDir.getFileMode().equals(FileMode.TREE))) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, getFullPath(name)));
}
commitActions.add(action -> action.openDir(name));
treeStack.push(new GitTreeUpdate(name, repo.loadTree(originalDir)));
}
@Override
public void checkDirProperties(@NotNull Map<String, String> props) throws SVNException, IOException {
commitActions.add(action -> action.checkProperties(null, props, null));
}
@Override
public void closeDir() throws SVNException, IOException {
final GitTreeUpdate last = treeStack.pop();
final GitTreeUpdate current = treeStack.element();
final String fullPath = getFullPath(last.getName());
if (last.getEntries().isEmpty()) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.CANCELLED, "Empty directories is not supported: " + fullPath));
}
final ObjectId subtreeId = last.buildTree(inserter);
log.debug("Create tree {} for dir: {}", subtreeId.name(), fullPath);
if (current.getEntries().put(last.getName(), new GitTreeEntry(FileMode.TREE, new GitObject<>(repo.getRepository(), subtreeId), last.getName())) != null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.FS_ALREADY_EXISTS, fullPath));
}
commitActions.add(CommitAction::closeDir);
}
@Override
public void saveFile(@NotNull String name, @NotNull VcsDeltaConsumer deltaConsumer, boolean modify) throws SVNException, IOException {
final GitDeltaConsumer gitDeltaConsumer = (GitDeltaConsumer) deltaConsumer;
final GitTreeUpdate current = treeStack.element();
final GitTreeEntry entry = current.getEntries().get(name);
final GitObject<ObjectId> originalId = gitDeltaConsumer.getOriginalId();
if (modify ^ (entry != null)) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.WC_NOT_UP_TO_DATE, "Working copy is not up-to-date: " + getFullPath(name)));
}
final GitObject<ObjectId> objectId = gitDeltaConsumer.getObjectId();
if (objectId == null) {
// Content not updated.
if (originalId == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.INCOMPLETE_DATA, "Added file without content: " + getFullPath(name)));
}
return;
}
current.getEntries().put(name, new GitTreeEntry(getFileMode(gitDeltaConsumer.getProperties()), objectId, name));
commitActions.add(action -> action.checkProperties(name, gitDeltaConsumer.getProperties(), gitDeltaConsumer));
}
private FileMode getFileMode(@NotNull Map<String, String> props) {
if (props.containsKey(SVNProperty.SPECIAL)) return FileMode.SYMLINK;
if (props.containsKey(SVNProperty.EXECUTABLE)) return FileMode.EXECUTABLE_FILE;
return FileMode.REGULAR_FILE;
}
@Override
public void delete(@NotNull String name) throws SVNException, IOException {
final GitTreeUpdate current = treeStack.element();
final GitTreeEntry entry = current.getEntries().remove(name);
if (entry == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, getFullPath(name)));
}
}
@Override
public GitRevision commit(@NotNull User userInfo, @NotNull String message) throws SVNException, IOException {
final GitTreeUpdate root = treeStack.element();
ObjectId treeId = root.buildTree(inserter);
log.debug("Create tree {} for commit.", treeId.name());
final CommitBuilder commitBuilder = new CommitBuilder();
final PersonIdent ident = createIdent(userInfo);
commitBuilder.setAuthor(ident);
commitBuilder.setCommitter(ident);
commitBuilder.setMessage(message);
final RevCommit parentCommit = revision.getGitNewCommit();
if (parentCommit != null) {
commitBuilder.setParentId(parentCommit.getId());
}
commitBuilder.setTreeId(treeId);
final ObjectId commitId = inserter.insert(commitBuilder);
inserter.flush();
log.info("Create commit {}: {}", commitId.name(), StringHelper.getFirstLine(message));
if (filterMigration(new RevWalk(repo.getRepository()).parseTree(treeId)) != 0) {
log.info("Need recreate tree after filter migration.");
return null;
}
synchronized (pushLock) {
log.info("Validate properties");
validateProperties(new RevWalk(repo.getRepository()).parseTree(treeId));
log.info("Try to push commit in branch: {}", branch);
if (!pusher.push(repo.getRepository(), commitId, branch, userInfo)) {
log.info("Non fast forward push rejected");
return null;
}
log.info("Commit is pushed");
repo.updateRevisions();
return repo.getRevision(commitId);
}
}
private void validateProperties(@NotNull RevTree tree) throws IOException, SVNException {
final GitFile root = GitFileTreeEntry.create(repo, tree, 0);
final GitPropertyValidator validator = new GitPropertyValidator(root);
for (VcsConsumer<CommitAction> validateAction : commitActions) {
validateAction.accept(validator);
}
validator.done();
}
private int filterMigration(@NotNull RevTree tree) throws IOException, SVNException {
final GitFile root = GitFileTreeEntry.create(repo, tree, 0);
final GitFilterMigration validator = new GitFilterMigration(root);
for (VcsConsumer<CommitAction> validateAction : commitActions) {
validateAction.accept(validator);
}
return validator.done();
}
private PersonIdent createIdent(User userInfo) {
final String realName = userInfo.getRealName();
final String email = userInfo.getEmail();
return new PersonIdent(realName, email == null ? "" : email);
}
@NotNull
private String getFullPath(String name) {
final StringBuilder fullPath = new StringBuilder();
final Iterator<GitTreeUpdate> iter = treeStack.descendingIterator();
while (iter.hasNext()) {
fullPath.append(iter.next().getName()).append('/');
}
fullPath.append(name);
return fullPath.toString();
}
}
private abstract class CommitAction {
@NotNull
private final Deque<GitFile> treeStack;
public CommitAction(@NotNull GitFile root) {
this.treeStack = new ArrayDeque<>();
this.treeStack.push(root);
}
protected GitFile getElement() {
return treeStack.element();
}
public final void openDir(@NotNull String name) throws IOException, SVNException {
final GitFile file = treeStack.element().getEntry(name);
if (file == null) {
throw new IllegalStateException("Invalid state: can't find file " + name + " in created commit.");
}
treeStack.push(file);
}
public abstract void checkProperties(@Nullable String name, @NotNull Map<String, String> props, @Nullable GitDeltaConsumer deltaConsumer) throws IOException, SVNException;
public final void closeDir() {
treeStack.pop();
}
}
private class GitFilterMigration extends CommitAction {
private int migrateCount = 0;
public GitFilterMigration(@NotNull GitFile root) {
super(root);
}
@Override
public void checkProperties(@Nullable String name, @NotNull Map<String, String> props, @Nullable GitDeltaConsumer deltaConsumer) throws IOException, SVNException {
final GitFile dir = getElement();
final GitFile node = name == null ? dir : dir.getEntry(name);
if (node == null) {
throw new IllegalStateException("Invalid state: can't find entry " + name + " in created commit.");
}
if (deltaConsumer != null) {
assert (node.getFilter() != null);
if (deltaConsumer.migrateFilter(node.getFilter())) {
migrateCount++;
}
}
}
public int done() throws SVNException {
return migrateCount;
}
}
private class GitPropertyValidator extends CommitAction {
@NotNull
private final Map<String, Set<String>> propertyMismatch = new TreeMap<>();
private int errorCount = 0;
public GitPropertyValidator(@NotNull GitFile root) {
super(root);
}
@Override
public void checkProperties(@Nullable String name, @NotNull Map<String, String> props, @Nullable GitDeltaConsumer deltaConsumer) throws IOException, SVNException {
final GitFile dir = getElement();
final GitFile node = name == null ? dir : dir.getEntry(name);
if (node == null) {
throw new IllegalStateException("Invalid state: can't find entry " + name + " in created commit.");
}
if (deltaConsumer != null) {
assert (node.getFilter() != null);
if (!node.getFilter().getName().equals(deltaConsumer.getFilterName())) {
throw new IllegalStateException("Invalid writer filter:\n"
+ "Expected: " + node.getFilter().getName() + "\n"
+ "Actual: " + deltaConsumer.getFilterName());
}
}
final Map<String, String> expected = node.getProperties();
if (!props.equals(expected)) {
if (errorCount < MAX_PROPERTY_ERRROS) {
final StringBuilder delta = new StringBuilder();
delta.append("Expected:\n");
for (Map.Entry<String, String> entry : expected.entrySet()) {
delta.append(" ").append(entry.getKey()).append(" = \"").append(entry.getValue()).append("\"\n");
}
delta.append("Actual:\n");
for (Map.Entry<String, String> entry : props.entrySet()) {
delta.append(" ").append(entry.getKey()).append(" = \"").append(entry.getValue()).append("\"\n");
}
propertyMismatch.compute(delta.toString(), (key, value) -> {
if (value == null) {
value = new TreeSet<>();
}
value.add(node.getFullPath());
return value;
});
errorCount++;
}
}
}
public void done() throws SVNException {
if (!propertyMismatch.isEmpty()) {
final StringBuilder message = new StringBuilder();
for (Map.Entry<String, Set<String>> entry : propertyMismatch.entrySet()) {
if (message.length() > 0) {
message.append("\n");
}
message.append("Invalid svn properties on files:\n");
for (String path : entry.getValue()) {
message.append(" ").append(path).append("\n");
}
message.append(entry.getKey());
}
message.append("\n"
+ "----------------\n" +
"Subversion properties must be consistent with Git config files:\n");
for (String configFile : PropertyMapping.getRegisteredFiles()) {
message.append(" ").append(configFile).append('\n');
}
message.append("\n" +
"For more detailed information you can see:").append("\n").append(ReferenceLink.Properties.getLinks());
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.REPOS_HOOK_FAILURE, message.toString()));
}
}
}
}