diff --git a/src/main/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilder.java b/src/main/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilder.java index 716a054..01d8c0c 100644 --- a/src/main/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilder.java +++ b/src/main/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilder.java @@ -252,10 +252,14 @@ private List collectFilteredCommits( commits.add(commitMetadata); } } + // Throw an exception to ensure we have commits to process before continuing + if (commits.isEmpty()) { + throw new RepositoryException("No relevant commits found at: " + workingDirectory); + } return commits; } catch (IOException e) { - throw new RepositoryException("Failed to build commit list", e); + throw new RepositoryException("Failed to build commit list at: " + workingDirectory, e); } } diff --git a/src/main/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImpl.java b/src/main/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImpl.java index c5a84bf..9b71cd9 100644 --- a/src/main/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImpl.java +++ b/src/main/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImpl.java @@ -165,7 +165,22 @@ boolean matchesFilter(String filePath, String filter) { if (filter.contains("*") || filter.contains("?")) { try { var matcher = FileSystems.getDefault().getPathMatcher("glob:" + filter); - return matcher.matches(FileSystems.getDefault().getPath(filePath)); + var path = FileSystems.getDefault().getPath(filePath); + + // Check if the path matches the filter + if (matcher.matches(path)) { + return true; + } + + // Special handling for **/ patterns to include also root directory files + // e.g., "**/*.owl" should also match any OWL files in the root directory + if (filter.startsWith("**/")) { + var rootPattern = filter.substring(3); // Remove "**/" prefix + var rootMatcher = FileSystems.getDefault().getPathMatcher("glob:" + rootPattern); + return rootMatcher.matches(path); + } + + return false; } catch (Exception e) { logger.warn("Invalid glob pattern: {}", filter, e); return false; diff --git a/src/test/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilderTest.java b/src/test/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilderTest.java new file mode 100644 index 0000000..ab40bc4 --- /dev/null +++ b/src/test/java/edu/stanford/protege/commitnavigator/CommitNavigatorBuilderTest.java @@ -0,0 +1,318 @@ +package edu.stanford.protege.commitnavigator; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.common.collect.Lists; +import edu.stanford.protege.commitnavigator.exceptions.RepositoryException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit tests for {@link CommitNavigatorBuilder}. + * + *

This test class verifies the builder pattern, configuration options, and commit navigation + * functionality including edge cases such as filtering and empty commit scenarios. + */ +class CommitNavigatorBuilderTest { + + @TempDir private Path tempDir; + + private Path testRepoPath; + private Git git; + + /** + * Sets up a test Git repository with sample commits before each test. + * + * @throws GitAPIException if Git operations fail + * @throws IOException if file operations fail + */ + @BeforeEach + void setUp() throws GitAPIException, IOException { + testRepoPath = tempDir.resolve("test-repo"); + Files.createDirectories(testRepoPath); + + // Initialize a Git repository + git = Git.init().setDirectory(testRepoPath.toFile()).call(); + + // Create initial commit with Java file + createFileAndCommit("Test.java", "public class Test {}", "Initial commit with Java file"); + + // Create second commit with Markdown file + createFileAndCommit("README.md", "# Test Project", "Add README"); + + // Create third commit with XML file + createFileAndCommit("pom.xml", "", "Add Maven configuration"); + } + + /** Closes Git resources after each test. */ + @AfterEach + void tearDown() { + if (git != null) { + git.close(); + } + } + + @Test + void testForWorkingDirectory_WithStringPath() { + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath.toString()); + + assertNotNull(builder); + } + + @Test + void testForWorkingDirectory_WithPathObject() { + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath); + + assertNotNull(builder); + } + + @Test + void testForWorkingDirectory_ThrowsNullPointerException_WhenPathIsNull() { + assertThrows( + NullPointerException.class, () -> CommitNavigatorBuilder.forWorkingDirectory((Path) null)); + } + + @Test + void testForWorkingDirectory_ThrowsNullPointerException_WhenStringIsNull() { + assertThrows( + NullPointerException.class, + () -> CommitNavigatorBuilder.forWorkingDirectory((String) null)); + } + + @Test + void testFileFilters_WithList() { + var filters = Arrays.asList("*.java", "*.md"); + + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).fileFilters(filters); + + assertNotNull(builder); + } + + @Test + void testFileFilters_WithVarargs() { + var builder = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).fileFilters("*.java", "*.md"); + + assertNotNull(builder); + } + + @Test + void testFileFilters_ThrowsNullPointerException_WhenListIsNull() { + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath); + + assertThrows(NullPointerException.class, () -> builder.fileFilters((List) null)); + } + + @Test + void testFileFilters_ThrowsNullPointerException_WhenVarargsIsNull() { + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath); + + assertThrows(NullPointerException.class, () -> builder.fileFilters((String[]) null)); + } + + @Test + void testStartingCommit_SetsCommitHash() { + var commitHash = "abc123def456"; + + var builder = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).startingCommit(commitHash); + + assertNotNull(builder); + } + + @Test + void testStartingCommit_ThrowsNullPointerException_WhenHashIsNull() { + var builder = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath); + + assertThrows(NullPointerException.class, () -> builder.startingCommit(null)); + } + + @Test + void testBuild_CreatesNavigatorSuccessfully() throws RepositoryException { + var navigator = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).build(); + + assertNotNull(navigator); + assertTrue(navigator.hasParent()); + } + + @Test + void testBuild_WithFileFilters_IncludesOnlyMatchingCommits() throws RepositoryException { + // Filter for only Java files + var navigator = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).fileFilters("*.java").build(); + + assertNotNull(navigator); + + // Should only include commits that modified Java files + var commitCount = 0; + while (navigator.hasParent()) { + var commit = navigator.checkoutParent(); + commitCount++; + // Verify that at least one changed file matches the filter + assertTrue( + commit.getChangedFiles().stream().anyMatch(file -> file.endsWith(".java")), + "Commit should contain Java file changes"); + } + + // Navigator collects matching commits but stops before the oldest (no parent) + // With 1 commit collected, we get 0 navigations + assertEquals(0, commitCount); + } + + @Test + void testBuild_WithMultipleFileFilters_IncludesMatchingCommits() throws RepositoryException { + // Filter for Java and Markdown files + var navigator = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath) + .fileFilters("*.java", "*.md") + .build(); + + assertNotNull(navigator); + + // Should include commits that modified Java or Markdown files + var commitCount = 0; + while (navigator.hasParent()) { + var commit = navigator.checkoutParent(); + commitCount++; + } + + // Navigator collects 2 commits but stops before the oldest (no parent) + assertEquals(1, commitCount); + } + + /** + * Tests the selected lines (254-257) in CommitNavigatorBuilder. + * + *

This test verifies that a RepositoryException is thrown when no commits match the specified + * file filters, ensuring the builder properly validates that there are commits to navigate before + * creating the navigator. + */ + @Test + void testBuild_ThrowsRepositoryException_WhenNoCommitsMatchFilters() { + // Use a filter that doesn't match any files in our test repository + var exception = + assertThrows( + RepositoryException.class, + () -> + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath) + .fileFilters("*.cpp", "*.h") + .build()); + + assertTrue(exception.getMessage().contains("No relevant commits found")); + assertTrue(exception.getMessage().contains(testRepoPath.toString())); + } + + @Test + void testBuild_ThrowsException_WhenDirectoryIsNotGitRepository() throws IOException { + // Create a directory that is not a Git repository + var nonGitDir = tempDir.resolve("non-git-dir"); + Files.createDirectories(nonGitDir); + + // Should throw either RepositoryException or NullPointerException + // depending on the state of the repository + assertThrows( + Exception.class, () -> CommitNavigatorBuilder.forWorkingDirectory(nonGitDir).build()); + } + + @Test + void testBuild_WithStartingCommit_NavigatesFromSpecificCommit() + throws RepositoryException, GitAPIException { + // Get the commit hash of the second commit (middle commit) + var commits = git.log().call(); + var commitIterator = commits.iterator(); + commitIterator.next(); // Skip first (most recent - pom.xml) + var secondCommit = commitIterator.next(); // Second commit (README.md) + var secondCommitHash = secondCommit.getName(); + + // Build navigator starting from second commit + var navigator = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath) + .startingCommit(secondCommitHash) + .build(); + + assertNotNull(navigator); + assertTrue(navigator.hasParent()); + + // Navigate and verify we can still move through commits + var commit = navigator.checkoutParent(); + assertNotNull(commit); + assertNotNull(commit.getCommitHash()); + } + + @Test + void testBuild_WithNonExistentStartingCommit_FallsBackToHead() throws RepositoryException { + // Use a non-existent commit hash + var nonExistentHash = "0000000000000000000000000000000000000000"; + + var navigator = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath) + .startingCommit(nonExistentHash) + .build(); + + assertNotNull(navigator); + // Should start from HEAD since the commit doesn't exist + assertTrue(navigator.hasParent()); + } + + @Test + void testBuild_CollectsCommitsInChronologicalOrder() throws RepositoryException { + var navigator = CommitNavigatorBuilder.forWorkingDirectory(testRepoPath).build(); + + var commitMessages = Lists.newArrayList(); + while (navigator.hasParent()) { + var commit = navigator.checkoutParent(); + commitMessages.add(commit.getCommitMessage().trim()); + } + + // Verify we got commits (navigator returns parent commits) + // Navigator starts at HEAD and returns parents, stopping before the oldest + assertEquals(2, commitMessages.size()); + assertEquals("Add README", commitMessages.get(0)); + assertEquals("Initial commit with Java file", commitMessages.get(1)); + } + + @Test + void testBuild_WithEmptyFileFilters_IncludesAllCommits() throws RepositoryException { + var navigator = + CommitNavigatorBuilder.forWorkingDirectory(testRepoPath) + .fileFilters(java.util.Collections.emptyList()) + .build(); + + assertNotNull(navigator); + + var commitCount = 0; + while (navigator.hasParent()) { + navigator.checkoutParent(); + commitCount++; + } + + // Navigator collects 3 commits but stops before the oldest (no parent) + assertEquals(2, commitCount); + } + + /** + * Helper method to create a file and commit it to the test repository. + * + * @param fileName the name of the file to create + * @param content the content to write to the file + * @param commitMessage the commit message + * @throws IOException if file operations fail + * @throws GitAPIException if Git operations fail + */ + private void createFileAndCommit(String fileName, String content, String commitMessage) + throws IOException, GitAPIException { + var filePath = testRepoPath.resolve(fileName); + Files.writeString(filePath, content); + git.add().addFilepattern(fileName).call(); + git.commit().setMessage(commitMessage).call(); + } +} diff --git a/src/test/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImplTest.java b/src/test/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImplTest.java index a0f9577..dad7725 100644 --- a/src/test/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImplTest.java +++ b/src/test/java/edu/stanford/protege/commitnavigator/utils/impl/FileChangeAnalyzerImplTest.java @@ -54,4 +54,115 @@ void testMatchesAnyFilter() { assertTrue(detector.matchesAnyFilter("pom.xml", filters)); assertFalse(detector.matchesAnyFilter("test.txt", filters)); } + + @Test + void testMatchesFilter_GlobPatternWithDoubleStarIncludesRootDirectory() { + var detector = new FileChangeAnalyzerImpl(); + + // Test that **/*.owl matches files in root directory + assertTrue(detector.matchesFilter("grocery.owl", "**/*.owl")); + assertTrue(detector.matchesFilter("ontology.owl", "**/*.owl")); + + // Test that **/*.owl still matches files in subdirectories + assertTrue(detector.matchesFilter("src/main/resources/grocery.owl", "**/*.owl")); + assertTrue(detector.matchesFilter("data/ontologies/pizza.owl", "**/*.owl")); + + // Test that **/*.owl matches files deep in subdirectories + assertTrue(detector.matchesFilter("dir1/grocery.owl", "**/*.owl")); + assertTrue(detector.matchesFilter("dir1/dir2/grocery.owl", "**/*.owl")); + assertTrue(detector.matchesFilter("dir1/dir2/dir3/grocery.owl", "**/*.owl")); + assertTrue(detector.matchesFilter("a/b/c/d/e/f/ontology.owl", "**/*.owl")); + + // Test that it doesn't match wrong extensions + assertFalse(detector.matchesFilter("grocery.txt", "**/*.owl")); + assertFalse(detector.matchesFilter("src/data.xml", "**/*.owl")); + assertFalse(detector.matchesFilter("dir1/dir2/dir3/data.txt", "**/*.owl")); + } + + @Test + void testMatchesFilter_GlobPatternWithDoubleStarForMultipleExtensions() { + var detector = new FileChangeAnalyzerImpl(); + + // Test **/*.java matches root and subdirectory files + assertTrue(detector.matchesFilter("Main.java", "**/*.java")); + assertTrue(detector.matchesFilter("src/Main.java", "**/*.java")); + assertTrue(detector.matchesFilter("src/main/java/Main.java", "**/*.java")); + + // Test **/*.md matches root and subdirectory files + assertTrue(detector.matchesFilter("README.md", "**/*.md")); + assertTrue(detector.matchesFilter("docs/README.md", "**/*.md")); + } + + @Test + void testMatchesFilter_RegularGlobPatternsStillWork() { + var detector = new FileChangeAnalyzerImpl(); + + // Test simple glob patterns without **/ prefix + assertTrue(detector.matchesFilter("Test.java", "*.java")); + assertTrue(detector.matchesFilter("README.md", "*.md")); + assertFalse(detector.matchesFilter("src/Test.java", "*.java")); + + // Test with specific directory prefix + assertTrue(detector.matchesFilter("src/main/java/Test.java", "src/**/*.java")); + assertFalse(detector.matchesFilter("Test.java", "src/**/*.java")); + } + + @Test + void testMatchesFilter_SingleStarOnlyMatchesRootDirectory() { + var detector = new FileChangeAnalyzerImpl(); + + // Pattern *.owl should ONLY match files in root directory + assertTrue(detector.matchesFilter("grocery.owl", "*.owl")); + assertTrue(detector.matchesFilter("ontology.owl", "*.owl")); + + // Should NOT match files in any subdirectories + assertFalse(detector.matchesFilter("src/grocery.owl", "*.owl")); + assertFalse(detector.matchesFilter("dir1/grocery.owl", "*.owl")); + assertFalse(detector.matchesFilter("dir1/dir2/grocery.owl", "*.owl")); + assertFalse(detector.matchesFilter("dir1/dir2/dir3/grocery.owl", "*.owl")); + + // Should NOT match wrong extensions even in root + assertFalse(detector.matchesFilter("grocery.txt", "*.owl")); + } + + @Test + void testMatchesFilter_DirectoryPrefixOnlyMatchesDirectFilesInThatDirectory() { + var detector = new FileChangeAnalyzerImpl(); + + // Pattern src/*.owl should ONLY match files directly in src directory + assertTrue(detector.matchesFilter("src/grocery.owl", "src/*.owl")); + assertTrue(detector.matchesFilter("src/ontology.owl", "src/*.owl")); + + // Should NOT match files in root + assertFalse(detector.matchesFilter("grocery.owl", "src/*.owl")); + + // Should NOT match files in subdirectories of src + assertFalse(detector.matchesFilter("src/main/grocery.owl", "src/*.owl")); + assertFalse(detector.matchesFilter("src/main/resources/grocery.owl", "src/*.owl")); + assertFalse(detector.matchesFilter("src/data/ontology.owl", "src/*.owl")); + + // Should NOT match files in other directories + assertFalse(detector.matchesFilter("data/grocery.owl", "src/*.owl")); + assertFalse(detector.matchesFilter("other/grocery.owl", "src/*.owl")); + + // Should NOT match wrong extensions even in src + assertFalse(detector.matchesFilter("src/grocery.txt", "src/*.owl")); + } + + @Test + void testMatchesFilter_NestedDirectoryPrefixOnlyMatchesDirectFilesInThatPath() { + var detector = new FileChangeAnalyzerImpl(); + + // Pattern src/main/*.java should ONLY match files directly in src/main + assertTrue(detector.matchesFilter("src/main/App.java", "src/main/*.java")); + assertTrue(detector.matchesFilter("src/main/Main.java", "src/main/*.java")); + + // Should NOT match files in root or parent directories + assertFalse(detector.matchesFilter("App.java", "src/main/*.java")); + assertFalse(detector.matchesFilter("src/App.java", "src/main/*.java")); + + // Should NOT match files in subdirectories + assertFalse(detector.matchesFilter("src/main/java/App.java", "src/main/*.java")); + assertFalse(detector.matchesFilter("src/main/resources/App.java", "src/main/*.java")); + } }