switch to ReentrantLock: this will provide ability to use tryLock
[teamcity/git-plugin.git] / git-server / src / jetbrains / buildServer / buildTriggers / vcs / git / Cleanup.java
1 /*
2  * Copyright 2000-2018 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package jetbrains.buildServer.buildTriggers.vcs.git;
18
19 import com.intellij.execution.configurations.GeneralCommandLine;
20 import com.intellij.openapi.diagnostic.Logger;
21 import com.intellij.openapi.util.Pair;
22 import jetbrains.buildServer.ExecResult;
23 import jetbrains.buildServer.SimpleCommandLineProcessRunner;
24 import jetbrains.buildServer.log.Loggers;
25 import jetbrains.buildServer.util.Dates;
26 import jetbrains.buildServer.util.FileUtil;
27 import jetbrains.buildServer.util.StringUtil;
28 import jetbrains.buildServer.vcs.VcsException;
29 import org.eclipse.jgit.internal.storage.file.FileRepository;
30 import org.eclipse.jgit.internal.storage.file.PackFile;
31 import org.eclipse.jgit.lib.Repository;
32 import org.eclipse.jgit.lib.RepositoryBuilder;
33 import org.jetbrains.annotations.NotNull;
34 import org.jetbrains.annotations.Nullable;
35
36 import java.io.File;
37 import java.io.IOException;
38 import java.util.ArrayList;
39 import java.util.Collections;
40 import java.util.List;
41 import java.util.concurrent.Semaphore;
42 import java.util.concurrent.TimeUnit;
43 import java.util.concurrent.atomic.AtomicReference;
44 import java.util.concurrent.locks.Lock;
45 import java.util.concurrent.locks.ReentrantLock;
46 import java.util.function.Consumer;
47 import java.util.regex.Pattern;
48
49 public class Cleanup {
50
51   private static final Logger LOG = Loggers.CLEANUP;
52   private static final Pattern PATTERN_LOOSE_OBJECT = Pattern.compile("[0-9a-fA-F]{38}");
53   private static final Semaphore ourSemaphore = new Semaphore(1);
54
55   private final RepositoryManager myRepositoryManager;
56   private final ServerPluginConfig myConfig;
57   private final GcErrors myGcErrors;
58   private final AtomicReference<RunGitError> myNativeGitError = new AtomicReference<>();
59   @NotNull
60   private volatile Consumer<Runnable> myCleanupCallWrapper = Runnable::run;
61
62   public Cleanup(@NotNull final ServerPluginConfig config,
63                  @NotNull final RepositoryManager repositoryManager,
64                  @NotNull final GcErrors gcErrors) {
65     myConfig = config;
66     myRepositoryManager = repositoryManager;
67     myGcErrors = gcErrors;
68   }
69
70   public void run() {
71     if (!ourSemaphore.tryAcquire()) {
72       LOG.info("Skip git cleanup: another git cleanup process is running");
73       return;
74     }
75
76     try {
77       LOG.info("Git cleanup started");
78       myCleanupCallWrapper.accept(() -> {
79         removeUnusedRepositories();
80         cleanupMonitoringData();
81         if (myConfig.isRunNativeGC()) {
82           runNativeGC();
83         } else if (myConfig.isRunJGitGC()) {
84           runJGitGC();
85         }
86       });
87       LOG.info("Git cleanup finished");
88     } finally {
89       ourSemaphore.release();
90     }
91   }
92
93   public void setCleanupCallWrapper(@NotNull Consumer<Runnable> cleanupCallWrapper) {
94     myCleanupCallWrapper = cleanupCallWrapper;
95   }
96
97   private void removeUnusedRepositories() {
98     List<File> unusedDirs = getUnusedDirs();
99     LOG.debug("Remove unused git repository clones started");
100     for (File dir : unusedDirs) {
101       LOG.info("Remove unused git repository dir " + dir.getAbsolutePath());
102       Lock rmLock = myRepositoryManager.getRmLock(dir).writeLock();
103       rmLock.lock();
104       boolean deleted = false;
105       try {
106         deleted = FileUtil.delete(dir);
107       } finally {
108         rmLock.unlock();
109       }
110       if (deleted) {
111         myGcErrors.clearError(dir);
112         LOG.debug("Remove unused git repository dir " + dir.getAbsolutePath() + " finished");
113       } else {
114         LOG.error("Cannot delete unused git repository dir " + dir.getAbsolutePath());
115         myRepositoryManager.invalidate(dir);
116       }
117     }
118     LOG.debug("Remove unused git repository clones finished");
119   }
120
121   @NotNull
122   private List<File> getUnusedDirs() {
123     return myRepositoryManager.getExpiredDirs();
124   }
125
126   private List<File> getAllRepositoryDirs() {
127     List<File> dirs = new ArrayList<>();
128     for (File d: FileUtil.getSubDirectories(myRepositoryManager.getBaseMirrorsDir())) {
129       if (d.getName().endsWith(".git")) { // there can be some other directories like .gc or .old, we should ignore them
130         dirs.add(d);
131       }
132     }
133     return dirs;
134   }
135
136   private void cleanupMonitoringData() {
137     LOG.debug("Start cleaning git monitoring data");
138     for (File repository : getAllRepositoryDirs()) {
139       File monitoring = new File(repository, myConfig.getMonitoringDirName());
140       File[] files = monitoring.listFiles();
141       if (files != null) {
142         for (File monitoringData : files) {
143           if (isExpired(monitoringData)) {
144             LOG.debug("Remove old git monitoring data " + monitoringData.getAbsolutePath());
145             FileUtil.delete(monitoringData);
146           }
147         }
148       }
149     }
150     LOG.debug("Finish cleaning git monitoring data");
151   }
152
153   private boolean isExpired(@NotNull File f) {
154     long age = System.currentTimeMillis() - f.lastModified();
155     long ageHours = age / Dates.ONE_HOUR;
156     return ageHours > myConfig.getMonitoringExpirationTimeoutHours();
157   }
158
159   private void runNativeGC() {
160     final long startNanos = System.nanoTime();
161     final long gcTimeQuotaNanos = TimeUnit.MINUTES.toNanos(myConfig.getNativeGCQuotaMinutes());
162     List<File> allDirs = getAllRepositoryDirs();
163     myGcErrors.retainErrors(allDirs);
164     if (allDirs.isEmpty()) {
165       LOG.debug("No repositories found");
166       //reset error, no reason to show it if there is no repositories
167       myNativeGitError.set(null);
168       return;
169     }
170     if (!isNativeGitInstalled()) {
171       LOG.info("Cannot find native git, skip running git gc");
172       return;
173     }
174     Long freeDiskSpace = FileUtil.getFreeSpace(myRepositoryManager.getBaseMirrorsDir());
175     LOG.info("Use git at path '" + myConfig.getPathToGit() + "'");
176     Collections.shuffle(allDirs);
177     int runGCCounter = 0;
178     LOG.info("Git garbage collection started");
179     boolean runInPlace = myConfig.runInPlaceGc();
180     for (File gitDir : allDirs) {
181       String url = myRepositoryManager.getUrl(gitDir.getName());
182       if (url != null) {
183         LOG.info("[" + gitDir.getName() + "] repository url: '" + url + "'");
184       }
185       if (enoughDiskSpaceForGC(gitDir, freeDiskSpace)) {
186         if (runInPlace) {
187           ReentrantLock lock = myRepositoryManager.getWriteLock(gitDir);
188           lock.lock();
189           try {
190             runNativeGC(gitDir);
191           } finally {
192             lock.unlock();
193           }
194         } else {
195           runGcInCopy(gitDir);
196         }
197       } else {
198         myGcErrors.registerError(gitDir, "Not enough disk space to run git gc");
199         LOG.warn("[" + gitDir.getName() + "] not enough disk space to run git gc (" + String.valueOf(freeDiskSpace) + " " + pluralize("byte", freeDiskSpace) + ")");
200       }
201       runGCCounter++;
202       final long repositoryFinishNanos = System.nanoTime();
203       if ((repositoryFinishNanos - startNanos) > gcTimeQuotaNanos) {
204         final int restRepositories = allDirs.size() - runGCCounter;
205         if (restRepositories > 0) {
206           LOG.info("Git garbage collection quota exceeded, skip " + restRepositories + " repositories");
207           break;
208         }
209       }
210     }
211     final long finishNanos = System.nanoTime();
212     LOG.info("Git garbage collection finished, it took " + TimeUnit.NANOSECONDS.toMillis(finishNanos - startNanos) + "ms");
213   }
214
215
216   private void runGcInCopy(@NotNull File originalRepo) {
217     Lock rmLock = myRepositoryManager.getRmLock(originalRepo).readLock();
218     rmLock.lock();
219     File gcRepo;
220     try {
221       if (!isGcNeeded(originalRepo)) {
222         LOG.info("[" + originalRepo.getName() + "] no git gc is needed");
223         myGcErrors.clearError(originalRepo);
224         return;
225       }
226
227       try {
228         gcRepo = setupGcRepo(originalRepo);
229       } catch (Exception e) {
230         myGcErrors.registerError(originalRepo, "Failed to create temporary repository for garbage collection", e);
231         LOG.warnAndDebugDetails("Failed to create temporary repository for garbage collection, original repository: " + originalRepo.getAbsolutePath(), e);
232         return;
233       }
234
235       LOG.info("[" + originalRepo.getName() + "] run git gc in dedicated dir [" + gcRepo.getName() + "]");
236
237       try {
238         repack(gcRepo);
239         packRefs(gcRepo);
240       } catch (Exception e) {
241         myGcErrors.registerError(originalRepo, "Error while running garbage collection", e);
242         LOG.warnAndDebugDetails("Error while running garbage collection in " + originalRepo.getAbsolutePath(), e);
243         FileUtil.delete(gcRepo);
244         return;
245       }
246     } finally {
247       rmLock.unlock();
248     }
249
250     //remove alternates pointing to the original repo before swapping repositories
251     FileUtil.delete(new File(gcRepo, "objects/info/alternates"));
252
253     long swapStart = System.currentTimeMillis();
254     File oldDir;
255     try {
256       oldDir = createTempDir(originalRepo.getParentFile(), originalRepo.getName() + ".old");
257       FileUtil.delete(oldDir);
258     } catch (Exception e) {
259       myGcErrors.registerError(originalRepo, "Error while creating temporary directory", e);
260       LOG.warnAndDebugDetails("Error while creating temporary directory for " + originalRepo.getAbsolutePath(), e);
261       FileUtil.delete(gcRepo);
262       return;
263     }
264
265     //swap repositories with write rm lock which guarantees no one uses the original repository
266     Lock rmWriteLock = myRepositoryManager.getRmLock(originalRepo).writeLock();
267     long lockStart = System.currentTimeMillis();
268     rmWriteLock.lock();
269     long lockDuration = System.currentTimeMillis() - lockStart;
270     try {
271       if (!renameDir(originalRepo, oldDir, 5)) {
272         myGcErrors.registerError(originalRepo, "Failed to rename " + originalRepo.getName() + " to " + oldDir.getName());
273         LOG.warn("Failed to rename " + originalRepo.getName() + " to " + oldDir.getName() + " after several attempts");
274         return;
275       }
276       if (!renameDir(gcRepo, originalRepo, 5)) {
277         myGcErrors.registerError(originalRepo, "Failed to rename " + gcRepo.getName() + " to " + originalRepo.getName());
278         LOG.warn("Failed to rename " + gcRepo.getName() + " to " + originalRepo.getName() + " after several attempts, will try restoring old repository");
279         if (!oldDir.renameTo(originalRepo)) {
280           LOG.warn("Failed to rename " + oldDir.getName() + " to " + originalRepo.getName());
281         }
282         return;
283       }
284     } finally {
285       rmWriteLock.unlock();
286       FileUtil.delete(oldDir);
287       FileUtil.delete(gcRepo);
288     }
289     long swapDuration = System.currentTimeMillis() - swapStart;
290     if (swapDuration > TimeUnit.SECONDS.toMillis(5)) {
291       String msg = "[" + originalRepo.getName() + "] swap with compacted repository finished in " + swapDuration + "ms";
292       if (lockDuration > TimeUnit.SECONDS.toMillis(1)) {
293         msg += " (lock acquired in " + lockDuration + "ms)";
294       }
295       LOG.info(msg);
296     }
297     myGcErrors.clearError(originalRepo);
298   }
299
300   @SuppressWarnings({"BooleanMethodIsAlwaysInverted", "SameParameterValue"})
301   private boolean renameDir(@NotNull File prevDir, @NotNull File newDir, int numAttempts) {
302     try {
303       for (int i=0; i<numAttempts; i++) {
304         if (prevDir.renameTo(newDir)) return true;
305         Thread.sleep(100);
306       }
307     } catch (InterruptedException e) {
308       LOG.warn("Could not rename directory " + prevDir.getAbsolutePath() + " to " + newDir.getAbsolutePath() + ", operation was interrupted");
309     }
310     return false;
311   }
312
313   private void repack(final File gcRepo) throws VcsException {
314     long start = System.currentTimeMillis();
315     GeneralCommandLine cmd = new GeneralCommandLine();
316     cmd.setWorkingDirectory(gcRepo);
317     cmd.setExePath(myConfig.getPathToGit());
318     cmd.addParameter("repack");
319     cmd.addParameters("-a", "-d");
320     ExecResult result = SimpleCommandLineProcessRunner.runCommand(cmd, null, new SimpleCommandLineProcessRunner.RunCommandEventsAdapter() {
321       @Override
322       public Integer getOutputIdleSecondsTimeout() {
323         return myConfig.getRepackIdleTimeoutSeconds();
324       }
325       @Override
326       public void onProcessFinished(@NotNull final Process ps) {
327         LOG.info("[" + gcRepo.getName() + "] 'git repack -a -d' finished in " + (System.currentTimeMillis() - start) + "ms");
328       }
329     });
330     VcsException commandError = CommandLineUtil.getCommandLineError("git repack", result);
331     if (commandError != null) {
332       LOG.warnAndDebugDetails("Error while running 'git repack' in " + gcRepo.getAbsolutePath(), commandError);
333       throw commandError;
334     }
335   }
336
337   private void packRefs(@NotNull File gcRepo) throws VcsException {
338     long start = System.currentTimeMillis();
339     GeneralCommandLine cmd = new GeneralCommandLine();
340     cmd.setWorkingDirectory(gcRepo);
341     cmd.setExePath(myConfig.getPathToGit());
342     cmd.addParameter("pack-refs");
343     cmd.addParameters("--all");
344     ExecResult result = SimpleCommandLineProcessRunner.runCommand(cmd, null, new SimpleCommandLineProcessRunner.RunCommandEventsAdapter() {
345       @Override
346       public Integer getOutputIdleSecondsTimeout() {
347         return myConfig.getPackRefsIdleTimeoutSeconds();
348       }
349       @Override
350       public void onProcessFinished(@NotNull final Process ps) {
351         LOG.info("[" + gcRepo.getName() + "] 'git pack-refs --all' finished in " + (System.currentTimeMillis() - start) + "ms");
352       }
353     });
354     VcsException commandError = CommandLineUtil.getCommandLineError("git pack-refs", result);
355     if (commandError != null) {
356       LOG.warnAndDebugDetails("Error while running 'git pack-refs' in " + gcRepo.getAbsolutePath(), commandError);
357       throw commandError;
358     }
359   }
360
361   private boolean isGcNeeded(@NotNull File gitDir) {
362     FileRepository db = null;
363     try {
364       //implement logic from git gc --auto, jgit version we use doesn't have it yet
365       //and native git doesn't provide a dedicated command for that
366       db = (FileRepository) new RepositoryBuilder().setBare().setGitDir(gitDir).build();
367       return tooManyPacks(db) || tooManyLooseObjects(db);
368     } catch (IOException e) {
369       LOG.warnAndDebugDetails("Error while checking if garbage collection is needed in " + gitDir.getAbsolutePath(), e);
370       return false;
371     } finally {
372       if (db != null)
373         db.close();
374     }
375   }
376
377   private boolean enoughDiskSpaceForGC(@NotNull File gitDir, @Nullable Long freeDiskSpace) {
378     if (freeDiskSpace == null)
379       return true;
380     File objects = new File(gitDir, "objects");
381     File pack = new File(objects, "pack");
382     return FileUtil.getTotalDirectorySize(pack) < freeDiskSpace;
383   }
384
385   private boolean tooManyPacks(@NotNull FileRepository repo) {
386     int limit = repo.getConfig().getInt("gc", "autopacklimit", 50);
387     if (limit <= 0)
388       return false;
389     int packCount = 0;
390     for (PackFile packFile : repo.getObjectDatabase().getPacks()) {
391       if (!packFile.shouldBeKept())
392         packCount++;
393       if (packCount > limit)
394         return true;
395     }
396     return false;
397   }
398
399
400   private boolean tooManyLooseObjects(@NotNull FileRepository repo) {
401     int limit = repo.getConfig().getInt("gc", "auto", 6700);
402     if (limit <= 0)
403       return false;
404     //SHA is evenly distributed, we can estimate number of loose object by counting them in a single bucket (from jgit internals)
405     int bucketLimit = (limit + 255) / 256;
406     File bucket = new File(repo.getObjectsDirectory(), "17");
407     if (!bucket.isDirectory())
408       return false;
409     String[] files = bucket.list();
410     if (files == null)
411       return false;
412     int count = 0;
413     for (String fileName : files) {
414       if (PATTERN_LOOSE_OBJECT.matcher(fileName).matches())
415         count++;
416       if (count > bucketLimit)
417         return true;
418     }
419     return false;
420   }
421
422
423   @NotNull
424   private File setupGcRepo(@NotNull File gitDir) throws IOException {
425     File result = createTempDir(gitDir.getParentFile(), gitDir.getName() + ".gc");
426     Repository repo = new RepositoryBuilder().setBare().setGitDir(result).build();
427     try {
428       repo.create(true);
429     } finally {
430       repo.close();
431     }
432
433     //setup alternates, 'git repack' in a repo with alternates creates a pack
434     //in this repo without affecting the repo alternates point to
435     File objectsDir = new File(result, "objects");
436     File objectsInfo = new File(objectsDir, "info");
437     objectsInfo.mkdirs();
438     FileUtil.writeFileAndReportErrors(new File(objectsInfo, "alternates"), new File(gitDir, "objects").getCanonicalPath());
439
440     copyIfExist(new File(gitDir, "packed-refs"), result);
441     copyIfExist(new File(gitDir, "timestamp"), result);
442     copyDirIfExist(new File(gitDir, "refs"), result);
443     copyDirIfExist(new File(gitDir, "monitoring"), result);
444     return result;
445   }
446
447   private void copyIfExist(@NotNull File srcFile, @NotNull File dstDir) throws IOException {
448     if (srcFile.exists())
449       FileUtil.copy(srcFile, new File(dstDir, srcFile.getName()));
450   }
451
452   private void copyDirIfExist(@NotNull File srcDir, @NotNull File dstDir) throws IOException {
453     if (srcDir.exists())
454       FileUtil.copyDir(srcDir, new File(dstDir, srcDir.getName()));
455   }
456
457   @NotNull
458   private File createTempDir(@NotNull final File parentDir, @NotNull String name) throws IOException {
459     File dir = new File(parentDir, name);
460     if (dir.mkdir())
461       return dir;
462
463     int suffix = 0;
464     while (true) {
465       suffix++;
466       String tmpDirName = name + suffix;
467       dir = new File(parentDir, tmpDirName);
468       if (dir.mkdir())
469         return dir;
470     }
471   }
472
473   private void runJGitGC() {
474     final long startNanos = System.nanoTime();
475     final long gcTimeQuotaNanos = TimeUnit.MINUTES.toNanos(myConfig.getNativeGCQuotaMinutes());
476     LOG.info("Git garbage collection started");
477     List<File> allDirs = getAllRepositoryDirs();
478     Collections.shuffle(allDirs);
479     int runGCCounter = 0;
480     Boolean nativeGitInstalled = null;
481     boolean enableNativeGitLogged = false;
482     for (File gitDir : allDirs) {
483       ReentrantLock lock = myRepositoryManager.getWriteLock(gitDir);
484       lock.lock();
485       try {
486         try {
487           LOG.info("Start garbage collection in " + gitDir.getAbsolutePath());
488           long repositoryStartNanos = System.nanoTime();
489           runJGitGC(gitDir);
490           LOG.info("Garbage collection finished in " + gitDir.getAbsolutePath() + ", duration: " +
491                    TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - repositoryStartNanos) + "ms");
492         } catch (Exception e) {
493           LOG.warnAndDebugDetails("Error while running garbage collection in " + gitDir.getAbsolutePath(), e);
494           if ((System.nanoTime() - startNanos) < gcTimeQuotaNanos) { //if quota is not exceeded try running a native git
495             if (nativeGitInstalled == null) {
496               LOG.info("Check if native git is installed");
497               nativeGitInstalled = isNativeGitInstalled();
498             }
499             if (nativeGitInstalled) {
500               runNativeGC(gitDir);
501             } else {
502               if (!enableNativeGitLogged) {
503                 LOG.info("Cannot find a native git, please install it and provide a path to git in the 'teamcity.server.git.executable.path' internal property.");
504                 enableNativeGitLogged = true;
505               }
506             }
507           }
508         }
509       } finally {
510         lock.unlock();
511       }
512
513       runGCCounter++;
514       final long repositoryFinishNanos = System.nanoTime();
515       if ((repositoryFinishNanos - startNanos) > gcTimeQuotaNanos) {
516         final int restRepositories = allDirs.size() - runGCCounter;
517         if (restRepositories > 0) {
518           LOG.info("Git garbage collection quota exceeded, skip " + restRepositories + " repositories");
519           break;
520         }
521       }
522     }
523     final long finishNanos = System.nanoTime();
524     LOG.info("Git garbage collection finished, it took " + TimeUnit.NANOSECONDS.toMillis(finishNanos - startNanos) + "ms");
525   }
526
527   private boolean isNativeGitInstalled() {
528     String pathToGit = myConfig.getPathToGit();
529     GeneralCommandLine cmd = new GeneralCommandLine();
530     cmd.setWorkingDirectory(myRepositoryManager.getBaseMirrorsDir());
531     cmd.setExePath(pathToGit);
532     cmd.addParameter("version");
533     ExecResult result = SimpleCommandLineProcessRunner.runCommand(cmd, null);
534     VcsException commandError = CommandLineUtil.getCommandLineError("git version", result);
535     if (commandError != null) {
536       myNativeGitError.set(new RunGitError(pathToGit, commandError));
537       LOG.warnAndDebugDetails("Failed to run git", commandError);
538       return false;
539     } else {
540       myNativeGitError.set(null);
541     }
542     return true;
543   }
544
545   private void runJGitGC(final File bareGitDir) throws IOException, VcsException {
546     GeneralCommandLine cmd = new GeneralCommandLine();
547     cmd.setWorkingDirectory(bareGitDir);
548     cmd.setExePath(myConfig.getFetchProcessJavaPath());
549     cmd.addParameters("-Xmx" + myConfig.getGcProcessMaxMemory(),
550                       "-cp", myConfig.getFetchClasspath(),
551                       GitGcProcess.class.getName(),
552                       bareGitDir.getCanonicalPath());
553     ExecResult result = SimpleCommandLineProcessRunner.runCommand(cmd, null, new SimpleCommandLineProcessRunner.RunCommandEventsAdapter() {
554       @Nullable
555       @Override
556       public Integer getOutputIdleSecondsTimeout() {
557         return 60 * myConfig.getNativeGCQuotaMinutes();
558       }
559     });
560     VcsException commandError = CommandLineUtil.getCommandLineError("git gc", result, false, true);
561     if (commandError != null)
562       throw commandError;
563   }
564
565   private void runNativeGC(final File bareGitDir) {
566     String pathToGit = myConfig.getPathToGit();
567     try {
568       final long start = System.currentTimeMillis();
569       GeneralCommandLine cl = new GeneralCommandLine();
570       cl.setWorkingDirectory(bareGitDir.getParentFile());
571       cl.setExePath(pathToGit);
572       cl.addParameter("--git-dir="+bareGitDir.getCanonicalPath());
573       cl.addParameter("gc");
574       cl.addParameter("--auto");
575       cl.addParameter("--quiet");
576
577       ExecResult result = SimpleCommandLineProcessRunner.runCommand(cl, null, new SimpleCommandLineProcessRunner.ProcessRunCallback() {
578         public void onProcessStarted(Process ps) {
579           LOG.info("Start 'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc'");
580         }
581         public void onProcessFinished(Process ps) {
582           final long finish = System.currentTimeMillis();
583           LOG.info("Finish 'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc', duration: " + (finish - start) + "ms");
584         }
585         public Integer getOutputIdleSecondsTimeout() {
586           return 60 * myConfig.getNativeGCQuotaMinutes();
587         }
588         public Integer getMaxAcceptedOutputSize() {
589           return null;
590         }
591       });
592
593       VcsException commandError = CommandLineUtil.getCommandLineError("'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc'", result);
594       if (commandError != null) {
595         LOG.warnAndDebugDetails("Error while running 'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc'", commandError);
596       }
597       if (result.getStderr().length() > 0) {
598         LOG.debug("Output produced by 'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc'");
599         LOG.debug(result.getStderr());
600       }
601     } catch (Exception e) {
602       myGcErrors.registerError(bareGitDir, e);
603       LOG.warnAndDebugDetails("Error while running 'git --git-dir=" + bareGitDir.getAbsolutePath() + " gc'", e);
604     }
605   }
606
607
608   @Nullable
609   public RunGitError getNativeGitError() {
610     return myNativeGitError.get();
611   }
612
613   public static class RunGitError extends Pair<String, VcsException> {
614     public RunGitError(@NotNull String gitPath, @NotNull VcsException error) {
615       super(gitPath, error);
616     }
617
618     @NotNull
619     public String getGitPath() {
620       return first;
621     }
622
623     @NotNull
624     public VcsException getError() {
625       return second;
626     }
627   }
628
629   @NotNull
630   private String pluralize(@NotNull String base, long n) {
631     //StringUtil doesn't work with longs
632     if (n == 1) return base;
633     return StringUtil.pluralize(base);
634   }
635 }