xref: /JGit/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/LocalDiskRepositoryTestCase.java (revision 5c5f7c6b146b24f2bd4afae1902df85ad6e57ea3)
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