diff --git a/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java b/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java index d2f8129762..b3bc6f28fa 100644 --- a/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java +++ b/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java @@ -69,6 +69,7 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; @@ -115,7 +116,17 @@ protected GitSCMFileSystem(GitClient client, String remote, final String head, @ cacheEntry = AbstractGitSCMSource.getCacheEntry(remote); listener = new LogTaskListener(LOGGER, Level.FINER); this.client = client; - commitId = rev == null ? invoke((Repository repository) -> repository.findRef(head).getObjectId()) : ObjectId.fromString(rev.getHash()); + if (rev == null) { + commitId = invoke((Repository repository) -> { + Ref ref = repository.findRef(head); + if (ref == null) { + throw new IOException("Expected ref " + head + " was not created by preceding git fetch"); + } + return repository.findRef(head).getObjectId(); + }); + } else { + commitId = ObjectId.fromString(rev.getHash()); + } } @Override diff --git a/src/test/java/jenkins/plugins/git/GitSCMFileSystemTest.java b/src/test/java/jenkins/plugins/git/GitSCMFileSystemTest.java index ffee98ff89..d00a4f0fbb 100644 --- a/src/test/java/jenkins/plugins/git/GitSCMFileSystemTest.java +++ b/src/test/java/jenkins/plugins/git/GitSCMFileSystemTest.java @@ -26,6 +26,7 @@ package jenkins.plugins.git; import hudson.EnvVars; +import hudson.model.Result; import hudson.model.TaskListener; import hudson.plugins.git.BranchSpec; import hudson.plugins.git.GitSCM; @@ -34,9 +35,11 @@ import java.io.File; import java.util.Collections; import java.util.Iterator; +import java.util.List; import java.util.Set; import java.util.TreeSet; +import hudson.plugins.git.UserRemoteConfig; import jenkins.plugins.git.junit.jupiter.WithGitSampleRepo; import jenkins.scm.api.SCMFile; import jenkins.scm.api.SCMFileSystem; @@ -49,6 +52,10 @@ import org.eclipse.jgit.lib.ObjectId; import org.jenkinsci.plugins.gitclient.Git; import org.jenkinsci.plugins.gitclient.GitClient; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -490,4 +497,349 @@ void null_pointer_exception() throws Exception { assertEquals("master-f", result1.headName); assertEquals(Constants.R_HEADS, result1.prefix); } + + /** Helper for test(s) below. Populates {@link #sampleRepo} with + * a bare git repository which has a "shallow/master" branch + * and a forged "master" branch, both with a git snapshot history + * one commit deep. They differ in metadata (one is shallow, + * another is complete as far as the git index is concerned). + * No remote repository is registered as an "origin" of this one. + */ + void prepare_bare_shallow_origin() throws Exception { + /* Populate a bare repository into sampleRepo with a shallow + * fetch from an existing repo. Unlike `git clone --depth...` + * this routine can be repeated to provide a snapshot of + * multiple git branches or tags into e.g. generated Jenkins + * containers, tailored for a purpose like offline automation. + */ + File gitDir = new File("."); + //GitClient client = Git.with(TaskListener.NULL, new EnvVars()).in(gitDir).using("git").getClient(); + + sampleRepo.initBare(); + sampleRepo.git("fetch", "--depth=1", gitDir.getAbsolutePath(), "master"); + + // NOTE: Code below is repeatable if multiple branches should be + // provided via shallow replicas: + String branchName = "master"; + + // The fetch (if successful) updated the local repository index, + // but did not record any symbolic references like branch names. + // Stash that commit as a branch on the side, and forge another + // branch with the original name (but necessarily different hash) + // which is completely responsible for its own history per metadata. + sampleRepo.git("branch", "shallow/" + branchName, "FETCH_HEAD"); + + // Original hash (refers to shallow metadata): + String hashOriginal = sampleRepo.gitOutput(false, + "rev-parse", "shallow/" + branchName); + if (hashOriginal != null) + hashOriginal = hashOriginal.lines().toList().get(0); + assertNotNull(hashOriginal); + assertNotEquals("", hashOriginal); + + // Same content as if it is the one and only commit to know about + // in the git data tree: + String hashTreeContent = sampleRepo.gitOutput(false, + "rev-parse", "shallow/" + branchName + "^{tree}"); + if (hashTreeContent != null) + hashTreeContent = hashTreeContent.lines().toList().get(0); + assertNotNull(hashTreeContent); + assertNotEquals("", hashTreeContent); + + // Use that tree content to create a new commit: + String hashNewTip = sampleRepo.gitOutput(false, + "commit-tree", hashTreeContent, + "-m", "Shallow snapshot of " + branchName + " at " + hashOriginal); + if (hashNewTip != null) + hashNewTip = hashNewTip.lines().toList().get(0); + assertNotNull(hashNewTip); + assertNotEquals("", hashNewTip); + + // Now there is a symbolic name for "master" pointing to + // a commit in the index. Note that by construct of the + // offline source repo, there is no "origin" and so no + // symbolic "refs/heads/origin/master", for example. + // This is to not be confused with clones in Jenkins git + // cache or in ultimate jobs, which could refer to this + // bare repo as their "origin". + sampleRepo.git("update-ref", "refs/heads/" + branchName, hashNewTip); + } + + /** By default, git has a problem cloning from a shallow repository + * as it does not know how to handle the history responsibly. + * There are flags to let it trust the shallow origin "as is", + * but the plugin currently does not seem to use that feature.
+ * + * As a result, repos cloned from this bare repo will be + * semi-instantiated with present index files but absent + * symbolic references.
+ * + * The test is to verify that the plugin can handle such repos + * with a reasonable error rather than an NPE somewhere in its + * call stack (bug present in git-5.10.1).
+ */ + @Test + void handle_bare_shallow_local_origin() throws Throwable { + GitSampleRepoRule cloneRepo = new GitSampleRepoRule(); + cloneRepo.before(); + + try { + prepare_bare_shallow_origin(); + + // See if we can clone the incomplete history + cloneRepo.git("clone", + "-b", "shallow/master", + sampleRepo.getRoot().toString(), + cloneRepo.getRoot().toString()); + + cloneRepo.git("remote", "-v"); + cloneRepo.git("branch", "-a"); + + String gitLog = cloneRepo.gitOutput(false, "log", "--oneline"); + assertNotNull(gitLog); + assertNotEquals(0, gitLog.length()); + assertEquals(1, gitLog.lines().toList().size()); + + // Do something like what "Pipeline from SCM" does; + // with git-5.10.1 this already fails (because git + // did not create symbolic ref for the branch name): + GitSCM scmFromOrigin = new GitSCM( + List.of(new UserRemoteConfig( + sampleRepo.getRoot().toString(), + null, + null, + null + )), + List.of(new BranchSpec("refs/heads/shallow/master")), + false, + Collections.emptyList(), + null, + null, + Collections.emptyList() + ); + assertNotNull(scmFromOrigin); + + List branches = scmFromOrigin.getBranches(); + assertNotNull(branches); + assertNotEquals(0, branches.size()); + branches.forEach(b -> { + assertNotNull(b.getName()); + }); + + GitSCM scmFromClone = new GitSCM( + List.of(new UserRemoteConfig( + cloneRepo.getRoot().toString(), + null, + null, + null + )), + List.of(new BranchSpec("refs/heads/shallow/master")), + false, + Collections.emptyList(), + null, + null, + Collections.emptyList() + ); + assertNotNull(scmFromClone); + + branches = scmFromClone.getBranches(); + assertNotNull(branches); + assertNotEquals(0, branches.size()); + branches.forEach(b -> { + assertNotNull(b.getName()); + }); + + if (r != null) { + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "pipeline-from-git-shallow"); + // This repo is used as test subject and has *some* Jenkinsfile; + // we don't plan to fully run that job, but it must pass git checkout + p.setDefinition(new CpsScmFlowDefinition(scmFromOrigin, "Jenkinsfile")); + + // This is the code path used by Pipeline lightweight checkout + // with the bare repo used as origin git source for the pipeline. + // This is actually the point where the test fails with broken + // plugin versions: + // java.lang.NullPointerException: Cannot invoke + // "org.eclipse.jgit.lib.Ref.getObjectId()" because the return + // value of "org.eclipse.jgit.lib.Repository.findRef(String)" is null + // Or after the initial fix, we should get a meaningful message: + // java.io.IOException: Expected ref refs/remotes/origin/shallow/master + // was not created by preceding git fetch + try { + SCMFileSystem fs = SCMFileSystem.of( + r.jenkins.getItemByFullName("pipeline-from-git-shallow"), + scmFromOrigin, + null + ); + assertNotNull(fs); + } catch (Exception e) { + e.printStackTrace(); + assertTrue(e.toString().contains("was not created by preceding git fetch")); + assertTrue(!(e.toString().contains("NullPointerException"))); + } + + // This may produce an NPE that we are looking for + // (to confirm the problem with initial code and the + // lack of one after the fix) in the job context, + // assuming we have miraculously survived till now. + WorkflowRun b = null; + Result res = null; + try { + b = p.scheduleBuild2(0).waitForStart(); + r.waitForCompletion(b); + res = b.getResult(); + } catch (Throwable t) { + // We do not care at this point that the job failed, + // as it may be due to the shallow clone or lack of + // the buildPlugin() step defined by Jenkins CI farm. + t.printStackTrace(); + } + + assertNotNull(res); + r.assertLogNotContains("java.lang.NullPointerException: ", b); + + r.assertLogContains("Cloning repository", b); + r.assertLogContains("Fetching upstream changes from", b); + + // We probably fail at this one until shallow checkout is fixed. + try { + r.assertLogContains("Checking out Revision", b); + r.assertLogContains("Start of Pipeline", b); + r.assertLogContains("End of Pipeline", b); + } catch (Throwable t) { + // Troubles are expected at the moment, this test confirms them + t.printStackTrace(); + + // NOTE: The plugin fix applied so far is to throw + // a meaningful message instead of the unhelpful + // NPE (when lightweight code path is used). + // There is no checkout still. + // Here we pass the actual git checkout path. + r.assertLogContains("Couldn't find any revision to build. Verify the repository and branch configuration for this job", b); + } + } + } finally { + cloneRepo.after(); + } + } + + /** This test is to verify that unlike the setup crafted in + * {@link #handle_bare_shallow_local_origin}, the equivalent + * branch name with a complete history (albeit also a single + * commit deep) just works as far as git tooling and this + * plugin are concerned. + */ + @Test + void handle_bare_complete_local_origin() throws Throwable { + GitSampleRepoRule cloneRepo = new GitSampleRepoRule(); + cloneRepo.before(); + + try { + prepare_bare_shallow_origin(); + + // Make sure we can clone the complete history + cloneRepo.git("clone", + "-b", "master", + sampleRepo.getRoot().toString(), + cloneRepo.getRoot().toString()); + + cloneRepo.git("remote", "-v"); + cloneRepo.git("branch", "-a"); + + String gitLog = cloneRepo.gitOutput(false, "log", "--oneline"); + assertNotNull(gitLog); + assertNotEquals(0, gitLog.length()); + assertEquals(1, gitLog.lines().toList().size()); + + // Do something like what "Pipeline from SCM" does; + // with git-5.10.1 this already fails (because git + // did not create symbolic ref for the branch name): + GitSCM scmFromOrigin = new GitSCM( + List.of(new UserRemoteConfig( + sampleRepo.getRoot().toString(), + null, + null, + null + )), + List.of(new BranchSpec("refs/heads/master")), + false, + Collections.emptyList(), + null, + null, + Collections.emptyList() + ); + assertNotNull(scmFromOrigin); + + List branches = scmFromOrigin.getBranches(); + assertNotNull(branches); + assertNotEquals(0, branches.size()); + branches.forEach(b -> { + assertNotNull(b.getName()); + }); + + GitSCM scmFromClone = new GitSCM( + List.of(new UserRemoteConfig( + cloneRepo.getRoot().toString(), + null, + null, + null + )), + List.of(new BranchSpec("refs/heads/master")), + false, + Collections.emptyList(), + null, + null, + Collections.emptyList() + ); + assertNotNull(scmFromClone); + + branches = scmFromClone.getBranches(); + assertNotNull(branches); + assertNotEquals(0, branches.size()); + branches.forEach(b -> { + assertNotNull(b.getName()); + }); + + if (r != null) { + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "pipeline-from-git-complete"); + // This repo is used as test subject and has *some* Jenkinsfile; + // we don't plan to fully run that job, but it must pass git checkout + p.setDefinition(new CpsScmFlowDefinition(scmFromOrigin, "Jenkinsfile")); + + // This is the code path used by Pipeline lightweight checkout + // with the bare repo used as origin git source for the pipeline + SCMFileSystem fs = SCMFileSystem.of( + r.jenkins.getItemByFullName("pipeline-from-git-complete"), + scmFromOrigin, + null + ); + assertNotNull(fs); + + // We do not anticipate grave problems with this build + WorkflowRun b = null; + Result res = null; + try { + b = p.scheduleBuild2(0).waitForStart(); + r.waitForCompletion(b); + res = b.getResult(); + } catch (Throwable t) { + // We do not care at this point that the job failed, + // as it may be due to lack of the buildPlugin() step + // defined by Jenkins CI farm. + t.printStackTrace(); + } + + assertNotNull(res); + r.assertLogNotContains("java.lang.NullPointerException: ", b); + + r.assertLogContains("Cloning repository", b); + r.assertLogContains("Fetching upstream changes from", b); + r.assertLogContains("Checking out Revision", b); + r.assertLogContains("Start of Pipeline", b); + r.assertLogContains("End of Pipeline", b); + } + } finally { + cloneRepo.after(); + } + } } diff --git a/src/test/java/jenkins/plugins/git/GitSampleRepoRule.java b/src/test/java/jenkins/plugins/git/GitSampleRepoRule.java index 03996c27b7..fb449220fd 100644 --- a/src/test/java/jenkins/plugins/git/GitSampleRepoRule.java +++ b/src/test/java/jenkins/plugins/git/GitSampleRepoRule.java @@ -35,13 +35,22 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import jenkins.scm.impl.mock.AbstractSampleDVCSRepoRule; +import jenkins.scm.impl.mock.AbstractSampleRepoRule; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.RepositoryBuilder; +import org.junit.jupiter.api.Assumptions; import org.jvnet.hudson.test.JenkinsRule; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + /** * Manages a sample Git repository. */ @@ -69,6 +78,60 @@ public void git(String... cmds) throws Exception { run("git", cmds); } + /** The commonly used {@link #git} command calls {@link #run} which + * "returns" {@code void}.
+ * + * Here we pick up code from {@link AbstractSampleDVCSRepoRule#run} + * and further {@link AbstractSampleRepoRule#run} (both from SCM plugin) + * to return the {@code stdout} of this git command. This output is not + * chomped in any way, so you may want to split it into an array of lines, + * using {@code String.split("\\R")} or {@code output.lines().toList()} + * for example (including a removal of {@code \n} ending the single line).
+ * + * Based on the {@code probing} argument, we "assume" or "assert" that + * the command did succeed (returned exit code 0).
+ */ + public String gitOutput(boolean probing, String... cmds) throws Exception { + List args = new ArrayList<>(); + args.add("git"); + args.addAll(Arrays.asList(cmds)); + + try { + // Collect tool output into our BAOS object; + // use it to retrieve the output of the command for + // calling code as well as print into the original log. + ByteArrayOutputStream baosStdout = new ByteArrayOutputStream(); + + // Run-time log goes here: + TaskListener listenerStdout = StreamTaskListener.fromStdout(); + + int r = new Launcher.LocalLauncher(listenerStdout).launch() + .cmds(args.toArray(new String[0])) + .pwd(sampleRepo) + .stdout(baosStdout) + .join(); + + // Report a copy of the resulting output: + listenerStdout.getLogger().println(baosStdout.toString(StandardCharsets.UTF_8)); + + String message = Arrays.toString(cmds) + " failed with error code"; + if (probing) { + Assumptions.assumeTrue(r == 0, message); + } else { + assertThat(message, r, is(0)); + } + + return baosStdout.toString(StandardCharsets.UTF_8); + } catch (Exception x) { + if (probing) { + Assumptions.abort(Arrays.toString(cmds) + " failed with exception (required tooling not installed?)\n" + x); + return null; + } else { + throw x; + } + } + } + private static void checkGlobalConfig() throws Exception { if (initialized) return; initialized = true; @@ -97,6 +160,27 @@ public void init() throws Exception { git("commit", "--message=init"); } + /** Similar to {@link #init}, but prepares a bare git repository + * without any commits or branches, just marks that the default + * branch (if/when one appears) would be "master". + */ + public void initBare() throws Exception { + run(true, tmp.getRoot(), "git", "version"); + checkGlobalConfig(); + git("init", "--bare", "-b", "master", "--template=", "."); // initialize without copying the installation defaults to ensure a vanilla repo that behaves the same everywhere + if (gitVersionAtLeast(2, 30)) { + // Force branch name to master even if system default is not master + // Fails on git 2.25 and earlier (Ubuntu 20.04, etc.) + // Works on git 2.30 and later + git("branch", "-m", "master"); + } + git("config", "user.name", "Git SampleRepoRule"); + git("config", "user.email", "gits@mplereporule"); + git("config", "init.defaultbranch", "master"); + git("config", "commit.gpgsign", "false"); + git("config", "tag.gpgSign", "false"); + } + public final boolean mkdirs(String rel) throws IOException { return new File(this.sampleRepo, rel).mkdirs(); }