diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8237e2c626..f133f38c74 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -20,6 +20,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2114[#2114]: Fix false "Cygwin is not supported" warning in Git Bash * https://github.com/devonfw/IDEasy/issues/865[#865]: az not working on Mac * https://github.com/devonfw/IDEasy/issues/2026[#2026]: Create UvRepository and UvBasedCommandlet +* https://github.com/devonfw/IDEasy/issues/1992[#1992] : Improve GitContextMock The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/46?closed=1[milestone 2026.07.001]. @@ -27,7 +28,7 @@ The full list of changes for this release can be found in https://github.com/dev Release with new features and bugfixes: -* https://github.com/devonfw/IDEasy/issues/797[#797]: Fix VSCode install on macOS +* https://github.com/devonfw/IDEasy/issues/797[#797]: Fix VSCode install on macOS * https://github.com/devonfw/IDEasy/issues/1956[#1956]: Merging MAVEN_ARGS buggy * https://github.com/devonfw/IDEasy/issues/1978[#1978]: Enhanced the quality status documentation * https://github.com/devonfw/IDEasy/issues/1946[#1946]: Add Spyder Python IDE @@ -93,8 +94,6 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1904[#1904]: Add Inso CLI to IDEasy commandlets * https://github.com/devonfw/IDEasy/issues/1952[#1952]: Ability for platform specific dependencies * https://github.com/devonfw/IDEasy/issues/1950[#1950]: Fix exit autocompletion -* https://github.com/devonfw/IDEasy/issues/1936[#1936]: Improve localization of GUI - * https://github.com/devonfw/IDEasy/issues/1958[#1958]: Fix git pull on settings with local branch without remote The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/44?closed=1[milestone 2026.05.001]. diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java index 9c4892976d..63d9a98be0 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java @@ -1,6 +1,19 @@ package com.devonfw.tools.ide.git; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import com.devonfw.tools.ide.io.ini.IniFile; +import com.devonfw.tools.ide.io.ini.IniFileImpl; +import com.devonfw.tools.ide.io.ini.IniSection; /** * Mock implementation of {@link GitContext}. @@ -9,19 +22,26 @@ public class GitContextMock implements GitContext { private static final String MOCKED_URL_VALUE = "mocked url value"; + private final Map> pending = new HashMap<>(); + @Override public void pullOrCloneIfNeeded(GitUrl gitUrl, Path repository) { - + Path gitFolder = repository.resolve(GIT_FOLDER); + if (Files.exists(gitFolder)) { + pull(repository); + } else { + clone(gitUrl, repository); + } } @Override public void pullOrCloneAndResetIfNeeded(GitUrl gitUrl, Path repository, String remoteName) { - + pullOrCloneIfNeeded(gitUrl, repository); } @Override public void pullSafelyWithStash(Path repository) { - + pull(repository); } @Override @@ -31,22 +51,116 @@ public boolean hasUntrackedFiles(Path repository) { @Override public void pullOrClone(GitUrl gitUrl, Path repository) { - + pullOrCloneIfNeeded(gitUrl, repository); } + /** + * Simulates cloning a remote repository into the provided local path for tests. + * + * @param gitUrl the {@link GitUrl} describing remote and branch + * @param repository the target repository path + */ @Override public void clone(GitUrl gitUrl, Path repository) { - + try { + Files.createDirectories(repository); + Path gitFolder = repository.resolve(GIT_FOLDER); + Files.createDirectories(gitFolder); + // create HEAD pointing to branch + String branch = gitUrl.branch(); + if (branch == null || branch.isEmpty()) { + branch = GitUrl.BRANCH_MAIN; + } + String headContent = "ref: refs/heads/" + branch; + Files.writeString(gitFolder.resolve(FILE_HEAD), headContent); + // create FETCH_HEAD containing hashCode of GitUrl and also create a refs/heads entry + String lastId = String.valueOf(gitUrl.hashCode()); + Files.writeString(gitFolder.resolve(FILE_FETCH_HEAD), lastId); + Path refsHeads = gitFolder.resolve("refs").resolve("heads"); + Files.createDirectories(refsHeads); + Path branchRefPath = refsHeads.resolve(branch); + Files.createDirectories(branchRefPath.getParent()); + Files.writeString(branchRefPath, lastId); + // create .git/config using the project's IniFile support + IniFile cfg = new IniFileImpl(); + IniSection originSection = cfg.getOrCreateSection("remote \"origin\""); + originSection.setProperty("url", gitUrl.url()); + Files.writeString(gitFolder.resolve("config"), cfg.toString()); + } catch (IOException e) { + throw new IllegalStateException("Failed to clone Git repository " + gitUrl + " to " + repository, e); + } } + /** + * Applies pending commits for the given repository. + * + * @param repository the repository to pull into + */ @Override public void pull(Path repository) { - + // apply all pending commits for this repository if any + List list = this.pending.get(repository); + if (list == null || list.isEmpty()) { + return; + } + // remember last pending commit id before applying + GitCommit last = list.get(list.size() - 1); + try { + for (GitCommit commit : new ArrayList<>(list)) { + for (GitChange change : commit.getChanges()) { + Path src = change.content(); + Path dest = repository.resolve(change.target()); + // ensure parent dirs + Files.createDirectories(dest.getParent()); + // copy file or directory + if (Files.isDirectory(src)) { + copyDirectory(src, dest); + } else { + Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING); + } + } + // remove applied commit + list.remove(0); + } + // update refs/heads/ and FETCH_HEAD to last applied commit id + Path gitFolder = repository.resolve(GIT_FOLDER); + if (!Files.exists(gitFolder)) { + Files.createDirectories(gitFolder); + } + String branch = determineCurrentBranch(repository); + String lastId = String.valueOf(last.hashCode()); + Path refsHeads = gitFolder.resolve("refs").resolve("heads"); + Files.createDirectories(refsHeads); + Path branchRefPath = refsHeads.resolve(branch); + Files.createDirectories(branchRefPath.getParent()); + Files.writeString(branchRefPath, lastId); + Files.writeString(gitFolder.resolve(FILE_FETCH_HEAD), lastId); + } catch (IOException e) { + throw new IllegalStateException("Failed to pull repository " + repository, e); + } } + /** + * Simulates fetching by updating {@code .git/FETCH_HEAD} to the id of the last pending commit. + * + * @param repository the repository to fetch into + * @param remote the remote name (ignored in the mock) + * @param branch the branch name (ignored in the mock) + */ @Override public void fetch(Path repository, String remote, String branch) { - + List list = this.pending.get(repository); + if (list == null || list.isEmpty()) { + return; + } + GitCommit last = list.get(list.size() - 1); + Path gitFolder = repository.resolve(GIT_FOLDER); + try { + Files.createDirectories(gitFolder); + Files.writeString(gitFolder.resolve(FILE_FETCH_HEAD), String.valueOf(last.hashCode())); + } catch (IOException e) { + throw new IllegalStateException("Failed to fetch repository " + repository, e); + } } @Override @@ -59,9 +173,30 @@ public void cleanup(Path repository) { } + /** + * Reads the remote URL from {@code .git/config}. + * + * @param repository the repository to inspect + * @return the remote URL or a mocked constant if no config exists + */ @Override public String retrieveGitUrl(Path repository) { - + Path cfg = repository.resolve(GIT_FOLDER).resolve("config"); + try { + if (Files.exists(cfg)) { + IniFile config = new IniFileImpl(); + readIniFile(cfg, config); + IniSection origin = config.getSection("remote \"origin\""); + if (origin != null) { + String url = origin.getPropertyValue("url"); + if (url != null && !url.isBlank()) { + return url.trim(); + } + } + } + } catch (IOException e) { + throw new IllegalStateException("Failed to read remote URL from repository config " + cfg, e); + } return MOCKED_URL_VALUE; } @@ -75,45 +210,295 @@ public Path findGit() { return null; } + /** + * Performs fetch only when pending commits exist for the repository. + * + * @param repository the repository to potentially fetch + * @param remoteName the remote name (ignored) + * @param branch the branch name (ignored) + * @return {@code true} if fetch was performed, {@code false} otherwise + */ @Override public boolean fetchIfNeeded(Path repository, String remoteName, String branch) { - - return false; + List list = this.pending.get(repository); + if (list == null || list.isEmpty()) { + return false; + } + fetch(repository, remoteName, branch); + return true; } @Override public boolean fetchIfNeeded(Path repository) { - - return false; + return fetchIfNeeded(repository, DEFAULT_REMOTE, null); } + /** + * Checks whether a mocked remote update is available by comparing {@code .git/FETCH_HEAD} with the current HEAD (resolving refs when necessary). + * + * @param repository the repository to check + * @return {@code true} if an update is available + */ @Override public boolean isRepositoryUpdateAvailable(Path repository) { - - return false; + Path gitFolder = repository.resolve(GIT_FOLDER); + Path fetch = gitFolder.resolve(FILE_FETCH_HEAD); + Path head = gitFolder.resolve(FILE_HEAD); + try { + if (!Files.exists(fetch) || !Files.exists(head)) { + return false; + } + String f = Files.readString(fetch).trim(); + String h = Files.readString(head).trim(); + if (h.startsWith("ref:")) { + String ref = h.substring("ref:".length()).trim(); + Path refPath = gitFolder.resolve(ref); + if (!Files.exists(refPath)) { + // cannot resolve HEAD ref -> treat as no update + return false; + } + h = Files.readString(refPath).trim(); + } + return !f.equals(h); + } catch (IOException e) { + throw new IllegalStateException("Failed to check for repository update in " + repository, e); + } } + /** + * Checks whether a mocked remote update is available by comparing {@code .git/FETCH_HEAD} with a tracked commit id file. + * + * @param repository the repository to check + * @param trackedCommitIdPath path to the tracked commit id file + * @return {@code true} if an update is available + */ @Override public boolean isRepositoryUpdateAvailable(Path repository, Path trackedCommitIdPath) { - - return false; + Path gitFolder = repository.resolve(GIT_FOLDER); + Path fetch = gitFolder.resolve(FILE_FETCH_HEAD); + try { + if (!Files.exists(fetch) || !Files.exists(trackedCommitIdPath)) { + return false; + } + String f = Files.readString(fetch).trim(); + String t = Files.readString(trackedCommitIdPath).trim(); + return !f.equals(t); + } catch (IOException e) { + throw new IllegalStateException("Failed to check for repository update in " + repository, e); + } } + /** + * Determines the current branch from {@code .git/HEAD}. If HEAD contains a ref the branch name is extracted from the ref. If HEAD does not exist the default + * branch {@link GitUrl#BRANCH_MAIN} is returned. + * + * @param repository the repository to inspect + * @return the current branch name + */ @Override public String determineCurrentBranch(Path repository) { - - return "main"; + Path head = repository.resolve(GIT_FOLDER).resolve(FILE_HEAD); + if (!Files.exists(head)) { + return GitUrl.BRANCH_MAIN; + } + try { + String content = Files.readString(head).trim(); + final String prefix = "ref: refs/heads/"; + if (content.startsWith(prefix)) { + return content.substring(prefix.length()); + } + // fallback: handle other ref formats + if (content.startsWith("ref:")) { + int idx = content.lastIndexOf('/'); + if (idx >= 0 && idx + 1 < content.length()) { + return content.substring(idx + 1); + } + } + // fallback: return content + return content; + } catch (IOException e) { + throw new IllegalStateException("Failed to determine current branch for repository " + repository, e); + } } @Override public String determineRemote(Path repository) { + return DEFAULT_REMOTE; + } + + /** + * Adds pending commits to simulate remote changes. Commits are stored per repository and applied on pull. + * + * @param repository the repository the commits belong to + * @param commits the commits to add + */ + public void addChanges(Path repository, GitCommit... commits) { + List list = this.pending.computeIfAbsent(repository, k -> new ArrayList<>()); + if (commits != null) { + Collections.addAll(list, commits); + } + } - return "origin"; + private static void readIniFile(Path file, IniFile iniFile) throws IOException { + List iniLines = Files.readAllLines(file); + IniSection currentIniSection = iniFile.getInitialSection(); + for (String line : iniLines) { + if (line.trim().startsWith("[")) { + currentIniSection = iniFile.getOrCreateSection(line); + } else if (line.trim().startsWith("#") || line.trim().startsWith(";")) { + currentIniSection.addComment(line); + } else { + int index = line.indexOf('='); + if (index > 0) { + currentIniSection.setPropertyLine(line); + } + } + } } + /** + * Copies a directory tree from {@code source} to {@code target}, overwriting existing files. + * + * @param source the source directory + * @param target the destination directory + * @throws IOException on IO errors + */ + private static void copyDirectory(Path source, Path target) throws IOException { + try (Stream stream = Files.walk(source)) { + stream.forEach(s -> { + try { + Path rel = source.relativize(s); + Path dest = target.resolve(rel); + if (Files.isDirectory(s)) { + Files.createDirectories(dest); + } else { + Files.createDirectories(dest.getParent()); + Files.copy(s, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to copy directory " + source + " to " + target, e); + } + }); + } + } + + /** + * Represents a file or directory change to apply when pulling a commit. + */ + public static class GitChange { + + private final Path content; + private final Path target; + + public GitChange(Path content, Path target) { + this.content = content; + this.target = target; + } + + public Path content() { + return this.content; + } + + public Path target() { + return this.target; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof GitChange other)) { + return false; + } + return this.content.equals(other.content) && this.target.equals(other.target); + } + + @Override + public int hashCode() { + int result = this.content.hashCode(); + result = 31 * result + this.target.hashCode(); + return result; + } + } + /** + * Represents a commit containing one or more {@link GitChange}s. + */ + public static class GitCommit { + + private final List changes = new ArrayList<>(); + + public GitCommit(GitChange... changes) { + if (changes != null) { + for (GitChange c : changes) { + this.changes.add(c); + } + } + } + + public List getChanges() { + return Collections.unmodifiableList(this.changes); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof GitCommit other)) { + return false; + } + return this.changes.equals(other.changes); + } + + @Override + public int hashCode() { + return this.changes.hashCode(); + } + } + + + /** + * Saves the current commit id of the repository into the given file. If HEAD is a ref (e.g. "ref: refs/heads/main") the referenced ref file + * (.git/refs/heads/main) is read to obtain the commit id. Otherwise the HEAD content is used directly. The commit id is written to trackedCommitIdPath + * (parent directories are created if necessary). + * + * @param repository the repository + * @param trackedCommitIdPath the file to store the commit id + */ @Override public void saveCurrentCommitId(Path repository, Path trackedCommitIdPath) { - + Path gitFolder = repository.resolve(GIT_FOLDER); + Path head = gitFolder.resolve(FILE_HEAD); + try { + String commitId = ""; + if (Files.exists(head)) { + String content = Files.readString(head).trim(); + if (content.startsWith("ref:")) { + String ref = content.substring("ref:".length()).trim(); + Path refPath = gitFolder.resolve(ref); + if (Files.exists(refPath)) { + commitId = Files.readString(refPath).trim(); + } else { + // ref file does not exist so do not save anything + return; + } + } else { + commitId = content; + } + } + if (commitId.isEmpty()) { + // nothing to save + return; + } + Path parent = trackedCommitIdPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(trackedCommitIdPath, commitId); + } catch (IOException e) { + throw new IllegalStateException("Failed to save current commit id to " + trackedCommitIdPath, e); + } } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMockTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMockTest.java new file mode 100644 index 0000000000..575bde874a --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMockTest.java @@ -0,0 +1,372 @@ +package com.devonfw.tools.ide.git; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Test of {@link GitContextMock}. + */ +class GitContextMockTest extends Assertions { + + private static final String TEST_URL = "https://github.com/test/repo.git"; + + private static final String TEST_BRANCH = "develop"; + + private static final String MAIN_BRANCH = "main"; + + @Test + void testCloneCreatesGitStructureWithMainBranch(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + GitUrl gitUrl = new GitUrl(TEST_URL, null); + Path repository = tempDir.resolve("repo"); + + mock.clone(gitUrl, repository); + + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + assertThat(gitFolder).exists().isDirectory(); + + Path head = gitFolder.resolve(GitContext.FILE_HEAD); + assertThat(head).exists().hasContent("ref: refs/heads/" + MAIN_BRANCH); + + Path fetchHead = gitFolder.resolve(GitContext.FILE_FETCH_HEAD); + assertThat(fetchHead).exists(); + assertThat(Files.readString(fetchHead)).isNotEmpty(); + + Path refFile = gitFolder.resolve("refs").resolve("heads").resolve(MAIN_BRANCH); + assertThat(refFile).exists(); + assertThat(Files.readString(refFile)).isNotEmpty(); + + Path config = gitFolder.resolve("config"); + assertThat(config).exists(); + assertThat(mock.retrieveGitUrl(repository)).isEqualTo(TEST_URL); + } + + @Test + void testCloneCreatesGitStructureWithCustomBranch(@TempDir Path tempDir) { + + GitContextMock mock = new GitContextMock(); + GitUrl gitUrl = new GitUrl(TEST_URL, TEST_BRANCH); + Path repository = tempDir.resolve("repo"); + + mock.clone(gitUrl, repository); + + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + Path head = gitFolder.resolve(GitContext.FILE_HEAD); + assertThat(head).hasContent("ref: refs/heads/" + TEST_BRANCH); + + Path refFile = gitFolder.resolve("refs").resolve("heads").resolve(TEST_BRANCH); + assertThat(refFile).exists(); + } + + @Test + void testFetchIfNeededReturnsFalseWithNoPendingCommits(@TempDir Path tempDir) { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + boolean result = mock.fetchIfNeeded(repository); + + assertThat(result).isFalse(); + } + + @Test + void testFetchIfNeededReturnsTrueWithPendingCommits(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path contentFile = tempDir.resolve("content.txt"); + Files.writeString(contentFile, "test content"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(contentFile, Path.of("file.txt")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + boolean result = mock.fetchIfNeeded(repository); + + assertThat(result).isTrue(); + } + + @Test + void testFetchUpdatesFetchHeadWithPendingCommits(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path fetchHeadPath = repository.resolve(GitContext.GIT_FOLDER).resolve(GitContext.FILE_FETCH_HEAD); + String initialFetchHead = Files.readString(fetchHeadPath); + + Path contentFile = tempDir.resolve("content.txt"); + Files.writeString(contentFile, "test content"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(contentFile, Path.of("file.txt")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + mock.fetch(repository, "origin", "main"); + + String updatedFetchHead = Files.readString(fetchHeadPath); + assertThat(updatedFetchHead).isNotEqualTo(initialFetchHead).isEqualTo(String.valueOf(commit.hashCode())); + } + + @Test + void testFetchMakesUpdateAvailable(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + assertThat(mock.isRepositoryUpdateAvailable(repository)).isFalse(); + + Path contentFile = tempDir.resolve("content.txt"); + Files.writeString(contentFile, "test content"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(contentFile, Path.of("file.txt")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + mock.fetch(repository, "origin", "main"); + + assertThat(mock.isRepositoryUpdateAvailable(repository)).isTrue(); + } + + @Test + void testPullAppliesPendingCommitAndUpdatesRepositoryState(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path sourceFile = tempDir.resolve("source.txt"); + Files.writeString(sourceFile, "updated content"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(sourceFile, Path.of("tracked.txt")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + mock.fetch(repository, "origin", "main"); + assertThat(mock.isRepositoryUpdateAvailable(repository)).isTrue(); + + mock.pull(repository); + + String commitHash = String.valueOf(commit.hashCode()); + + assertThat(repository.resolve("tracked.txt")).exists().hasContent("updated content"); + + Path refFile = repository.resolve(GitContext.GIT_FOLDER).resolve("refs").resolve("heads").resolve(MAIN_BRANCH); + assertThat(refFile).hasContent(commitHash); + + Path fetchHeadFile = repository.resolve(GitContext.GIT_FOLDER).resolve(GitContext.FILE_FETCH_HEAD); + assertThat(fetchHeadFile).hasContent(commitHash); + + assertThat(mock.isRepositoryUpdateAvailable(repository)).isFalse(); + assertThat(mock.fetchIfNeeded(repository)).isFalse(); + } + + @Test + void testPullAppliesMultipleCommitsInOrder(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path sourceFile1 = tempDir.resolve("source1.txt"); + Files.writeString(sourceFile1, "content 1"); + GitContextMock.GitCommit commit1 = new GitContextMock.GitCommit( + new GitContextMock.GitChange(sourceFile1, Path.of("file1.txt"))); + + Path sourceFile2 = tempDir.resolve("source2.txt"); + Files.writeString(sourceFile2, "content 2"); + GitContextMock.GitCommit commit2 = new GitContextMock.GitCommit( + new GitContextMock.GitChange(sourceFile2, Path.of("file2.txt"))); + + mock.addChanges(repository, commit1, commit2); + + mock.pull(repository); + + assertThat(repository.resolve("file1.txt")).exists().hasContent("content 1"); + assertThat(repository.resolve("file2.txt")).exists().hasContent("content 2"); + } + + @Test + void testPullAppliesDirectoryChange(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path sourceDir = tempDir.resolve("sourceDir"); + Files.createDirectories(sourceDir); + Files.writeString(sourceDir.resolve("file1.txt"), "content1"); + Files.writeString(sourceDir.resolve("file2.txt"), "content2"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(sourceDir, Path.of("targetDir")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + mock.pull(repository); + + assertThat(repository.resolve("targetDir")).isDirectory(); + assertThat(repository.resolve("targetDir").resolve("file1.txt")).hasContent("content1"); + assertThat(repository.resolve("targetDir").resolve("file2.txt")).hasContent("content2"); + } + + @Test + void testSaveCurrentCommitIdResolvesHeadRef(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + Path refFile = gitFolder.resolve("refs").resolve("heads").resolve(MAIN_BRANCH); + String expectedCommitId = "abc123def456"; + Files.writeString(refFile, expectedCommitId); + + Path trackedFile = tempDir.resolve("tracked-commit.txt"); + + mock.saveCurrentCommitId(repository, trackedFile); + + assertThat(trackedFile).exists().hasContent(expectedCommitId); + } + + @Test + void testSaveCurrentCommitIdCreatesParentDirectories(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + Path refFile = gitFolder.resolve("refs").resolve("heads").resolve(MAIN_BRANCH); + String expectedCommitId = "xyz789"; + Files.writeString(refFile, expectedCommitId); + + Path trackedFile = tempDir.resolve("nested").resolve("dir").resolve("commit.txt"); + + mock.saveCurrentCommitId(repository, trackedFile); + + assertThat(trackedFile).exists().hasContent(expectedCommitId); + } + + @Test + void testSaveCurrentCommitIdWithDirectCommitId(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + Files.createDirectories(gitFolder); + + String directCommitId = "direct123commit456"; + Files.writeString(gitFolder.resolve(GitContext.FILE_HEAD), directCommitId); + + Path trackedFile = tempDir.resolve("tracked.txt"); + + mock.saveCurrentCommitId(repository, trackedFile); + + assertThat(trackedFile).exists().hasContent(directCommitId); + } + + @Test + void testSaveCurrentCommitIdDoesNotWriteWhenRefNotFound(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + Path gitFolder = repository.resolve(GitContext.GIT_FOLDER); + Files.createDirectories(gitFolder); + + Files.writeString(gitFolder.resolve(GitContext.FILE_HEAD), "ref: refs/heads/nonexistent"); + + Path trackedFile = tempDir.resolve("tracked.txt"); + + mock.saveCurrentCommitId(repository, trackedFile); + + assertThat(trackedFile).doesNotExist(); + } + + @Test + void testDetermineCurrentBranchAfterClone(@TempDir Path tempDir) { + + GitContextMock mock = new GitContextMock(); + GitUrl gitUrl = new GitUrl(TEST_URL, TEST_BRANCH); + Path repository = tempDir.resolve("repo"); + mock.clone(gitUrl, repository); + + String branch = mock.determineCurrentBranch(repository); + + assertThat(branch).isEqualTo(TEST_BRANCH); + } + + @Test + void testRetrieveGitUrlFromConfig(@TempDir Path tempDir) { + + GitContextMock mock = new GitContextMock(); + GitUrl gitUrl = new GitUrl(TEST_URL, null); + Path repository = tempDir.resolve("repo"); + mock.clone(gitUrl, repository); + + String url = mock.retrieveGitUrl(repository); + + assertThat(url).isEqualTo(TEST_URL); + } + + @Test + void testIsRepositoryUpdateAvailableInitiallyFalse(@TempDir Path tempDir) { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + boolean result = mock.isRepositoryUpdateAvailable(repository); + + assertThat(result).isFalse(); + } + + @Test + void testIsRepositoryUpdateAvailableWithTrackedCommitId(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path trackedFile = tempDir.resolve("tracked-commit.txt"); + mock.saveCurrentCommitId(repository, trackedFile); + + Path sourceFile = tempDir.resolve("source.txt"); + Files.writeString(sourceFile, "content"); + + GitContextMock.GitChange change = new GitContextMock.GitChange(sourceFile, Path.of("file.txt")); + GitContextMock.GitCommit commit = new GitContextMock.GitCommit(change); + mock.addChanges(repository, commit); + + mock.fetch(repository, "origin", "main"); + + boolean result = mock.isRepositoryUpdateAvailable(repository, trackedFile); + + assertThat(result).isTrue(); + } + + @Test + void testIsRepositoryUpdateAvailableWithTrackedCommitIdNoUpdate(@TempDir Path tempDir) throws IOException { + + GitContextMock mock = new GitContextMock(); + Path repository = tempDir.resolve("repo"); + mock.clone(GitUrl.of(TEST_URL), repository); + + Path trackedFile = tempDir.resolve("tracked-commit.txt"); + mock.saveCurrentCommitId(repository, trackedFile); + + boolean result = mock.isRepositoryUpdateAvailable(repository, trackedFile); + + assertThat(result).isFalse(); + } +}