Page MenuHomePhorge

No OneTemporary

diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
index 1dfc8012..3ebdf895 100644
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1,1592 +1,1672 @@
<?php
/**
* Interfaces with Git working copies.
*/
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
private $repositoryHasNoCommits = false;
const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
/**
* For the repository's initial commit, 'git diff HEAD^' and similar do
* not work. Using this instead does work; it is the hash of the empty tree.
*/
const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
private $symbolicHeadCommit;
private $resolvedHeadCommit;
protected function buildLocalFuture(array $argv) {
$argv[0] = 'git '.$argv[0];
$future = newv('ExecFuture', $argv);
$future->setCWD($this->getPath());
return $future;
}
public function execPassthru($pattern /* , ... */) {
$args = func_get_args();
static $git = null;
if ($git === null) {
if (phutil_is_windows()) {
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
// everything goes to hell if we don't. We must provide an absolute
// path to Git for this to work properly.
$git = Filesystem::resolveBinary('git');
$git = csprintf('%s', $git);
} else {
$git = 'git';
}
}
$args[0] = $git.' '.$args[0];
return call_user_func_array('phutil_passthru', $args);
}
public function getSourceControlSystemName() {
return 'git';
}
public function getGitVersion() {
static $version = null;
if ($version === null) {
list($stdout) = $this->execxLocal('--version');
$version = rtrim(str_replace('git version ', '', $stdout));
}
return $version;
}
public function getMetadataPath() {
static $path = null;
if ($path === null) {
list($stdout) = $this->execxLocal('rev-parse --git-dir');
$path = rtrim($stdout, "\n");
// the output of git rev-parse --git-dir is an absolute path, unless
// the cwd is the root of the repository, in which case it uses the
// relative path of .git. If we get this relative path, turn it into
// an absolute path.
if ($path === '.git') {
$path = $this->getPath('.git');
}
}
return $path;
}
public function getHasCommits() {
return !$this->repositoryHasNoCommits;
}
/**
* Tests if a child commit is descendant of a parent commit.
* If child and parent are the same, it returns false.
* @param Child commit SHA.
* @param Parent commit SHA.
* @return bool True if the child is a descendant of the parent.
*/
private function isDescendant($child, $parent) {
list($common_ancestor) = $this->execxLocal(
'merge-base %s %s',
$child,
$parent);
$common_ancestor = trim($common_ancestor);
return ($common_ancestor == $parent) && ($common_ancestor != $child);
}
public function getLocalCommitInformation() {
if ($this->repositoryHasNoCommits) {
// Zero commits.
throw new Exception(
pht(
"You can't get local commit information for a repository with no ".
"commits."));
} else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
// One commit.
$against = 'HEAD';
} else {
// 2..N commits. We include commits reachable from HEAD which are
// not reachable from the base commit; this is consistent with user
// expectations even though it is not actually the diff range.
// Particularly:
//
// |
// D <----- master branch
// |
// C Y <- feature branch
// | /|
// B X
// | /
// A
// |
//
// If "A, B, C, D" are master, and the user is at Y, when they run
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
// this as being the commits X and Y. If we log "B..Y", we only show
// Y. With "Y --not B", we show X and Y.
if ($this->symbolicHeadCommit !== null) {
$base_commit = $this->getBaseCommit();
$resolved_base = $this->resolveCommit($base_commit);
$head_commit = $this->symbolicHeadCommit;
$resolved_head = $this->getHeadCommit();
if (!$this->isDescendant($resolved_head, $resolved_base)) {
// NOTE: Since the base commit will have been resolved as the
// merge-base of the specified base and the specified HEAD, we can't
// easily tell exactly what's wrong with the range.
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
// is reversed, but resolving the commit "HEAD" will compute its
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
// appear empty.
throw new ArcanistUsageException(
pht(
'The specified commit range is empty, backward or invalid: the '.
'base (%s) is not an ancestor of the head (%s). You can not '.
'diff an empty or reversed commit range.',
$base_commit,
$head_commit));
}
}
$against = csprintf(
'%s --not %s',
$this->getHeadCommit(),
$this->getBaseCommit());
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed through escapeshellarg() they are replaced with spaces.
// TODO: Learn how cmd.exe works and find some clever workaround?
// NOTE: If we use "%x00", output is truncated in Windows.
list($info) = $this->execxLocal(
phutil_is_windows()
? 'log %C --format=%C --'
: 'log %C --format=%s --',
$against,
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02');
$commits = array();
$info = trim($info, " \n\2");
if (!strlen($info)) {
return array();
}
$info = explode("\2", $info);
foreach ($info as $line) {
list($commit, $tree, $parents, $time, $author, $author_email,
$title, $message) = explode("\1", trim($line), 8);
$message = rtrim($message);
$commits[$commit] = array(
'commit' => $commit,
'tree' => $tree,
'parents' => array_filter(explode(' ', $parents)),
'time' => $time,
'author' => $author,
'summary' => $title,
'message' => $message,
'authorEmail' => $author_email,
);
}
return $commits;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) {
$this->setBaseCommitExplanation(
pht('you explicitly specified the empty tree.'));
return $symbolic_commit;
}
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s %s',
$symbolic_commit,
$this->getHeadCommit());
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
if ($this->symbolicHeadCommit === null) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and HEAD.",
$symbolic_commit));
} else {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and the explicitly specified head commit '%s'.",
$symbolic_commit,
$this->symbolicHeadCommit));
}
return trim($merge_base);
}
// Detect zero-commit or one-commit repositories. There is only one
// relative-commit value that makes any sense in these repositories: the
// empty tree.
list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
if ($err) {
list($err) = $this->execManualLocal('rev-parse --verify HEAD');
if ($err) {
$this->repositoryHasNoCommits = true;
}
if ($this->repositoryHasNoCommits) {
$this->setBaseCommitExplanation(pht('the repository has no commits.'));
} else {
$this->setBaseCommitExplanation(
pht('the repository has only one commit.'));
}
return self::GIT_MAGIC_ROOT_COMMIT;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
$do_write = false;
$default_relative = null;
$working_copy = $this->getWorkingCopyIdentity();
if ($working_copy) {
$default_relative = $working_copy->getProjectConfig(
'git.default-relative-commit');
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s' in ".
"'%s'. This setting overrides other settings.",
$default_relative,
'git.default-relative-commit',
'.arcconfig'));
}
if (!$default_relative) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$default_relative = trim($upstream);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' (the Git upstream ".
"of the current branch) HEAD.",
$default_relative));
}
}
if (!$default_relative) {
$default_relative = $this->readScratchFile('default-relative-commit');
$default_relative = trim($default_relative);
if ($default_relative) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s'.",
$default_relative,
'.git/arc/default-relative-commit'));
}
}
if (!$default_relative) {
// TODO: Remove the history lesson soon.
echo phutil_console_format(
"<bg:green>** %s **</bg>\n\n",
pht('Select a Default Commit Range'));
echo phutil_console_wrap(
pht(
"You're running a command which operates on a range of revisions ".
"(usually, from some revision to HEAD) but have not specified the ".
"revision that should determine the start of the range.\n\n".
"Previously, arc assumed you meant '%s' when you did not specify ".
"a start revision, but this behavior does not make much sense in ".
"most workflows outside of Facebook's historic %s workflow.\n\n".
"arc no longer assumes '%s'. You must specify a relative commit ".
"explicitly when you invoke a command (e.g., `%s`, not just `%s`) ".
"or select a default for this working copy.\n\nIn most cases, the ".
"best default is '%s'. You can also select '%s' to preserve the ".
"old behavior, or some other remote or branch. But you almost ".
"certainly want to select 'origin/master'.\n\n".
"(Technically: the merge-base of the selected revision and HEAD is ".
"used to determine the start of the commit range.)",
'HEAD^',
'git-svn',
'HEAD^',
'arc diff HEAD^',
'arc diff',
'origin/master',
'HEAD^'));
$prompt = pht('What default do you want to use? [origin/master]');
$default = phutil_console_prompt($prompt);
if (!strlen(trim($default))) {
$default = 'origin/master';
}
$default_relative = $default;
$do_write = true;
}
list($object_type) = $this->execxLocal(
'cat-file -t %s',
$default_relative);
if (trim($object_type) !== 'commit') {
throw new Exception(
pht(
"Relative commit '%s' is not the name of a commit!",
$default_relative));
}
if ($do_write) {
// Don't perform this write until we've verified that the object is a
// valid commit name.
$this->writeScratchFile('default-relative-commit', $default_relative);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as you just specified.",
$default_relative));
}
list($merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$default_relative);
return trim($merge_base);
}
public function getHeadCommit() {
if ($this->resolvedHeadCommit === null) {
$this->resolvedHeadCommit = $this->resolveCommit(
coalesce($this->symbolicHeadCommit, 'HEAD'));
}
return $this->resolvedHeadCommit;
}
public function setHeadCommit($symbolic_commit) {
$this->symbolicHeadCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
/**
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
* @param string_symbol commit.
* @return string the commit SHA.
*/
private function resolveCommit($symbolic_commit) {
list($err, $commit_hash) = $this->execManualLocal(
'rev-parse %s',
$symbolic_commit);
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
return trim($commit_hash);
}
private function getDiffFullOptions($detect_moves_and_renames = true) {
$options = array(
self::getDiffBaseOptions(),
'--no-color',
'--src-prefix=a/',
'--dst-prefix=b/',
'-U'.$this->getDiffLinesOfContext(),
);
if ($detect_moves_and_renames) {
$options[] = '-M';
$options[] = '-C';
}
return implode(' ', $options);
}
private function getDiffBaseOptions() {
$options = array(
// Disable external diff drivers, like graphical differs, since Arcanist
// needs to capture the diff text.
'--no-ext-diff',
// Disable textconv so we treat binary files as binary, even if they have
// an alternative textual representation. TODO: Ideally, Differential
// would ship up the binaries for 'arc patch' but display the textconv
// output in the visual diff.
'--no-textconv',
// Provide a standard view of submodule changes; the 'log' and 'diff'
// values do not parse by the diff parser.
'--submodule=short',
);
return implode(' ', $options);
}
/**
* @param the base revision
* @param head revision. If this is null, the generated diff will include the
* working copy
*/
public function getFullGitDiff($base, $head = null) {
$options = $this->getDiffFullOptions();
$config_options = array();
// See T13432. Disable the rare "diff.suppressBlankEmpty" configuration
// option, which discards the " " (space) change type prefix on unchanged
// blank lines. At time of writing the parser does not handle these
// properly, but generating a more-standard diff is generally desirable
// even if a future parser handles this case more gracefully.
$config_options[] = '-c';
$config_options[] = 'diff.suppressBlankEmpty=false';
if ($head !== null) {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s %s --",
$config_options,
$base,
$head);
} else {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s --",
$config_options,
$base);
}
return $stdout;
}
/**
* @param string Path to generate a diff for.
* @param bool If true, detect moves and renames. Otherwise, ignore
* moves/renames; this is useful because it prompts git to
* generate real diff text.
*/
public function getRawDiffText($path, $detect_moves_and_renames = true) {
$options = $this->getDiffFullOptions($detect_moves_and_renames);
list($stdout) = $this->execxLocal(
"diff {$options} %s -- %s",
$this->getBaseCommit(),
$path);
return $stdout;
}
private function getBranchNameFromRef($ref) {
$count = 0;
$branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count);
if ($count !== 1) {
return null;
}
if (!strlen($branch)) {
return null;
}
return $branch;
}
public function getBranchName() {
list($err, $stdout, $stderr) = $this->execManualLocal(
'symbolic-ref --quiet HEAD');
if ($err === 0) {
// We expect the branch name to come qualified with a refs/heads/ prefix.
// Verify this, and strip it.
$ref = rtrim($stdout);
$branch = $this->getBranchNameFromRef($ref);
if ($branch === null) {
throw new Exception(
pht('Failed to parse %s output!', 'git symbolic-ref'));
}
return $branch;
} else if ($err === 1) {
// Exit status 1 with --quiet indicates that HEAD is detached.
return null;
} else {
throw new Exception(
pht('Command %s failed: %s', 'git symbolic-ref', $stderr));
}
}
public function getRemoteURI() {
// Determine which remote to examine; default to 'origin'
$remote = 'origin';
$branch = $this->getBranchName();
if ($branch) {
$path = $this->getPathToUpstream($branch);
if ($path->isConnectedToRemote()) {
$remote = $path->getRemoteRemoteName();
}
}
- // "git ls-remote --get-url" is the appropriate plumbing to get the remote
- // URI. "git config remote.origin.url", on the other hand, may not be as
- // accurate (for example, it does not take into account possible URL
- // rewriting rules set by the user through "url.<base>.insteadOf"). However,
- // the --get-url flag requires git 1.7.5.
- $version = $this->getGitVersion();
- if (version_compare($version, '1.7.5', '>=')) {
- list($stdout) = $this->execxLocal('ls-remote --get-url %s', $remote);
- } else {
- list($stdout) = $this->execxLocal('config %s', "remote.{$remote}.url");
- }
-
- $uri = rtrim($stdout);
- // ls-remote echos the remote name (ie 'origin') if no remote URI is found
- // TODO: In 2.7.0 (circa 2016) git introduced `git remote get-url`
- // with saner error handling.
- if (!$uri || $uri === $remote) {
- return null;
- }
-
- return $uri;
+ return $this->getGitRemoteFetchURI($remote);
}
public function getSourceControlPath() {
// TODO: Try to get something useful here.
return null;
}
public function getGitCommitLog() {
$relative = $this->getBaseCommit();
if ($this->repositoryHasNoCommits) {
// No commits yet.
return '';
} else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
// First commit.
list($stdout) = $this->execxLocal(
'log --format=medium HEAD');
} else {
// 2..N commits.
list($stdout) = $this->execxLocal(
'log --first-parent --format=medium %s..%s',
$this->getBaseCommit(),
$this->getHeadCommit());
}
return $stdout;
}
public function getGitHistoryLog() {
list($stdout) = $this->execxLocal(
'log --format=medium -n%d %s',
self::SEARCH_LENGTH_FOR_PARENT_REVISIONS,
$this->getBaseCommit());
return $stdout;
}
public function getSourceControlBaseRevision() {
list($stdout) = $this->execxLocal(
'rev-parse %s',
$this->getBaseCommit());
return rtrim($stdout, "\n");
}
public function getCanonicalRevisionName($string) {
$match = null;
if (preg_match('/@([0-9]+)$/', $string, $match)) {
$stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
} else {
list($stdout) = $this->execxLocal(
phutil_is_windows()
? 'show -s --format=%C %s --'
: 'show -s --format=%s %s --',
'%H',
$string);
}
return rtrim($stdout);
}
private function executeSVNFindRev($input, $vcs) {
$match = array();
list($stdout) = $this->execxLocal(
'svn find-rev %s',
$input);
if (!$stdout) {
throw new ArcanistUsageException(
pht(
'Cannot find the %s equivalent of %s.',
$vcs,
$input));
}
// When git performs a partial-rebuild during svn
// look-up, we need to parse the final line
$lines = explode("\n", $stdout);
$stdout = $lines[count($lines) - 2];
return rtrim($stdout);
}
// Convert svn revision number to git hash
public function getHashFromFromSVNRevisionNumber($revision_id) {
return $this->executeSVNFindRev('r'.$revision_id, 'Git');
}
// Convert a git hash to svn revision number
public function getSVNRevisionNumberFromHash($hash) {
return $this->executeSVNFindRev($hash, 'SVN');
}
private function buildUncommittedStatusViaStatus() {
$status = $this->buildLocalFuture(
array(
'status --porcelain=2 -z',
));
list($stdout) = $status->resolvex();
$result = new PhutilArrayWithDefaultValue();
$parts = explode("\0", $stdout);
while (count($parts) > 1) {
$entry = array_shift($parts);
$entry_parts = explode(' ', $entry, 2);
if ($entry_parts[0] == '1') {
$entry_parts = explode(' ', $entry, 9);
$path = $entry_parts[8];
} else if ($entry_parts[0] == '2') {
$entry_parts = explode(' ', $entry, 10);
$path = $entry_parts[9];
} else if ($entry_parts[0] == 'u') {
$entry_parts = explode(' ', $entry, 11);
$path = $entry_parts[10];
} else if ($entry_parts[0] == '?') {
$entry_parts = explode(' ', $entry, 2);
$result[$entry_parts[1]] = self::FLAG_UNTRACKED;
continue;
}
$result[$path] |= self::FLAG_UNCOMMITTED;
$index_state = substr($entry_parts[1], 0, 1);
$working_state = substr($entry_parts[1], 1, 1);
if ($index_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($index_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($index_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
if ($working_state != '.') {
$result[$path] |= self::FLAG_UNSTAGED;
if ($index_state == '.') {
if ($working_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($working_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($working_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
}
}
$submodule_tracked = substr($entry_parts[2], 2, 1);
$submodule_untracked = substr($entry_parts[2], 3, 1);
if ($submodule_tracked == 'M' || $submodule_untracked == 'U') {
$result[$path] |= self::FLAG_EXTERNALS;
}
if ($entry_parts[0] == '2') {
$result[array_shift($parts)] = $result[$path] | self::FLAG_DELETED;
$result[$path] |= self::FLAG_ADDED;
}
}
return $result->toArray();
}
protected function buildUncommittedStatus() {
if (version_compare($this->getGitVersion(), '2.11.0', '>=')) {
return $this->buildUncommittedStatusViaStatus();
}
$diff_options = $this->getDiffBaseOptions();
if ($this->repositoryHasNoCommits) {
$diff_base = self::GIT_MAGIC_ROOT_COMMIT;
} else {
$diff_base = 'HEAD';
}
// Find uncommitted changes.
$uncommitted_future = $this->buildLocalFuture(
array(
'diff %C --raw %s --',
$diff_options,
$diff_base,
));
$untracked_future = $this->buildLocalFuture(
array(
'ls-files --others --exclude-standard',
));
// Unstaged changes
$unstaged_future = $this->buildLocalFuture(
array(
'diff-files --name-only',
));
$futures = array(
$uncommitted_future,
$untracked_future,
// NOTE: `git diff-files` races with each of these other commands
// internally, and resolves with inconsistent results if executed
// in parallel. To work around this, DO NOT run it at the same time.
// After the other commands exit, we can start the `diff-files` command.
);
id(new FutureIterator($futures))->resolveAll();
// We're clear to start the `git diff-files` now.
$unstaged_future->start();
$result = new PhutilArrayWithDefaultValue();
list($stdout) = $uncommitted_future->resolvex();
$uncommitted_files = $this->parseGitRawDiff($stdout);
foreach ($uncommitted_files as $path => $mask) {
$result[$path] |= ($mask | self::FLAG_UNCOMMITTED);
}
list($stdout) = $untracked_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNTRACKED;
}
}
list($stdout, $stderr) = $unstaged_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNSTAGED;
}
}
return $result->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout, $stderr) = $this->execxLocal(
'diff %C --raw %s HEAD --',
$this->getDiffBaseOptions(),
$this->getBaseCommit());
return $this->parseGitRawDiff($stdout);
}
public function getGitConfig($key, $default = null) {
list($err, $stdout) = $this->execManualLocal('config %s', $key);
if ($err) {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
}
public function addToCommit(array $paths) {
$this->execxLocal(
'add -A -- %Ls',
$paths);
$this->reloadWorkingCopy();
return $this;
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
// so we do not provide it and thus require a message.
$this->execxLocal(
'commit -F %s',
$tmp_file);
$this->reloadWorkingCopy();
return $this;
}
public function amendCommit($message = null) {
if ($message === null) {
$this->execxLocal('commit --amend --allow-empty -C HEAD');
} else {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend --allow-empty -F %s',
$tmp_file);
}
$this->reloadWorkingCopy();
return $this;
}
private function parseGitRawDiff($status, $full = false) {
static $flags = array(
'A' => self::FLAG_ADDED,
'M' => self::FLAG_MODIFIED,
'D' => self::FLAG_DELETED,
);
$status = trim($status);
$lines = array();
foreach (explode("\n", $status) as $line) {
if ($line) {
$lines[] = preg_split("/[ \t]/", $line, 6);
}
}
$files = array();
foreach ($lines as $line) {
$mask = 0;
// "git diff --raw" lines begin with a ":" character.
$old_mode = ltrim($line[0], ':');
$new_mode = $line[1];
// The hashes may be padded with "." characters for alignment. Discard
// them.
$old_hash = rtrim($line[2], '.');
$new_hash = rtrim($line[3], '.');
$flag = $line[4];
$file = $line[5];
$new_value = intval($new_mode, 8);
$is_submodule = (($new_value & 0160000) === 0160000);
if (($is_submodule) &&
($flag == 'M') &&
($old_hash === $new_hash) &&
($old_mode === $new_mode)) {
// See T9455. We see this submodule as "modified", but the old and new
// hashes are the same and the old and new modes are the same, so we
// don't directly see a modification.
// We can end up here if we have a submodule which has uncommitted
// changes inside of it (for example, the user has added untracked
// files or made uncommitted changes to files in the submodule). In
// this case, we set a different flag because we can't meaningfully
// give users the same prompt.
// Note that if the submodule has real changes from the parent
// perspective (the base commit has changed) and also has uncommitted
// changes, we'll only see the real changes and miss the uncommitted
// changes. At the time of writing, there is no reasonable porcelain
// for finding those changes, and the impact of this error seems small.
$mask |= self::FLAG_EXTERNALS;
} else if (isset($flags[$flag])) {
$mask |= $flags[$flag];
} else if ($flag[0] == 'R') {
$both = explode("\t", $file);
if ($full) {
$files[$both[0]] = array(
'mask' => $mask | self::FLAG_DELETED,
'ref' => str_repeat('0', 40),
);
} else {
$files[$both[0]] = $mask | self::FLAG_DELETED;
}
$file = $both[1];
$mask |= self::FLAG_ADDED;
} else if ($flag[0] == 'C') {
$both = explode("\t", $file);
$file = $both[1];
$mask |= self::FLAG_ADDED;
}
if ($full) {
$files[$file] = array(
'mask' => $mask,
'ref' => $new_hash,
);
} else {
$files[$file] = $mask;
}
}
return $files;
}
public function getAllFiles() {
$future = $this->buildLocalFuture(array('ls-files -z'));
return id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'diff --raw %s',
$since_commit);
return $this->parseGitRawDiff($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'blame --porcelain -w -M %s -- %s',
$this->getBaseCommit(),
$path);
// the --porcelain format prints at least one header line per source line,
// then the source line prefixed by a tab character
$blame_info = preg_split('/^\t.*\n/m', rtrim($stdout));
// commit info is not repeated in these headers, so cache it
$revision_data = array();
$blame = array();
foreach ($blame_info as $line_info) {
$revision = substr($line_info, 0, 40);
$data = idx($revision_data, $revision, array());
if (empty($data)) {
$matches = array();
if (!preg_match('/^author (.*)$/m', $line_info, $matches)) {
throw new Exception(
pht(
'Unexpected output from %s: no author for commit %s',
'git blame',
$revision));
}
$data['author'] = $matches[1];
$data['from_first_commit'] = preg_match('/^boundary$/m', $line_info);
$revision_data[$revision] = $data;
}
// Ignore lines predating the git repository (on a boundary commit)
// rather than blaming them on the oldest diff's unfortunate author
if (!$data['from_first_commit']) {
$blame[] = array($data['author'], $revision);
}
}
return $blame;
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision($path, 'HEAD');
}
private function parseGitTree($stdout) {
$result = array();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return $result;
}
$lines = explode("\n", $stdout);
foreach ($lines as $line) {
$matches = array();
$ok = preg_match(
'/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/',
$line,
$matches);
if (!$ok) {
throw new Exception(pht('Failed to parse %s output!', 'git ls-tree'));
}
$result[$matches[4]] = array(
'mode' => $matches[1],
'type' => $matches[2],
'ref' => $matches[3],
);
}
return $result;
}
private function getFileDataAtRevision($path, $revision) {
// NOTE: We don't want to just "git show {$revision}:{$path}" since if the
// path was a directory at the given revision we'll get a list of its files
// and treat it as though it as a file containing a list of other files,
// which is silly.
if (!strlen($path)) {
// No filename, so there's no content (Probably new/deleted file).
return null;
}
list($stdout) = $this->execxLocal(
'ls-tree %s -- %s',
$revision,
$path);
$info = $this->parseGitTree($stdout);
if (empty($info[$path])) {
// No such path, or the path is a directory and we executed 'ls-tree dir/'
// and got a list of its contents back.
return null;
}
if ($info[$path]['type'] != 'blob') {
// Path is or was a directory, not a file.
return null;
}
list($stdout) = $this->execxLocal(
'cat-file blob %s',
$info[$path]['ref']);
return $stdout;
}
/**
* Returns names of all the branches in the current repository.
*
* @return list<dict<string, string>> Dictionary of branch information.
*/
public function getAllBranches() {
$field_list = array(
'%(refname)',
'%(objectname)',
'%(committerdate:raw)',
'%(tree)',
'%(subject)',
'%(subject)%0a%0a%(body)',
'%02',
);
list($stdout) = $this->execxLocal(
'for-each-ref --format=%s -- refs/heads',
implode('%01', $field_list));
$current = $this->getBranchName();
$result = array();
$lines = explode("\2", $stdout);
foreach ($lines as $line) {
$line = trim($line);
if (!strlen($line)) {
continue;
}
$fields = explode("\1", $line, 6);
list($ref, $hash, $epoch, $tree, $desc, $text) = $fields;
$branch = $this->getBranchNameFromRef($ref);
if ($branch !== null) {
$result[] = array(
'current' => ($branch === $current),
'name' => $branch,
'hash' => $hash,
'tree' => $tree,
'epoch' => (int)$epoch,
'desc' => $desc,
'text' => $text,
);
}
}
return $result;
}
public function getWorkingCopyRevision() {
list($stdout) = $this->execxLocal('rev-parse HEAD');
return rtrim($stdout, "\n");
}
public function getUnderlyingWorkingCopyRevision() {
list($err, $stdout) = $this->execManualLocal('svn find-rev HEAD');
if (!$err && $stdout) {
return rtrim($stdout, "\n");
}
return $this->getWorkingCopyRevision();
}
public function isHistoryDefaultImmutable() {
return false;
}
public function supportsAmend() {
return true;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function hasLocalCommit($commit) {
try {
if (!$this->getCanonicalRevisionName($commit)) {
return false;
}
} catch (CommandException $exception) {
return false;
}
return true;
}
public function getAllLocalChanges() {
$diff = $this->getFullGitDiff($this->getBaseCommit());
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function supportsLocalBranchMerge() {
return true;
}
public function performLocalBranchMerge($branch, $message) {
if (!$branch) {
throw new ArcanistUsageException(
pht('Under git, you must specify the branch you want to merge.'));
}
$err = phutil_passthru(
'(cd %s && git merge --no-ff -m %s %s)',
$this->getPath(),
$message,
$branch);
if ($err) {
throw new ArcanistUsageException(pht('Merge failed!'));
}
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s', or '%s', or by printing and faxing it).",
'git push',
'git svn dcommit');
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log -n1 --format=%C %s --',
'%s%n%n%b',
$commit);
return $message;
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getGitCommitLog();
if (!strlen($messages)) {
return array();
}
$parser = new ArcanistDiffParser();
$messages = $parser->parseDiff($messages);
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$message->getMetadata('message'));
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $message->getCommitHash();
}
}
if ($revision_ids) {
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'ids' => $revision_ids,
));
foreach ($results as $key => $result) {
$hash = substr($reason_map[$result['id']], 0, 16);
$results[$key]['why'] = pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
return $results;
}
// If we didn't succeed, try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('gtcm', $commit['commit']);
$hashes[] = array('gttr', $commit['tree']);
}
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $result) {
$results[$key]['why'] = pht(
'A git commit or tree hash in the commit range is already attached '.
'to the Differential revision.');
}
return $results;
}
public function updateWorkingCopy() {
$this->execxLocal('pull');
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
return pht('(The Empty Tree)');
}
list($summary) = $this->execxLocal(
'log -n 1 --format=%C %s',
'%s',
$commit);
return trim($summary);
}
public function backoutCommit($commit_hash) {
$this->execxLocal('revert %s -n --no-edit', $commit_hash);
$this->reloadWorkingCopy();
if (!$this->getUncommittedStatus()) {
throw new ArcanistUsageException(
pht('%s has already been reverted.', $commit_hash));
}
}
public function getBackoutMessage($commit_hash) {
return pht('This reverts commit %s.', $commit_hash);
}
public function isGitSubversionRepo() {
return Filesystem::pathExists($this->getPath('.git/svn'));
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
switch ($type) {
case 'git':
$matches = null;
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified by ".
"'%s' in your %s 'base' configuration.",
$matches[1],
$rule,
$source));
return trim($merge_base);
}
} else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if ($err) {
return null;
}
$merge_base = trim($merge_base);
list($commits) = $this->execxLocal(
'log --format=%C %s..HEAD --',
'%H',
$merge_base);
$commits = array_filter(explode("\n", $commits));
if (!$commits) {
return null;
}
$commits[] = $merge_base;
$head_branch_count = null;
$all_branch_names = ipull($this->getAllBranches(), 'name');
foreach ($commits as $commit) {
// Ideally, we would use something like "for-each-ref --contains"
// to get a filtered list of branches ready for script consumption.
// Instead, try to get predictable output from "branch --contains".
$flags = array();
$flags[] = '--no-color';
// NOTE: The "--no-column" flag was introduced in Git 1.7.11, so
// don't pass it if we're running an older version. See T9953.
$version = $this->getGitVersion();
if (version_compare($version, '1.7.11', '>=')) {
$flags[] = '--no-column';
}
list($branches) = $this->execxLocal(
'branch %Ls --contains %s',
$flags,
$commit);
$branches = array_filter(explode("\n", $branches));
// Filter the list, removing the "current" marker (*) and ignoring
// anything other than known branch names (mainly, any possible
// "detached HEAD" or "no branch" line).
foreach ($branches as $key => $branch) {
$branch = trim($branch, ' *');
if (in_array($branch, $all_branch_names)) {
$branches[$key] = $branch;
} else {
unset($branches[$key]);
}
}
if ($head_branch_count === null) {
// If this is the first commit, it's HEAD. Count how many
// branches it is on; we want to include commits on the same
// number of branches. This covers a case where this branch
// has sub-branches and we're running "arc diff" here again
// for whatever reason.
$head_branch_count = count($branches);
} else if (count($branches) > $head_branch_count) {
$branches = implode(', ', $branches);
$this->setBaseCommitExplanation(
pht(
"it is the first commit between '%s' (the merge-base of ".
"'%s' and HEAD) which is also contained by another branch ".
"(%s).",
$merge_base,
$matches[1],
$branches));
return $commit;
}
}
} else {
list($err) = $this->execManualLocal(
'cat-file -t %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return self::GIT_MAGIC_ROOT_COMMIT;
case 'amended':
$text = $this->getCommitMessage('HEAD');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"HEAD has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
break;
case 'upstream':
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$upstream = rtrim($upstream);
list($upstream_merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$upstream);
$upstream_merge_base = rtrim($upstream_merge_base);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the upstream of the current branch ".
"and HEAD, and matched the rule '%s' in your %s ".
"'base' configuration.",
$rule,
$source));
return $upstream_merge_base;
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
default:
return null;
}
return null;
}
public function canStashChanges() {
return true;
}
public function stashChanges() {
$this->execxLocal('stash');
$this->reloadWorkingCopy();
}
public function unstashChanges() {
$this->execxLocal('stash pop');
}
protected function didReloadCommitRange() {
// After an amend, the symbolic head may resolve to a different commit.
$this->resolvedHeadCommit = null;
}
/**
* Follow the chain of tracking branches upstream until we reach a remote
* or cycle locally.
*
* @param string Ref to start from.
* @return ArcanistGitUpstreamPath Path to an upstream.
*/
public function getPathToUpstream($start) {
$cursor = $start;
$path = new ArcanistGitUpstreamPath();
while (true) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --symbolic-full-name %s@{upstream}',
$cursor);
if ($err) {
// We ended up somewhere with no tracking branch, so we're done.
break;
}
$upstream = trim($upstream);
if (preg_match('(^refs/heads/)', $upstream)) {
$upstream = preg_replace('(^refs/heads/)', '', $upstream);
$is_cycle = $path->getUpstream($upstream);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_LOCAL,
'name' => $upstream,
'cycle' => $is_cycle,
));
if ($is_cycle) {
// We ran into a local cycle, so we're done.
break;
}
// We found another local branch, so follow that one upriver.
$cursor = $upstream;
continue;
}
if (preg_match('(^refs/remotes/)', $upstream)) {
$upstream = preg_replace('(^refs/remotes/)', '', $upstream);
list($remote, $branch) = explode('/', $upstream, 2);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_REMOTE,
'name' => $branch,
'remote' => $remote,
));
// We found a remote, so we're done.
break;
}
throw new Exception(
pht(
'Got unrecognized upstream format ("%s") from Git, expected '.
'"refs/heads/..." or "refs/remotes/...".',
$upstream));
}
return $path;
}
public function isPerforceRemote($remote_name) {
// See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs
// under "refs/remotes/", but does not define a real remote named "p4".
// We treat this remote as though it were a real remote during "arc land",
// but it does not respond to commands like "git remote show p4", so we
// need to handle it specially.
if ($remote_name !== 'p4') {
return false;
}
$remote_dir = $this->getMetadataPath().'/refs/remotes/p4';
if (!Filesystem::pathExists($remote_dir)) {
return false;
}
return true;
}
public function isPushableRemote($remote_name) {
+ $uri = $this->getGitRemotePushURI($remote_name);
+ return ($uri !== null);
+ }
+
+ private function getGitRemoteFetchURI($remote_name) {
+ return $this->getGitRemoteURI($remote_name, $for_push = false);
+ }
+
+ private function getGitRemotePushURI($remote_name) {
+ return $this->getGitRemoteURI($remote_name, $for_push = true);
+ }
+
+ private function getGitRemoteURI($remote_name, $for_push) {
+ $remote_uri = $this->loadGitRemoteURI($remote_name, $for_push);
+
+ if ($remote_uri !== null) {
+ $remote_uri = rtrim($remote_uri);
+ if (!strlen($remote_uri)) {
+ $remote_uri = null;
+ }
+ }
+
+ return $remote_uri;
+ }
+
+ private function loadGitRemoteURI($remote_name, $for_push) {
+ // Try to identify the best URI for a given remote. This is complicated
+ // because remotes may have different "push" and "fetch" URIs, may
+ // rewrite URIs with "insteadOf" configuration, and different versions
+ // of Git support different URI resolution commands.
+
+ // Remotes may also have more than one URI of a given type, but we ignore
+ // those cases here.
+
+ // Start with "git remote get-url [--push]". This is the simplest and
+ // most accurate command, but was introduced most recently in Git's
+ // history.
+
+ $argv = array();
+ if ($for_push) {
+ $argv[] = '--push';
+ }
+
+ list($err, $stdout) = $this->execManualLocal(
+ 'remote get-url %Ls -- %s',
+ $argv,
+ $remote_name);
+ if (!$err) {
+ return $stdout;
+ }
+
+ // See T13481. If "git remote get-url [--push]" failed, it might be because
+ // the remote does not exist, but it might also be because the version of
+ // Git is too old to support "git remote get-url", which was introduced
+ // in Git 2.7 (circa late 2015).
+
+ $git_version = $this->getGitVersion();
+ if (version_compare($git_version, '2.7', '>=')) {
+ // This version of Git should support "git remote get-url --push", but
+ // the command failed, so conclude this is not a valid remote and thus
+ // there is no remote URI.
+ return null;
+ }
+
+ // If we arrive here, we're in a version of Git which is too old to
+ // support "git remote get-url [--push]". We're going to fall back to
+ // older and less accurate mechanisms for figuring out the remote URI.
+
+ // The first mechanism we try is "git ls-remote --get-url". This exists
+ // in Git 1.7.5 or newer. It only gives us the fetch URI, so this result
+ // will be incorrect if a remote has different fetch and push URIs.
+ // However, this is very rare, and this result is almost always correct.
+
list($err, $stdout) = $this->execManualLocal(
- 'remote get-url --push -- %s',
+ 'ls-remote --get-url -- %s',
$remote_name);
- return !$err;
+ if (!$err) {
+ // The "git ls-remote --get-url" command just echoes the remote name
+ // (like "origin") if no remote URI is found. Treat this like a failure.
+ $output_is_input = (rtrim($stdout) === $remote_name);
+ if (!$output_is_input) {
+ return $stdout;
+ }
+ }
+
+ if (version_compare($git_version, '1.7.5', '>=')) {
+ // This version of Git should support "git ls-remote --get-url", but
+ // the command failed (or echoed the input), so conclude the remote
+ // really does not exist.
+ return null;
+ }
+
+ // Fall back to the very old "git config -- remote.origin.url" command.
+ // This does not give us push URLs and does not resolve "insteadOf"
+ // aliases, but still works in the simplest (and most common) cases.
+
+ list($err, $stdout) = $this->execManualLocal(
+ 'config -- %s',
+ sprintf('remote.%s.url', $remote_name));
+ if (!$err) {
+ return $stdout;
+ }
+
+ return null;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 15:33 (3 w, 23 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1126035
Default Alt Text
(52 KB)

Event Timeline