| /* |
| * 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.util; |
| |
| import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; |
| |
| import java.io.BufferedInputStream; |
| 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.nio.file.Files; |
| import java.nio.file.StandardCopyOption; |
| import java.time.Instant; |
| import java.util.HashMap; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.TreeMap; |
| 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.CheckoutMetadata; |
| import org.eclipse.jgit.dircache.DirCacheEntry; |
| import org.eclipse.jgit.errors.IndexWriteException; |
| import org.eclipse.jgit.errors.LargeObjectException; |
| 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.Constants; |
| 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.ObjectLoader; |
| import org.eclipse.jgit.lib.ObjectReader; |
| import org.eclipse.jgit.lib.ObjectStream; |
| 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.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. |
| * |
| * @since 6.3 |
| */ |
| public class WorkTreeUpdater implements Closeable { |
| |
| /** |
| * The result of writing the index changes. |
| */ |
| public static class Result { |
| |
| /** |
| * Files modified during this operation. |
| */ |
| public List<String> modifiedFiles = new LinkedList<>(); |
| |
| /** |
| * Files in this list were failed to be deleted. |
| */ |
| public List<String> failedToDelete = new LinkedList<>(); |
| |
| /** |
| * Modified tree ID if any, or null otherwise. |
| */ |
| public ObjectId treeId = null; |
| } |
| |
| 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 = null; |
| |
| /** |
| * 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> checkoutMetadata; |
| |
| /** |
| * Keeps {@link CheckoutMetadata} for {@link #revertModifiedFiles()}. |
| */ |
| private Map<String, CheckoutMetadata> cleanupMetadata; |
| |
| /** |
| * Whether the changes were successfully written |
| */ |
| private boolean indexChangesWritten = false; |
| |
| /** |
| * @param repo the {@link org.eclipse.jgit.lib.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(); |
| this.workingTreeOptions = repo.getConfig().get(WorkingTreeOptions.KEY); |
| this.checkoutMetadata = new HashMap<>(); |
| this.cleanupMetadata = new HashMap<>(); |
| this.inCoreFileSizeLimit = setInCoreFileSizeLimit(repo.getConfig()); |
| } |
| |
| /** |
| * @param repo the {@link org.eclipse.jgit.lib.Repository}. |
| * @param dirCache if set, use the provided dir cache. Otherwise, use the default repository one |
| * @return an IO handler. |
| */ |
| public static WorkTreeUpdater createWorkTreeUpdater(Repository repo, DirCache dirCache) { |
| return new WorkTreeUpdater(repo, dirCache); |
| } |
| |
| /** |
| * @param repo the {@link org.eclipse.jgit.lib.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 = setInCoreFileSizeLimit(repo.getConfig()); |
| } |
| } |
| |
| /** |
| * @param repo the {@link org.eclipse.jgit.lib.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 an IO handler. |
| */ |
| public static WorkTreeUpdater createInCoreWorkTreeUpdater(Repository repo, DirCache dirCache, |
| ObjectInserter oi) { |
| return new WorkTreeUpdater(repo, dirCache, oi); |
| } |
| |
| /** |
| * Something that can supply an {@link InputStream}. |
| */ |
| public interface StreamSupplier { |
| |
| /** |
| * Loads the input stream. |
| * |
| * @return the loaded stream |
| * @throws IOException if any reading error occurs |
| */ |
| InputStream load() throws IOException; |
| } |
| |
| /** |
| * We write the patch result to a {@link org.eclipse.jgit.util.TemporaryBuffer} and then use |
| * {@link DirCacheCheckout}.getContent() to run the result through the CR-LF and smudge filters. |
| * DirCacheCheckout needs an ObjectLoader, not a TemporaryBuffer, so this class bridges between |
| * the two, making any Stream provided by a {@link StreamSupplier} look like an ordinary git blob |
| * to DirCacheCheckout. |
| */ |
| public static class StreamLoader extends ObjectLoader { |
| |
| private final StreamSupplier data; |
| |
| private final long size; |
| |
| private StreamLoader(StreamSupplier data, long length) { |
| this.data = data; |
| this.size = length; |
| } |
| |
| @Override |
| public int getType() { |
| return Constants.OBJ_BLOB; |
| } |
| |
| @Override |
| public long getSize() { |
| return size; |
| } |
| |
| @Override |
| public boolean isLarge() { |
| return true; |
| } |
| |
| @Override |
| public byte[] getCachedBytes() throws LargeObjectException { |
| throw new LargeObjectException(); |
| } |
| |
| @Override |
| public ObjectStream openStream() throws IOException { |
| return new ObjectStream.Filter(getType(), getSize(), new BufferedInputStream(data.load())); |
| } |
| } |
| |
| /** |
| * Creates stream loader for the given supplier. |
| * |
| * @param supplier to wrap |
| * @param length of the supplied content |
| * @return the result stream loader |
| */ |
| public static StreamLoader createStreamLoader(StreamSupplier supplier, long length) { |
| return new StreamLoader(supplier, length); |
| } |
| |
| private static int setInCoreFileSizeLimit(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 = nonNullNonBareRepo().lockDirCache(); |
| } |
| } |
| if (builder == null) { |
| builder = dirCache.builder(); |
| } |
| return dirCache; |
| } |
| |
| /** |
| * Creates build iterator for the handler's builder. |
| * |
| * @return the iterator |
| */ |
| public DirCacheBuildIterator createDirCacheBuildIterator() { |
| return new DirCacheBuildIterator(builder); |
| } |
| |
| /** |
| * Writes the changes to the WorkTree (but not 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 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 |
| * @since 6.1 |
| */ |
| 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(cleanupMetadata, path, cleanupStreamType, cleanupSmudgeCommand); |
| addCheckoutMetadata(checkoutMetadata, path, checkoutStreamType, checkoutSmudgeCommand); |
| } |
| |
| /** |
| * Get 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; |
| } |
| |
| /** |
| * Deletes the given file |
| * <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 |
| * @throws IOException if the file cannot be deleted |
| */ |
| public void deleteFile(String path, File file, EolStreamType streamType, String smudgeCommand) |
| throws IOException { |
| toBeDeleted.put(path, file); |
| if (file != null && file.isFile()) { |
| addCheckoutMetadata(cleanupMetadata, 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 |
| * @since 6.1 |
| */ |
| 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 { |
| // 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 (Map.Entry<String, DirCacheEntry> entry : toBeCheckedOut.entrySet()) { |
| DirCacheEntry dirCacheEntry = entry.getValue(); |
| if (dirCacheEntry.getFileMode() == FileMode.GITLINK) { |
| new File(nonNullNonBareRepo().getWorkTree(), entry.getKey()).mkdirs(); |
| } else { |
| DirCacheCheckout.checkoutEntry( |
| repo, dirCacheEntry, reader, false, |
| checkoutMetadata.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 if inCore |
| * operation just clear the history of modified files. |
| * |
| * @throws java.io.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, cleanupMetadata.get(path), |
| workingTreeOptions); |
| } |
| } |
| } |
| |
| @Override |
| public void close() throws IOException { |
| if (implicitDirCache) { |
| dirCache.unlock(); |
| } |
| } |
| |
| /** |
| * Updates the file in the checkout with the given content. |
| * |
| * @param resultStreamLoader with 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 |
| * @param safeWrite whether the content should be written to a buffer first |
| * @throws IOException if the {@link CheckoutMetadata} cannot be determined |
| */ |
| public void updateFileWithContent( |
| StreamLoader resultStreamLoader, |
| EolStreamType streamType, |
| String smudgeCommand, |
| String path, |
| File file, |
| boolean safeWrite) |
| throws IOException { |
| if (inCore) { |
| return; |
| } |
| CheckoutMetadata metadata = new CheckoutMetadata(streamType, |
| smudgeCommand); |
| if (safeWrite) { |
| // Write to a buffer and copy to the file only if everything was |
| // fine. |
| TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(null); |
| try { |
| try (TemporaryBuffer buf = buffer) { |
| DirCacheCheckout.getContent(repo, path, metadata, |
| resultStreamLoader, workingTreeOptions, buf); |
| } |
| try (InputStream bufIn = buffer.openInputStream()) { |
| Files.copy(bufIn, file.toPath(), |
| StandardCopyOption.REPLACE_EXISTING); |
| } |
| } finally { |
| buffer.destroy(); |
| } |
| return; |
| } |
| try (OutputStream outputStream = new FileOutputStream(file)) { |
| DirCacheCheckout.getContent(repo, path, metadata, |
| resultStreamLoader, workingTreeOptions, outputStream); |
| } |
| } |
| |
| /** |
| * Creates a path with the given content, and adds it to the specified stage to the index builder |
| * |
| * @param inputStream with 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 inputStream, |
| byte[] path, |
| FileMode fileMode, |
| int entryStage, |
| Instant lastModified, |
| int len, |
| Attribute lfsAttribute) throws IOException { |
| StreamLoader contentLoader = createStreamLoader(() -> inputStream, len); |
| return insertToIndex(contentLoader, path, fileMode, entryStage, lastModified, len, |
| lfsAttribute); |
| } |
| |
| /** |
| * Creates a path with the given content, and adds it to the specified stage to the index builder |
| * |
| * @param resultStreamLoader with 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( |
| StreamLoader resultStreamLoader, |
| byte[] path, |
| FileMode fileMode, |
| int entryStage, |
| Instant lastModified, |
| int len, |
| Attribute lfsAttribute) throws IOException { |
| return addExistingToIndex(insertResult(resultStreamLoader, lfsAttribute), |
| 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(StreamLoader resultStreamLoader, Attribute lfsAttribute) |
| throws IOException { |
| try (LfsInputStream is = |
| org.eclipse.jgit.util.LfsFactory.getInstance() |
| .applyCleanFilter( |
| repo, |
| resultStreamLoader.data.load(), |
| resultStreamLoader.size, |
| lfsAttribute)) { |
| return inserter.insert(OBJ_BLOB, is.getLength(), is); |
| } |
| } |
| |
| /** |
| * Gets non-null repository instance |
| * |
| * @return non-null repository instance |
| * @throws java.lang.NullPointerException if the handler was constructed without a repository. |
| */ |
| private Repository nonNullRepo() throws NullPointerException { |
| if (repo == null) { |
| throw new NullPointerException(JGitText.get().repositoryIsRequired); |
| } |
| return repo; |
| } |
| |
| |
| /** |
| * Gets non-null and non-bare repository instance |
| * |
| * @return non-null and non-bare repository instance |
| * @throws java.lang.NullPointerException if the handler was constructed without a repository. |
| * @throws NoWorkTreeException if the handler was constructed with a bare repository |
| */ |
| private Repository nonNullNonBareRepo() throws NullPointerException, NoWorkTreeException { |
| if (nonNullRepo().isBare()) { |
| throw new NoWorkTreeException(); |
| } |
| return repo; |
| } |
| } |