diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php index ee55d721..9c40719c 100644 --- a/src/parser/ArcanistDiffParser.php +++ b/src/parser/ArcanistDiffParser.php @@ -222,7 +222,7 @@ final class ArcanistDiffParser { '(?Pcommit) (?P[a-f0-9]+)(?: \(.*\))?', // This is a git diff, probably from "git show" or "git diff". // Note that the filenames may appear quoted. - '(?Pdiff --git) (?P"?.+"?) (?P"?.+"?)', + '(?Pdiff --git) (?P.*)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary) files '. @@ -267,14 +267,9 @@ final class ArcanistDiffParser { if (isset($match['type'])) { if ($match['type'] == 'diff --git') { - if (isset($match['old'])) { - $match['old'] = $this->unescapeFilename($match['old']); - $match['old'] = self::stripGitPathPrefix($match['old']); - } - if (isset($match['cur'])) { - $match['cur'] = $this->unescapeFilename($match['cur']); - $match['cur'] = self::stripGitPathPrefix($match['cur']); - } + list($old, $new) = self::splitGitDiffPaths($match['oldnew']); + $match['old'] = $old; + $match['cur'] = $new; } } @@ -598,12 +593,12 @@ final class ArcanistDiffParser { } if (!empty($match['old'])) { - $match['old'] = $this->unescapeFilename($match['old']); + $match['old'] = self::unescapeFilename($match['old']); $change->setOldPath($match['old']); } if (!empty($match['cur'])) { - $match['cur'] = $this->unescapeFilename($match['cur']); + $match['cur'] = self::unescapeFilename($match['cur']); $change->setCurrentPath($match['cur']); } @@ -1134,7 +1129,7 @@ final class ArcanistDiffParser { /** * Unescape escaped filenames, e.g. from "git diff". */ - private function unescapeFilename($name) { + private static function unescapeFilename($name) { if (preg_match('/^".+"$/', $name)) { return stripcslashes(substr($name, 1, -1)); } else { @@ -1200,6 +1195,7 @@ final class ArcanistDiffParser { $this->changes = $changes; } + /** * Strip prefixes off paths from `git diff`. By default git uses a/ and b/, * but you can set `diff.mnemonicprefix` to get a different set of prefixes, @@ -1239,4 +1235,59 @@ final class ArcanistDiffParser { return preg_replace($regex, '', $path); } + + /** + * Split the paths on a "diff --git" line into old and new paths. This + * is difficult because they may be ambiguous if the files contain spaces. + * + * @param string Text from a diff line after "diff --git ". + * @return pair Old and new paths. + */ + public static function splitGitDiffPaths($paths) { + $matches = null; + $paths = rtrim($paths, "\r\n"); + + $patterns = array( + // Try quoted paths, used for unicode filenames or filenames with quotes. + '@^(?P"(?:\\\\.|[^"\\\\]+)+") (?P"(?:\\\\.|[^"\\\\]+)+")$@', + + // Try paths without spaces. + '@^(?P[^ ]+) (?P[^ ]+)$@', + + // Try paths with well-known prefixes. + '@^(?P[abicwo12]/.*) (?P[abicwo12]/.*)$@', + + // Try the exact same string twice in a row separated by a space. + // This can hit a false positive for moves from files like "old file old" + // to "file", but such a case combined with custom diff prefixes is + // incredibly obscure. + '@^(?P.*) (?P\\1)$@', + ); + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $paths, $matches)) { + break; + } + } + + if (!$matches) { + throw new Exception( + "Input diff contains ambiguous line 'diff --git {$paths}'. This line ". + "is ambiguous because there are spaces in the file names, so the ". + "parser can not determine where the file names begin and end. To ". + "resolve this ambiguity, use standard prefixes ('a/' and 'b/') when ". + "generating diffs."); + } + + $old = $matches['old']; + $old = self::unescapeFilename($old); + $old = self::stripGitPathPrefix($old); + + $new = $matches['new']; + $new = self::unescapeFilename($new); + $new = self::stripGitPathPrefix($new); + + return array($old, $new); + } + } diff --git a/src/parser/__tests__/ArcanistDiffParserTestCase.php b/src/parser/__tests__/ArcanistDiffParserTestCase.php index e4c4c3e8..ed664145 100644 --- a/src/parser/__tests__/ArcanistDiffParserTestCase.php +++ b/src/parser/__tests__/ArcanistDiffParserTestCase.php @@ -580,4 +580,60 @@ EOTEXT } } + public function testGitPathSplitting() { + static $tests = array( + "a/old.c b/new.c" => array('old.c', 'new.c'), + "a/old.c b/new.c\n" => array('old.c', 'new.c'), + "a/old.c b/new.c\r\n" => array('old.c', 'new.c'), + "old.c new.c" => array('old.c', 'new.c'), + "1/old.c 2/new.c" => array('old.c', 'new.c'), + '"a/\\"quotes1\\"" "b/\\"quotes2\\""' => array( + '"quotes1"', + '"quotes2"', + ), + '"a/\\"quotes and spaces1\\"" "b/\\"quotes and spaces2\\""' => array( + '"quotes and spaces1"', + '"quotes and spaces2"', + ), + '"a/\\342\\230\\2031" "b/\\342\\230\\2032"' => array( + "\xE2\x98\x831", + "\xE2\x98\x832", + ), + "a/Core Data/old.c b/Core Data/new.c" => array( + 'Core Data/old.c', + 'Core Data/new.c', + ), + "some file with spaces.c some file with spaces.c" => array( + 'some file with spaces.c', + 'some file with spaces.c', + ), + ); + + foreach ($tests as $input => $expect) { + $result = ArcanistDiffParser::splitGitDiffPaths($input); + $this->assertEqual( + $expect, + $result, + "Split: {$input}"); + } + + + static $ambiguous = array( + "old file with spaces.c new file with spaces.c", + ); + + foreach ($ambiguous as $input) { + $caught = null; + try { + ArcanistDiffParser::splitGitDiffPaths($input); + } catch (Exception $ex) { + $caught = $ex; + } + $this->assertEqual( + true, + ($caught instanceof Exception), + "Ambiguous: {$input}"); + } + } + }