WorkTreeUpdater.java

/*
 * Copyright (C) 2022, 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.merge;

import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;

import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.attributes.Attribute;
import org.eclipse.jgit.attributes.Attributes;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheBuildIterator;
import org.eclipse.jgit.dircache.DirCacheBuilder;
import org.eclipse.jgit.dircache.DirCacheCheckout;
import org.eclipse.jgit.dircache.DirCacheCheckout.StreamSupplier;
import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata;
import org.eclipse.jgit.dircache.DirCacheEntry;
import org.eclipse.jgit.errors.IndexWriteException;
import org.eclipse.jgit.errors.NoWorkTreeException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ConfigConstants;
import org.eclipse.jgit.lib.CoreConfig.EolStreamType;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.treewalk.TreeWalk.OperationType;
import org.eclipse.jgit.treewalk.WorkingTreeOptions;
import org.eclipse.jgit.util.LfsFactory;
import org.eclipse.jgit.util.LfsFactory.LfsInputStream;
import org.eclipse.jgit.util.io.EolStreamTypeUtil;

/**
 * Handles work tree updates on both the checkout and the index.
 * <p>
 * You should use a single instance for all of your file changes. In case of an
 * error, make sure your instance is released, and initiate a new one if
 * necessary.
 */
class WorkTreeUpdater implements Closeable {

	/**
	 * The result of writing the index changes.
	 */
	public static class Result {

		private final List<String> modifiedFiles = new LinkedList<>();


		private final List<String> failedToDelete = new LinkedList<>();

		private ObjectId treeId = null;

		/**
		 * @return Modified tree ID if any, or null otherwise.
		 */
		public ObjectId getTreeId() {
			return treeId;
		}

		/**
		 * @return Files that couldn't be deleted.
		 */
		public List<String> getFailedToDelete() {
			return failedToDelete;
		}

		/**
		 * @return Files modified during this operation.
		 */
		public List<String> getModifiedFiles() {
			return modifiedFiles;
		}
	}

	Result result = new Result();

	/**
	 * The repository this handler operates on.
	 */
	@Nullable
	private final Repository repo;

	/**
	 * Set to true if this operation should work in-memory. The repo's dircache
	 * and workingtree are not touched by this method. Eventually needed files
	 * are created as temporary files and a new empty, in-memory dircache will
	 * be used instead the repo's one. Often used for bare repos where the repo
	 * doesn't even have a workingtree and dircache.
	 */
	private final boolean inCore;

	private final ObjectInserter inserter;

	private final ObjectReader reader;

	private DirCache dirCache;

	private boolean implicitDirCache = false;

	/**
	 * Builder to update the dir cache during this operation.
	 */
	private DirCacheBuilder builder;

	/**
	 * The {@link WorkingTreeOptions} are needed to determine line endings for
	 * affected files.
	 */
	private WorkingTreeOptions workingTreeOptions;

	/**
	 * The size limit (bytes) which controls a file to be stored in {@code Heap}
	 * or {@code LocalFile} during the operation.
	 */
	private int inCoreFileSizeLimit;

	/**
	 * If the operation has nothing to do for a file but check it out at the end
	 * of the operation, it can be added here.
	 */
	private final Map<String, DirCacheEntry> toBeCheckedOut = new HashMap<>();

	/**
	 * Files in this list will be deleted from the local copy at the end of the
	 * operation.
	 */
	private final TreeMap<String, File> toBeDeleted = new TreeMap<>();

	/**
	 * Keeps {@link CheckoutMetadata} for {@link #checkout()}.
	 */
	private Map<String, CheckoutMetadata> checkoutMetadataByPath;

	/**
	 * Keeps {@link CheckoutMetadata} for {@link #revertModifiedFiles()}.
	 */
	private Map<String, CheckoutMetadata> cleanupMetadataByPath;

	/**
	 * Whether the changes were successfully written.
	 */
	private boolean indexChangesWritten;

	/**
	 * @param repo
	 *            the {@link Repository}.
	 * @param dirCache
	 *            if set, use the provided dir cache. Otherwise, use the default
	 *            repository one
	 */
	private WorkTreeUpdater(Repository repo, DirCache dirCache) {
		this.repo = repo;
		this.dirCache = dirCache;

		this.inCore = false;
		this.inserter = repo.newObjectInserter();
		this.reader = inserter.newReader();
		Config config = repo.getConfig();
		this.workingTreeOptions = config.get(WorkingTreeOptions.KEY);
		this.inCoreFileSizeLimit = getInCoreFileSizeLimit(config);
		this.checkoutMetadataByPath = new HashMap<>();
		this.cleanupMetadataByPath = new HashMap<>();
	}

	/**
	 * Creates a new {@link WorkTreeUpdater} for the given repository.
	 *
	 * @param repo
	 *            the {@link Repository}.
	 * @param dirCache
	 *            if set, use the provided dir cache. Otherwise, use the default
	 *            repository one
	 * @return the {@link WorkTreeUpdater}.
	 */
	public static WorkTreeUpdater createWorkTreeUpdater(Repository repo,
			DirCache dirCache) {
		return new WorkTreeUpdater(repo, dirCache);
	}

	/**
	 * @param repo
	 *            the {@link Repository}.
	 * @param dirCache
	 *            if set, use the provided dir cache. Otherwise, creates a new
	 *            one
	 * @param oi
	 *            to use for writing the modified objects with.
	 */
	private WorkTreeUpdater(Repository repo, DirCache dirCache,
			ObjectInserter oi) {
		this.repo = repo;
		this.dirCache = dirCache;
		this.inserter = oi;

		this.inCore = true;
		this.reader = oi.newReader();
		if (repo != null) {
			this.inCoreFileSizeLimit = getInCoreFileSizeLimit(repo.getConfig());
		}
	}

	/**
	 * Creates a new {@link WorkTreeUpdater} that works in memory only.
	 *
	 * @param repo
	 *            the {@link Repository}.
	 * @param dirCache
	 *            if set, use the provided dir cache. Otherwise, creates a new
	 *            one
	 * @param oi
	 *            to use for writing the modified objects with.
	 * @return the {@link WorkTreeUpdater}
	 */
	public static WorkTreeUpdater createInCoreWorkTreeUpdater(Repository repo,
			DirCache dirCache, ObjectInserter oi) {
		return new WorkTreeUpdater(repo, dirCache, oi);
	}

	private static int getInCoreFileSizeLimit(Config config) {
		return config.getInt(ConfigConstants.CONFIG_MERGE_SECTION,
				ConfigConstants.CONFIG_KEY_IN_CORE_LIMIT, 10 << 20);
	}

	/**
	 * Gets the size limit for in-core files in this config.
	 *
	 * @return the size
	 */
	public int getInCoreFileSizeLimit() {
		return inCoreFileSizeLimit;
	}

	/**
	 * Gets dir cache for the repo. Locked if not inCore.
	 *
	 * @return the result dir cache
	 * @throws IOException
	 *             is case the dir cache cannot be read
	 */
	public DirCache getLockedDirCache() throws IOException {
		if (dirCache == null) {
			implicitDirCache = true;
			if (inCore) {
				dirCache = DirCache.newInCore();
			} else {
				dirCache = nonNullRepo().lockDirCache();
			}
		}
		if (builder == null) {
			builder = dirCache.builder();
		}
		return dirCache;
	}

	/**
	 * Creates a {@link DirCacheBuildIterator} for the builder of this
	 * {@link WorkTreeUpdater}.
	 *
	 * @return the {@link DirCacheBuildIterator}
	 */
	public DirCacheBuildIterator createDirCacheBuildIterator() {
		return new DirCacheBuildIterator(builder);
	}

	/**
	 * Writes the changes to the working tree (but not to the index).
	 *
	 * @param shouldCheckoutTheirs
	 *            before committing the changes
	 * @throws IOException
	 *             if any of the writes fail
	 */
	public void writeWorkTreeChanges(boolean shouldCheckoutTheirs)
			throws IOException {
		handleDeletedFiles();

		if (inCore) {
			builder.finish();
			return;
		}
		if (shouldCheckoutTheirs) {
			// No problem found. The only thing left to be done is to
			// check out all files from "theirs" which have been selected to
			// go into the new index.
			checkout();
		}

		// All content operations are successfully done. If we can now write the
		// new index we are on quite safe ground. Even if the checkout of
		// files coming from "theirs" fails the user can work around such
		// failures by checking out the index again.
		if (!builder.commit()) {
			revertModifiedFiles();
			throw new IndexWriteException();
		}
	}

	/**
	 * Writes the changes to the index.
	 *
	 * @return the {@link Result} of the operation.
	 * @throws IOException
	 *             if any of the writes fail
	 */
	public Result writeIndexChanges() throws IOException {
		result.treeId = getLockedDirCache().writeTree(inserter);
		indexChangesWritten = true;
		return result;
	}

	/**
	 * Adds a {@link DirCacheEntry} for direct checkout and remembers its
	 * {@link CheckoutMetadata}.
	 *
	 * @param path
	 *            of the entry
	 * @param entry
	 *            to add
	 * @param cleanupStreamType
	 *            to use for the cleanup metadata
	 * @param cleanupSmudgeCommand
	 *            to use for the cleanup metadata
	 * @param checkoutStreamType
	 *            to use for the checkout metadata
	 * @param checkoutSmudgeCommand
	 *            to use for the checkout metadata
	 */
	public void addToCheckout(String path, DirCacheEntry entry,
			EolStreamType cleanupStreamType, String cleanupSmudgeCommand,
			EolStreamType checkoutStreamType, String checkoutSmudgeCommand) {
		if (entry != null) {
			// In some cases, we just want to add the metadata.
			toBeCheckedOut.put(path, entry);
		}
		addCheckoutMetadata(cleanupMetadataByPath, path, cleanupStreamType,
				cleanupSmudgeCommand);
		addCheckoutMetadata(checkoutMetadataByPath, path, checkoutStreamType,
				checkoutSmudgeCommand);
	}

	/**
	 * Gets a map which maps the paths of files which have to be checked out
	 * because the operation created new fully-merged content for this file into
	 * the index.
	 * <p>
	 * This means: the operation wrote a new stage 0 entry for this path.
	 * </p>
	 *
	 * @return the map
	 */
	public Map<String, DirCacheEntry> getToBeCheckedOut() {
		return toBeCheckedOut;
	}

	/**
	 * Remembers the given file to be deleted.
	 * <p>
	 * Note the actual deletion is only done in {@link #writeWorkTreeChanges}.
	 *
	 * @param path
	 *            of the file to be deleted
	 * @param file
	 *            to be deleted
	 * @param streamType
	 *            to use for cleanup metadata
	 * @param smudgeCommand
	 *            to use for cleanup metadata
	 */
	public void deleteFile(String path, File file, EolStreamType streamType,
			String smudgeCommand) {
		toBeDeleted.put(path, file);
		if (file != null && file.isFile()) {
			addCheckoutMetadata(cleanupMetadataByPath, path, streamType,
					smudgeCommand);
		}
	}

	/**
	 * Remembers the {@link CheckoutMetadata} for the given path; it may be
	 * needed in {@link #checkout()} or in {@link #revertModifiedFiles()}.
	 *
	 * @param map
	 *            to add the metadata to
	 * @param path
	 *            of the current node
	 * @param streamType
	 *            to use for the metadata
	 * @param smudgeCommand
	 *            to use for the metadata
	 */
	private void addCheckoutMetadata(Map<String, CheckoutMetadata> map,
			String path, EolStreamType streamType, String smudgeCommand) {
		if (inCore || map == null) {
			return;
		}
		map.put(path, new CheckoutMetadata(streamType, smudgeCommand));
	}

	/**
	 * Detects if CRLF conversion has been configured.
	 * <p>
	 * </p>
	 * See {@link EolStreamTypeUtil#detectStreamType} for more info.
	 *
	 * @param attributes
	 *            of the file for which the type is to be detected
	 * @return the detected type
	 */
	public EolStreamType detectCheckoutStreamType(Attributes attributes) {
		if (inCore) {
			return null;
		}
		return EolStreamTypeUtil.detectStreamType(OperationType.CHECKOUT_OP,
				workingTreeOptions, attributes);
	}

	private void handleDeletedFiles() {
		// Iterate in reverse so that "folder/file" is deleted before
		// "folder". Otherwise, this could result in a failing path because
		// of a non-empty directory, for which delete() would fail.
		for (String path : toBeDeleted.descendingKeySet()) {
			File file = inCore ? null : toBeDeleted.get(path);
			if (file != null && !file.delete()) {
				if (!file.isDirectory()) {
					result.failedToDelete.add(path);
				}
			}
		}
	}

	/**
	 * Marks the given path as modified in the operation.
	 *
	 * @param path
	 *            to mark as modified
	 */
	public void markAsModified(String path) {
		result.modifiedFiles.add(path);
	}

	/**
	 * Gets the list of files which were modified in this operation.
	 *
	 * @return the list
	 */
	public List<String> getModifiedFiles() {
		return result.modifiedFiles;
	}

	private void checkout() throws NoWorkTreeException, IOException {
		for (Map.Entry<String, DirCacheEntry> entry : toBeCheckedOut
				.entrySet()) {
			DirCacheEntry dirCacheEntry = entry.getValue();
			if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
				new File(nonNullRepo().getWorkTree(), entry.getKey())
						.mkdirs();
			} else {
				DirCacheCheckout.checkoutEntry(repo, dirCacheEntry, reader,
						false, checkoutMetadataByPath.get(entry.getKey()),
						workingTreeOptions);
				result.modifiedFiles.add(entry.getKey());
			}
		}
	}

	/**
	 * Reverts any uncommitted changes in the worktree. We know that for all
	 * modified files the old content was in the old index and the index
	 * contained only stage 0. In case of inCore operation just clear the
	 * history of modified files.
	 *
	 * @throws IOException
	 *             in case the cleaning up failed
	 */
	public void revertModifiedFiles() throws IOException {
		if (inCore) {
			result.modifiedFiles.clear();
			return;
		}
		if (indexChangesWritten) {
			return;
		}
		for (String path : result.modifiedFiles) {
			DirCacheEntry entry = dirCache.getEntry(path);
			if (entry != null) {
				DirCacheCheckout.checkoutEntry(repo, entry, reader, false,
						cleanupMetadataByPath.get(path), workingTreeOptions);
			}
		}
	}

	@Override
	public void close() throws IOException {
		if (implicitDirCache) {
			dirCache.unlock();
		}
	}

	/**
	 * Updates the file in the checkout with the given content.
	 *
	 * @param inputStream
	 *            the content to be updated
	 * @param streamType
	 *            for parsing the content
	 * @param smudgeCommand
	 *            for formatting the content
	 * @param path
	 *            of the file to be updated
	 * @param file
	 *            to be updated
	 * @throws IOException
	 *             if the file cannot be updated
	 */
	public void updateFileWithContent(StreamSupplier inputStream,
			EolStreamType streamType, String smudgeCommand, String path,
			File file) throws IOException {
		if (inCore) {
			return;
		}
		CheckoutMetadata metadata = new CheckoutMetadata(streamType,
				smudgeCommand);

		try (OutputStream outputStream = new FileOutputStream(file)) {
			DirCacheCheckout.getContent(repo, path, metadata,
					inputStream, workingTreeOptions, outputStream);
		}
	}

	/**
	 * Creates a path with the given content, and adds it to the specified stage
	 * to the index builder.
	 *
	 * @param input
	 *            the content to be updated
	 * @param path
	 *            of the file to be updated
	 * @param fileMode
	 *            of the modified file
	 * @param entryStage
	 *            of the new entry
	 * @param lastModified
	 *            instant of the modified file
	 * @param len
	 *            of the content
	 * @param lfsAttribute
	 *            for checking for LFS enablement
	 * @return the entry which was added to the index
	 * @throws IOException
	 *             if inserting the content fails
	 */
	public DirCacheEntry insertToIndex(InputStream input,
			byte[] path, FileMode fileMode, int entryStage,
			Instant lastModified, int len, Attribute lfsAttribute)
			throws IOException {
		return addExistingToIndex(insertResult(input, lfsAttribute, len), path,
				fileMode, entryStage, lastModified, len);
	}

	/**
	 * Adds a path with the specified stage to the index builder.
	 *
	 * @param objectId
	 *            of the existing object to add
	 * @param path
	 *            of the modified file
	 * @param fileMode
	 *            of the modified file
	 * @param entryStage
	 *            of the new entry
	 * @param lastModified
	 *            instant of the modified file
	 * @param len
	 *            of the modified file content
	 * @return the entry which was added to the index
	 */
	public DirCacheEntry addExistingToIndex(ObjectId objectId, byte[] path,
			FileMode fileMode, int entryStage, Instant lastModified, int len) {
		DirCacheEntry dce = new DirCacheEntry(path, entryStage);
		dce.setFileMode(fileMode);
		if (lastModified != null) {
			dce.setLastModified(lastModified);
		}
		dce.setLength(inCore ? 0 : len);
		dce.setObjectId(objectId);
		builder.add(dce);
		return dce;
	}

	private ObjectId insertResult(InputStream input,
			Attribute lfsAttribute, long length) throws IOException {
		try (LfsInputStream is = LfsFactory.getInstance().applyCleanFilter(repo,
				input, length,
				lfsAttribute)) {
			return inserter.insert(OBJ_BLOB, is.getLength(), is);
		}
	}

	/**
	 * Gets the non-null repository instance of this {@link WorkTreeUpdater}.
	 *
	 * @return non-null repository instance
	 * @throws NullPointerException
	 *             if the handler was constructed without a repository.
	 */
	@NonNull
	private Repository nonNullRepo() throws NullPointerException {
		return Objects.requireNonNull(repo,
				() -> JGitText.get().repositoryIsRequired);
	}
}