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