1 /* 2 * Copyright (C) 2009-2010, Google Inc. 3 * Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com> 4 * Copyright (C) 2007, Shawn O. Pearce <spearce@spearce.org> and others 5 * 6 * This program and the accompanying materials are made available under the 7 * terms of the Eclipse Distribution License v. 1.0 which is available at 8 * https://www.eclipse.org/org/documents/edl-v10.php. 9 * 10 * SPDX-License-Identifier: BSD-3-Clause 11 */ 12 13 package org.eclipse.jgit.junit; 14 15 import static java.nio.charset.StandardCharsets.UTF_8; 16 import static org.junit.Assert.assertFalse; 17 import static org.junit.Assert.fail; 18 19 import java.io.File; 20 import java.io.IOException; 21 import java.io.PrintStream; 22 import java.time.Instant; 23 import java.util.ArrayList; 24 import java.util.Collections; 25 import java.util.HashMap; 26 import java.util.HashSet; 27 import java.util.List; 28 import java.util.Map; 29 import java.util.Set; 30 import java.util.TreeSet; 31 32 import org.eclipse.jgit.dircache.DirCache; 33 import org.eclipse.jgit.dircache.DirCacheEntry; 34 import org.eclipse.jgit.internal.storage.file.FileRepository; 35 import org.eclipse.jgit.lib.ConfigConstants; 36 import org.eclipse.jgit.lib.Constants; 37 import org.eclipse.jgit.lib.ObjectId; 38 import org.eclipse.jgit.lib.PersonIdent; 39 import org.eclipse.jgit.lib.Repository; 40 import org.eclipse.jgit.lib.RepositoryCache; 41 import org.eclipse.jgit.storage.file.FileBasedConfig; 42 import org.eclipse.jgit.storage.file.WindowCacheConfig; 43 import org.eclipse.jgit.util.FS; 44 import org.eclipse.jgit.util.FileUtils; 45 import org.eclipse.jgit.util.SystemReader; 46 import org.junit.After; 47 import org.junit.Before; 48 49 /** 50 * JUnit TestCase with specialized support for temporary local repository. 51 * <p> 52 * A temporary directory is created for each test, allowing each test to use a 53 * fresh environment. The temporary directory is cleaned up after the test ends. 54 * <p> 55 * Callers should not use {@link org.eclipse.jgit.lib.RepositoryCache} from 56 * within these tests as it may wedge file descriptors open past the end of the 57 * test. 58 * <p> 59 * A system property {@code jgit.junit.usemmap} defines whether memory mapping 60 * is used. Memory mapping has an effect on the file system, in that memory 61 * mapped files in Java cannot be deleted as long as the mapped arrays have not 62 * been reclaimed by the garbage collector. The programmer cannot control this 63 * with precision, so temporary files may hang around longer than desired during 64 * a test, or tests may fail altogether if there is insufficient file 65 * descriptors or address space for the test process. 66 */ 67 public abstract class LocalDiskRepositoryTestCase { 68 private static final boolean useMMAP = "true".equals(System 69 .getProperty("jgit.junit.usemmap")); 70 71 /** A fake (but stable) identity for author fields in the test. */ 72 protected PersonIdent author; 73 74 /** A fake (but stable) identity for committer fields in the test. */ 75 protected PersonIdent committer; 76 77 /** 78 * A {@link SystemReader} used to coordinate time, envars, etc. 79 * @since 4.2 80 */ 81 protected MockSystemReader mockSystemReader; 82 83 private final Set<Repository> toClose = new HashSet<>(); 84 private File tmp; 85 86 /** 87 * Setup test 88 * 89 * @throws Exception 90 */ 91 @Before setUp()92 public void setUp() throws Exception { 93 tmp = File.createTempFile("jgit_test_", "_tmp"); 94 CleanupThread.deleteOnShutdown(tmp); 95 if (!tmp.delete() || !tmp.mkdir()) { 96 throw new IOException("Cannot create " + tmp); 97 } 98 99 mockSystemReader = new MockSystemReader(); 100 SystemReader.setInstance(mockSystemReader); 101 102 // Measure timer resolution before the test to avoid time critical tests 103 // are affected by time needed for measurement. 104 // The MockSystemReader must be configured first since we need to use 105 // the same one here 106 FS.getFileStoreAttributes(tmp.toPath().getParent()); 107 108 FileBasedConfig jgitConfig = new FileBasedConfig( 109 new File(tmp, "jgitconfig"), FS.DETECTED); 110 FileBasedConfig systemConfig = new FileBasedConfig(jgitConfig, 111 new File(tmp, "systemgitconfig"), FS.DETECTED); 112 FileBasedConfig userConfig = new FileBasedConfig(systemConfig, 113 new File(tmp, "usergitconfig"), FS.DETECTED); 114 // We have to set autoDetach to false for tests, because tests expect to be able 115 // to clean up by recursively removing the repository, and background GC might be 116 // in the middle of writing or deleting files, which would disrupt this. 117 userConfig.setBoolean(ConfigConstants.CONFIG_GC_SECTION, 118 null, ConfigConstants.CONFIG_KEY_AUTODETACH, false); 119 userConfig.save(); 120 mockSystemReader.setJGitConfig(jgitConfig); 121 mockSystemReader.setSystemGitConfig(systemConfig); 122 mockSystemReader.setUserGitConfig(userConfig); 123 124 ceilTestDirectories(getCeilings()); 125 126 author = new PersonIdent("J. Author", "jauthor@example.com"); 127 committer = new PersonIdent("J. Committer", "jcommitter@example.com"); 128 129 final WindowCacheConfig c = new WindowCacheConfig(); 130 c.setPackedGitLimit(128 * WindowCacheConfig.KB); 131 c.setPackedGitWindowSize(8 * WindowCacheConfig.KB); 132 c.setPackedGitMMAP(useMMAP); 133 c.setDeltaBaseCacheLimit(8 * WindowCacheConfig.KB); 134 c.install(); 135 } 136 137 /** 138 * Get temporary directory. 139 * 140 * @return the temporary directory 141 */ getTemporaryDirectory()142 protected File getTemporaryDirectory() { 143 return tmp.getAbsoluteFile(); 144 } 145 146 /** 147 * Get list of ceiling directories 148 * 149 * @return list of ceiling directories 150 */ getCeilings()151 protected List<File> getCeilings() { 152 return Collections.singletonList(getTemporaryDirectory()); 153 } 154 ceilTestDirectories(List<File> ceilings)155 private void ceilTestDirectories(List<File> ceilings) { 156 mockSystemReader.setProperty(Constants.GIT_CEILING_DIRECTORIES_KEY, makePath(ceilings)); 157 } 158 makePath(List<?> objects)159 private static String makePath(List<?> objects) { 160 final StringBuilder stringBuilder = new StringBuilder(); 161 for (Object object : objects) { 162 if (stringBuilder.length() > 0) 163 stringBuilder.append(File.pathSeparatorChar); 164 stringBuilder.append(object.toString()); 165 } 166 return stringBuilder.toString(); 167 } 168 169 /** 170 * Tear down the test 171 * 172 * @throws Exception 173 */ 174 @After tearDown()175 public void tearDown() throws Exception { 176 RepositoryCache.clear(); 177 for (Repository r : toClose) 178 r.close(); 179 toClose.clear(); 180 181 // Since memory mapping is controlled by the GC we need to 182 // tell it this is a good time to clean up and unlock 183 // memory mapped files. 184 // 185 if (useMMAP) 186 System.gc(); 187 if (tmp != null) 188 recursiveDelete(tmp, false, true); 189 if (tmp != null && !tmp.exists()) 190 CleanupThread.removed(tmp); 191 192 SystemReader.setInstance(null); 193 } 194 195 /** 196 * Increment the {@link #author} and {@link #committer} times. 197 */ tick()198 protected void tick() { 199 mockSystemReader.tick(5 * 60); 200 final long now = mockSystemReader.getCurrentTime(); 201 final int tz = mockSystemReader.getTimezone(now); 202 203 author = new PersonIdent(author, now, tz); 204 committer = new PersonIdent(committer, now, tz); 205 } 206 207 /** 208 * Recursively delete a directory, failing the test if the delete fails. 209 * 210 * @param dir 211 * the recursively directory to delete, if present. 212 */ recursiveDelete(File dir)213 protected void recursiveDelete(File dir) { 214 recursiveDelete(dir, false, true); 215 } 216 recursiveDelete(final File dir, boolean silent, boolean failOnError)217 private static boolean recursiveDelete(final File dir, 218 boolean silent, boolean failOnError) { 219 assert !(silent && failOnError); 220 int options = FileUtils.RECURSIVE | FileUtils.RETRY 221 | FileUtils.SKIP_MISSING; 222 if (silent) { 223 options |= FileUtils.IGNORE_ERRORS; 224 } 225 try { 226 FileUtils.delete(dir, options); 227 } catch (IOException e) { 228 reportDeleteFailure(failOnError, dir, e); 229 return !failOnError; 230 } 231 return true; 232 } 233 reportDeleteFailure(boolean failOnError, File f, Exception cause)234 private static void reportDeleteFailure(boolean failOnError, File f, 235 Exception cause) { 236 String severity = failOnError ? "ERROR" : "WARNING"; 237 String msg = severity + ": Failed to delete " + f; 238 if (failOnError) { 239 fail(msg); 240 } else { 241 System.err.println(msg); 242 } 243 cause.printStackTrace(new PrintStream(System.err)); 244 } 245 246 /** Constant <code>MOD_TIME=1</code> */ 247 public static final int MOD_TIME = 1; 248 249 /** Constant <code>SMUDGE=2</code> */ 250 public static final int SMUDGE = 2; 251 252 /** Constant <code>LENGTH=4</code> */ 253 public static final int LENGTH = 4; 254 255 /** Constant <code>CONTENT_ID=8</code> */ 256 public static final int CONTENT_ID = 8; 257 258 /** Constant <code>CONTENT=16</code> */ 259 public static final int CONTENT = 16; 260 261 /** Constant <code>ASSUME_UNCHANGED=32</code> */ 262 public static final int ASSUME_UNCHANGED = 32; 263 264 /** 265 * Represent the state of the index in one String. This representation is 266 * useful when writing tests which do assertions on the state of the index. 267 * By default information about path, mode, stage (if different from 0) is 268 * included. A bitmask controls which additional info about 269 * modificationTimes, smudge state and length is included. 270 * <p> 271 * The format of the returned string is described with this BNF: 272 * 273 * <pre> 274 * result = ( "[" path mode stage? time? smudge? length? sha1? content? "]" )* . 275 * mode = ", mode:" number . 276 * stage = ", stage:" number . 277 * time = ", time:t" timestamp-index . 278 * smudge = "" | ", smudged" . 279 * length = ", length:" number . 280 * sha1 = ", sha1:" hex-sha1 . 281 * content = ", content:" blob-data . 282 * </pre> 283 * 284 * 'stage' is only presented when the stage is different from 0. All 285 * reported time stamps are mapped to strings like "t0", "t1", ... "tn". The 286 * smallest reported time-stamp will be called "t0". This allows to write 287 * assertions against the string although the concrete value of the time 288 * stamps is unknown. 289 * 290 * @param repo 291 * the repository the index state should be determined for 292 * @param includedOptions 293 * a bitmask constructed out of the constants {@link #MOD_TIME}, 294 * {@link #SMUDGE}, {@link #LENGTH}, {@link #CONTENT_ID} and 295 * {@link #CONTENT} controlling which info is present in the 296 * resulting string. 297 * @return a string encoding the index state 298 * @throws IllegalStateException 299 * @throws IOException 300 */ indexState(Repository repo, int includedOptions)301 public static String indexState(Repository repo, int includedOptions) 302 throws IllegalStateException, IOException { 303 DirCache dc = repo.readDirCache(); 304 StringBuilder sb = new StringBuilder(); 305 TreeSet<Instant> timeStamps = new TreeSet<>(); 306 307 // iterate once over the dircache just to collect all time stamps 308 if (0 != (includedOptions & MOD_TIME)) { 309 for (int i = 0; i < dc.getEntryCount(); ++i) { 310 timeStamps.add(dc.getEntry(i).getLastModifiedInstant()); 311 } 312 } 313 314 // iterate again, now produce the result string 315 for (int i=0; i<dc.getEntryCount(); ++i) { 316 DirCacheEntry entry = dc.getEntry(i); 317 sb.append("["+entry.getPathString()+", mode:" + entry.getFileMode()); 318 int stage = entry.getStage(); 319 if (stage != 0) 320 sb.append(", stage:" + stage); 321 if (0 != (includedOptions & MOD_TIME)) { 322 sb.append(", time:t"+ 323 timeStamps.headSet(entry.getLastModifiedInstant()) 324 .size()); 325 } 326 if (0 != (includedOptions & SMUDGE)) 327 if (entry.isSmudged()) 328 sb.append(", smudged"); 329 if (0 != (includedOptions & LENGTH)) 330 sb.append(", length:" 331 + Integer.toString(entry.getLength())); 332 if (0 != (includedOptions & CONTENT_ID)) 333 sb.append(", sha1:" + ObjectId.toString(entry.getObjectId())); 334 if (0 != (includedOptions & CONTENT)) { 335 sb.append(", content:" 336 + new String(repo.open(entry.getObjectId(), 337 Constants.OBJ_BLOB).getCachedBytes(), UTF_8)); 338 } 339 if (0 != (includedOptions & ASSUME_UNCHANGED)) 340 sb.append(", assume-unchanged:" 341 + Boolean.toString(entry.isAssumeValid())); 342 sb.append("]"); 343 } 344 return sb.toString(); 345 } 346 347 348 /** 349 * Creates a new empty bare repository. 350 * 351 * @return the newly created bare repository, opened for access. The 352 * repository will not be closed in {@link #tearDown()}; the caller 353 * is responsible for closing it. 354 * @throws IOException 355 * the repository could not be created in the temporary area 356 */ createBareRepository()357 protected FileRepository createBareRepository() throws IOException { 358 return createRepository(true /* bare */); 359 } 360 361 /** 362 * Creates a new empty repository within a new empty working directory. 363 * 364 * @return the newly created repository, opened for access. The repository 365 * will not be closed in {@link #tearDown()}; the caller is 366 * responsible for closing it. 367 * @throws IOException 368 * the repository could not be created in the temporary area 369 */ createWorkRepository()370 protected FileRepository createWorkRepository() throws IOException { 371 return createRepository(false /* not bare */); 372 } 373 374 /** 375 * Creates a new empty repository. 376 * 377 * @param bare 378 * true to create a bare repository; false to make a repository 379 * within its working directory 380 * @return the newly created repository, opened for access. The repository 381 * will not be closed in {@link #tearDown()}; the caller is 382 * responsible for closing it. 383 * @throws IOException 384 * the repository could not be created in the temporary area 385 * @since 5.3 386 */ createRepository(boolean bare)387 protected FileRepository createRepository(boolean bare) 388 throws IOException { 389 return createRepository(bare, false /* auto close */); 390 } 391 392 /** 393 * Creates a new empty repository. 394 * 395 * @param bare 396 * true to create a bare repository; false to make a repository 397 * within its working directory 398 * @param autoClose 399 * auto close the repository in {@link #tearDown()} 400 * @return the newly created repository, opened for access 401 * @throws IOException 402 * the repository could not be created in the temporary area 403 * @deprecated use {@link #createRepository(boolean)} instead 404 */ 405 @Deprecated createRepository(boolean bare, boolean autoClose)406 public FileRepository createRepository(boolean bare, boolean autoClose) 407 throws IOException { 408 File gitdir = createUniqueTestGitDir(bare); 409 FileRepository db = new FileRepository(gitdir); 410 assertFalse(gitdir.exists()); 411 db.create(bare); 412 if (autoClose) { 413 addRepoToClose(db); 414 } 415 return db; 416 } 417 418 /** 419 * Adds a repository to the list of repositories which is closed at the end 420 * of the tests 421 * 422 * @param r 423 * the repository to be closed 424 */ addRepoToClose(Repository r)425 public void addRepoToClose(Repository r) { 426 toClose.add(r); 427 } 428 429 /** 430 * Creates a unique directory for a test 431 * 432 * @param name 433 * a subdirectory 434 * @return a unique directory for a test 435 * @throws IOException 436 */ createTempDirectory(String name)437 protected File createTempDirectory(String name) throws IOException { 438 File directory = new File(createTempFile(), name); 439 FileUtils.mkdirs(directory); 440 return directory.getCanonicalFile(); 441 } 442 443 /** 444 * Creates a new unique directory for a test repository 445 * 446 * @param bare 447 * true for a bare repository; false for a repository with a 448 * working directory 449 * @return a unique directory for a test repository 450 * @throws IOException 451 */ createUniqueTestGitDir(boolean bare)452 protected File createUniqueTestGitDir(boolean bare) throws IOException { 453 String gitdirName = createTempFile().getPath(); 454 if (!bare) 455 gitdirName += "/"; 456 return new File(gitdirName + Constants.DOT_GIT); 457 } 458 459 /** 460 * Allocates a new unique file path that does not exist. 461 * <p> 462 * Unlike the standard {@code File.createTempFile} the returned path does 463 * not exist, but may be created by another thread in a race with the 464 * caller. Good luck. 465 * <p> 466 * This method is inherently unsafe due to a race condition between creating 467 * the name and the first use that reserves it. 468 * 469 * @return a unique path that does not exist. 470 * @throws IOException 471 */ createTempFile()472 protected File createTempFile() throws IOException { 473 File p = File.createTempFile("tmp_", "", tmp); 474 if (!p.delete()) { 475 throw new IOException("Cannot obtain unique path " + tmp); 476 } 477 return p; 478 } 479 480 /** 481 * Run a hook script in the repository, returning the exit status. 482 * 483 * @param db 484 * repository the script should see in GIT_DIR environment 485 * @param hook 486 * path of the hook script to execute, must be executable file 487 * type on this platform 488 * @param args 489 * arguments to pass to the hook script 490 * @return exit status code of the invoked hook 491 * @throws IOException 492 * the hook could not be executed 493 * @throws InterruptedException 494 * the caller was interrupted before the hook completed 495 */ runHook(final Repository db, final File hook, final String... args)496 protected int runHook(final Repository db, final File hook, 497 final String... args) throws IOException, InterruptedException { 498 final String[] argv = new String[1 + args.length]; 499 argv[0] = hook.getAbsolutePath(); 500 System.arraycopy(args, 0, argv, 1, args.length); 501 502 final Map<String, String> env = cloneEnv(); 503 env.put("GIT_DIR", db.getDirectory().getAbsolutePath()); 504 putPersonIdent(env, "AUTHOR", author); 505 putPersonIdent(env, "COMMITTER", committer); 506 507 final File cwd = db.getWorkTree(); 508 final Process p = Runtime.getRuntime().exec(argv, toEnvArray(env), cwd); 509 p.getOutputStream().close(); 510 p.getErrorStream().close(); 511 p.getInputStream().close(); 512 return p.waitFor(); 513 } 514 putPersonIdent(final Map<String, String> env, final String type, final PersonIdent who)515 private static void putPersonIdent(final Map<String, String> env, 516 final String type, final PersonIdent who) { 517 final String ident = who.toExternalString(); 518 final String date = ident.substring(ident.indexOf("> ") + 2); 519 env.put("GIT_" + type + "_NAME", who.getName()); 520 env.put("GIT_" + type + "_EMAIL", who.getEmailAddress()); 521 env.put("GIT_" + type + "_DATE", date); 522 } 523 524 /** 525 * Create a string to a UTF-8 temporary file and return the path. 526 * 527 * @param body 528 * complete content to write to the file. If the file should end 529 * with a trailing LF, the string should end with an LF. 530 * @return path of the temporary file created within the trash area. 531 * @throws IOException 532 * the file could not be written. 533 */ write(String body)534 protected File write(String body) throws IOException { 535 final File f = File.createTempFile("temp", "txt", tmp); 536 try { 537 write(f, body); 538 return f; 539 } catch (Error | RuntimeException | IOException e) { 540 f.delete(); 541 throw e; 542 } 543 } 544 545 /** 546 * Write a string as a UTF-8 file. 547 * 548 * @param f 549 * file to write the string to. Caller is responsible for making 550 * sure it is in the trash directory or will otherwise be cleaned 551 * up at the end of the test. If the parent directory does not 552 * exist, the missing parent directories are automatically 553 * created. 554 * @param body 555 * content to write to the file. 556 * @throws IOException 557 * the file could not be written. 558 */ write(File f, String body)559 protected void write(File f, String body) throws IOException { 560 JGitTestUtil.write(f, body); 561 } 562 563 /** 564 * Read a file's content 565 * 566 * @param f 567 * the file 568 * @return the content of the file 569 * @throws IOException 570 */ read(File f)571 protected String read(File f) throws IOException { 572 return JGitTestUtil.read(f); 573 } 574 toEnvArray(Map<String, String> env)575 private static String[] toEnvArray(Map<String, String> env) { 576 final String[] envp = new String[env.size()]; 577 int i = 0; 578 for (Map.Entry<String, String> e : env.entrySet()) 579 envp[i++] = e.getKey() + "=" + e.getValue(); 580 return envp; 581 } 582 cloneEnv()583 private static HashMap<String, String> cloneEnv() { 584 return new HashMap<>(System.getenv()); 585 } 586 587 private static final class CleanupThread extends Thread { 588 private static final CleanupThread me; 589 static { 590 me = new CleanupThread(); 591 Runtime.getRuntime().addShutdownHook(me); 592 } 593 deleteOnShutdown(File tmp)594 static void deleteOnShutdown(File tmp) { 595 synchronized (me) { 596 me.toDelete.add(tmp); 597 } 598 } 599 removed(File tmp)600 static void removed(File tmp) { 601 synchronized (me) { 602 me.toDelete.remove(tmp); 603 } 604 } 605 606 private final List<File> toDelete = new ArrayList<>(); 607 608 @Override run()609 public void run() { 610 // On windows accidentally open files or memory 611 // mapped regions may prevent files from being deleted. 612 // Suggesting a GC increases the likelihood that our 613 // test repositories actually get removed after the 614 // tests, even in the case of failure. 615 System.gc(); 616 synchronized (this) { 617 boolean silent = false; 618 boolean failOnError = false; 619 for (File tmp : toDelete) 620 recursiveDelete(tmp, silent, failOnError); 621 } 622 } 623 } 624 } 625