From 9072103f3b3cf64dd12ad2949836ab98f62dabf1 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Fri, 11 Aug 2023 21:40:13 +0200 Subject: [PATCH] Checkout: better directory handling When checking out a file into the working tree ensure that all parent directories of the file below the working tree root are actually directories and do exist before we try to create the file. When multiple files are to be checked out (or even a whole tree), this may check the same directories over and over again. Asking the file system every time for file attributes is a potentially expensive operation. As a remedy, introduce an in-memory cache of directory states for a particular check-out operation. Apply the same fix also in the ResolveMerger, which may also check out files, and also in the PatchApplier. In PatchApplier, also validate paths. Change-Id: Ie12864c54c9f901a2ccee7caddec73027f353111 Signed-off-by: Thomas Wolf Signed-off-by: Matthias Sohn --- .../org/eclipse/jgit/diff/dotgit.patch | 9 + .../org/eclipse/jgit/diff/dotgit2.patch | 9 + .../org/eclipse/jgit/symlinks/.gitattributes | 1 + .../org/eclipse/jgit/symlinks/dirtest.patch | 9 + .../jgit/dircache/DirCacheEntryTest.java | 12 +- .../dircache/InvalidPathCheckoutTest.java | 61 ++++ .../eclipse/jgit/patch/PatchApplierTest.java | 26 ++ .../eclipse/jgit/symlinks/DirectoryTest.java | 259 +++++++++++++++ org.eclipse.jgit/.settings/.api_filters | 16 + .../eclipse/jgit/internal/JGitText.properties | 2 + .../org/eclipse/jgit/api/CheckoutCommand.java | 26 +- .../eclipse/jgit/api/StashApplyCommand.java | 23 +- .../org/eclipse/jgit/dircache/Checkout.java | 238 ++++++++++++++ .../jgit/dircache/DirCacheCheckout.java | 111 +------ .../org/eclipse/jgit/internal/JGitText.java | 2 + .../org/eclipse/jgit/lib/FileModeCache.java | 309 ++++++++++++++++++ .../org/eclipse/jgit/merge/ResolveMerger.java | 47 +-- .../org/eclipse/jgit/patch/PatchApplier.java | 33 +- 18 files changed, 1044 insertions(+), 149 deletions(-) create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch new file mode 100644 index 0000000000..802fa15465 --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch @@ -0,0 +1,9 @@ +diff --git a/.git/b b/.git/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/.git/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch new file mode 100644 index 0000000000..03cacbaeed --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch @@ -0,0 +1,9 @@ +diff --git a/.GIT/b b/.GIT/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/.git/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes new file mode 100644 index 0000000000..b38f87f9e3 --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes @@ -0,0 +1 @@ +*.patch -crlf diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch new file mode 100644 index 0000000000..a275c8593f --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch @@ -0,0 +1,9 @@ +diff --git a/a/b b/a/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/a/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java index 8e84dfa318..01d1e0282d 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2009, 2020 Google Inc. and others + * Copyright (C) 2009, 2023 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 @@ -46,6 +46,16 @@ public class DirCacheEntryTest { assertFalse(isValidPath("a/")); assertFalse(isValidPath("ab/cd/ef/")); assertFalse(isValidPath("a\u0000b")); + assertFalse(isValidPath(".git")); + assertFalse(isValidPath(".GIT")); + assertFalse(isValidPath(".Git")); + assertFalse(isValidPath(".git/b")); + assertFalse(isValidPath(".GIT/b")); + assertFalse(isValidPath(".Git/b")); + assertFalse(isValidPath("x/y/.git/z/b")); + assertFalse(isValidPath("x/y/.GIT/z/b")); + assertFalse(isValidPath("x/y/.Git/z/b")); + assertTrue(isValidPath("git/b")); } @SuppressWarnings("unused") diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java new file mode 100644 index 0000000000..e3bc85a512 --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 Thomas Wolf 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.dircache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.io.File; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand.ResetType; +import org.eclipse.jgit.junit.RepositoryTestCase; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevBlob; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Test; + +/** + * Tests for checking out with invalid paths. + */ +public class InvalidPathCheckoutTest extends RepositoryTestCase { + + private DirCacheEntry brokenEntry(String fileName, RevBlob blob) { + DirCacheEntry entry = new DirCacheEntry("XXXX/" + fileName); + entry.path[0] = '.'; + entry.path[1] = 'g'; + entry.path[2] = 'i'; + entry.path[3] = 't'; + entry.setFileMode(FileMode.REGULAR_FILE); + entry.setObjectId(blob); + return entry; + } + + @Test + public void testCheckoutIntoDotGit() throws Exception { + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + // DirCacheEntry does not allow any path component to contain + // ".git". C git also forbids this. But what if somebody creates + // such an entry explicitly? + RevCommit base = repo + .commit(repo.tree(brokenEntry("b", repo.blob("test")))); + try (Git git = new Git(db)) { + assertThrows(InvalidPathException.class, () -> git.reset() + .setMode(ResetType.HARD).setRef(base.name()).call()); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + } + } + } + +} diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/patch/PatchApplierTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/patch/PatchApplierTest.java index e2637257c5..92d47c2966 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/patch/PatchApplierTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/patch/PatchApplierTest.java @@ -24,6 +24,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; + import org.eclipse.jgit.annotations.Nullable; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.attributes.FilterCommand; @@ -892,5 +893,30 @@ public class PatchApplierTest { FilterCommandRegistry.unregister("jgit://builtin/a2e/smudge"); } } + + private void dotGitTest(String fileName) throws Exception { + init(fileName, false, false); + Result result = null; + IOException ex = null; + try { + result = applyPatch(); + } catch (IOException e) { + ex = e; + } + assertTrue(ex != null + || (result != null && !result.getErrors().isEmpty())); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + } + + @Test + public void testDotGit() throws Exception { + dotGitTest("dotgit"); + } + + @Test + public void testDotGit2() throws Exception { + dotGitTest("dotgit2"); + } } } diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java new file mode 100644 index 0000000000..490c45b558 --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2023 Thomas Wolf 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.symlinks; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand.ResetType; +import org.eclipse.jgit.junit.RepositoryTestCase; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.patch.Patch; +import org.eclipse.jgit.patch.PatchApplier; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class DirectoryTest extends RepositoryTestCase { + + @BeforeClass + public static void checkPrecondition() throws Exception { + Assume.assumeTrue(FS.DETECTED.supportsSymlinks()); + Path tempDir = Files.createTempDirectory("jgit"); + try { + Path a = tempDir.resolve("a"); + Files.writeString(a, "test"); + Path b = tempDir.resolve("A"); + Assume.assumeTrue(Files.exists(b)); + } finally { + FileUtils.delete(tempDir.toFile(), + FileUtils.RECURSIVE | FileUtils.IGNORE_ERRORS); + } + } + + @Parameters(name = "core.symlinks={0}") + public static Boolean[] parameters() { + return new Boolean[] { Boolean.TRUE, Boolean.FALSE }; + } + + @Parameter(0) + public boolean useSymlinks; + + private void checkFiles() throws Exception { + File a = new File(trash, "a"); + assertTrue("a should be a directory", + Files.isDirectory(a.toPath(), LinkOption.NOFOLLOW_LINKS)); + File b = new File(a, "b"); + assertTrue("a/b should exist", b.isFile()); + File x = new File(trash, "x"); + assertTrue("x should be a directory", + Files.isDirectory(x.toPath(), LinkOption.NOFOLLOW_LINKS)); + File y = new File(x, "y"); + assertTrue("x/y should exist", y.isFile()); + } + + @Test + public void testCheckout() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + // Create links directly in the git repo, then use a hard reset + // to get them into the workspace. + RevCommit base = repo.commit( + repo.tree( + repo.link("A", repo.blob(".git")), + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testCheckout2() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.link("A/B", repo.blob("../.git")), + repo.file("a/b/a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + boolean testFiles = true; + try { + git.reset().setMode(ResetType.HARD).setRef(base.name()) + .call(); + } catch (Exception e) { + if (!useSymlinks) { + // There is a file in the middle of the path where we'd + // expect a directory. This case is not handled + // anywhere. What would be a better reply than an IOE? + testFiles = false; + } else { + throw e; + } + } + File a = new File(new File(trash, ".git"), "a"); + assertFalse(".git/a should not exist", a.exists()); + if (testFiles) { + a = new File(trash, "a"); + assertTrue("a should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + File b = new File(a, "b"); + assertTrue("a/b should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + a = new File(b, "a"); + assertTrue("a/b/a should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + b = new File(a, "b"); + assertTrue("a/b/a/b should exist", b.isFile()); + File x = new File(trash, "x"); + assertTrue("x should be a directory", Files.isDirectory( + x.toPath(), LinkOption.NOFOLLOW_LINKS)); + File y = new File(x, "y"); + assertTrue("x/y should exist", y.isFile()); + } + } + } + } + + @Test + public void testMerge() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree(repo.file("q", repo.blob("test")))); + RevCommit side = repo.commit( + repo.tree( + repo.link("A", repo.blob(".git")), + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + git.merge().include(side) + .setMessage("merged").call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testMerge2() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.file("q", repo.blob("test")), + repo.link("A", repo.blob(".git")))); + RevCommit side = repo.commit( + repo.tree( + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + git.merge().include(side) + .setMessage("merged").call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testApply() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + // PatchApplier doesn't do symlinks yet. + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.file("x", repo.blob("test")), + repo.link("A", repo.blob(".git")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + Patch patch = new Patch(); + try (InputStream patchStream = this.getClass() + .getResourceAsStream("dirtest.patch")) { + patch.parse(patchStream); + } + boolean testFiles = true; + try { + PatchApplier.Result result = new PatchApplier(db) + .applyPatch(patch); + assertNotNull(result); + } catch (IOException e) { + if (!useSymlinks) { + // There is a file there, so the patch won't apply. + // Unclear whether an IOE is the correct response, + // though. Probably some negative PatchApplier.Result is + // more appropriate. + testFiles = false; + } else { + throw e; + } + } + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + if (testFiles) { + File a = new File(trash, "a"); + assertTrue("a should be a directory", + Files.isDirectory(a.toPath(), LinkOption.NOFOLLOW_LINKS)); + b = new File(a, "b"); + assertTrue("a/b should exist", b.isFile()); + } + } + } + } +} diff --git a/org.eclipse.jgit/.settings/.api_filters b/org.eclipse.jgit/.settings/.api_filters index 75697fd88a..35a3a0aeba 100644 --- a/org.eclipse.jgit/.settings/.api_filters +++ b/org.eclipse.jgit/.settings/.api_filters @@ -1,5 +1,13 @@ + + + + + + + + @@ -22,6 +30,14 @@ + + + + + + + + diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties index bc8144f1c9..3ac1fde6f0 100644 --- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties +++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties @@ -20,6 +20,8 @@ applyBinaryPatchTypeNotSupported=Couldn't apply binary patch of type {0} applyTextPatchCannotApplyHunk=Hunk cannot be applied applyTextPatchSingleClearingHunk=Expected a single hunk for clearing all content applyBinaryResultOidWrong=Result of binary patch for file {0} has wrong OID +applyPatchDestInvalid=Destination path in patch is invalid +applyPatchSourceInvalid==Source path in patch is invalid applyPatchWithoutSourceOnAlreadyExistingSource=Cannot perform {0} action on an existing file applyPatchWithCreationOverAlreadyExistingDestination=Cannot perform {0} action which overrides an existing file applyPatchWithSourceOnNonExistentSource=Cannot perform {0} action on a non-existent file diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java index 7319ff4b2f..8edae5a580 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2010, Chris Aniszczyk - * Copyright (C) 2011, 2020 Matthias Sohn and others + * Copyright (C) 2011, 2023 Matthias Sohn 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 @@ -28,6 +28,7 @@ import org.eclipse.jgit.api.errors.InvalidRefNameException; import org.eclipse.jgit.api.errors.JGitInternalException; import org.eclipse.jgit.api.errors.RefAlreadyExistsException; import org.eclipse.jgit.api.errors.RefNotFoundException; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; @@ -55,7 +56,6 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.PathFilterGroup; /** @@ -412,8 +412,7 @@ public class CheckoutCommand extends GitCommand { protected CheckoutCommand checkoutPaths() throws IOException, RefNotFoundException { actuallyModifiedPaths = new HashSet<>(); - WorkingTreeOptions options = repo.getConfig() - .get(WorkingTreeOptions.KEY); + Checkout checkout = new Checkout(repo).setRecursiveDeletion(true); DirCache dc = repo.lockDirCache(); try (RevWalk revWalk = new RevWalk(repo); TreeWalk treeWalk = new TreeWalk(repo, @@ -422,10 +421,10 @@ public class CheckoutCommand extends GitCommand { if (!checkoutAllPaths) treeWalk.setFilter(PathFilterGroup.createFromStrings(paths)); if (isCheckoutIndex()) - checkoutPathsFromIndex(treeWalk, dc, options); + checkoutPathsFromIndex(treeWalk, dc, checkout); else { RevCommit commit = revWalk.parseCommit(getStartPointObjectId()); - checkoutPathsFromCommit(treeWalk, dc, commit, options); + checkoutPathsFromCommit(treeWalk, dc, commit, checkout); } } finally { try { @@ -443,7 +442,7 @@ public class CheckoutCommand extends GitCommand { } private void checkoutPathsFromIndex(TreeWalk treeWalk, DirCache dc, - WorkingTreeOptions options) + Checkout checkout) throws IOException { DirCacheIterator dci = new DirCacheIterator(dc); treeWalk.addTree(dci); @@ -469,7 +468,7 @@ public class CheckoutCommand extends GitCommand { if (stage > DirCacheEntry.STAGE_0) { if (checkoutStage != null) { if (stage == checkoutStage.number) { - checkoutPath(ent, r, options, + checkoutPath(ent, r, checkout, path, new CheckoutMetadata(eolStreamType, filterCommand)); actuallyModifiedPaths.add(path); @@ -480,7 +479,7 @@ public class CheckoutCommand extends GitCommand { throw new JGitInternalException(e.getMessage(), e); } } else { - checkoutPath(ent, r, options, + checkoutPath(ent, r, checkout, path, new CheckoutMetadata(eolStreamType, filterCommand)); actuallyModifiedPaths.add(path); @@ -494,7 +493,7 @@ public class CheckoutCommand extends GitCommand { } private void checkoutPathsFromCommit(TreeWalk treeWalk, DirCache dc, - RevCommit commit, WorkingTreeOptions options) throws IOException { + RevCommit commit, Checkout checkout) throws IOException { treeWalk.addTree(commit.getTree()); final ObjectReader r = treeWalk.getObjectReader(); DirCacheEditor editor = dc.editor(); @@ -516,7 +515,7 @@ public class CheckoutCommand extends GitCommand { } ent.setObjectId(blobId); ent.setFileMode(mode); - checkoutPath(ent, r, options, + checkoutPath(ent, r, checkout, path, new CheckoutMetadata(eolStreamType, filterCommand)); actuallyModifiedPaths.add(path); } @@ -526,10 +525,9 @@ public class CheckoutCommand extends GitCommand { } private void checkoutPath(DirCacheEntry entry, ObjectReader reader, - WorkingTreeOptions options, CheckoutMetadata checkoutMetadata) { + Checkout checkout, String path, CheckoutMetadata checkoutMetadata) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader, true, - checkoutMetadata, options); + checkout.checkout(entry, checkoutMetadata, reader, path); } catch (IOException e) { throw new JGitInternalException(MessageFormat.format( JGitText.get().checkoutConflictWithFile, diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java index 17036a9cd3..e4157286f1 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012, 2021 GitHub Inc. and others + * Copyright (C) 2012, 2023 GitHub 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 @@ -23,6 +23,7 @@ import org.eclipse.jgit.api.errors.JGitInternalException; import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.api.errors.StashApplyFailureException; import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuilder; import org.eclipse.jgit.dircache.DirCacheCheckout; @@ -48,7 +49,6 @@ import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.AbstractTreeIterator; import org.eclipse.jgit.treewalk.FileTreeIterator; import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.treewalk.WorkingTreeOptions; /** * Command class to apply a stashed commit. @@ -383,8 +383,7 @@ public class StashApplyCommand extends GitCommand { private void resetUntracked(RevTree tree) throws CheckoutConflictException, IOException { Set actuallyModifiedPaths = new HashSet<>(); - WorkingTreeOptions options = repo.getConfig() - .get(WorkingTreeOptions.KEY); + Checkout checkout = new Checkout(repo).setRecursiveDeletion(true); // TODO maybe NameConflictTreeWalk ? try (TreeWalk walk = new TreeWalk(repo)) { walk.addTree(tree); @@ -408,17 +407,17 @@ public class StashApplyCommand extends GitCommand { FileTreeIterator fIter = walk .getTree(1, FileTreeIterator.class); + String gitPath = entry.getPathString(); if (fIter != null) { if (fIter.isModified(entry, true, reader)) { // file exists and is dirty - throw new CheckoutConflictException( - entry.getPathString()); + throw new CheckoutConflictException(gitPath); } } - checkoutPath(entry, reader, options, + checkoutPath(entry, gitPath, reader, checkout, new CheckoutMetadata(eolStreamType, null)); - actuallyModifiedPaths.add(entry.getPathString()); + actuallyModifiedPaths.add(gitPath); } } finally { if (!actuallyModifiedPaths.isEmpty()) { @@ -428,11 +427,11 @@ public class StashApplyCommand extends GitCommand { } } - private void checkoutPath(DirCacheEntry entry, ObjectReader reader, - WorkingTreeOptions options, CheckoutMetadata checkoutMetadata) { + private void checkoutPath(DirCacheEntry entry, String gitPath, + ObjectReader reader, + Checkout checkout, CheckoutMetadata checkoutMetadata) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader, true, - checkoutMetadata, options); + checkout.checkout(entry, checkoutMetadata, reader, gitPath); } catch (IOException e) { throw new JGitInternalException(MessageFormat.format( JGitText.get().checkoutConflictWithFile, diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java new file mode 100644 index 0000000000..accf732dc7 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2023, Thomas Wolf 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.dircache; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.StandardCopyOption; +import java.text.MessageFormat; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; +import org.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.FileModeCache; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.CoreConfig.EolStreamType; +import org.eclipse.jgit.lib.CoreConfig.SymLinks; +import org.eclipse.jgit.lib.FileModeCache.CacheItem; +import org.eclipse.jgit.treewalk.WorkingTreeOptions; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; +import org.eclipse.jgit.util.RawParseUtils; + +/** + * An object that can be used to check out many files. + * + * @since 6.6.1 + */ +public class Checkout { + + private final FileModeCache cache; + + private final WorkingTreeOptions options; + + private boolean recursiveDelete; + + /** + * Creates a new {@link Checkout} for checking out from the given + * repository. + * + * @param repo + * the {@link Repository} to check out from + */ + public Checkout(@NonNull Repository repo) { + this(repo, null); + } + + /** + * Creates a new {@link Checkout} for checking out from the given + * repository. + * + * @param repo + * the {@link Repository} to check out from + * @param options + * the {@link WorkingTreeOptions} to use; if {@code null}, + * read from the {@code repo} config when this object is + * created + */ + public Checkout(@NonNull Repository repo, WorkingTreeOptions options) { + this.cache = new FileModeCache(repo); + this.options = options != null ? options + : repo.getConfig().get(WorkingTreeOptions.KEY); + } + + /** + * Retrieves the {@link WorkingTreeOptions} of the repository that are + * used. + * + * @return the {@link WorkingTreeOptions} + */ + public WorkingTreeOptions getWorkingTreeOptions() { + return options; + } + + /** + * Defines whether directories that are in the way of the file to be checked + * out shall be deleted recursively. + * + * @param recursive + * whether to delete such directories recursively + * @return {@code this} + */ + public Checkout setRecursiveDeletion(boolean recursive) { + this.recursiveDelete = recursive; + return this; + } + + /** + * Ensure that the given parent directory exists, and cache the information + * that gitPath refers to a file. + * + * @param gitPath + * of the file to be written + * @param parentDir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to delete a possibly existing file at + * {@code parentDir} + * @throws IOException + * if the directory cannot be created, if necessary + */ + public void safeCreateParentDirectory(String gitPath, File parentDir, + boolean makeSpace) throws IOException { + cache.safeCreateParentDirectory(gitPath, parentDir, makeSpace); + } + + /** + * Checks out the gitlink given by the {@link DirCacheEntry}. + * + * @param entry + * {@link DirCacheEntry} to check out + * @param gitPath + * the git path of the entry, if known already; otherwise + * {@code null} and it's read from the entry itself + * @throws IOException + * if the gitlink cannot be checked out + */ + public void checkoutGitlink(DirCacheEntry entry, String gitPath) + throws IOException { + FS fs = cache.getRepository().getFS(); + File workingTree = cache.getRepository().getWorkTree(); + String path = gitPath != null ? gitPath : entry.getPathString(); + File gitlinkDir = new File(workingTree, path); + File parentDir = gitlinkDir.getParentFile(); + CacheItem cachedParent = cache.safeCreateDirectory(path, parentDir, + false); + FileUtils.mkdirs(gitlinkDir, true); + cachedParent.insert(path.substring(path.lastIndexOf('/') + 1), + FileMode.GITLINK); + entry.setLastModified(fs.lastModifiedInstant(gitlinkDir)); + } + + /** + * Checks out the file given by the {@link DirCacheEntry}. + * + * @param entry + * {@link DirCacheEntry} to check out + * @param metadata + * {@link CheckoutMetadata} to use for CR/LF handling and + * smudge filtering + * @param reader + * {@link ObjectReader} to use + * @param gitPath + * the git path of the entry, if known already; otherwise + * {@code null} and it's read from the entry itself + * @throws IOException + * if the file cannot be checked out + */ + public void checkout(DirCacheEntry entry, CheckoutMetadata metadata, + ObjectReader reader, String gitPath) throws IOException { + if (metadata == null) { + metadata = CheckoutMetadata.EMPTY; + } + FS fs = cache.getRepository().getFS(); + ObjectLoader ol = reader.open(entry.getObjectId()); + String path = gitPath != null ? gitPath : entry.getPathString(); + File f = new File(cache.getRepository().getWorkTree(), path); + File parentDir = f.getParentFile(); + CacheItem cachedParent = cache.safeCreateDirectory(path, parentDir, + true); + if (entry.getFileMode() == FileMode.SYMLINK + && options.getSymLinks() == SymLinks.TRUE) { + byte[] bytes = ol.getBytes(); + String target = RawParseUtils.decode(bytes); + if (recursiveDelete && Files.isDirectory(f.toPath(), + LinkOption.NOFOLLOW_LINKS)) { + FileUtils.delete(f, FileUtils.RECURSIVE); + } + fs.createSymLink(f, target); + cachedParent.insert(f.getName(), FileMode.SYMLINK); + entry.setLength(bytes.length); + entry.setLastModified(fs.lastModifiedInstant(f)); + return; + } + + String name = f.getName(); + if (name.length() > 200) { + name = name.substring(0, 200); + } + File tmpFile = File.createTempFile("._" + name, null, parentDir); //$NON-NLS-1$ + + DirCacheCheckout.getContent(cache.getRepository(), path, metadata, ol, + options, + new FileOutputStream(tmpFile)); + + // The entry needs to correspond to the on-disk file size. If the + // content was filtered (either by autocrlf handling or smudge + // filters) ask the file system again for the length. Otherwise the + // object loader knows the size + if (metadata.eolStreamType == EolStreamType.DIRECT + && metadata.smudgeFilterCommand == null) { + entry.setLength(ol.getSize()); + } else { + entry.setLength(tmpFile.length()); + } + + if (options.isFileMode() && fs.supportsExecute()) { + if (FileMode.EXECUTABLE_FILE.equals(entry.getRawMode())) { + if (!fs.canExecute(tmpFile)) + fs.setExecute(tmpFile, true); + } else { + if (fs.canExecute(tmpFile)) + fs.setExecute(tmpFile, false); + } + } + try { + if (recursiveDelete && Files.isDirectory(f.toPath(), + LinkOption.NOFOLLOW_LINKS)) { + FileUtils.delete(f, FileUtils.RECURSIVE); + } + FileUtils.rename(tmpFile, f, StandardCopyOption.ATOMIC_MOVE); + cachedParent.remove(f.getName()); + } catch (IOException e) { + throw new IOException( + MessageFormat.format(JGitText.get().renameFileFailed, + tmpFile.getPath(), f.getPath()), + e); + } finally { + if (tmpFile.exists()) { + FileUtils.delete(tmpFile); + } + } + entry.setLastModified(fs.lastModifiedInstant(f)); + } +} \ No newline at end of file diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java index 1fb81b71e9..d54df8d20e 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java @@ -5,7 +5,7 @@ * Copyright (C) 2006, Shawn O. Pearce * Copyright (C) 2010, Chrisian Halstrick * Copyright (C) 2019, 2020, Andre Bossert - * Copyright (C) 2017, 2022, Thomas Wolf and others + * Copyright (C) 2017, 2023, Thomas Wolf 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 @@ -19,11 +19,9 @@ package org.eclipse.jgit.dircache; import static org.eclipse.jgit.treewalk.TreeWalk.OperationType.CHECKOUT_OP; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.StandardCopyOption; import java.text.MessageFormat; import java.time.Instant; import java.util.ArrayList; @@ -49,7 +47,6 @@ import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.CoreConfig.AutoCRLF; import org.eclipse.jgit.lib.CoreConfig.EolStreamType; -import org.eclipse.jgit.lib.CoreConfig.SymLinks; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.NullProgressMonitor; import org.eclipse.jgit.lib.ObjectChecker; @@ -69,9 +66,7 @@ import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FS.ExecutionResult; -import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.IntList; -import org.eclipse.jgit.util.RawParseUtils; import org.eclipse.jgit.util.SystemReader; import org.eclipse.jgit.util.io.EolStreamTypeUtil; import org.slf4j.Logger; @@ -144,7 +139,7 @@ public class DirCacheCheckout { private boolean performingCheckout; - private WorkingTreeOptions options; + private Checkout checkout; private ProgressMonitor monitor = NullProgressMonitor.INSTANCE; @@ -495,9 +490,8 @@ public class DirCacheCheckout { MissingObjectException, IncorrectObjectTypeException, CheckoutConflictException, IndexWriteException, CanceledException { toBeDeleted.clear(); - options = repo.getConfig() - .get(WorkingTreeOptions.KEY); try (ObjectReader objectReader = repo.getObjectDatabase().newReader()) { + checkout = new Checkout(repo, null); if (headCommitTree != null) preScanTwoTrees(); else @@ -564,10 +558,9 @@ public class DirCacheCheckout { CheckoutMetadata meta = e.getValue(); DirCacheEntry entry = dc.getEntry(path); if (FileMode.GITLINK.equals(entry.getRawMode())) { - checkoutGitlink(path, entry); + checkout.checkoutGitlink(entry, path); } else { - checkoutEntry(repo, entry, objectReader, false, meta, - options); + checkout.checkout(entry, meta, objectReader, path); } e = null; @@ -602,8 +595,8 @@ public class DirCacheCheckout { break; } if (entry.getStage() == DirCacheEntry.STAGE_3) { - checkoutEntry(repo, entry, objectReader, false, - null, options); + checkout.checkout(entry, null, objectReader, + conflict); break; } ++entryIdx; @@ -626,14 +619,6 @@ public class DirCacheCheckout { return toBeDeleted.isEmpty(); } - private void checkoutGitlink(String path, DirCacheEntry entry) - throws IOException { - File gitlinkDir = new File(repo.getWorkTree(), path); - FileUtils.mkdirs(gitlinkDir, true); - FS fs = repo.getFS(); - entry.setLastModified(fs.lastModifiedInstant(gitlinkDir)); - } - private static ArrayList filterOut(ArrayList strings, IntList indicesToRemove) { int n = indicesToRemove.size(); @@ -1232,10 +1217,11 @@ public class DirCacheCheckout { if (force) { if (f == null || f.isModified(e, true, walk.getObjectReader())) { kept.add(path); - checkoutEntry(repo, e, walk.getObjectReader(), false, + checkout.checkout(e, new CheckoutMetadata(walk.getEolStreamType(CHECKOUT_OP), walk.getFilterCommand( - Constants.ATTR_FILTER_TYPE_SMUDGE)), options); + Constants.ATTR_FILTER_TYPE_SMUDGE)), + walk.getObjectReader(), path); } } } @@ -1494,83 +1480,16 @@ public class DirCacheCheckout { * they are loaded from the repository config * @throws java.io.IOException * @since 6.3 + * @deprecated since 6.6.1; use {@link Checkout} instead */ + @Deprecated public static void checkoutEntry(Repository repo, DirCacheEntry entry, ObjectReader or, boolean deleteRecursive, CheckoutMetadata checkoutMetadata, WorkingTreeOptions options) throws IOException { - if (checkoutMetadata == null) { - checkoutMetadata = CheckoutMetadata.EMPTY; - } - ObjectLoader ol = or.open(entry.getObjectId()); - File f = new File(repo.getWorkTree(), entry.getPathString()); - File parentDir = f.getParentFile(); - if (parentDir.isFile()) { - FileUtils.delete(parentDir); - } - FileUtils.mkdirs(parentDir, true); - FS fs = repo.getFS(); - WorkingTreeOptions opt = options != null ? options - : repo.getConfig().get(WorkingTreeOptions.KEY); - if (entry.getFileMode() == FileMode.SYMLINK - && opt.getSymLinks() == SymLinks.TRUE) { - byte[] bytes = ol.getBytes(); - String target = RawParseUtils.decode(bytes); - if (deleteRecursive && f.isDirectory()) { - FileUtils.delete(f, FileUtils.RECURSIVE); - } - fs.createSymLink(f, target); - entry.setLength(bytes.length); - entry.setLastModified(fs.lastModifiedInstant(f)); - return; - } - - String name = f.getName(); - if (name.length() > 200) { - name = name.substring(0, 200); - } - File tmpFile = File.createTempFile( - "._" + name, null, parentDir); //$NON-NLS-1$ - - getContent(repo, entry.getPathString(), checkoutMetadata, ol, opt, - new FileOutputStream(tmpFile)); - - // The entry needs to correspond to the on-disk filesize. If the content - // was filtered (either by autocrlf handling or smudge filters) ask the - // filesystem again for the length. Otherwise the objectloader knows the - // size - if (checkoutMetadata.eolStreamType == EolStreamType.DIRECT - && checkoutMetadata.smudgeFilterCommand == null) { - entry.setLength(ol.getSize()); - } else { - entry.setLength(tmpFile.length()); - } - - if (opt.isFileMode() && fs.supportsExecute()) { - if (FileMode.EXECUTABLE_FILE.equals(entry.getRawMode())) { - if (!fs.canExecute(tmpFile)) - fs.setExecute(tmpFile, true); - } else { - if (fs.canExecute(tmpFile)) - fs.setExecute(tmpFile, false); - } - } - try { - if (deleteRecursive && f.isDirectory()) { - FileUtils.delete(f, FileUtils.RECURSIVE); - } - FileUtils.rename(tmpFile, f, StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - throw new IOException( - MessageFormat.format(JGitText.get().renameFileFailed, - tmpFile.getPath(), f.getPath()), - e); - } finally { - if (tmpFile.exists()) { - FileUtils.delete(tmpFile); - } - } - entry.setLastModified(fs.lastModifiedInstant(f)); + Checkout checkout = new Checkout(repo, options) + .setRecursiveDeletion(deleteRecursive); + checkout.checkout(entry, checkoutMetadata, or, null); } /** diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java index 518e0b7d9b..3b5cc3d1f5 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java @@ -46,6 +46,8 @@ public class JGitText extends TranslationBundle { /***/ public String applyBinaryOidTooShort; /***/ public String applyBinaryPatchTypeNotSupported; /***/ public String applyBinaryResultOidWrong; + /***/ public String applyPatchDestInvalid; + /***/ public String applyPatchSourceInvalid; /***/ public String applyPatchWithoutSourceOnAlreadyExistingSource; /***/ public String applyPatchWithCreationOverAlreadyExistingDestination; /***/ public String applyPatchWithSourceOnNonExistentSource; diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java new file mode 100644 index 0000000000..073bf7a0ca --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2023, Thomas Wolf 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.lib; + +import java.io.File; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; + +/** + * A hierarchical cache of {@link FileMode}s per git path. + * + * @since 6.6.1 + */ +public class FileModeCache { + + @NonNull + private final CacheItem root = new CacheItem(FileMode.TREE); + + @NonNull + private final Repository repo; + + /** + * Creates a new {@link FileModeCache} for a {@link Repository}. + * + * @param repo + * {@link Repository} this cache is for + */ + public FileModeCache(@NonNull Repository repo) { + this.repo = repo; + } + + /** + * Retrieves the {@link Repository}. + * + * @return the {@link Repository} this {@link FileModeCache} was created for + */ + @NonNull + public Repository getRepository() { + return repo; + } + + /** + * Obtains the {@link CacheItem} for the working tree root. + * + * @return the {@link CacheItem} + */ + @NonNull + public CacheItem getRoot() { + return root; + } + + /** + * Ensure that the given parent directory exists, and cache the information + * that gitPath refers to a file. + * + * @param gitPath + * of the file to be written + * @param parentDir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to delete a possibly existing file at + * {@code parentDir} + * @throws IOException + * if the directory cannot be created, if necessary + */ + public void safeCreateParentDirectory(String gitPath, File parentDir, + boolean makeSpace) throws IOException { + CacheItem cachedParent = safeCreateDirectory(gitPath, parentDir, + makeSpace); + cachedParent.remove(gitPath.substring(gitPath.lastIndexOf('/') + 1)); + } + + /** + * Ensures the given directory {@code dir} with the given git path exists. + * + * @param gitPath + * of a file to be written + * @param dir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to remove a file that already at that name + * @return A {@link CacheItem} describing the directory, which is guaranteed + * to exist + * @throws IOException + * if the directory cannot be made to exist at the given + * location + */ + public CacheItem safeCreateDirectory(String gitPath, File dir, + boolean makeSpace) throws IOException { + FS fs = repo.getFS(); + int i = gitPath.lastIndexOf('/'); + String parentPath = null; + if (i >= 0) { + if ((makeSpace && dir.isFile()) || fs.isSymLink(dir)) { + FileUtils.delete(dir); + } + parentPath = gitPath.substring(0, i); + deleteSymlinkParent(fs, parentPath, repo.getWorkTree()); + } + FileUtils.mkdirs(dir, true); + CacheItem cachedParent = getRoot(); + if (parentPath != null) { + cachedParent = add(parentPath, FileMode.TREE); + } + return cachedParent; + } + + private void deleteSymlinkParent(FS fs, String gitPath, File workingTree) + throws IOException { + if (!fs.supportsSymlinks()) { + return; + } + String[] parts = gitPath.split("/"); //$NON-NLS-1$ + int n = parts.length; + CacheItem cached = getRoot(); + File p = workingTree; + for (int i = 0; i < n; i++) { + p = new File(p, parts[i]); + CacheItem cachedChild = cached != null ? cached.child(parts[i]) + : null; + boolean delete = false; + if (cachedChild != null) { + if (FileMode.SYMLINK.equals(cachedChild.getMode())) { + delete = true; + } + } else { + try { + Path nioPath = FileUtils.toPath(p); + BasicFileAttributes attributes = nioPath.getFileSystem() + .provider() + .getFileAttributeView(nioPath, + BasicFileAttributeView.class, + LinkOption.NOFOLLOW_LINKS) + .readAttributes(); + if (attributes.isSymbolicLink()) { + delete = p.isDirectory(); + } else if (attributes.isRegularFile()) { + break; + } + } catch (InvalidPathException | IOException e) { + // If we can't get the attributes the path does not exist, + // or if it does a subsequent mkdirs() will also throw an + // exception. + break; + } + } + if (delete) { + // Deletes the symlink + FileUtils.delete(p, FileUtils.SKIP_MISSING); + if (cached != null) { + cached.remove(parts[i]); + } + break; + } + cached = cachedChild; + } + } + + /** + * Records the given {@link FileMode} for the given git path in the cache. + * If an entry already exists for the given path, the previously cached file + * mode is overwritten. + * + * @param gitPath + * to cache the {@link FileMode} for + * @param finalMode + * {@link FileMode} to cache + * @return the {@link CacheItem} for the path + */ + @NonNull + private CacheItem add(String gitPath, FileMode finalMode) { + if (gitPath.isEmpty()) { + throw new IllegalArgumentException(); + } + String[] parts = gitPath.split("/"); //$NON-NLS-1$ + int n = parts.length; + int i = 0; + CacheItem curr = getRoot(); + while (i < n) { + CacheItem next = curr.child(parts[i]); + if (next == null) { + break; + } + curr = next; + i++; + } + if (i == n) { + curr.setMode(finalMode); + } else { + while (i < n) { + curr = curr.insert(parts[i], + i + 1 == n ? finalMode : FileMode.TREE); + i++; + } + } + return curr; + } + + /** + * An item from a {@link FileModeCache}, recording information about a git + * path (known from context). + */ + public static class CacheItem { + + @NonNull + private FileMode mode; + + private Map children; + + /** + * Creates a new {@link CacheItem}. + * + * @param mode + * {@link FileMode} to cache + */ + public CacheItem(@NonNull FileMode mode) { + this.mode = mode; + } + + /** + * Retrieves the cached {@link FileMode}. + * + * @return the {@link FileMode} + */ + @NonNull + public FileMode getMode() { + return mode; + } + + /** + * Retrieves an immediate child of this {@link CacheItem} by name. + * + * @param childName + * name of the child to get + * @return the {@link CacheItem}, or {@code null} if no such child is + * known + */ + public CacheItem child(String childName) { + if (children == null) { + return null; + } + return children.get(childName); + } + + /** + * Inserts a new cached {@link FileMode} as an immediate child of this + * {@link CacheItem}. If there is already a child with the same name, it + * is overwritten. + * + * @param childName + * name of the child to create + * @param childMode + * {@link FileMode} to cache + * @return the new {@link CacheItem} created for the child + */ + public CacheItem insert(String childName, @NonNull FileMode childMode) { + if (!FileMode.TREE.equals(mode)) { + throw new IllegalArgumentException(); + } + if (children == null) { + children = new HashMap<>(); + } + CacheItem newItem = new CacheItem(childMode); + children.put(childName, newItem); + return newItem; + } + + /** + * Removes the immediate child with the given name. + * + * @param childName + * name of the child to remove + * @return the previously cached {@link CacheItem}, if any + */ + public CacheItem remove(String childName) { + if (children == null) { + return null; + } + return children.remove(childName); + } + + void setMode(@NonNull FileMode mode) { + this.mode = mode; + if (!FileMode.TREE.equals(mode)) { + children = null; + } + } + } + +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java index e56513d4e9..04c17be392 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java @@ -3,8 +3,8 @@ * Copyright (C) 2010-2012, Matthias Sohn * Copyright (C) 2012, Research In Motion Limited * Copyright (C) 2017, Obeo (mathieu.cartaud@obeo.fr) - * Copyright (C) 2018, 2022 Thomas Wolf - * Copyright (C) 2022, Google Inc. and others + * Copyright (C) 2018, 2023 Thomas Wolf + * Copyright (C) 2023, 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 @@ -47,6 +47,7 @@ import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm; import org.eclipse.jgit.diff.RawText; import org.eclipse.jgit.diff.RawTextComparator; import org.eclipse.jgit.diff.Sequence; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuildIterator; import org.eclipse.jgit.dircache.DirCacheBuilder; @@ -79,7 +80,6 @@ import org.eclipse.jgit.treewalk.TreeWalk.OperationType; import org.eclipse.jgit.treewalk.WorkingTreeIterator; import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.TreeFilter; -import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.LfsFactory; import org.eclipse.jgit.util.LfsFactory.LfsInputStream; import org.eclipse.jgit.util.TemporaryBuffer; @@ -204,6 +204,12 @@ public class ResolveMerger extends ThreeWayMerger { */ private boolean indexChangesWritten; + /** + * {@link Checkout} to use for actually checking out files if + * {@link #inCore} is {@code false}. + */ + private Checkout checkout; + /** * @param repo * the {@link Repository}. @@ -223,6 +229,7 @@ public class ResolveMerger extends ThreeWayMerger { this.inCoreFileSizeLimit = getInCoreFileSizeLimit(config); this.checkoutMetadataByPath = new HashMap<>(); this.cleanupMetadataByPath = new HashMap<>(); + this.checkout = new Checkout(nonNullRepo(), workingTreeOptions); } /** @@ -350,9 +357,8 @@ public class ResolveMerger extends ThreeWayMerger { } // 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 + // 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(); @@ -518,14 +524,14 @@ public class ResolveMerger extends ThreeWayMerger { for (Map.Entry entry : toBeCheckedOut .entrySet()) { DirCacheEntry dirCacheEntry = entry.getValue(); + String gitPath = entry.getKey(); if (dirCacheEntry.getFileMode() == FileMode.GITLINK) { - new File(nonNullRepo().getWorkTree(), entry.getKey()) - .mkdirs(); + checkout.checkoutGitlink(dirCacheEntry, gitPath); } else { - DirCacheCheckout.checkoutEntry(repo, dirCacheEntry, reader, - false, checkoutMetadataByPath.get(entry.getKey()), - workingTreeOptions); - result.modifiedFiles.add(entry.getKey()); + checkout.checkout(dirCacheEntry, + checkoutMetadataByPath.get(gitPath), reader, + gitPath); + result.modifiedFiles.add(gitPath); } } } @@ -550,9 +556,8 @@ public class ResolveMerger extends ThreeWayMerger { for (String path : result.modifiedFiles) { DirCacheEntry entry = dirCache.getEntry(path); if (entry != null) { - DirCacheCheckout.checkoutEntry(repo, entry, reader, false, - cleanupMetadataByPath.get(path), - workingTreeOptions); + checkout.checkout(entry, cleanupMetadataByPath.get(path), + reader, path); } } } @@ -586,6 +591,8 @@ public class ResolveMerger extends ThreeWayMerger { if (inCore) { return; } + checkout.safeCreateParentDirectory(path, file.getParentFile(), + false); CheckoutMetadata metadata = new CheckoutMetadata(streamType, smudgeCommand); @@ -1576,15 +1583,11 @@ public class ResolveMerger extends ThreeWayMerger { Attributes attributes) throws IOException { File workTree = nonNullRepo().getWorkTree(); - FS fs = nonNullRepo().getFS(); - File of = new File(workTree, tw.getPathString()); - File parentFolder = of.getParentFile(); + String gitPath = tw.getPathString(); + File of = new File(workTree, gitPath); EolStreamType eol = workTreeUpdater.detectCheckoutStreamType(attributes); - if (!fs.exists(parentFolder)) { - parentFolder.mkdirs(); - } workTreeUpdater.updateFileWithContent(rawMerged::openInputStream, - eol, tw.getSmudgeCommand(attributes), of.getPath(), of); + eol, tw.getSmudgeCommand(attributes), gitPath, of); return of; } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/patch/PatchApplier.java b/org.eclipse.jgit/src/org/eclipse/jgit/patch/PatchApplier.java index da698d6bf6..04300a9765 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/patch/PatchApplier.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/patch/PatchApplier.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022, Google Inc. and others + * Copyright (C) 2023, 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 @@ -52,6 +52,7 @@ import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; import org.eclipse.jgit.dircache.DirCacheCheckout.StreamSupplier; import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.dircache.DirCacheIterator; +import org.eclipse.jgit.errors.CorruptObjectException; import org.eclipse.jgit.errors.IndexWriteException; import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.lib.Config; @@ -59,6 +60,7 @@ 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.FileModeCache; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; import org.eclipse.jgit.lib.ObjectLoader; @@ -81,6 +83,7 @@ import org.eclipse.jgit.util.LfsFactory; import org.eclipse.jgit.util.LfsFactory.LfsInputStream; import org.eclipse.jgit.util.RawParseUtils; import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.util.SystemReader; import org.eclipse.jgit.util.TemporaryBuffer; import org.eclipse.jgit.util.TemporaryBuffer.LocalFile; import org.eclipse.jgit.util.io.BinaryDeltaInputStream; @@ -258,6 +261,7 @@ public class PatchApplier { DirCache dirCache = inCore() ? DirCache.read(reader, beforeTree) : repo.lockDirCache(); + FileModeCache directoryCache = new FileModeCache(repo); DirCacheBuilder dirCacheBuilder = dirCache.builder(); Set modifiedPaths = new HashSet<>(); for (FileHeader fh : p.getFiles()) { @@ -270,7 +274,8 @@ public class PatchApplier { switch (type) { case ADD: { if (dest != null) { - FileUtils.mkdirs(dest.getParentFile(), true); + directoryCache.safeCreateParentDirectory(fh.getNewPath(), + dest.getParentFile(), false); FileUtils.createNewFile(dest); } apply(fh.getNewPath(), dirCache, dirCacheBuilder, dest, fh, result); @@ -295,7 +300,8 @@ public class PatchApplier { * apply() will write a fresh stream anyway, which will * overwrite if there were hunks in the patch. */ - FileUtils.mkdirs(dest.getParentFile(), true); + directoryCache.safeCreateParentDirectory(fh.getNewPath(), + dest.getParentFile(), false); FileUtils.rename(src, dest, StandardCopyOption.ATOMIC_MOVE); } @@ -306,7 +312,8 @@ public class PatchApplier { } case COPY: { if (!inCore()) { - FileUtils.mkdirs(dest.getParentFile(), true); + directoryCache.safeCreateParentDirectory(fh.getNewPath(), + dest.getParentFile(), false); Files.copy(src.toPath(), dest.toPath()); } apply(fh.getOldPath(), dirCache, dirCacheBuilder, dest, fh, result); @@ -401,9 +408,27 @@ public class PatchApplier { fh.getPatchType()), fh.getNewPath(), null); isValid = false; } + if (srcShouldExist && !validGitPath(fh.getOldPath())) { + result.addError(JGitText.get().applyPatchSourceInvalid, + fh.getOldPath(), null); + isValid = false; + } + if (destShouldNotExist && !validGitPath(fh.getNewPath())) { + result.addError(JGitText.get().applyPatchDestInvalid, + fh.getNewPath(), null); + isValid = false; + } return isValid; } + private boolean validGitPath(String path) { + try { + SystemReader.getInstance().checkPath(path); + return true; + } catch (CorruptObjectException e) { + return false; + } + } private static final int FILE_TREE_INDEX = 1; /** -- 2.39.5