xref: /OpenGrok/opengrok-indexer/src/main/java/org/opengrok/indexer/history/MercurialRepository.java (revision 794d0b7601051ece0392a8b2dc98950bb17570e1)
1 /*
2  * CDDL HEADER START
3  *
4  * The contents of this file are subject to the terms of the
5  * Common Development and Distribution License (the "License").
6  * You may not use this file except in compliance with the License.
7  *
8  * See LICENSE.txt included in this distribution for the specific
9  * language governing permissions and limitations under the License.
10  *
11  * When distributing Covered Code, include this CDDL HEADER in each
12  * file and include the License file at LICENSE.txt.
13  * If applicable, add the following below this CDDL HEADER, with the
14  * fields enclosed by brackets "[]" replaced with your own identifying
15  * information: Portions Copyright [yyyy] [name of copyright owner]
16  *
17  * CDDL HEADER END
18  */
19 
20 /*
21  * Copyright (c) 2006, 2022, Oracle and/or its affiliates. All rights reserved.
22  * Portions Copyright (c) 2017, 2019, Chris Fraire <cfraire@me.com>.
23  */
24 package org.opengrok.indexer.history;
25 
26 import java.io.BufferedReader;
27 import java.io.File;
28 import java.io.IOException;
29 import java.io.InputStreamReader;
30 import java.io.OutputStream;
31 import java.util.ArrayList;
32 import java.util.Arrays;
33 import java.util.HashMap;
34 import java.util.List;
35 import java.util.TreeSet;
36 import java.util.function.Consumer;
37 import java.util.function.Supplier;
38 import java.util.logging.Level;
39 import java.util.logging.Logger;
40 import java.util.regex.Matcher;
41 import java.util.regex.Pattern;
42 
43 import org.jetbrains.annotations.Nullable;
44 import org.opengrok.indexer.configuration.CommandTimeoutType;
45 import org.opengrok.indexer.configuration.RuntimeEnvironment;
46 import org.opengrok.indexer.logger.LoggerFactory;
47 import org.opengrok.indexer.util.BufferSink;
48 import org.opengrok.indexer.util.Executor;
49 import org.opengrok.indexer.util.LazilyInstantiate;
50 
51 /**
52  * Access to a Mercurial repository.
53  *
54  */
55 public class MercurialRepository extends RepositoryWithHistoryTraversal {
56 
57     private static final Logger LOGGER = LoggerFactory.getLogger(MercurialRepository.class);
58 
59     private static final long serialVersionUID = 1L;
60 
61     public static final int MAX_CHANGESETS = 131072;
62 
63     /**
64      * The property name used to obtain the client command for this repository.
65      */
66     public static final String CMD_PROPERTY_KEY = "org.opengrok.indexer.history.Mercurial";
67     /**
68      * The command to use to access the repository if none was given explicitly.
69      */
70     public static final String CMD_FALLBACK = "hg";
71 
72     /**
73      * The boolean property and environment variable name to indicate whether
74      * forest-extension in Mercurial adds repositories inside the repositories.
75      */
76     public static final String NOFOREST_PROPERTY_KEY
77             = "org.opengrok.indexer.history.mercurial.disableForest";
78 
79     static final String CHANGESET = "changeset: ";
80     static final String USER = "user: ";
81     static final String DATE = "date: ";
82     static final String DESCRIPTION = "description: ";
83     static final String FILE_COPIES = "file_copies: ";
84     static final String FILES = "files: ";
85     static final String END_OF_ENTRY
86             = "mercurial_history_end_of_entry";
87 
88     private static final String TEMPLATE_REVS = "{rev}:{node|short}\\n";
89     private static final String TEMPLATE_STUB
90             = CHANGESET + TEMPLATE_REVS
91             + USER + "{author}\\n" + DATE + "{date|isodate}\\n"
92             + DESCRIPTION + "{desc|strip|obfuscate}\\n";
93 
94     private static final String FILE_LIST = FILES + "{files}\\n";
95 
96     /**
97      * Templates for formatting hg log output for files.
98      */
99     private static final String FILE_TEMPLATE = TEMPLATE_STUB
100             + END_OF_ENTRY + "\\n";
101 
102     /**
103      * Template for formatting {@code hg log} output for directories.
104      */
105     private static final String DIR_TEMPLATE_RENAMED
106             = TEMPLATE_STUB + FILE_LIST
107             + FILE_COPIES + "{file_copies}\\n" + END_OF_ENTRY + "\\n";
108     private static final String DIR_TEMPLATE
109             = TEMPLATE_STUB + FILE_LIST
110             + END_OF_ENTRY + "\\n";
111 
112     private static final Pattern LOG_COPIES_PATTERN
113             = Pattern.compile("^(\\d+):(.*)");
114 
115     /**
116      * This is a static replacement for 'working' field. Effectively, check if hg is working once in a JVM
117      * instead of calling it for every MercurialRepository instance.
118      */
119     private static final Supplier<Boolean> HG_IS_WORKING = LazilyInstantiate.using(MercurialRepository::isHgWorking);
120 
MercurialRepository()121     public MercurialRepository() {
122         type = "Mercurial";
123         datePatterns = new String[]{
124             "yyyy-MM-dd hh:mm ZZZZ"
125         };
126 
127         ignoredFiles.add(".hgtags");
128         ignoredFiles.add(".hgignore");
129         ignoredDirs.add(".hg");
130     }
131 
132     /**
133      * Return name of the branch or "default".
134      */
135     @Override
determineBranch(CommandTimeoutType cmdType)136     String determineBranch(CommandTimeoutType cmdType) throws IOException {
137         List<String> cmd = new ArrayList<>();
138         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
139         cmd.add(RepoCommand);
140         cmd.add("branch");
141 
142         Executor executor = new Executor(cmd, new File(getDirectoryName()),
143                 RuntimeEnvironment.getInstance().getCommandTimeout(cmdType));
144         if (executor.exec(false) != 0) {
145             throw new IOException(executor.getErrorString());
146         }
147 
148         return executor.getOutputString().trim();
149     }
150 
getPerPartesCount()151     public int getPerPartesCount() {
152         return MAX_CHANGESETS;
153     }
154 
getRevisionNum(String changeset)155     private String getRevisionNum(String changeset) throws HistoryException {
156         String[] parts = changeset.split(":");
157         if (parts.length == 2) {
158             return parts[0];
159         } else {
160             throw new HistoryException("Don't know how to parse changeset identifier: " + changeset);
161         }
162     }
163 
getHistoryLogExecutor(File file, String sinceRevision, String tillRevision, boolean revisionsOnly)164     Executor getHistoryLogExecutor(File file, String sinceRevision, String tillRevision, boolean revisionsOnly)
165             throws HistoryException, IOException {
166         return getHistoryLogExecutor(file, sinceRevision, tillRevision, revisionsOnly, null);
167     }
168 
169     /**
170      * Get an executor to be used for retrieving the history log for the named
171      * file or directory.
172      *
173      * @param file The file or directory to retrieve history for
174      * @param sinceRevision the oldest changeset to return from the executor, or
175      *                  {@code null} if all changesets should be returned.
176      *                  For files this does not apply and full history is returned.
177      * @param tillRevision end revision
178      * @param revisionsOnly get only revision numbers
179      * @param numRevisions number of revisions to get
180      * @return An Executor ready to be started
181      */
getHistoryLogExecutor(File file, String sinceRevision, String tillRevision, boolean revisionsOnly, Integer numRevisions)182     Executor getHistoryLogExecutor(File file, String sinceRevision, String tillRevision, boolean revisionsOnly,
183                                    Integer numRevisions)
184             throws HistoryException, IOException {
185 
186         String filename = getRepoRelativePath(file);
187 
188         List<String> cmd = new ArrayList<>();
189         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
190         cmd.add(RepoCommand);
191         cmd.add("log");
192 
193         if (file.isDirectory()) {
194             // Note: assumes one of them is not null
195             if ((sinceRevision != null) || (tillRevision != null)) {
196                 cmd.add("-r");
197                 StringBuilder stringBuilder = new StringBuilder();
198                 if (!revisionsOnly) {
199                     stringBuilder.append("reverse(");
200                 }
201                 if (sinceRevision != null) {
202                     stringBuilder.append(getRevisionNum(sinceRevision));
203                 }
204                 stringBuilder.append("::");
205                 if (tillRevision != null) {
206                     stringBuilder.append(getRevisionNum(tillRevision));
207                 } else {
208                     // If this is non-default branch we would like to get the changesets
209                     // on that branch and also follow any changesets from the parent branch.
210                     stringBuilder.append("'").append(getBranch()).append("'");
211                 }
212                 if (!revisionsOnly) {
213                     stringBuilder.append(")");
214                 }
215                 cmd.add(stringBuilder.toString());
216             } else {
217                 cmd.add("-r");
218                 cmd.add("reverse(0::'" + getBranch() + "')");
219             }
220         } else {
221             // For plain files we would like to follow the complete history
222             // (this is necessary for getting the original name in given revision
223             // when handling renamed files)
224             // It is not needed to filter on a branch as 'hg log' will follow
225             // the active branch.
226             // Due to behavior of recent Mercurial versions, it is not possible
227             // to filter the changesets of a file based on revision.
228             // For files this does not matter since if getHistory() is called
229             // for a file, the file has to be renamed so we want its complete history
230             // if renamed file handling is enabled for this repository.
231             //
232             // Getting history for individual files should only be done when generating history for renamed files
233             // so the fact that filtering on sinceRevision does not work does not matter there as history
234             // from the initial changeset is needed. The tillRevision filtering works however not
235             // in combination with --follow so the filtering is done in MercurialHistoryParser.parse().
236             // Even if the revision filtering worked, this approach would be probably faster and consumed less memory.
237             if (this.isHandleRenamedFiles()) {
238                 // When using --follow, the returned revisions are from newest to oldest, hence no reverse() is needed.
239                 cmd.add("--follow");
240             }
241         }
242 
243         cmd.add("--template");
244         if (revisionsOnly) {
245             cmd.add(TEMPLATE_REVS);
246         } else {
247             if (file.isDirectory()) {
248                 cmd.add(this.isHandleRenamedFiles() ? DIR_TEMPLATE_RENAMED : DIR_TEMPLATE);
249             } else {
250                 cmd.add(FILE_TEMPLATE);
251             }
252         }
253 
254         if (numRevisions != null && numRevisions > 0) {
255             cmd.add("-l");
256             cmd.add(numRevisions.toString());
257         }
258 
259         if (!filename.isEmpty()) {
260             cmd.add("--");
261             cmd.add(filename);
262         }
263 
264         return new Executor(cmd, new File(getDirectoryName()), sinceRevision != null);
265     }
266 
267     /**
268      * Try to get file contents for given revision.
269      *
270      * @param sink a required target sink
271      * @param fullpath full pathname of the file
272      * @param rev revision
273      * @return a defined instance with {@code success} == {@code true} if no
274      * error occurred and with non-zero {@code iterations} if some data was
275      * transferred
276      */
getHistoryRev(BufferSink sink, String fullpath, String rev)277     private HistoryRevResult getHistoryRev(BufferSink sink, String fullpath, String rev) {
278 
279         HistoryRevResult result = new HistoryRevResult();
280         File directory = new File(getDirectoryName());
281 
282         String revision = rev;
283         if (rev.indexOf(':') != -1) {
284             revision = rev.substring(0, rev.indexOf(':'));
285         }
286 
287         try {
288             String filename = fullpath.substring(getDirectoryName().length() + 1);
289             ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
290             String[] argv = {RepoCommand, "cat", "-r", revision, filename};
291             Executor executor = new Executor(Arrays.asList(argv), directory,
292                     RuntimeEnvironment.getInstance().getInteractiveCommandTimeout());
293             int status = executor.exec();
294             result.iterations = copyBytes(sink, executor.getOutputStream());
295 
296             /*
297              * If exit value of the process was not 0 then the file did
298              * not exist or internal hg error occured.
299              */
300             result.success = (status == 0);
301         } catch (Exception exp) {
302             LOGGER.log(Level.SEVERE, "Failed to get history", exp);
303         }
304 
305         return result;
306     }
307 
308     /**
309      * Get the name of file in given revision. This is used to get contents
310      * of a file in historical revision.
311      *
312      * @param fullpath file path
313      * @param fullRevToFind revision number (in the form of
314      * {rev}:{node|short})
315      * @return original filename
316      */
findOriginalName(String fullpath, String fullRevToFind)317     private String findOriginalName(String fullpath, String fullRevToFind) throws IOException {
318         Matcher matcher = LOG_COPIES_PATTERN.matcher("");
319         String file = fullpath.substring(getDirectoryName().length() + 1);
320         ArrayList<String> argv = new ArrayList<>();
321         File directory = new File(getDirectoryName());
322 
323         // Extract {rev} from the full revision specification string.
324         String[] revArray = fullRevToFind.split(":");
325         String revToFind = revArray[0];
326         if (revToFind.isEmpty()) {
327             LOGGER.log(Level.SEVERE, "Invalid revision string: {0}", fullRevToFind);
328             return null;
329         }
330 
331         /*
332          * Get the list of file renames for given file to the specified
333          * revision. We need to get them from the newest to the oldest
334          * so that we can follow the renames down to the revision we are after.
335          */
336         argv.add(ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK));
337         argv.add("log");
338         argv.add("--follow");
339         /*
340          * hg log --follow -r behavior has changed since Mercurial 3.4
341          * so filtering the changesets of a file no longer works with --follow.
342          * This is tracked by https://bz.mercurial-scm.org/show_bug.cgi?id=4959
343          * Once this is fixed and Mercurial versions with the fix are prevalent,
344          * we can revert to the old behavior.
345          */
346         // argv.add("-r");
347         // Use reverse() to get the changesets from newest to oldest.
348         // argv.add("reverse(" + rev_to_find + ":)");
349         argv.add("--template");
350         argv.add("{rev}:{file_copies}\\n");
351         argv.add(fullpath);
352 
353         Executor executor = new Executor(argv, directory,
354                 RuntimeEnvironment.getInstance().getInteractiveCommandTimeout());
355         int status = executor.exec();
356 
357         try (BufferedReader in = new BufferedReader(
358                 new InputStreamReader(executor.getOutputStream()))) {
359             String line;
360             while ((line = in.readLine()) != null) {
361                 matcher.reset(line);
362                 if (!matcher.find()) {
363                     LOGGER.log(Level.SEVERE,
364                             "Failed to match: {0}", line);
365                     return (null);
366                 }
367                 String rev = matcher.group(1);
368                 String content = matcher.group(2);
369 
370                 if (rev.equals(revToFind)) {
371                     break;
372                 }
373 
374                 if (!content.isEmpty()) {
375                     /*
376                      * Split string of 'newfile1 (oldfile1)newfile2 (oldfile2) ...' into pairs of renames.
377                      */
378                     String[] splitArray = content.split("\\)");
379                     for (String s : splitArray) {
380                         /*
381                          * This will fail for file names containing ' ('.
382                          */
383                         String[] move = s.split(" \\(");
384 
385                         if (file.equals(move[0])) {
386                             file = move[1];
387                             break;
388                         }
389                     }
390                 }
391             }
392         }
393 
394         if (status != 0) {
395             LOGGER.log(Level.WARNING,
396                     "Failed to get original name in revision {2} for: \"{0}\" Exit code: {1}",
397                     new Object[]{fullpath, String.valueOf(status), fullRevToFind});
398             return null;
399         }
400 
401         return (fullpath.substring(0, getDirectoryName().length() + 1) + file);
402     }
403 
404     @Override
getHistoryGet(OutputStream out, String parent, String basename, String rev)405     boolean getHistoryGet(OutputStream out, String parent, String basename, String rev) {
406 
407         String fullpath;
408         try {
409             fullpath = new File(parent, basename).getCanonicalPath();
410         } catch (IOException exp) {
411             LOGGER.log(Level.SEVERE,
412                     "Failed to get canonical path: {0}", exp.getClass().toString());
413             return false;
414         }
415 
416         HistoryRevResult result = getHistoryRev(out::write, fullpath, rev);
417         if (!result.success && result.iterations < 1) {
418             /*
419              * If we failed to get the contents it might be that the file was
420              * renamed so we need to find its original name in that revision
421              * and retry with the original name.
422              */
423             String origpath;
424             try {
425                 origpath = findOriginalName(fullpath, rev);
426             } catch (IOException exp) {
427                 LOGGER.log(Level.SEVERE,
428                         "Failed to get original revision: {0}",
429                         exp.getClass().toString());
430                 return false;
431             }
432             if (origpath != null && !origpath.equals(fullpath)) {
433                 result = getHistoryRev(out::write, origpath, rev);
434             }
435         }
436 
437         return result.success;
438     }
439 
440     /**
441      * Annotate the specified file/revision.
442      *
443      * @param file file to annotate
444      * @param revision revision to annotate
445      * @return file annotation
446      * @throws java.io.IOException if I/O exception occurred
447      */
448     @Override
annotate(File file, String revision)449     public Annotation annotate(File file, String revision) throws IOException {
450         ArrayList<String> argv = new ArrayList<>();
451         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
452         argv.add(RepoCommand);
453         argv.add("annotate");
454         argv.add("-n");
455         if (!this.isHandleRenamedFiles()) {
456             argv.add("--no-follow");
457         }
458         if (revision != null) {
459             argv.add("-r");
460             if (revision.indexOf(':') == -1) {
461                 argv.add(revision);
462             } else {
463                 argv.add(revision.substring(0, revision.indexOf(':')));
464             }
465         }
466         argv.add(file.getName());
467         Executor executor = new Executor(argv, file.getParentFile(),
468                 RuntimeEnvironment.getInstance().getInteractiveCommandTimeout());
469         HashMap<String, HistoryEntry> revs = new HashMap<>();
470 
471         // Construct hash map for history entries from history cache. This is
472         // needed later to get user string for particular revision.
473         try {
474             History hist = HistoryGuru.getInstance().getHistory(file, false);
475             for (HistoryEntry e : hist.getHistoryEntries()) {
476                 // Chop out the colon and all hexadecimal what follows.
477                 // This is because the whole changeset identification is
478                 // stored in history index while annotate only needs the
479                 // revision identifier.
480                 revs.put(e.getRevision().replaceFirst(":[a-f0-9]+", ""), e);
481             }
482         } catch (HistoryException he) {
483             LOGGER.log(Level.SEVERE,
484                     "Error: cannot get history for file {0}", file);
485             return null;
486         }
487 
488         MercurialAnnotationParser annotator = new MercurialAnnotationParser(file, revs);
489         executor.exec(true, annotator);
490 
491         return annotator.getAnnotation();
492     }
493 
494     @Override
getRevisionForAnnotate(String historyRevision)495     protected String getRevisionForAnnotate(String historyRevision) {
496         String[] brev = historyRevision.split(":");
497 
498         return brev[0];
499     }
500 
501     @Override
fileHasAnnotation(File file)502     public boolean fileHasAnnotation(File file) {
503         return true;
504     }
505 
506     @Override
fileHasHistory(File file)507     public boolean fileHasHistory(File file) {
508         // Todo: is there a cheap test for whether mercurial has history
509         // available for a file?
510         // Otherwise, this is harmless, since mercurial's commands will just
511         // print nothing if there is no history.
512         return true;
513     }
514 
515     @Override
isRepositoryFor(File file, CommandTimeoutType cmdType)516     boolean isRepositoryFor(File file, CommandTimeoutType cmdType) {
517         if (file.isDirectory()) {
518             File f = new File(file, ".hg");
519             return f.exists() && f.isDirectory();
520         }
521         return false;
522     }
523 
524     @Override
supportsSubRepositories()525     boolean supportsSubRepositories() {
526         String val = System.getenv(NOFOREST_PROPERTY_KEY);
527         return !(val == null
528                 ? Boolean.getBoolean(NOFOREST_PROPERTY_KEY)
529                 : Boolean.parseBoolean(val));
530     }
531 
532     /**
533      * Gets a value indicating the instance is nestable.
534      * @return {@code true}
535      */
536     @Override
isNestable()537     boolean isNestable() {
538         return true;
539     }
540 
isHgWorking()541     private static boolean isHgWorking() {
542         String repoCommand = getCommand(MercurialRepository.class, CMD_PROPERTY_KEY, CMD_FALLBACK);
543         return checkCmd(repoCommand);
544     }
545 
546     @Override
isWorking()547     public boolean isWorking() {
548         if (working == null) {
549             working = HG_IS_WORKING.get();
550             ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
551         }
552         return working;
553     }
554 
555     @Override
hasHistoryForDirectories()556     boolean hasHistoryForDirectories() {
557         return true;
558     }
559 
560     @Override
getHistory(File file)561     History getHistory(File file) throws HistoryException {
562         return getHistory(file, null);
563     }
564 
accept(String sinceRevision, Consumer<String> visitor)565     public void accept(String sinceRevision, Consumer<String> visitor) throws HistoryException {
566         new MercurialHistoryParserRevisionsOnly(this, visitor).
567                 parse(new File(getDirectoryName()), sinceRevision);
568     }
569 
570     @Nullable
571     @Override
getLastHistoryEntry(File file, boolean ui)572     public HistoryEntry getLastHistoryEntry(File file, boolean ui) throws HistoryException {
573         History hist = getHistory(file, null, null, 1);
574         return hist.getLastHistoryEntry();
575     }
576 
577     @Override
getHistory(File file, String sinceRevision)578     History getHistory(File file, String sinceRevision) throws HistoryException {
579         return getHistory(file, sinceRevision, null);
580     }
581 
582     @Override
getHistory(File file, String sinceRevision, String tillRevision)583     History getHistory(File file, String sinceRevision, String tillRevision) throws HistoryException {
584         return getHistory(file, sinceRevision, tillRevision, null);
585     }
586 
587     // TODO: add a test for this
traverseHistory(File file, String sinceRevision, String tillRevision, Integer numCommits, List<ChangesetVisitor> visitors)588     public void traverseHistory(File file, String sinceRevision, String tillRevision,
589                                 Integer numCommits, List<ChangesetVisitor> visitors) throws HistoryException {
590 
591         new MercurialHistoryParser(this, visitors).
592                 parse(file, sinceRevision, tillRevision, numCommits);
593     }
594 
595     /**
596      * We need to create list of all tags prior to creation of HistoryEntries
597      * per file.
598      *
599      * @return true.
600      */
601     @Override
hasFileBasedTags()602     boolean hasFileBasedTags() {
603         return true;
604     }
605 
606     @Override
buildTagList(File directory, CommandTimeoutType cmdType)607     protected void buildTagList(File directory, CommandTimeoutType cmdType) {
608         this.tagList = new TreeSet<>();
609         ArrayList<String> argv = new ArrayList<>();
610         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
611         argv.add(RepoCommand);
612         argv.add("tags");
613 
614         Executor executor = new Executor(argv, directory,
615                 RuntimeEnvironment.getInstance().getCommandTimeout(cmdType));
616         MercurialTagParser parser = new MercurialTagParser();
617         int status = executor.exec(true, parser);
618         if (status != 0) {
619             LOGGER.log(Level.WARNING,
620                     "Failed to get tags for: \"{0}\" Exit code: {1}",
621                     new Object[]{directory.getAbsolutePath(), String.valueOf(status)});
622         } else {
623             this.tagList = parser.getEntries();
624         }
625     }
626 
627     @Override
determineParent(CommandTimeoutType cmdType)628     String determineParent(CommandTimeoutType cmdType) throws IOException {
629         File directory = new File(getDirectoryName());
630 
631         List<String> cmd = new ArrayList<>();
632         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
633         cmd.add(RepoCommand);
634         cmd.add("paths");
635         cmd.add("default");
636         Executor executor = new Executor(cmd, directory,
637                 RuntimeEnvironment.getInstance().getCommandTimeout(cmdType));
638         if (executor.exec(false) != 0) {
639             throw new IOException(executor.getErrorString());
640         }
641 
642         return executor.getOutputString().trim();
643     }
644 
645     @Override
determineCurrentVersion(CommandTimeoutType cmdType)646     public String determineCurrentVersion(CommandTimeoutType cmdType) throws IOException {
647         File directory = new File(getDirectoryName());
648 
649         List<String> cmd = new ArrayList<>();
650         ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
651         cmd.add(RepoCommand);
652         cmd.add("log");
653         cmd.add("-l");
654         cmd.add("1");
655         cmd.add("--template");
656         cmd.add("{date|isodate} {node|short} {author} {desc|strip}");
657 
658         Executor executor = new Executor(cmd, directory,
659                 RuntimeEnvironment.getInstance().getCommandTimeout(cmdType));
660         if (executor.exec(false) != 0) {
661             throw new IOException(executor.getErrorString());
662         }
663 
664         return executor.getOutputString().trim();
665     }
666 }
667