diff --git a/scripts/repository/commit_hook.php b/scripts/repository/commit_hook.php index 86e927f41a..25afb714e5 100755 --- a/scripts/repository/commit_hook.php +++ b/scripts/repository/commit_hook.php @@ -1,79 +1,91 @@ #!/usr/bin/env php ')); } $engine = new DiffusionCommitHookEngine(); $repository = id(new PhabricatorRepositoryQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withCallsigns(array($argv[1])) ->executeOne(); if (!$repository) { throw new Exception(pht('No such repository "%s"!', $callsign)); } if (!$repository->isHosted()) { // This should be redundant, but double check just in case. throw new Exception(pht('Repository "%s" is not hosted!', $callsign)); } $engine->setRepository($repository); // Figure out which user is writing the commit. -if ($repository->isGit()) { +if ($repository->isGit() || $repository->isHg()) { $username = getenv('PHABRICATOR_USER'); if (!strlen($username)) { throw new Exception(pht('usage: PHABRICATOR_USER should be defined!')); } + + // TODO: If this is a Mercurial repository, the hook we're responding to + // is available in $argv[2]. It's unclear if we actually need this, or if + // we can block all actions we care about with just pretxnchangegroup. + } else if ($repository->isSVN()) { // NOTE: In Subversion, the entire environment gets wiped so we can't read // PHABRICATOR_USER. Instead, we've set "--tunnel-user" to specify the // correct user; read this user out of the commit log. if ($argc < 4) { throw new Exception(pht('usage: commit-hook ')); } $svn_repo = $argv[2]; $svn_txn = $argv[3]; list($username) = execx('svnlook author -t %s %s', $svn_txn, $svn_repo); $username = rtrim($username, "\n"); $engine->setSubversionTransactionInfo($svn_txn, $svn_repo); } else { - throw new Exceptiont(pht('Unknown repository type.')); + throw new Exception(pht('Unknown repository type.')); } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { throw new Exception(pht('No such user "%s"!', $username)); } $engine->setViewer($user); // Read stdin for the hook engine. -$stdin = @file_get_contents('php://stdin'); -if ($stdin === false) { - throw new Exception(pht('Failed to read stdin!')); +if ($repository->isHg()) { + // Mercurial leaves stdin open, so we can't just read it until EOF. + $stdin = ''; +} else { + // Git and Subversion write data into stdin and then close it. Read the + // data. + $stdin = @file_get_contents('php://stdin'); + if ($stdin === false) { + throw new Exception(pht('Failed to read stdin!')); + } } $engine->setStdin($stdin); $err = $engine->execute(); exit($err); diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index de7c2e1eb2..6d72184216 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -1,557 +1,561 @@ getHTTPHeader('Content-Type'); $user_agent = idx($_SERVER, 'HTTP_USER_AGENT'); $vcs = null; if ($request->getExists('service')) { $service = $request->getStr('service'); // We get this initially for `info/refs`. // Git also gives us a User-Agent like "git/1.8.2.3". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (strncmp($user_agent, "git/", 4) === 0) { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-upload-pack-request') { // We get this for `git-upload-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-receive-pack-request') { // We get this for `git-receive-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($request->getExists('cmd')) { // Mercurial also sends an Accept header like // "application/mercurial-0.1", and a User-Agent like // "mercurial/proto-1.0". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; } else { // Subversion also sends an initial OPTIONS request (vs GET/POST), and // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) // serf/1.3.2". $dav = $request->getHTTPHeader('DAV'); $dav = new PhutilURI($dav); if ($dav->getDomain() === 'subversion.tigris.org') { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; } } return $vcs; } private static function getCallsign(AphrontRequest $request) { $uri = $request->getRequestURI(); $regex = '@^/diffusion/(?P[A-Z]+)(/|$)@'; $matches = null; if (!preg_match($regex, (string)$uri, $matches)) { return null; } return $matches['callsign']; } public function processRequest() { $request = $this->getRequest(); $callsign = self::getCallsign($request); // If authentication credentials have been provided, try to find a user // that actually matches those credentials. if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); $viewer = $this->authenticateHTTPRepositoryUser($username, $password); if (!$viewer) { return new PhabricatorVCSResponse( 403, pht('Invalid credentials.')); } } else { // User hasn't provided credentials, which means we count them as // being "not logged in". $viewer = new PhabricatorUser(); } $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); if (!$allow_public) { if (!$viewer->isLoggedIn()) { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access repositories.')); } else { return new PhabricatorVCSResponse( 403, pht('Public and authenticated HTTP access are both forbidden.')); } } } try { $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { return new PhabricatorVCSResponse( 404, pht('No such repository exists.')); } } catch (PhabricatorPolicyException $ex) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to access this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'This repository requires authentication, which is forbidden '. 'over HTTP.')); } } } if (!$repository->isTracked()) { return new PhabricatorVCSResponse( 403, pht('This repository is inactive.')); } $is_push = !$this->isReadOnlyRequest($repository); switch ($repository->getServeOverHTTP()) { case PhabricatorRepository::SERVE_READONLY: if ($is_push) { return new PhabricatorVCSResponse( 403, pht('This repository is read-only over HTTP.')); } break; case PhabricatorRepository::SERVE_READWRITE: if ($is_push) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionCapabilityPush::CAPABILITY); if (!$can_push) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to push to this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to push to this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'Pushing to this repository requires authentication, '. 'which is forbidden over HTTP.')); } } } } break; case PhabricatorRepository::SERVE_OFF: default: return new PhabricatorVCSResponse( 403, pht('This repository is not available over HTTP.')); } $vcs_type = $repository->getVersionControlSystem(); $req_type = $this->isVCSRequest($request); if ($vcs_type != $req_type) { switch ($req_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = new PhabricatorVCSResponse( 500, pht('This is not a Git repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = new PhabricatorVCSResponse( 500, pht('This is not a Mercurial repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht('This is not a Subversion repository.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown request type.')); break; } } else { switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = $this->serveGitRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveMercurialRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'Phabricator does not support HTTP access to Subversion '. 'repositories.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown version control system.')); break; } } $code = $result->getHTTPResponseCode(); if ($is_push && ($code == 200)) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); unset($unguarded); } return $result; } private function isReadOnlyRequest( PhabricatorRepository $repository) { $request = $this->getRequest(); $method = $_SERVER['REQUEST_METHOD']; // TODO: This implementation is safe by default, but very incomplete. switch ($repository->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $service = $request->getStr('service'); $path = $this->getRequestDirectoryPath(); // NOTE: Service names are the reverse of what you might expect, as they // are from the point of view of the server. The main read service is // "git-upload-pack", and the main write service is "git-receive-pack". if ($method == 'GET' && $path == '/info/refs' && $service == 'git-upload-pack') { return true; } if ($path == '/git-upload-pack') { return true; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $cmd = $request->getStr('cmd'); if ($cmd == 'batch') { $cmds = idx($this->getMercurialArguments(), 'cmds'); return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); } return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; } return false; } /** * @phutil-external-symbol class PhabricatorStartup */ private function serveGitRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $request_path = $this->getRequestDirectoryPath(); $repository_root = $repository->getLocalPath(); // Rebuild the query string to strip `__magic__` parameters and prevent // issues where we might interpret inputs like "service=read&service=write" // differently than the server does and pass it an unsafe command. // NOTE: This does not use getPassthroughRequestParameters() because // that code is HTTP-method agnostic and will encode POST data. $query_data = $_GET; foreach ($query_data as $key => $value) { if (!strncmp($key, '__', 2)) { unset($query_data[$key]); } } $query_string = http_build_query($query_data, '', '&'); // We're about to wipe out PATH with the rest of the environment, so // resolve the binary first. $bin = Filesystem::resolveBinary('git-http-backend'); if (!$bin) { throw new Exception("Unable to find `git-http-backend` in PATH!"); } $env = array( 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'QUERY_STRING' => $query_string, 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 'HTTP_CONTENT_ENCODING' => $request->getHTTPHeader('Content-Encoding'), 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 'GIT_PROJECT_ROOT' => $repository_root, 'GIT_HTTP_EXPORT_ALL' => '1', 'PATH_INFO' => $request_path, 'REMOTE_USER' => $viewer->getUsername(), 'PHABRICATOR_USER' => $viewer->getUsername(), // TODO: Set these correctly. // GIT_COMMITTER_NAME // GIT_COMMITTER_EMAIL ); $input = PhabricatorStartup::getRawInput(); $command = csprintf('%s', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->write($input) ->resolve(); if ($err) { if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { // Ignore the error if the response passes this special check for // validity. $err = 0; } } if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } return id(new DiffusionGitResponse())->setGitData($stdout); } private function getRequestDirectoryPath() { $request = $this->getRequest(); $request_path = $request->getRequestURI()->getPath(); return preg_replace('@^/diffusion/[A-Z]+@', '', $request_path); } private function authenticateHTTPRepositoryUser( $username, PhutilOpaqueEnvelope $password) { if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { // No HTTP auth permitted. return null; } if (!strlen($username)) { // No username. return null; } if (!strlen($password->openEnvelope())) { // No password. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { // Username doesn't match anything. return null; } if (!$user->isUserActivated()) { // User is not activated. return null; } $password_entry = id(new PhabricatorRepositoryVCSPassword()) ->loadOneWhere('userPHID = %s', $user->getPHID()); if (!$password_entry) { // User doesn't have a password set. return null; } if (!$password_entry->comparePassword($password, $user)) { // Password doesn't match. return null; } return $user; } - private function serveMercurialRequest(PhabricatorRepository $repository) { + private function serveMercurialRequest( + PhabricatorRepository $repository, + PhabricatorUser $viewer) { $request = $this->getRequest(); $bin = Filesystem::resolveBinary('hg'); if (!$bin) { throw new Exception("Unable to find `hg` in PATH!"); } - $env = array(); + $env = array( + 'PHABRICATOR_USER' => $viewer->getUsername(), + ); $input = PhabricatorStartup::getRawInput(); $cmd = $request->getStr('cmd'); $args = $this->getMercurialArguments(); $args = $this->formatMercurialArguments($cmd, $args); if (strlen($input)) { $input = strlen($input)."\n".$input."0\n"; } $command = csprintf('%s serve --stdio', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->setCWD($repository->getLocalPath()) ->write("{$cmd}\n{$args}{$input}") ->resolve(); if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } if ($cmd == 'getbundle' || $cmd == 'changegroup' || $cmd == 'changegroupsubset') { // We're not completely sure that "changegroup" and "changegroupsubset" // actually work, they're for very old Mercurial. $body = gzcompress($stdout); } else if ($cmd == 'unbundle') { // This includes diagnostic information and anything echoed by commit // hooks. We ignore `stdout` since it just has protocol garbage, and // substitute `stderr`. $body = strlen($stderr)."\n".$stderr; } else { list($length, $body) = explode("\n", $stdout, 2); } return id(new DiffusionMercurialResponse())->setContent($body); } private function getMercurialArguments() { // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, // "Why would you do this?". $args_raw = array(); for ($ii = 1; ; $ii++) { $header = 'HTTP_X_HGARG_'.$ii; if (!array_key_exists($header, $_SERVER)) { break; } $args_raw[] = $_SERVER[$header]; } $args_raw = implode('', $args_raw); return id(new PhutilQueryStringParser()) ->parseQueryString($args_raw); } private function formatMercurialArguments($command, array $arguments) { $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); $out = array(); // Mercurial takes normal arguments like this: // // name // value $has_star = false; foreach ($spec as $arg_key) { if ($arg_key == '*') { $has_star = true; continue; } if (isset($arguments[$arg_key])) { $value = $arguments[$arg_key]; $size = strlen($value); $out[] = "{$arg_key} {$size}\n{$value}"; unset($arguments[$arg_key]); } } if ($has_star) { // Mercurial takes arguments for variable argument lists roughly like // this: // // * // argname1 // argvalue1 // argname2 // argvalue2 $count = count($arguments); $out[] = "* {$count}\n"; foreach ($arguments as $key => $value) { if (in_array($key, $spec)) { // We already added this argument above, so skip it. continue; } $size = strlen($value); $out[] = "{$key} {$size}\n{$value}"; } } return implode('', $out); } private function isValidGitShallowCloneResponse($stdout, $stderr) { // If you execute `git clone --depth N ...`, git sends a request which // `git-http-backend` responds to by emitting valid output and then exiting // with a failure code and an error message. If we ignore this error, // everything works. // This is a pretty funky fix: it would be nice to more precisely detect // that a request is a `--depth N` clone request, but we don't have any code // to decode protocol frames yet. Instead, look for reasonable evidence // in the error and output that we're looking at a `--depth` clone. // For evidence this isn't completely crazy, see: // https://github.com/schacon/grack/pull/7 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; $stderr_regexp = '(The remote end hung up unexpectedly)'; $has_pack = preg_match($stdout_regexp, $stdout); $is_hangup = preg_match($stderr_regexp, $stderr); return $has_pack && $is_hangup; } } diff --git a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php index 282ae16697..7a6855de46 100644 --- a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php +++ b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php @@ -1,95 +1,105 @@ subversionTransaction = $transaction; $this->subversionRepository = $repository; return $this; } public function setStdin($stdin) { $this->stdin = $stdin; return $this; } public function getStdin() { return $this->stdin; } public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function execute() { $type = $this->getRepository()->getVersionControlSystem(); switch ($type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $err = $this->executeGitHook(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $err = $this->executeSubversionHook(); break; + case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: + $err = $this->executeMercurialHook(); + break; default: throw new Exception(pht('Unsupported repository type "%s"!', $type)); } return $err; } private function executeGitHook() { $updates = $this->parseGitUpdates($this->getStdin()); // TODO: Do useful things. return 0; } private function executeSubversionHook() { // TODO: Do useful things here, too. return 0; } + private function executeMercurialHook() { + + // TODO: Here, too, useful things should be done. + + return 0; + } + private function parseGitUpdates($stdin) { $updates = array(); $lines = phutil_split_lines($stdin, $retain_endings = false); foreach ($lines as $line) { $parts = explode(' ', $line, 3); if (count($parts) != 3) { throw new Exception(pht('Expected "old new ref", got "%s".', $line)); } $updates[] = array( 'old' => $parts[0], 'new' => $parts[1], 'ref' => $parts[2], ); } return $updates; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php index 46eee57610..e7697e6083 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php @@ -1,104 +1,105 @@ setName('hg'); $this->setArguments( array( array( 'name' => 'repository', 'short' => 'R', 'param' => 'repo', ), array( 'name' => 'stdio', ), array( 'name' => 'command', 'wildcard' => true, ), )); } protected function executeRepositoryOperations() { $args = $this->getArgs(); $path = $args->getArg('repository'); $repository = $this->loadRepository($path); $args = $this->getArgs(); if (!$args->getArg('stdio')) { throw new Exception("Expected `hg ... --stdio`!"); } if ($args->getArg('command') !== array('serve')) { throw new Exception("Expected `hg ... serve`!"); } $command = csprintf('hg -R %s serve --stdio', $repository->getLocalPath()); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); - $future = new ExecFuture('%C', $command); + $future = id(new ExecFuture('%C', $command)) + ->setEnv($this->getEnvironment()); $io_channel = $this->getIOChannel(); $protocol_channel = new DiffusionSSHMercurialWireClientProtocolChannel( $io_channel); $err = id($this->newPassthruCommand()) ->setIOChannel($protocol_channel) ->setCommandChannelFromExecFuture($future) ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) ->execute(); // TODO: It's apparently technically possible to communicate errors to // Mercurial over SSH by writing a special "\n\n-\n" string. However, // my attempt to implement that resulted in Mercurial closing the socket and // then hanging, without showing the error. This might be an issue on our // side (we need to close our half of the socket?), or maybe the code // for this in Mercurial doesn't actually work, or maybe something else // is afoot. At some point, we should look into doing this more cleanly. // For now, when we, e.g., reject writes for policy reasons, the user will // see "abort: unexpected response: empty string" after the diagnostically // useful, e.g., "remote: This repository is read-only over SSH." message. if (!$err && $this->didSeeWrite) { $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); } return $err; } public function willWriteMessageCallback( PhabricatorSSHPassthruCommand $command, $message) { $command = $message['command']; // Check if this is a readonly command. $is_readonly = false; if ($command == 'batch') { $cmds = idx($message['arguments'], 'cmds'); if (DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds)) { $is_readonly = true; } } else if (DiffusionMercurialWireProtocol::isReadOnlyCommand($command)) { $is_readonly = true; } if (!$is_readonly) { $this->requireWriteAccess(); $this->didSeeWrite = true; } // If we're good, return the raw message data. return $message['raw']; } } diff --git a/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php b/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php index ab3c759fae..c7e8e0ae7e 100644 --- a/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php +++ b/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php @@ -1,410 +1,439 @@ getRepository(); $is_hg = false; $is_git = false; $is_svn = false; $vcs = $repository->getVersionControlSystem(); $callsign = $repository->getCallsign(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // We never pull a local copy of non-hosted Subversion repositories. if (!$repository->isHosted()) { $this->skipPull( pht( "Repository '%s' is a non-hosted Subversion repository, which ". "does not require a local working copy to be pulled.", $callsign)); return; } $is_svn = true; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $is_git = true; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $is_hg = true; break; default: $this->abortPull(pht('Unknown VCS "%s"!', $vcs)); } $callsign = $repository->getCallsign(); $local_path = $repository->getLocalPath(); if ($local_path === null) { $this->abortPull( pht( "No local path is configured for repository '%s'.", $callsign)); } try { $dirname = dirname($local_path); if (!Filesystem::pathExists($dirname)) { Filesystem::createDirectory($dirname, 0755, $recursive = true); } if (!Filesystem::pathExists($local_path)) { $this->logPull( pht( "Creating a new working copy for repository '%s'.", $callsign)); if ($is_git) { $this->executeGitCreate(); } else if ($is_hg) { $this->executeMercurialCreate(); } else { $this->executeSubversionCreate(); } } else { if ($repository->isHosted()) { if ($is_git) { $this->installGitHook(); } else if ($is_svn) { $this->installSubversionHook(); + } else if ($is_hg) { + $this->installMercurialHook(); } else { $this->logPull( pht( "Repository '%s' is hosted, so Phabricator does not pull ". "updates for it.", $callsign)); } } else { $this->logPull( pht( "Updating the working copy for repository '%s'.", $callsign)); if ($is_git) { $this->executeGitUpdate(); } else { $this->executeMercurialUpdate(); } } } } catch (Exception $ex) { $this->abortPull( pht('Pull of "%s" failed: %s', $callsign, $ex->getMessage()), $ex); } $this->donePull(); return $this; } private function skipPull($message) { $this->log('%s', $message); $this->donePull(); } private function abortPull($message, Exception $ex = null) { $code_error = PhabricatorRepositoryStatusMessage::CODE_ERROR; $this->updateRepositoryInitStatus($code_error, $message); if ($ex) { throw $ex; } else { throw new Exception($message); } } private function logPull($message) { $code_working = PhabricatorRepositoryStatusMessage::CODE_WORKING; $this->updateRepositoryInitStatus($code_working, $message); $this->log('%s', $message); } private function donePull() { $code_okay = PhabricatorRepositoryStatusMessage::CODE_OKAY; $this->updateRepositoryInitStatus($code_okay); } private function updateRepositoryInitStatus($code, $message = null) { $this->getRepository()->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_INIT, $code, array( 'message' => $message )); } private function installHook($path) { $this->log('%s', pht('Installing commit hook to "%s"...', $path)); $repository = $this->getRepository(); $callsign = $repository->getCallsign(); $root = dirname(phutil_get_library_root('phabricator')); $bin = $root.'/bin/commit-hook'; $cmd = csprintf('exec -- %s %s "$@"', $bin, $callsign); $hook = "#!/bin/sh\n{$cmd}\n"; Filesystem::writeFile($path, $hook); Filesystem::changePermissions($path, 0755); } /* -( Pulling Git Working Copies )----------------------------------------- */ /** * @task git */ private function executeGitCreate() { $repository = $this->getRepository(); $path = rtrim($repository->getLocalPath(), '/'); if ($repository->isHosted()) { $repository->execxRemoteCommand( 'init --bare -- %s', $path); } else { $repository->execxRemoteCommand( 'clone --bare -- %s %s', $repository->getRemoteURI(), $path); } } /** * @task git */ private function executeGitUpdate() { $repository = $this->getRepository(); list($err, $stdout) = $repository->execLocalCommand( 'rev-parse --show-toplevel'); $message = null; $path = $repository->getLocalPath(); if ($err) { // Try to raise a more tailored error message in the more common case // of the user creating an empty directory. (We could try to remove it, // but might not be able to, and it's much simpler to raise a good // message than try to navigate those waters.) if (is_dir($path)) { $files = Filesystem::listDirectory($path, $include_hidden = true); if (!$files) { $message = "Expected to find a git repository at '{$path}', but there ". "is an empty directory there. Remove the directory: the daemon ". "will run 'git clone' for you."; } else { $message = "Expected to find a git repository at '{$path}', but there is ". "a non-repository directory (with other stuff in it) there. Move ". "or remove this directory (or reconfigure the repository to use a ". "different directory), and then either clone a repository ". "yourself or let the daemon do it."; } } else if (is_file($path)) { $message = "Expected to find a git repository at '{$path}', but there is a ". "file there instead. Remove it and let the daemon clone a ". "repository for you."; } else { $message = "Expected to find a git repository at '{$path}', but did not."; } } else { $repo_path = rtrim($stdout, "\n"); if (empty($repo_path)) { // This can mean one of two things: we're in a bare repository, or // we're inside a git repository inside another git repository. Since // the first is dramatically more likely now that we perform bare // clones and I don't have a great way to test for the latter, assume // we're OK. } else if (!Filesystem::pathsAreEquivalent($repo_path, $path)) { $err = true; $message = "Expected to find repo at '{$path}', but the actual ". "git repository root for this directory is '{$repo_path}'. ". "Something is misconfigured. The repository's 'Local Path' should ". "be set to some place where the daemon can check out a working ". "copy, and should not be inside another git repository."; } } if ($err && $repository->canDestroyWorkingCopy()) { phlog("Repository working copy at '{$path}' failed sanity check; ". "destroying and re-cloning. {$message}"); Filesystem::remove($path); $this->executeGitCreate(); } else if ($err) { throw new Exception($message); } $retry = false; do { // This is a local command, but needs credentials. if ($repository->isWorkingCopyBare()) { // For bare working copies, we need this magic incantation. $future = $repository->getRemoteCommandFuture( 'fetch origin %s --prune', '+refs/heads/*:refs/heads/*'); } else { $future = $repository->getRemoteCommandFuture( 'fetch --all --prune'); } $future->setCWD($path); list($err, $stdout, $stderr) = $future->resolve(); if ($err && !$retry && $repository->canDestroyWorkingCopy()) { $retry = true; // Fix remote origin url if it doesn't match our configuration $origin_url = $repository->execLocalCommand( 'config --get remote.origin.url'); $remote_uri = $repository->getDetail('remote-uri'); if ($origin_url != $remote_uri) { $repository->execLocalCommand( 'remote set-url origin %s', $remote_uri); } } else if ($err) { throw new Exception( "git fetch failed with error #{$err}:\n". "stdout:{$stdout}\n\n". "stderr:{$stderr}\n"); } else { $retry = false; } } while ($retry); } /** * @task git */ private function installGitHook() { $repository = $this->getRepository(); $path = $repository->getLocalPath(); if ($repository->isWorkingCopyBare()) { $path .= 'hooks/pre-receive'; } else { $path .= '.git/hooks/pre-receive'; } $this->installHook($path); } /* -( Pulling Mercurial Working Copies )----------------------------------- */ /** * @task hg */ private function executeMercurialCreate() { $repository = $this->getRepository(); $path = rtrim($repository->getLocalPath(), '/'); if ($repository->isHosted()) { $repository->execxRemoteCommand( 'init -- %s', $path); } else { $repository->execxRemoteCommand( - 'clone -- %s %s', + 'clone --noupdate -- %s %s', $repository->getRemoteURI(), $path); } } /** * @task hg */ private function executeMercurialUpdate() { $repository = $this->getRepository(); $path = $repository->getLocalPath(); // This is a local command, but needs credentials. $future = $repository->getRemoteCommandFuture('pull -u'); $future->setCWD($path); try { $future->resolvex(); } catch (CommandException $ex) { $err = $ex->getError(); $stdout = $ex->getStdOut(); // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the behavior // of "hg pull" to return 1 in case of a successful pull with no changes. // This behavior has been reverted, but users who updated between Feb 1, // 2012 and Mar 1, 2012 will have the erroring version. Do a dumb test // against stdout to check for this possibility. // See: https://github.com/facebook/phabricator/issues/101/ // NOTE: Mercurial has translated versions, which translate this error // string. In a translated version, the string will be something else, // like "aucun changement trouve". There didn't seem to be an easy way // to handle this (there are hard ways but this is not a common problem // and only creates log spam, not application failures). Assume English. // TODO: Remove this once we're far enough in the future that deployment // of 2.1 is exceedingly rare? if ($err == 1 && preg_match('/no changes found/', $stdout)) { return; } else { throw $ex; } } } + /** + * @task hg + */ + private function installMercurialHook() { + $repository = $this->getRepository(); + $path = $repository->getLocalPath().'.hg/hgrc'; + + $root = dirname(phutil_get_library_root('phabricator')); + $bin = $root.'/bin/commit-hook'; + + $data = array(); + $data[] = '[hooks]'; + $data[] = csprintf( + 'pretxnchangegroup.phabricator = %s %s %s', + $bin, + $repository->getCallsign(), + 'pretxnchangegroup'); + $data[] = null; + + $data = implode("\n", $data); + + $this->log('%s', pht('Installing commit hook config to "%s"...', $path)); + + Filesystem::writeFile($path, $data); + } + + /* -( Pulling Subversion Working Copies )---------------------------------- */ /** * @task svn */ private function executeSubversionCreate() { $repository = $this->getRepository(); $path = rtrim($repository->getLocalPath(), '/'); execx('svnadmin create -- %s', $path); } /** * @task svn */ private function installSubversionHook() { $repository = $this->getRepository(); $path = $repository->getLocalPath().'hooks/pre-commit'; $this->installHook($path); } }