diff --git a/plugins/git4idea/src/git4idea/GitVcs.java b/plugins/git4idea/src/git4idea/GitVcs.java index ae255e990959..12ab13e48880 100644 --- a/plugins/git4idea/src/git4idea/GitVcs.java +++ b/plugins/git4idea/src/git4idea/GitVcs.java @@ -85,6 +85,7 @@ import git4idea.vfs.GitVFSListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.File; import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; @@ -339,11 +340,7 @@ public class GitVcs extends AbstractVcs { @Override protected void activate() { - if (!ApplicationManager.getApplication().isHeadlessEnvironment()) { - if (myExecutableValidator.checkExecutableAndNotifyIfNeeded()) { - checkVersion(); - } - } + checkExecutableAndVersion(); if (myVFSListener == null) { myVFSListener = new GitVFSListener(myProject, this, myGit); @@ -364,6 +361,27 @@ public class GitVcs extends AbstractVcs { ((GitCommitsSequentialIndex) ServiceManager.getService(GitCommitsSequentially.class)).activate(); } + private void checkExecutableAndVersion() { + boolean executableIsAlreadyCheckedAndFine = false; + String pathToGit = myAppSettings.getPathToGitAtStartup(); + if (pathToGit == null) { + myAppSettings.setPathToGit(new GitExecutableDetector().detect()); + } + else if (!pathToGit.contains(File.separator)) { // no path, just sole executable, with a hope that it is in path + // subject to redetect the path if executable validator fails + if (!myExecutableValidator.isExecutableValid()) { + myAppSettings.setPathToGit(new GitExecutableDetector().detect()); + } + else { + executableIsAlreadyCheckedAndFine = true; // not to check it twice + } + } + + if (executableIsAlreadyCheckedAndFine || myExecutableValidator.checkExecutableAndNotifyIfNeeded()) { + checkVersion(); + } + } + @Override protected void deactivate() { if (myVFSListener != null) { diff --git a/plugins/git4idea/src/git4idea/config/GitExecutableDetector.java b/plugins/git4idea/src/git4idea/config/GitExecutableDetector.java new file mode 100644 index 000000000000..6495773e71b7 --- /dev/null +++ b/plugins/git4idea/src/git4idea/config/GitExecutableDetector.java @@ -0,0 +1,261 @@ +/* + * Copyright 2000-2012 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package git4idea.config; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.CapturingProcessHandler; +import com.intellij.execution.process.ProcessOutput; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.vfs.CharsetToolkit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.FileFilter; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Tries to detect the path to Git executable. + * + * @author Kirill Likhodedov + */ +public class GitExecutableDetector { + + private static final Logger LOG = Logger.getInstance(GitExecutableDetector.class); + private static final String[] UNIX_PATHS = { "/usr/local/bin", + "/usr/bin", + "/opt/local/bin", + "/opt/bin", + "/usr/local/git/bin"}; + private static final String UNIX_EXECUTABLE = "git"; + + private static final File WIN_ROOT = new File("C:"); // the constant is extracted to be able to create files in "Program Files" in tests + private static final String GIT_CMD = "git.cmd"; + private static final String GIT_EXE = "git.exe"; + + @NotNull + public String detect() { + if (SystemInfo.isWindows) { + return detectForWindows(); + } + return detectForUnix(); + } + + @NotNull + private static String detectForUnix() { + for (String p : UNIX_PATHS) { + File f = new File(p, UNIX_EXECUTABLE); + if (f.exists()) { + return f.getPath(); + } + } + return UNIX_EXECUTABLE; + } + + @NotNull + private String detectForWindows() { + String exec = checkMsys(); + if (exec != null) { + return exec; + } + + exec = checkCygwin(); + if (exec != null) { + return exec; + } + + return checkSoleExecutable(); + } + + @Nullable + private static String checkMsys() { + final String[] PROGRAM_FILES = { "Program Files", "Program Files (x86)" }; + + // collecting all potential msys distributives + List distrs = new ArrayList(); + for (String programFiles : PROGRAM_FILES) { + File pf = new File(WIN_ROOT, programFiles); + File[] children = pf.listFiles(new FileFilter() { + @Override + public boolean accept(File pathname) { + return pathname.isDirectory() && pathname.getName().toLowerCase().startsWith("git"); + } + }); + if (!pf.exists() || children == null) { + continue; + } + distrs.addAll(Arrays.asList(children)); + } + + // greater is better => sorting in the descending order to match the best version first, when iterating + Collections.sort(distrs, Collections.reverseOrder(new VersionDirsComparator())); + + for (File distr : distrs) { + String exec = checkDistributive(distr); + if (exec != null) { + return exec; + } + } + return null; + } + + @Nullable + private static String checkCygwin() { + final String[] OTHER_WINDOWS_PATHS = { FileUtil.toSystemDependentName("cygwin/bin/git.exe") }; + for (String otherPath : OTHER_WINDOWS_PATHS) { + File file = new File(WIN_ROOT, otherPath); + if (file.exists()) { + return file.getPath(); + } + } + return null; + } + + @NotNull + private String checkSoleExecutable() { + if (runs(GIT_CMD)) { + return GIT_CMD; + } + return GIT_EXE; + } + + @Nullable + private static String checkDistributive(@Nullable File gitDir) { + if (gitDir == null || !gitDir.exists()) { + return null; + } + + final String[] binDirs = { "cmd", "bin" }; + for (String binDir : binDirs) { + String exec = checkBinDir(new File(gitDir, binDir)); + if (exec != null) { + return exec; + } + } + + return null; + } + + @Nullable + private static String checkBinDir(@NotNull File binDir) { + if (!binDir.exists()) { + return null; + } + + for (String exec : new String[]{ GIT_CMD, GIT_EXE }) { + File fe = new File(binDir, exec); + if (fe.exists()) { + return fe.getPath(); + } + } + + return null; + } + + /** + * Checks if it is possible to run the specified program. + * Made protected for tests not to start a process there. + */ + protected boolean runs(@NotNull String exec) { + GeneralCommandLine commandLine = new GeneralCommandLine(); + commandLine.setExePath(exec); + try { + CapturingProcessHandler handler = new CapturingProcessHandler(commandLine.createProcess(), CharsetToolkit.getDefaultSystemCharset()); + ProcessOutput result = handler.runProcess((int)TimeUnit.SECONDS.toMillis(5)); + return !result.isTimeout(); + } + catch (ExecutionException e) { + return false; + } + } + + // Compare strategy: greater is better (if v1 > v2, then v1 is a better candidate for the Git executable) + private static class VersionDirsComparator implements Comparator { + + @Override + public int compare(File f1, File f2) { + String name1 = f1.getName().toLowerCase(); + String name2 = f2.getName().toLowerCase(); + + // C:\Program Files\Git is better candidate for _default_ than C:\Program Files\Git_1.8.0 + if (name1.equals("git")) { + return name2.equals("git") ? fallback(f1, f2) : 1; + } + else if (name2.equals("git")) { + return -1; + } + + final Pattern GIT_WITH_VERSION = Pattern.compile("^git[ _]*([\\d\\.]*).*$"); + Matcher m1 = GIT_WITH_VERSION.matcher(name1); + Matcher m2 = GIT_WITH_VERSION.matcher(name2); + if (m1.matches() && m2.matches()) { + GitVersion v1 = parseGitVersion(m1.group(1)); + GitVersion v2 = parseGitVersion(m2.group(1)); + if (v1 == null || v2 == null) { + return fallback(f1, f2); + } + int compareVersions = v1.compareTo(v2); + return compareVersions == 0 ? fallback(f1, f2) : compareVersions; + } + return fallback(f1, f2); + } + + private static int fallback(@NotNull File f1, @NotNull File f2) { + // "Program Files" is preferable over "Program Files (x86)" + int compareParents = f1.getParentFile().getName().compareTo(f2.getParentFile().getName()); + if (compareParents != 0) { + return -compareParents; // greater is better => reversing + } + + // probably some unrecognized format of Git directory naming => just compare lexicographically + String name1 = f1.getName().toLowerCase(); + String name2 = f2.getName().toLowerCase(); + return name1.compareTo(name2); + } + + // not using GitVersion#parse(), because it requires at least 3 items in the version (1.7.3), + // and parses the `git version` command output, not just the version string. + @Nullable + private static GitVersion parseGitVersion(@Nullable String name) { + if (name == null) { + return null; + } + final Pattern VERSION = Pattern.compile("(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?(?:\\.(\\d+))?.*"); + Matcher m = VERSION.matcher(name); + if (!m.matches()) { + return null; + } + try { + int major = Integer.parseInt(m.group(1)); + return new GitVersion(major, parseOrNull(m.group(2)), parseOrNull(m.group(3)), parseOrNull(m.group(4))); + } + catch (NumberFormatException e) { + LOG.info("Unexpected NFE when parsing [" + name + "]", e); + return null; + } + } + + private static int parseOrNull(String group) { + return group == null ? 0 : Integer.parseInt(group); + } + } +} diff --git a/plugins/git4idea/src/git4idea/config/GitVcsApplicationSettings.java b/plugins/git4idea/src/git4idea/config/GitVcsApplicationSettings.java index 23c18e83a8ad..97aa12c33624 100644 --- a/plugins/git4idea/src/git4idea/config/GitVcsApplicationSettings.java +++ b/plugins/git4idea/src/git4idea/config/GitVcsApplicationSettings.java @@ -16,13 +16,9 @@ package git4idea.config; import com.intellij.openapi.components.*; -import com.intellij.openapi.util.SystemInfo; -import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.File; - /** * The application wide settings for the git */ @@ -31,17 +27,7 @@ import java.io.File; storages = {@Storage(file = StoragePathMacros.APP_CONFIG + "/vcs.xml")}) public class GitVcsApplicationSettings implements PersistentStateComponent { - @NonNls private static final String[] DEFAULT_WINDOWS_PATHS = { "C:\\Program Files\\Git\\bin", - "C:\\Program Files (x86)\\Git\\bin", - "C:\\cygwin\\bin" }; - @NonNls private static final String[] DEFAULT_UNIX_PATHS = { "/usr/local/bin", - "/usr/bin", - "/opt/local/bin", - "/opt/bin", - "/usr/local/git/bin" }; - @NonNls private static final String[] DEFAULT_WINDOWS_GITS = { "git.cmd", "git.exe" }; - @NonNls private static final String DEFAULT_UNIX_GIT = "git"; - + private State myState = new State(); /** @@ -70,42 +56,22 @@ public class GitVcsApplicationSettings implements PersistentStateComponentThis method differs from {@link #getPathToGit()} only in the @Nullable annotation: initially the path can be null, + * but after VCS is initialized for the first time, correct path is set in {@link git4idea.GitVcs} and is never null, + * so we want the @NotNull annotation there.

+ *

This method should be called only from {@link git4idea.GitVcs#activate()}.

+ * + * @return Path to Git or null if the path was not written to the config yet. + */ + @Nullable + public String getPathToGitAtStartup() { + return myState.myPathToGit; } public void setPathToGit(String pathToGit) { diff --git a/plugins/git4idea/tests/git4idea/config/GitExecutableDetectorWindowsTest.groovy b/plugins/git4idea/tests/git4idea/config/GitExecutableDetectorWindowsTest.groovy new file mode 100644 index 000000000000..a8a565fb578e --- /dev/null +++ b/plugins/git4idea/tests/git4idea/config/GitExecutableDetectorWindowsTest.groovy @@ -0,0 +1,169 @@ +/* + * Copyright 2000-2012 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package git4idea.config +import com.intellij.openapi.util.io.FileUtil +import git4idea.test.GitTestUtil +import org.jetbrains.annotations.NotNull +import org.junit.Before +import org.junit.Test + +import java.lang.reflect.Field +import java.lang.reflect.Modifier + +import static junit.framework.Assert.assertEquals + +/** + * + * @author Kirill Likhodedov + */ +class GitExecutableDetectorWindowsTest { + + private File testRoot + private def canRun = [] + + @Before + void setUp() { + GitTestUtil.setWindows(true); + testRoot = FileUtil.createTempDirectory("", "") + setWindowsRoot(testRoot); + } + + @Test + void "Simple case"() { + fs "/Program Files/Git/bin/git.exe" + assertExecutable "C:/Program Files/Git/bin/git.exe" + } + + @Test + void "Prefer default Git without version to versioned ones"() { + fs "/Program Files/Git/bin/git.exe", "/Program Files/Git 1.8/bin/git.exe", "/Program Files/Git_1.7.1/bin/git.exe" + assertExecutable "C:/Program Files/Git/bin/git.exe" + } + + @Test + void "Prefer the latest version"() { + fs "/Program Files (x86)/Git 1.8/cmd/git.exe", + "/Program Files/Git_1.7.1/bin/git.exe", + "/Program Files/Git_1.7.5/cmd/git.cmd", + "/Program Files (x86)/Git_1.7.0.2/bin/git.exe" + assertExecutable "C:/Program Files (x86)/Git 1.8/cmd/git.exe" + } + + @Test + void "Prefer Program Files over x86"() { + fs "/Program Files (x86)/Git 1.8/bin/git.exe", "/Program Files/Git 1.8/bin/git.exe" + assertExecutable "C:/Program Files/Git 1.8/bin/git.exe" + } + + @Test + void "Prefer git.cmd over git.exe"() { + fs "/Program Files (x86)/Git 1.7.4/bin/git.exe", "/Program Files/Git 1.7.4/cmd/git.cmd" + assertExecutable "C:/Program Files/Git 1.7.4/cmd/git.cmd" + } + + @Test + void "Prefer cmd over bin in newer versions of Git"() { + fs "/Program Files (x86)/Git 1.8/bin/git.exe", "/Program Files/Git 1.8/cmd/git.exe" + assertExecutable "C:/Program Files/Git 1.8/cmd/git.exe" + } + + @Test + void "1.8.0 Prefer cmd/git.cmd over cmd/git.exe and bin/git.exe"() { + fs "/Program Files (x86)/Git_1.8/bin/git.exe", + "/Program Files (x86)/Git_1.8/cmd/git.cmd", + "/Program Files (x86)/Git_1.8/cmd/git.exe" + assertExecutable "C:/Program Files (x86)/Git_1.8/cmd/git.cmd" + } + + @Test + void "Prefer msys over cygwin"() { + fs "/Program Files (x86)/Git_1.8.0.2/cmd/git.exe", + "/Program Files (x86)/Git_1.8.0.2/bin/git.exe", + "/cygwin/bin/git.exe" + assertExecutable "C:/Program Files (x86)/Git_1.8.0.2/cmd/git.exe" + } + + @Test + void "Only cygwin"() { + fs "/cygwin/bin/git.exe" + assertExecutable "C:/cygwin/bin/git.exe" + } + + @Test + void "Many different versions, real case"() { + fs "/Program Files (x86)/Git_1.7.0.2/bin/git.exe", + "/Program Files (x86)/Git_1.7.0.2/cmd/git.cmd", + "/Program Files (x86)/Git_1.7.8/bin/git.exe", + "/Program Files (x86)/Git_1.7.8/cmd/git.cmd", + "/Program Files (x86)/Git_1.8/bin/git.exe", + "/Program Files (x86)/Git_1.8/cmd/git.cmd", + "/Program Files (x86)/Git_1.8/cmd/git.exe", + "/Program Files (x86)/Git_1.8.0.2/cmd/git.exe", + "/Program Files (x86)/Git_1.8.0.2/bin/git.exe", + "/cygwin/bin/git.exe" + assertExecutable "C:/Program Files (x86)/Git_1.8.0.2/cmd/git.exe" + } + + @Test + void "Program not found, try git.exe"() { + canRun = [ "git.exe" ] + assertExecutable "git.exe" + } + + @Test + void "For both git.exe and git.cmd prefer git.cmd"() { + canRun = [ "git.exe", "git.cmd" ] + assertExecutable "git.cmd" + } + + def assertExecutable(String expected) { + expected = FileUtil.toSystemDependentName(expected).replace("C:", testRoot.path) + assertEquals "Incorrect executable detected", expected, detect() + } + + def fs(String... paths) { + paths.each { + mkPath(it) + } + } + + def mkPath(String path) { + def file = new File(testRoot.getPath() + FileUtil.toSystemDependentName(path)) + file.getParentFile().mkdirs() + file.createNewFile() + } + + private String detect() { + new GitExecutableDetector() { + @Override + protected boolean runs(@NotNull String exec) { + return canRun.contains(exec); + } + }.detect(); + } + + static def setWindowsRoot(File file) { + Field field = GitExecutableDetector.class.getDeclaredField("WIN_ROOT"); + field.setAccessible(true); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + + field.set(null, file); + } + +}