diff --git a/launchable/jar/exe_deploy.jar b/launchable/jar/exe_deploy.jar index 49f9f56ca..ee296dbf6 100755 Binary files a/launchable/jar/exe_deploy.jar and b/launchable/jar/exe_deploy.jar differ diff --git a/src/main/java/com/launchableinc/ingest/commits/CommitGraphCollector.java b/src/main/java/com/launchableinc/ingest/commits/CommitGraphCollector.java index 3ac660f2b..cc1cf8d6a 100644 --- a/src/main/java/com/launchableinc/ingest/commits/CommitGraphCollector.java +++ b/src/main/java/com/launchableinc/ingest/commits/CommitGraphCollector.java @@ -25,6 +25,7 @@ import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.errors.InvalidObjectIdException; import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.ObjectId; @@ -65,6 +66,7 @@ import static com.google.common.collect.ImmutableList.*; import static java.util.Arrays.*; +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; /** * Compares what commits the local repository and the remote repository have, then send delta over. @@ -451,25 +453,47 @@ private void collectFiles(RevCommit start, TreeWalk treeWalk, TreeReceiver treeR OUTER: while (treeWalk.next()) { ObjectId head = treeWalk.getObjectId(0); + + if (treeWalk.isSubtree()) { + // For directories, compare tree IDs to skip unchanged subtrees entirely + for (int i = 1; i < c; i++) { + if (head.equals(treeWalk.getObjectId(i))) { + continue OUTER; + } + } + treeWalk.enterSubtree(); + continue; + } + + String filePath = treeWalk.getPathString(); + FileMode mode = treeWalk.getFileMode(0); + ObjectId blobId; + + if (mode == FileMode.SYMLINK) { + blobId = resolveSymlinkTarget(start.getTree(), filePath, head); + if (blobId == null) { + continue; + } + } else if ((mode.getBits() & FileMode.TYPE_MASK) == FileMode.TYPE_FILE) { + blobId = head; + } else { + continue; + } + + // Dedup check using the actual content blob ID. + // For symlinks, this uses the target file's blob ID so changes to the target + // are detected even when the symlink itself (its path string) is unchanged. for (int i = 1; i < c; i++) { - if (head.equals(treeWalk.getObjectId(i))) { + if (blobId.equals(treeWalk.getObjectId(i))) { // file at the head is identical to one of the uninteresting commits, - // meaning we have already seen this file/directory on the server. - // if it is a dir, there's no need to visit this whole subtree, so skip over + // meaning we have already seen this file on the server. continue OUTER; } } - if (treeWalk.isSubtree()) { - treeWalk.enterSubtree(); - } else { - if ((treeWalk.getFileMode(0).getBits() & FileMode.TYPE_MASK) == FileMode.TYPE_FILE) { - GitFile f = new GitFile(name, treeWalk.getPathString(), head, objectReader); - // to avoid excessive data transfer, skip files that are too big - if (f.size() < 1024 * 1024 && f.isText() && !f.path.equals(HEADER_FILE)) { - treeReceiver.accept(f); - } - } + GitFile f = new GitFile(name, filePath, blobId, objectReader); + if (f.size() < 1024 * 1024 && f.isText() && !f.path.equals(HEADER_FILE)) { + treeReceiver.accept(f); } } @@ -488,6 +512,47 @@ private void collectFiles(RevCommit start, TreeWalk treeWalk, TreeReceiver treeR } } + private ObjectId resolveSymlinkTarget(AnyObjectId treeId, String symlinkPath, ObjectId symlinkBlobId) { + try { + byte[] raw = objectReader.open(symlinkBlobId, OBJ_BLOB).getCachedBytes(10_000); + String targetRelative = new String(raw, StandardCharsets.UTF_8).trim(); + + java.nio.file.Path symlinkDir = java.nio.file.Paths.get(symlinkPath).getParent(); + java.nio.file.Path resolved; + if (symlinkDir != null) { + resolved = symlinkDir.resolve(targetRelative).normalize(); + } else { + resolved = java.nio.file.Paths.get(targetRelative).normalize(); + } + + if (resolved.isAbsolute() || resolved.startsWith("..")) { + logger.debug("Skipping symlink {} -> {} (points outside repository)", symlinkPath, targetRelative); + return null; + } + + String resolvedPath = resolved.toString().replace(java.io.File.separatorChar, '/'); + + try (TreeWalk tw = TreeWalk.forPath(git, resolvedPath, treeId)) { + if (tw == null) { + logger.debug("Skipping symlink {} -> {} (target not found in tree)", symlinkPath, resolvedPath); + return null; + } + + FileMode targetMode = tw.getFileMode(0); + if ((targetMode.getBits() & FileMode.TYPE_MASK) == FileMode.TYPE_FILE) { + return tw.getObjectId(0); + } + + logger.debug("Skipping symlink {} -> {} (target is not a regular file, mode={})", + symlinkPath, resolvedPath, targetMode); + return null; + } + } catch (IOException e) { + logger.warn("Failed to resolve symlink {}: {}", symlinkPath, e.getMessage()); + return null; + } + } + /** * Creates a per repository "header" file as a {@link VirtualFile}. * Currently, this is just the list of files in the repository. diff --git a/src/test/java/com/launchableinc/ingest/commits/CommitGraphCollectorTest.java b/src/test/java/com/launchableinc/ingest/commits/CommitGraphCollectorTest.java index b069bee94..ba43807c7 100644 --- a/src/test/java/com/launchableinc/ingest/commits/CommitGraphCollectorTest.java +++ b/src/test/java/com/launchableinc/ingest/commits/CommitGraphCollectorTest.java @@ -226,6 +226,98 @@ private void addCommitInSubRepo(Git mainrepo) throws Exception { } } + @Test + public void symlinkResolvedAsRegularFile() throws Exception { + try (Git repo = Git.init().setDirectory(ws).call()) { + Files.writeString(ws.toPath().resolve("real.txt"), "hello"); + repo.add().addFilepattern("real.txt").call(); + commit(repo).setMessage("add real file").call(); + + Files.createSymbolicLink(ws.toPath().resolve("link.txt"), java.nio.file.Path.of("real.txt")); + repo.add().addFilepattern("link.txt").call(); + commit(repo).setMessage("add symlink").call(); + + assertThat(collectFilePaths(repo)).containsAtLeast("real.txt", "link.txt"); + } + } + + @Test + public void symlinkAcrossDirectories() throws Exception { + try (Git repo = Git.init().setDirectory(ws).call()) { + Files.createDirectory(ws.toPath().resolve("sub")); + + // subdir -> parent: sub/link.txt -> ../root.txt + Files.writeString(ws.toPath().resolve("root.txt"), "root content"); + Files.createSymbolicLink(ws.toPath().resolve("sub").resolve("link.txt"), java.nio.file.Path.of("../root.txt")); + repo.add().addFilepattern("root.txt").addFilepattern("sub/link.txt").call(); + + // parent -> subdir: link2.txt -> sub/deep.txt + Files.writeString(ws.toPath().resolve("sub").resolve("deep.txt"), "deep content"); + Files.createSymbolicLink(ws.toPath().resolve("link2.txt"), java.nio.file.Path.of("sub/deep.txt")); + repo.add().addFilepattern("sub/deep.txt").addFilepattern("link2.txt").call(); + + commit(repo).setMessage("add cross-directory symlinks").call(); + + assertThat(collectFilePaths(repo)).containsAtLeast("root.txt", "sub/link.txt", "sub/deep.txt", "link2.txt"); + } + } + + @Test + public void brokenSymlinkSkipped() throws Exception { + try (Git repo = Git.init().setDirectory(ws).call()) { + Files.writeString(ws.toPath().resolve("target.txt"), "content"); + Files.createSymbolicLink(ws.toPath().resolve("broken.txt"), java.nio.file.Path.of("target.txt")); + repo.add().addFilepattern("target.txt").addFilepattern("broken.txt").call(); + commit(repo).setMessage("add files").call(); + + repo.rm().addFilepattern("target.txt").call(); + commit(repo).setMessage("remove target").call(); + + assertThat(collectFilePaths(repo)).doesNotContain("broken.txt"); + } + } + + @Test + public void symlinkOutsideRepoSkipped() throws Exception { + try (Git repo = Git.init().setDirectory(ws).call()) { + Files.createSymbolicLink(ws.toPath().resolve("escape.txt"), java.nio.file.Path.of("../outside.txt")); + repo.add().addFilepattern("escape.txt").call(); + commit(repo).setMessage("add symlink to outside").call(); + + assertThat(collectFilePaths(repo)).doesNotContain("escape.txt"); + } + } + + @Test + public void regularFilesUnaffectedBySymlinkChange() throws Exception { + try (Git repo = Git.init().setDirectory(ws).call()) { + Files.writeString(ws.toPath().resolve("a.txt"), "aaa"); + Files.writeString(ws.toPath().resolve("b.txt"), "bbb"); + repo.add().addFilepattern("a.txt").addFilepattern("b.txt").call(); + commit(repo).setMessage("add regular files").call(); + + assertThat(collectFilePaths(repo)).containsExactly("a.txt", "b.txt"); + } + } + + private List collectFilePaths(Git repo) throws IOException { + List files = new ArrayList<>(); + CommitGraphCollector cgc = new CommitGraphCollector("test", repo.getRepository()); + cgc.collectFiles(true); + cgc.new ByRepository(repo.getRepository(), "main") + .transfer(Collections.emptyList(), c -> {}, + new PassThroughTreeReceiverImpl(), + FlushableConsumer.of(files::add)); + + List paths = new ArrayList<>(); + for (VirtualFile f : files) { + if (!f.path().equals(CommitGraphCollector.HEADER_FILE)) { + paths.add(f.path()); + } + } + return paths; + } + private CommitCommand commit(Git r) { return r.commit().setAll(true).setSign(false); }