diff --git a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/JGitTestUtil.java b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/JGitTestUtil.java index 136c64726..521593ea8 100644 --- a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/JGitTestUtil.java +++ b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/JGitTestUtil.java @@ -55,6 +55,7 @@ import java.lang.reflect.Method; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Path; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.util.FileUtils; @@ -240,4 +241,10 @@ public static void deleteTrashFile(final Repository db, FileUtils.delete(path); } + public static Path writeLink(Repository db, String link, + String target) throws Exception { + return FileUtils.createSymLink(new File(db.getWorkTree(), link), + target); + } + } diff --git a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/RepositoryTestCase.java b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/RepositoryTestCase.java index ac4539a84..28c61778c 100644 --- a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/RepositoryTestCase.java +++ b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/RepositoryTestCase.java @@ -55,6 +55,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; +import java.nio.file.Path; import java.util.Map; import org.eclipse.jgit.api.Git; @@ -107,6 +108,11 @@ protected File writeTrashFile(final String name, final String data) return JGitTestUtil.writeTrashFile(db, name, data); } + protected Path writeLink(final String link, final String target) + throws Exception { + return JGitTestUtil.writeLink(db, link, target); + } + protected File writeTrashFile(final String subdir, final String name, final String data) throws IOException { diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/DirCacheCheckoutTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/DirCacheCheckoutTest.java index a8a54a8df..6d62528f8 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/DirCacheCheckoutTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/DirCacheCheckoutTest.java @@ -923,6 +923,299 @@ public void testCheckoutOutChanges() throws IOException { } } + @Test + public void testCheckoutChangeLinkToEmptyDir() throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + + // Add a link to file + String linkName = "link"; + File link = writeLink(linkName, fname).toFile(); + git.add().addFilepattern(linkName).call(); + git.commit().setMessage("Added file and link").call(); + + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + // replace link with empty directory + FileUtils.delete(link); + FileUtils.mkdir(link); + assertTrue("Link must be a directory now", link.isDirectory()); + + // modify file + writeTrashFile(fname, "b"); + assertWorkDir(mkmap(fname, "b", linkName, "/")); + + // revert both paths to HEAD state + git.checkout().setStartPoint(Constants.HEAD) + .addPath(fname).addPath(linkName).call(); + + assertWorkDir(mkmap(fname, "a", linkName, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeLinkToEmptyDirs() throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + + // Add a link to file + String linkName = "link"; + File link = writeLink(linkName, fname).toFile(); + git.add().addFilepattern(linkName).call(); + git.commit().setMessage("Added file and link").call(); + + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + // replace link with directory containing only directories, no files + FileUtils.delete(link); + FileUtils.mkdirs(new File(link, "dummyDir")); + assertTrue("Link must be a directory now", link.isDirectory()); + + assertFalse("Must not delete non empty directory", link.delete()); + + // modify file + writeTrashFile(fname, "b"); + assertWorkDir(mkmap(fname, "b", linkName + "/dummyDir", "/")); + + // revert both paths to HEAD state + git.checkout().setStartPoint(Constants.HEAD) + .addPath(fname).addPath(linkName).call(); + + assertWorkDir(mkmap(fname, "a", linkName, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeLinkToNonEmptyDirs() throws Exception { + String fname = "file"; + Git git = Git.wrap(db); + + // Add a file + writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + + // Add a link to file + String linkName = "link"; + File link = writeLink(linkName, fname).toFile(); + git.add().addFilepattern(linkName).call(); + git.commit().setMessage("Added file and link").call(); + + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + // replace link with directory containing only directories, no files + FileUtils.delete(link); + + // create but do not add a file in the new directory to the index + writeTrashFile(linkName + "/dir1", "file1", "c"); + + // create but do not add a file in the new directory to the index + writeTrashFile(linkName + "/dir2", "file2", "d"); + + assertTrue("File must be a directory now", link.isDirectory()); + assertFalse("Must not delete non empty directory", link.delete()); + + // 2 extra files are created + assertWorkDir(mkmap(fname, "a", linkName + "/dir1/file1", "c", + linkName + "/dir2/file2", "d")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(linkName).call(); + + // expect only the one added to the index + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeLinkToNonEmptyDirsAndNewIndexEntry() + throws Exception { + String fname = "file"; + Git git = Git.wrap(db); + + // Add a file + writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + + // Add a link to file + String linkName = "link"; + File link = writeLink(linkName, fname).toFile(); + git.add().addFilepattern(linkName).call(); + git.commit().setMessage("Added file and link").call(); + + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + // replace link with directory containing only directories, no files + FileUtils.delete(link); + + // create and add a file in the new directory to the index + writeTrashFile(linkName + "/dir1", "file1", "c"); + git.add().addFilepattern(linkName + "/dir1/file1").call(); + + // create but do not add a file in the new directory to the index + writeTrashFile(linkName + "/dir2", "file2", "d"); + + assertTrue("File must be a directory now", link.isDirectory()); + assertFalse("Must not delete non empty directory", link.delete()); + + // 2 extra files are created + assertWorkDir(mkmap(fname, "a", linkName + "/dir1/file1", "c", + linkName + "/dir2/file2", "d")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(linkName).call(); + + // original file and link + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + Status st = git.status().call(); + assertFalse(st.isClean()); + } + + @Test + public void testCheckoutChangeFileToEmptyDir() throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + File file = writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + git.commit().setMessage("Added file").call(); + + // replace file with empty directory + FileUtils.delete(file); + FileUtils.mkdir(file); + assertTrue("File must be a directory now", file.isDirectory()); + + assertWorkDir(mkmap(fname, "/")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(fname).call(); + + assertWorkDir(mkmap(fname, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeFileToEmptyDirs() throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + File file = writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + git.commit().setMessage("Added file").call(); + + // replace file with directory containing only directories, no files + FileUtils.delete(file); + FileUtils.mkdirs(new File(file, "dummyDir")); + assertTrue("File must be a directory now", file.isDirectory()); + assertFalse("Must not delete non empty directory", file.delete()); + + assertWorkDir(mkmap(fname + "/dummyDir", "/")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(fname).call(); + + assertWorkDir(mkmap(fname, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeFileToNonEmptyDirs() throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + File file = writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + git.commit().setMessage("Added file").call(); + + assertWorkDir(mkmap(fname, "a")); + + // replace file with directory containing only directories, no files + FileUtils.delete(file); + + // create but do not add a file in the new directory to the index + writeTrashFile(fname + "/dir1", "file1", "c"); + + // create but do not add a file in the new directory to the index + writeTrashFile(fname + "/dir2", "file2", "d"); + + assertTrue("File must be a directory now", file.isDirectory()); + assertFalse("Must not delete non empty directory", file.delete()); + + // 2 extra files are created + assertWorkDir( + mkmap(fname + "/dir1/file1", "c", fname + "/dir2/file2", "d")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(fname).call(); + + // expect only the one added to the index + assertWorkDir(mkmap(fname, "a")); + + Status st = git.status().call(); + assertTrue(st.isClean()); + } + + @Test + public void testCheckoutChangeFileToNonEmptyDirsAndNewIndexEntry() + throws Exception { + String fname = "was_file"; + Git git = Git.wrap(db); + + // Add a file + File file = writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + git.commit().setMessage("Added file").call(); + + assertWorkDir(mkmap(fname, "a")); + + // replace file with directory containing only directories, no files + FileUtils.delete(file); + + // create and add a file in the new directory to the index + writeTrashFile(fname + "/dir", "file1", "c"); + git.add().addFilepattern(fname + "/dir/file1").call(); + + // create but do not add a file in the new directory to the index + writeTrashFile(fname + "/dir", "file2", "d"); + + assertTrue("File must be a directory now", file.isDirectory()); + assertFalse("Must not delete non empty directory", file.delete()); + + // 2 extra files are created + assertWorkDir( + mkmap(fname + "/dir/file1", "c", fname + "/dir/file2", "d")); + + // revert path to HEAD state + git.checkout().setStartPoint(Constants.HEAD).addPath(fname).call(); + assertWorkDir(mkmap(fname, "a")); + + Status st = git.status().call(); + assertFalse(st.isClean()); + assertEquals(1, st.getAdded().size()); + assertTrue(st.getAdded().contains(fname + "/dir/file1")); + } + @Test public void testCheckoutOutChangesAutoCRLFfalse() throws IOException { setupCase(mk("foo"), mkmap("foo/bar", "foo\nbar"), mk("foo")); @@ -1023,6 +1316,102 @@ public void testOverwriteUntrackedIgnoredFile() throws IOException, assertTrue(git.status().call().isClean()); } + @Test + public void testOverwriteUntrackedFileModeChange() + throws IOException, GitAPIException { + String fname = "file.txt"; + Git git = Git.wrap(db); + + // Add a file + File file = writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + git.commit().setMessage("create file").call(); + assertWorkDir(mkmap(fname, "a")); + + // Create branch + git.branchCreate().setName("side").call(); + + // Switch branches + git.checkout().setName("side").call(); + + // replace file with directory containing files + FileUtils.delete(file); + + // create and add a file in the new directory to the index + writeTrashFile(fname + "/dir1", "file1", "c"); + git.add().addFilepattern(fname + "/dir1/file1").call(); + + // create but do not add a file in the new directory to the index + writeTrashFile(fname + "/dir2", "file2", "d"); + + assertTrue("File must be a directory now", file.isDirectory()); + assertFalse("Must not delete non empty directory", file.delete()); + + // 2 extra files are created + assertWorkDir( + mkmap(fname + "/dir1/file1", "c", fname + "/dir2/file2", "d")); + + try { + git.checkout().setName("master").call(); + fail("did not throw exception"); + } catch (Exception e) { + // 2 extra files are still there + assertWorkDir(mkmap(fname + "/dir1/file1", "c", + fname + "/dir2/file2", "d")); + } + } + + @Test + public void testOverwriteUntrackedLinkModeChange() + throws Exception { + String fname = "file.txt"; + Git git = Git.wrap(db); + + // Add a file + writeTrashFile(fname, "a"); + git.add().addFilepattern(fname).call(); + + // Add a link to file + String linkName = "link"; + File link = writeLink(linkName, fname).toFile(); + git.add().addFilepattern(linkName).call(); + git.commit().setMessage("Added file and link").call(); + + assertWorkDir(mkmap(linkName, "a", fname, "a")); + + // Create branch + git.branchCreate().setName("side").call(); + + // Switch branches + git.checkout().setName("side").call(); + + // replace link with directory containing files + FileUtils.delete(link); + + // create and add a file in the new directory to the index + writeTrashFile(linkName + "/dir1", "file1", "c"); + git.add().addFilepattern(linkName + "/dir1/file1").call(); + + // create but do not add a file in the new directory to the index + writeTrashFile(linkName + "/dir2", "file2", "d"); + + assertTrue("Link must be a directory now", link.isDirectory()); + assertFalse("Must not delete non empty directory", link.delete()); + + // 2 extra files are created + assertWorkDir(mkmap(fname, "a", linkName + "/dir1/file1", "c", + linkName + "/dir2/file2", "d")); + + try { + git.checkout().setName("master").call(); + fail("did not throw exception"); + } catch (Exception e) { + // 2 extra files are still there + assertWorkDir(mkmap(fname, "a", linkName + "/dir1/file1", "c", + linkName + "/dir2/file2", "d")); + } + } + @Test public void testFileModeChangeWithNoContentChangeUpdate() throws Exception { if (!FS.DETECTED.supportsExecute()) @@ -1219,7 +1608,8 @@ public void testFileModeChangeAndContentChangeNoConflict() throws Exception { assertNotNull(git.checkout().setName(Constants.MASTER).call()); } - public void assertWorkDir(HashMap i) throws CorruptObjectException, + public void assertWorkDir(Map i) + throws CorruptObjectException, IOException { TreeWalk walk = new TreeWalk(db); walk.setRecursive(false); diff --git a/org.eclipse.jgit/.settings/.api_filters b/org.eclipse.jgit/.settings/.api_filters index a1e79e2d2..f34226c42 100644 --- a/org.eclipse.jgit/.settings/.api_filters +++ b/org.eclipse.jgit/.settings/.api_filters @@ -21,4 +21,12 @@ + + + + + + + + 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 8d8aada62..8d85bfcb1 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java @@ -457,7 +457,7 @@ public void apply(DirCacheEntry ent) { private void checkoutPath(DirCacheEntry entry, ObjectReader reader) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader); + DirCacheCheckout.checkoutEntry(repo, entry, reader, true); } 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 6de25a052..8ef550871 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java @@ -357,7 +357,7 @@ private void resetUntracked(RevTree tree) throws CheckoutConflictException, private void checkoutPath(DirCacheEntry entry, ObjectReader reader) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader); + DirCacheCheckout.checkoutEntry(repo, entry, reader, true); } catch (IOException e) { throw new JGitInternalException(MessageFormat.format( JGitText.get().checkoutConflictWithFile, 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 00252547d..0036ab508 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java @@ -447,7 +447,7 @@ private boolean doCheckout() throws CorruptObjectException, IOException, for (String path : updated.keySet()) { DirCacheEntry entry = dc.getEntry(path); if (!FileMode.GITLINK.equals(entry.getRawMode())) - checkoutEntry(repo, entry, objectReader); + checkoutEntry(repo, entry, objectReader, false); } // commit the index builder - a new index is persisted @@ -1127,6 +1127,13 @@ private boolean isModifiedSubtree_IndexTree(String path, ObjectId tree) * final filename. * *

+ * Note: if the entry path on local file system exists as a non-empty + * directory, and the target entry type is a link or file, the checkout will + * fail with {@link IOException} since existing non-empty directory cannot + * be renamed to file or link without deleting it recursively. + *

+ * + *

* TODO: this method works directly on File IO, we may need another * abstraction (like WorkingTreeIterator). This way we could tell e.g. * Eclipse that Files in the workspace got changed @@ -1143,6 +1150,42 @@ private boolean isModifiedSubtree_IndexTree(String path, ObjectId tree) */ public static void checkoutEntry(Repository repo, DirCacheEntry entry, ObjectReader or) throws IOException { + checkoutEntry(repo, entry, or, false); + } + + /** + * Updates the file in the working tree with content and mode from an entry + * in the index. The new content is first written to a new temporary file in + * the same directory as the real file. Then that new file is renamed to the + * final filename. + * + *

+ * Note: if the entry path on local file system exists as a file, it + * will be deleted and if it exists as a directory, it will be deleted + * recursively, independently if has any content. + *

+ * + *

+ * TODO: this method works directly on File IO, we may need another + * abstraction (like WorkingTreeIterator). This way we could tell e.g. + * Eclipse that Files in the workspace got changed + *

+ * + * @param repo + * repository managing the destination work tree. + * @param entry + * the entry containing new mode and content + * @param or + * object reader to use for checkout + * @param deleteRecursive + * true to recursively delete final path if it exists on the file + * system + * + * @throws IOException + * @since 4.2 + */ + public static void checkoutEntry(Repository repo, DirCacheEntry entry, + ObjectReader or, boolean deleteRecursive) throws IOException { ObjectLoader ol = or.open(entry.getObjectId()); File f = new File(repo.getWorkTree(), entry.getPathString()); File parentDir = f.getParentFile(); @@ -1153,6 +1196,9 @@ public static void checkoutEntry(Repository repo, DirCacheEntry entry, && 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.lastModified(f)); @@ -1183,11 +1229,18 @@ public static void checkoutEntry(Repository repo, DirCacheEntry entry, } } try { + if (deleteRecursive && f.isDirectory()) { + FileUtils.delete(f, FileUtils.RECURSIVE); + } FileUtils.rename(tmpFile, f); } catch (IOException e) { throw new IOException(MessageFormat.format( JGitText.get().renameFileFailed, tmpFile.getPath(), f.getPath())); + } finally { + if (tmpFile.exists()) { + FileUtils.delete(tmpFile); + } } entry.setLastModified(f.lastModified()); } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/FileUtils.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/FileUtils.java index 548d239c8..720fdedef 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/util/FileUtils.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/FileUtils.java @@ -399,20 +399,25 @@ public static void createNewFile(File f) throws IOException { * * @param path * @param target + * @return path to the created link * @throws IOException - * @since 3.0 + * @since 4.2 */ - public static void createSymLink(File path, String target) + public static Path createSymLink(File path, String target) throws IOException { Path nioPath = path.toPath(); if (Files.exists(nioPath, LinkOption.NOFOLLOW_LINKS)) { - Files.delete(nioPath); + if (Files.isRegularFile(nioPath)) { + delete(path); + } else { + delete(path, EMPTY_DIRECTORIES_ONLY | RECURSIVE); + } } if (SystemReader.getInstance().isWindows()) { target = target.replace('/', '\\'); } Path nioTarget = new File(target).toPath(); - Files.createSymbolicLink(nioPath, nioTarget); + return Files.createSymbolicLink(nioPath, nioTarget); } /**