diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 9cb1533a3..88c4d6a05 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -4,8 +4,6 @@ import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.FileTime; -import java.time.Duration; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -39,7 +37,6 @@ import com.devonfw.tools.ide.os.SystemInfoImpl; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessContextImpl; -import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.property.Property; import com.devonfw.tools.ide.repo.CustomToolRepository; @@ -54,6 +51,8 @@ */ public abstract class AbstractIdeContext implements IdeContext { + private static final String IDE_URLS_GIT = "https://github.com/devonfw/ide-urls.git"; + private final Map loggers; private final Path ideHome; @@ -120,8 +119,6 @@ public abstract class AbstractIdeContext implements IdeContext { private UrlMetadata urlMetadata; - private static final Duration GIT_PULL_CACHE_DELAY_MILLIS = Duration.ofMillis(30 * 60 * 1000); - /** * The constructor. * @@ -186,10 +183,11 @@ public AbstractIdeContext(IdeLogLevel minLogLevel, Function remotes = result.getOut(); - if (remotes.isEmpty()) { - String message = "This is a local git repo with no remote - if you did this for testing, you may continue...\n" - + "Do you want to ignore the problem and continue anyhow?"; - askToContinue(message); - } else { - pc.errorHandling(ProcessErrorHandling.WARNING); - result = pc.addArg("pull").run(false, false); - if (!result.isSuccessful()) { - String message = "Failed to update git repository at " + target; - if (this.offlineMode) { - warning(message); - interaction("Continuing as we are in offline mode - results may be outdated!"); - } else { - error(message); - if (isOnline()) { - error("See above error for details. If you have local changes, please stash or revert and retry."); - } else { - error( - "It seems you are offline - please ensure Internet connectivity and retry or activate offline mode (-o or --offline)."); - } - askToContinue("Typically you should abort and fix the problem. Do you want to continue anyways?"); - } - } - } - } else { - String branch = null; - int hashIndex = gitRepoUrl.indexOf("#"); - if (hashIndex != -1) { - branch = gitRepoUrl.substring(hashIndex + 1); - gitRepoUrl = gitRepoUrl.substring(0, hashIndex); - } - this.fileAccess.mkdirs(target); - requireOnline("git clone of " + gitRepoUrl); - pc.addArg("clone"); - if (isQuietMode()) { - pc.addArg("-q"); - } else { - } - pc.addArgs("--recursive", gitRepoUrl, "--config", "core.autocrlf=false", "."); - pc.run(); - if (branch != null) { - pc.addArgs("checkout", branch); - pc.run(); - } - } - } - - /** - * Checks if the Git repository in the specified target folder needs an update by inspecting the modification time of - * a magic file. - * - * @param urlsPath The Path to the Urls repository. - * @param repoUrl The git remote URL of the Urls repository. - */ - - private void gitPullOrCloneIfNeeded(Path urlsPath, String repoUrl) { - - Path gitDirectory = urlsPath.resolve(".git"); - - // Check if the .git directory exists - if (Files.isDirectory(gitDirectory)) { - Path magicFilePath = gitDirectory.resolve("HEAD"); - long currentTime = System.currentTimeMillis(); - // Get the modification time of the magic file - long fileMTime; - try { - fileMTime = Files.getLastModifiedTime(magicFilePath).toMillis(); - } catch (IOException e) { - throw new IllegalStateException("Could not read " + magicFilePath, e); - } + public ProcessContext newProcess() { - // Check if the file modification time is older than the delta threshold - if ((currentTime - fileMTime > GIT_PULL_CACHE_DELAY_MILLIS.toMillis()) || isForceMode()) { - gitPullOrClone(urlsPath, repoUrl); - try { - Files.setLastModifiedTime(magicFilePath, FileTime.fromMillis(currentTime)); - } catch (IOException e) { - throw new IllegalStateException("Could not read or write in " + magicFilePath, e); - } - } - } else { - // If the .git directory does not exist, perform git clone - gitPullOrClone(urlsPath, repoUrl); - } + return new ProcessContextImpl(this); } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/GitContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/GitContext.java new file mode 100644 index 000000000..acb7e2b6d --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/context/GitContext.java @@ -0,0 +1,87 @@ +package com.devonfw.tools.ide.context; + +import java.nio.file.Path; + +import com.devonfw.tools.ide.log.IdeLogger; + +/** + * Interface for git commands with input and output of information for the user. + */ +public interface GitContext extends IdeLogger { + + /** + * Checks if the Git repository in the specified target folder needs an update by inspecting the modification time of + * a magic file. + * + * @param repoUrl the git remote URL to clone from. May be suffixed with a hash-sign ('#') followed by the branch name + * to check-out. + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + void pullOrCloneIfNeeded(String repoUrl, Path targetRepository); + + /** + * Attempts a git pull and reset if required. + * + * @param repoUrl the git remote URL to clone from. May be suffixed with a hash-sign ('#') followed by the branch name + * to check-out. + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + * @param remoteName the remote name e.g. origin. + * @param branchName the branch name e.g. master. + */ + void pullOrFetchAndResetIfNeeded(String repoUrl, Path targetRepository, String remoteName, String branchName); + + /** + * Runs a git pull or a git clone. + * + * @param gitRepoUrl the git remote URL to clone from. May be suffixed with a hash-sign ('#') followed by the branch + * name to check-out. + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + void pullOrClone(String gitRepoUrl, Path targetRepository); + + /** + * Runs a git clone. Throws a CliException if in offline mode. + * + * @param gitRepoUrl the {@link GitUrl} to use for the repository URL. + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + void clone(GitUrl gitRepoUrl, Path targetRepository); + + /** + * Runs a git pull. + * + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + void pull(Path targetRepository); + + /** + * Runs a git reset if files were modified. + * + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + * @param remoteName the remote server name. + * @param branchName the name of the branch. + */ + void reset(Path targetRepository, String remoteName, String branchName); + + /** + * Runs a git cleanup if untracked files were found. + * + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + void cleanup(Path targetRepository); + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/GitContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/context/GitContextImpl.java new file mode 100644 index 000000000..cc2f4568c --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/context/GitContextImpl.java @@ -0,0 +1,286 @@ +package com.devonfw.tools.ide.context; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.log.IdeSubLogger; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessResult; + +/** + * Implements the {@link GitContext}. + */ +public class GitContextImpl implements GitContext { + private static final Duration GIT_PULL_CACHE_DELAY_MILLIS = Duration.ofMillis(30 * 60 * 1000);; + + private final IdeContext context; + + private ProcessContext processContext; + + /** + * @param context the {@link IdeContext context}. + */ + public GitContextImpl(IdeContext context) { + + this.context = context; + + } + + @Override + public void pullOrCloneIfNeeded(String repoUrl, Path targetRepository) { + + Path gitDirectory = targetRepository.resolve(".git"); + + // Check if the .git directory exists + if (Files.isDirectory(gitDirectory)) { + Path magicFilePath = gitDirectory.resolve("HEAD"); + long currentTime = System.currentTimeMillis(); + // Get the modification time of the magic file + long fileMTime; + try { + fileMTime = Files.getLastModifiedTime(magicFilePath).toMillis(); + } catch (IOException e) { + throw new IllegalStateException("Could not read " + magicFilePath, e); + } + + // Check if the file modification time is older than the delta threshold + if ((currentTime - fileMTime > GIT_PULL_CACHE_DELAY_MILLIS.toMillis()) || context.isForceMode()) { + pullOrClone(repoUrl, targetRepository); + try { + Files.setLastModifiedTime(magicFilePath, FileTime.fromMillis(currentTime)); + } catch (IOException e) { + throw new IllegalStateException("Could not read or write in " + magicFilePath, e); + } + } + } else { + // If the .git directory does not exist, perform git clone + pullOrClone(repoUrl, targetRepository); + } + } + + public void pullOrFetchAndResetIfNeeded(String repoUrl, Path targetRepository, String remoteName, String branchName) { + + pullOrCloneIfNeeded(repoUrl, targetRepository); + + if (remoteName.isEmpty()) { + reset(targetRepository, "origin", "master"); + } else { + reset(targetRepository, remoteName, "master"); + } + + cleanup(targetRepository); + } + + @Override + public void pullOrClone(String gitRepoUrl, Path targetRepository) { + + Objects.requireNonNull(targetRepository); + Objects.requireNonNull(gitRepoUrl); + + if (!gitRepoUrl.startsWith("http")) { + throw new IllegalArgumentException("Invalid git URL '" + gitRepoUrl + "'!"); + } + + initializeProcessContext(targetRepository); + if (Files.isDirectory(targetRepository.resolve(".git"))) { + // checks for remotes + ProcessResult result = this.processContext.addArg("remote").run(true, false); + List remotes = result.getOut(); + if (remotes.isEmpty()) { + String message = targetRepository + + " is a local git repository with no remote - if you did this for testing, you may continue...\n" + + "Do you want to ignore the problem and continue anyhow?"; + this.context.askToContinue(message); + } else { + this.processContext.errorHandling(ProcessErrorHandling.WARNING); + + if (!this.context.isOffline()) { + pull(targetRepository); + } + } + } else { + String branch = ""; + int hashIndex = gitRepoUrl.indexOf("#"); + if (hashIndex != -1) { + branch = gitRepoUrl.substring(hashIndex + 1); + gitRepoUrl = gitRepoUrl.substring(0, hashIndex); + } + clone(new GitUrl(gitRepoUrl, branch), targetRepository); + if (!branch.isEmpty()) { + this.processContext.addArgs("checkout", branch); + this.processContext.run(); + } + } + } + + /** + * Handles errors which occurred during git pull. + * + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + * @param result the {@link ProcessResult} to evaluate. + */ + private void handleErrors(Path targetRepository, ProcessResult result) { + + if (!result.isSuccessful()) { + String message = "Failed to update git repository at " + targetRepository; + if (this.context.isOffline()) { + this.context.warning(message); + this.context.interaction("Continuing as we are in offline mode - results may be outdated!"); + } else { + this.context.error(message); + if (this.context.isOnline()) { + this.context + .error("See above error for details. If you have local changes, please stash or revert and retry."); + } else { + this.context.error( + "It seems you are offline - please ensure Internet connectivity and retry or activate offline mode (-o or --offline)."); + } + this.context.askToContinue("Typically you should abort and fix the problem. Do you want to continue anyways?"); + } + } + } + + /** + * Lazily initializes the {@link ProcessContext}. + * + * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. + * It is not the parent directory where git will by default create a sub-folder by default on clone but the * + * final folder that will contain the ".git" subfolder. + */ + private void initializeProcessContext(Path targetRepository) { + + if (this.processContext == null) { + this.processContext = this.context.newProcess().directory(targetRepository).executable("git") + .withEnvVar("GIT_TERMINAL_PROMPT", "0"); + } + } + + @Override + public void clone(GitUrl gitRepoUrl, Path targetRepository) { + + URL parsedUrl = gitRepoUrl.parseUrl(); + initializeProcessContext(targetRepository); + ProcessResult result; + if (!this.context.isOffline()) { + this.context.getFileAccess().mkdirs(targetRepository); + this.context.requireOnline("git clone of " + parsedUrl); + this.processContext.addArg("clone"); + if (this.context.isQuietMode()) { + this.processContext.addArg("-q"); + } + this.processContext.addArgs("--recursive", parsedUrl, "--config", "core.autocrlf=false", "."); + result = this.processContext.run(true, false); + if (!result.isSuccessful()) { + this.context.warning("Git failed to clone {} into {}.", parsedUrl, targetRepository); + } + } else { + throw new CliException("Could not clone " + parsedUrl + " to " + targetRepository + " because you are offline."); + } + } + + @Override + public void pull(Path targetRepository) { + + initializeProcessContext(targetRepository); + ProcessResult result; + // pull from remote + result = this.processContext.addArg("--no-pager").addArg("pull").run(true, false); + + if (!result.isSuccessful()) { + Map remoteAndBranchName = retrieveRemoteAndBranchName(); + context.warning("Git pull for {}/{} failed for repository {}.", remoteAndBranchName.get("remote"), + remoteAndBranchName.get("branch"), targetRepository); + handleErrors(targetRepository, result); + } + } + + private Map retrieveRemoteAndBranchName() { + + Map remoteAndBranchName = new HashMap<>(); + ProcessResult remoteResult = this.processContext.addArg("branch").addArg("-vv").run(true, false); + List remotes = remoteResult.getOut(); + if (!remotes.isEmpty()) { + for (String remote : remotes) { + if (remote.startsWith("*")) { + String checkedOutBranch = remote.substring(remote.indexOf("[") + 1, remote.indexOf("]")); + remoteAndBranchName.put("remote", checkedOutBranch.substring(0, checkedOutBranch.indexOf("/"))); + // check if current repo is behind remote and omit message + if (checkedOutBranch.contains(":")) { + remoteAndBranchName.put("branch", + checkedOutBranch.substring(checkedOutBranch.indexOf("/") + 1, checkedOutBranch.indexOf(":"))); + } else { + remoteAndBranchName.put("branch", checkedOutBranch.substring(checkedOutBranch.indexOf("/") + 1)); + } + + } + } + } else { + return Map.ofEntries(new AbstractMap.SimpleEntry<>("remote", "unknown"), + new AbstractMap.SimpleEntry<>("branch", "unknown")); + } + + return remoteAndBranchName; + } + + @Override + public void reset(Path targetRepository, String remoteName, String branchName) { + + initializeProcessContext(targetRepository); + ProcessResult result; + // check for changed files + result = this.processContext.addArg("diff-index").addArg("--quiet").addArg("HEAD").run(true, false); + + if (!result.isSuccessful()) { + // reset to origin/master + context.warning("Git has detected modified files -- attempting to reset {} to '{}/{}'.", targetRepository, + remoteName, branchName); + result = this.processContext.addArg("reset").addArg("--hard").addArg(remoteName + "/" + branchName).run(true, + false); + + if (!result.isSuccessful()) { + context.warning("Git failed to reset {} to '{}/{}'.", remoteName, branchName, targetRepository); + handleErrors(targetRepository, result); + } + } + } + + @Override + public void cleanup(Path targetRepository) { + + initializeProcessContext(targetRepository); + ProcessResult result; + // check for untracked files + result = this.processContext.addArg("ls-files").addArg("--other").addArg("--directory").addArg("--exclude-standard") + .run(true, false); + + if (!result.getOut().isEmpty()) { + // delete untracked files + context.warning("Git detected untracked files in {} and is attempting a cleanup.", targetRepository); + result = this.processContext.addArg("clean").addArg("-df").run(true, false); + + if (!result.isSuccessful()) { + context.warning("Git failed to clean the repository {}.", targetRepository); + } + } + } + + @Override + public IdeSubLogger level(IdeLogLevel level) { + + return null; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/GitUrl.java b/cli/src/main/java/com/devonfw/tools/ide/context/GitUrl.java new file mode 100644 index 000000000..daee4352f --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/context/GitUrl.java @@ -0,0 +1,33 @@ +package com.devonfw.tools.ide.context; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Handles parsing of git URLs. + * + * @param url the git url e.g. https://github.com/devonfw/ide-urls.git. + * @param branch the branch name e.g. master. + */ +public record GitUrl(String url, String branch) { + + /** + * Parses a git URL and omits the branch name if not provided. + * + * @return parsed URL. + */ + public URL parseUrl() { + + String parsedUrl = url; + if (!branch.isEmpty()) { + parsedUrl += "#" + branch; + } + URL validUrl; + try { + validUrl = new URL(parsedUrl); + } catch (MalformedURLException e) { + throw new RuntimeException("Git URL is not valid " + parsedUrl, e); + } + return validUrl; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 0c8d0782f..a633a67b0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -363,15 +363,6 @@ default void requireOnline(String purpose) { */ Locale getLocale(); - /** - * @param target the {@link Path} to the target folder where the git repository should be cloned or pulled. It is not - * the parent directory where git will by default create a sub-folder by default on clone but the final folder - * that will contain the ".git" subfolder. - * @param gitRepoUrl the git remote URL to clone from. May be suffixed with a hash-sign ('#') followed by the branch - * name to check-out. - */ - void gitPullOrClone(Path target, String gitRepoUrl); - /** * @return a new {@link ProcessContext} to {@link ProcessContext#run() run} external commands. */ @@ -392,4 +383,9 @@ default void requireOnline(String purpose) { */ DirectoryMerger getWorkspaceMerger(); + /** + * @return the {@link GitContext} used to run several git commands. + */ + GitContext getGitContext(); + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java index a3a0ef379..4fb1556f9 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; @@ -44,6 +45,7 @@ public ProcessContextImpl(IdeContext context) { this.context = context; this.processBuilder = new ProcessBuilder(); // TODO needs to be configurable for GUI + // this.processBuilder.inheritIO(); this.processBuilder.redirectOutput(Redirect.INHERIT).redirectError(Redirect.INHERIT); this.errorHandling = ProcessErrorHandling.THROW; Map environment = this.processBuilder.environment(); @@ -134,26 +136,11 @@ public ProcessResult run(boolean capture, boolean isBackgroundProcess) { List err = null; Process process = this.processBuilder.start(); if (capture) { - out = new ArrayList<>(); - err = new ArrayList<>(); - try (BufferedReader outReader = new BufferedReader(new InputStreamReader(process.getInputStream())); - BufferedReader errReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String outLine = ""; - String errLine = ""; - while ((outLine != null) || (errLine != null)) { - if (outLine != null) { - outLine = outReader.readLine(); - if (outLine != null) { - out.add(outLine); - } - } - if (errLine != null) { - errLine = errReader.readLine(); - if (errLine != null) { - err.add(errLine); - } - } - } + try (BufferedReader outReader = new BufferedReader(new InputStreamReader(process.getInputStream()));) { + out = outReader.lines().collect(Collectors.toList()); + } + try (BufferedReader errReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + err = errReader.lines().collect(Collectors.toList()); } } int exitCode = process.waitFor(); diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeContextTest.java index cdea75d8f..3d147ab63 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeContextTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeContextTest.java @@ -90,6 +90,25 @@ protected static IdeTestContext newContext(Path projectPath) { return new IdeTestContext(projectPath); } + /** + * @param projectPath the relative path inside the test project where to create the context. + * @param errors list of error messages. + * @param outs list of out messages. + * @param exitCode the exit code. + * @param isOnline boolean if it should be run in online mode. + * @return the {@link GitContextTestContext} pointing to that project. + */ + protected static GitContextTestContext newGitContext(Path projectPath, List errors, List outs, + int exitCode, boolean isOnline) { + + GitContextTestContext context; + context = new GitContextTestContext(isOnline, projectPath); + context.setErrors(errors); + context.setOuts(outs); + context.setExitCode(exitCode); + return context; + } + /** * @param context the {@link IdeContext} that was created via the {@link #newContext(String) newContext} method. * @param level the expected {@link IdeLogLevel}. diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextMock.java new file mode 100644 index 000000000..f90fc6f49 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextMock.java @@ -0,0 +1,49 @@ +package com.devonfw.tools.ide.context; + +import java.nio.file.Path; + +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.log.IdeSubLogger; + +public class GitContextMock implements GitContext { + @Override + public void pullOrCloneIfNeeded(String repoUrl, Path targetRepository) { + + } + + @Override + public void pullOrFetchAndResetIfNeeded(String repoUrl, Path targetRepository, String remoteName, String branchName) { + + } + + @Override + public void pullOrClone(String gitRepoUrl, Path targetRepository) { + + } + + @Override + public void clone(GitUrl gitRepoUrl, Path targetRepository) { + + } + + @Override + public void pull(Path targetRepository) { + + } + + @Override + public void reset(Path targetRepository, String remoteName, String branchName) { + + } + + @Override + public void cleanup(Path targetRepository) { + + } + + @Override + public IdeSubLogger level(IdeLogLevel level) { + + return null; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/GitContextProcessContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextProcessContextMock.java new file mode 100644 index 000000000..f79a2f6bb --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextProcessContextMock.java @@ -0,0 +1,139 @@ +package com.devonfw.tools.ide.context; + +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.Date; +import java.util.List; + +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; + +/** + * Mocks the {@link ProcessContext}. + */ +public class GitContextProcessContextMock implements ProcessContext { + + private final List arguments; + + private final List errors; + + private final List outs; + + private int exitCode; + + private final Path directory; + + /** + * @param errors List of errors. + * @param outs List of out texts. + * @param exitCode the exit code. + * @param directory + */ + public GitContextProcessContextMock(List errors, List outs, int exitCode, Path directory) { + + this.arguments = new ArrayList<>(); + this.errors = errors; + this.outs = outs; + this.exitCode = exitCode; + this.directory = directory; + } + + @Override + public ProcessContext errorHandling(ProcessErrorHandling handling) { + + return this; + } + + @Override + public ProcessContext directory(Path directory) { + + return this; + } + + @Override + public ProcessContext executable(Path executable) { + + return this; + } + + @Override + public ProcessContext addArg(String arg) { + + this.arguments.add(arg); + return this; + } + + @Override + public ProcessContext withEnvVar(String key, String value) { + + return this; + } + + @Override + public ProcessResult run(boolean capture, boolean isBackgroundProcess) { + + Path gitFolderPath = this.directory.resolve(".git"); + // deletes a newly added folder + if (this.arguments.contains("clean")) { + try { + Files.deleteIfExists(this.directory.resolve("new-folder")); + this.exitCode = 0; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + // part of git cleanup checks if a new directory 'new-folder' exists + if (this.arguments.contains("ls-files")) { + if (Files.exists(this.directory.resolve("new-folder"))) { + outs.add("new-folder"); + this.exitCode = 0; + } + } + if (this.arguments.contains("clone")) { + try { + Files.createDirectories(gitFolderPath); + Path newFile = Files.createFile(gitFolderPath.resolve("url")); + // 3rd argument = repository Url + Files.writeString(newFile, arguments.get(2)); + this.exitCode = 0; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + // always consider that files were changed + if (this.arguments.contains("diff-index")) { + this.exitCode = 1; + } + // changes file back to initial state (uses reference file in .git folder) + if (this.arguments.contains("reset")) { + try { + if (Files.exists(gitFolderPath.resolve("objects").resolve("referenceFile"))) { + Files.copy(gitFolderPath.resolve("objects").resolve("referenceFile"), this.directory.resolve("trackedFile"), + StandardCopyOption.REPLACE_EXISTING); + } + this.exitCode = 0; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + if (this.arguments.contains("pull")) { + try { + Files.createDirectories(gitFolderPath); + Path newFile = Files.createFile(gitFolderPath.resolve("update")); + Date currentDate = new Date(); + Files.writeString(newFile, currentDate.toString()); + this.exitCode = 0; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + this.arguments.clear(); + return new ProcessResultImpl(exitCode, outs, errors); + } + +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTest.java new file mode 100644 index 000000000..4d773fd3d --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTest.java @@ -0,0 +1,151 @@ +package com.devonfw.tools.ide.context; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.io.FileAccessImpl; + +public class GitContextTest extends AbstractIdeContextTest { + + /** + * Runs a git clone in offline mode and expects an exception to be thrown with a message. + */ + @Test + public void testRunGitCloneInOfflineModeThrowsException(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + List errors = new ArrayList<>(); + List outs = new ArrayList<>(); + outs.add("test-remote"); + IdeContext context = newGitContext(tempDir, errors, outs, 0, false); + GitContext gitContext = new GitContextImpl(context); + // act + CliException e1 = assertThrows(CliException.class, () -> { + gitContext.pullOrClone(gitRepoUrl, tempDir); + }); + // assert + assertThat(e1).hasMessageContaining(gitRepoUrl).hasMessageContaining(tempDir.toString()) + .hasMessageContaining("offline"); + + } + + /** + * Runs a simulated git clone and checks if a new file with the correct repository URL was created. + */ + @Test + public void testRunGitClone(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + List errors = new ArrayList<>(); + List outs = new ArrayList<>(); + outs.add("test-remote"); + IdeContext context = newGitContext(tempDir, errors, outs, 0, true); + GitContext gitContext = new GitContextImpl(context); + // act + gitContext.pullOrClone(gitRepoUrl, tempDir); + // assert + assertThat(tempDir.resolve(".git").resolve("url")).hasContent(gitRepoUrl); + } + + /** + * Runs a simulated git pull without force mode, checks if a new file with the current date was created. + */ + @Test + public void testRunGitPullWithoutForce(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + List errors = new ArrayList<>(); + List outs = new ArrayList<>(); + outs.add("test-remote"); + IdeContext context = newGitContext(tempDir, errors, outs, 0, true); + GitContext gitContext = new GitContextImpl(context); + Date currentDate = new Date(); + FileAccess fileAccess = new FileAccessImpl(context); + Path gitFolderPath = tempDir.resolve(".git"); + fileAccess.mkdirs(gitFolderPath); + // act + gitContext.pullOrClone(gitRepoUrl, tempDir); + // assert + assertThat(tempDir.resolve(".git").resolve("update")).hasContent(currentDate.toString()); + } + + /** + * Runs a git pull with force mode, creates temporary files to simulate a proper cleanup. + */ + @Test + public void testRunGitPullWithForceStartsReset(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + List errors = new ArrayList<>(); + List outs = new ArrayList<>(); + outs.add("test-remote"); + Path gitFolderPath = tempDir.resolve(".git"); + try { + Files.createDirectory(gitFolderPath); + Files.createDirectory(gitFolderPath.resolve("objects")); + } catch (IOException e) { + throw new RuntimeException(e); + } + Path referenceFile; + Path modifiedFile; + try { + Files.createFile(gitFolderPath.resolve("HEAD")); + referenceFile = Files.createFile(gitFolderPath.resolve("objects").resolve("referenceFile")); + Files.writeString(referenceFile, "original"); + modifiedFile = Files.createFile(tempDir.resolve("trackedFile")); + Files.writeString(modifiedFile, "changed"); + } catch (IOException e) { + throw new RuntimeException(e); + } + IdeContext context = newGitContext(tempDir, errors, outs, 0, true); + GitContext gitContext = new GitContextImpl(context); + // act + gitContext.pullOrFetchAndResetIfNeeded(gitRepoUrl, tempDir, "origin", "master"); + // assert + assertThat(modifiedFile).hasContent("original"); + } + + /** + * Runs a git pull with force and starts a cleanup (checks if an untracked folder was removed). + */ + @Test + public void testRunGitPullWithForceStartsCleanup(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + List errors = new ArrayList<>(); + List outs = new ArrayList<>(); + outs.add("test-remote"); + IdeContext context = newGitContext(tempDir, errors, outs, 0, true); + GitContext gitContext = new GitContextImpl(context); + FileAccess fileAccess = new FileAccessImpl(context); + Path gitFolderPath = tempDir.resolve(".git"); + fileAccess.mkdirs(gitFolderPath); + fileAccess.mkdirs(tempDir.resolve("new-folder")); + try { + Files.createFile(gitFolderPath.resolve("HEAD")); + } catch (IOException e) { + throw new RuntimeException(e); + } + // act + gitContext.pullOrFetchAndResetIfNeeded(gitRepoUrl, tempDir, "origin", "master"); + // assert + assertThat(tempDir.resolve("new-folder")).doesNotExist(); + } + +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTestContext.java b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTestContext.java new file mode 100644 index 000000000..5a0eafb57 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/GitContextTestContext.java @@ -0,0 +1,90 @@ +package com.devonfw.tools.ide.context; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.log.IdeTestLogger; +import com.devonfw.tools.ide.process.ProcessContext; + +/** + * Implementation of {@link IdeContext} for testing. + */ +public class GitContextTestContext extends AbstractIdeTestContext { + + private List errors; + + private List outs; + + private int exitCode; + + private Path directory; + + private static boolean testOnlineMode; + + /** + * The constructor. + * + * @param isOnline boolean if it should be run in online mode. + * @param userDir the optional {@link Path} to current working directory. + * @param answers the automatic answers simulating a user in test. + */ + public GitContextTestContext(boolean isOnline, Path userDir, String... answers) { + + super(level -> new IdeTestLogger(level), userDir, answers); + testOnlineMode = isOnline; + this.errors = new ArrayList<>(); + this.outs = new ArrayList<>(); + this.exitCode = 0; + this.directory = userDir; + } + + @Override + public boolean isOnline() { + + return testOnlineMode; + } + + @Override + public IdeTestLogger level(IdeLogLevel level) { + + return (IdeTestLogger) super.level(level); + } + + /** + * @return a dummy {@link GitContextTestContext}. + */ + public static GitContextTestContext of() { + + return new GitContextTestContext(testOnlineMode, Paths.get("/")); + } + + @Override + public ProcessContext newProcess() { + + return new GitContextProcessContextMock(this.errors, this.outs, this.exitCode, this.directory); + } + + public void setErrors(List errors) { + + this.errors = errors; + } + + public void setOuts(List outs) { + + this.outs = outs; + } + + public void setExitCode(int exitCode) { + + this.exitCode = exitCode; + } + + public void setDirectory(Path directory) { + + this.directory = directory; + } + +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/IdeTestContext.java b/cli/src/test/java/com/devonfw/tools/ide/context/IdeTestContext.java index 1ad4ee8bd..35a0ad858 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/IdeTestContext.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/IdeTestContext.java @@ -27,6 +27,12 @@ public IdeTestLogger level(IdeLogLevel level) { return (IdeTestLogger) super.level(level); } + @Override + public GitContext getGitContext() { + + return new GitContextMock(); + } + /** * @return a dummy {@link IdeTestContext}. */