@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class DiffusionBrowseQueryConduitAPIMethod
4 extends DiffusionQueryConduitAPIMethod {
5
6 public function getAPIMethodName() {
7 return 'diffusion.browsequery';
8 }
9
10 public function getMethodDescription() {
11 return pht(
12 'File(s) information for a repository at an (optional) path and '.
13 '(optional) commit.');
14 }
15
16 protected function defineReturnType() {
17 return 'array';
18 }
19
20 protected function defineCustomParamTypes() {
21 return array(
22 'path' => 'optional string',
23 'commit' => 'optional string',
24 'needValidityOnly' => 'optional bool',
25 'limit' => 'optional int',
26 'offset' => 'optional int',
27 );
28 }
29
30 protected function getResult(ConduitAPIRequest $request) {
31 $result = parent::getResult($request);
32 return $result->toDictionary();
33 }
34
35 protected function getGitResult(ConduitAPIRequest $request) {
36 $drequest = $this->getDiffusionRequest();
37 $repository = $drequest->getRepository();
38
39 $path = $request->getValue('path');
40 if (!phutil_nonempty_string($path) || $path === '/') {
41 $path = null;
42 }
43
44 $commit = $request->getValue('commit');
45 $offset = (int)$request->getValue('offset');
46 $limit = (int)$request->getValue('limit');
47 $result = $this->getEmptyResultSet();
48
49 if ($path === null) {
50 // Fast path to improve the performance of the repository view; we know
51 // the root is always a tree at any commit and always exists.
52 $path_type = 'tree';
53 } else {
54 try {
55 list($stdout) = $repository->execxLocalCommand(
56 'cat-file -t -- %s',
57 sprintf('%s:%s', $commit, $path));
58 $path_type = trim($stdout);
59 } catch (CommandException $e) {
60 // The "cat-file" command may fail if the path legitimately does not
61 // exist, but it may also fail if the path is a submodule. This can
62 // produce either "Not a valid object name" or "could not get object
63 // info".
64
65 // To detect if we have a submodule, use `git ls-tree`. If the path
66 // is a submodule, we'll get a "160000" mode mask with type "commit".
67
68 list($sub_err, $sub_stdout) = $repository->execLocalCommand(
69 'ls-tree %s -- %s',
70 gitsprintf('%s', $commit),
71 $path);
72 if (!$sub_err) {
73 // If the path failed "cat-file" but "ls-tree" worked, we assume it
74 // must be a submodule. If it is, the output will look something
75 // like this:
76 //
77 // 160000 commit <hash> <path>
78 //
79 // We make sure it has the 160000 mode mask to confirm that it's
80 // definitely a submodule.
81 $mode = (int)$sub_stdout;
82 if ($mode & 160000) {
83 $submodule_reason = DiffusionBrowseResultSet::REASON_IS_SUBMODULE;
84 $result
85 ->setReasonForEmptyResultSet($submodule_reason);
86 return $result;
87 }
88 }
89
90 $stderr = $e->getStderr();
91 if (preg_match('/^fatal: Not a valid object name/', $stderr)) {
92 // Grab two logs, since the first one is when the object was deleted.
93 list($stdout) = $repository->execxLocalCommand(
94 'log -n2 %s %s -- %s',
95 '--format=%H',
96 gitsprintf('%s', $commit),
97 $path);
98 $stdout = trim($stdout);
99 if ($stdout) {
100 $commits = explode("\n", $stdout);
101 $result
102 ->setReasonForEmptyResultSet(
103 DiffusionBrowseResultSet::REASON_IS_DELETED)
104 ->setDeletedAtCommit(idx($commits, 0))
105 ->setExistedAtCommit(idx($commits, 1));
106 return $result;
107 }
108
109 $result->setReasonForEmptyResultSet(
110 DiffusionBrowseResultSet::REASON_IS_NONEXISTENT);
111 return $result;
112 } else {
113 throw $e;
114 }
115 }
116 }
117
118 if ($path_type === 'blob') {
119 $result->setReasonForEmptyResultSet(
120 DiffusionBrowseResultSet::REASON_IS_FILE);
121 return $result;
122 }
123
124 $result->setIsValidResults(true);
125 if ($this->shouldOnlyTestValidity($request)) {
126 return $result;
127 }
128
129 if ($path === null) {
130 list($stdout) = $repository->execxLocalCommand(
131 'ls-tree -z -l %s --',
132 gitsprintf('%s', $commit));
133 } else {
134 if ($path_type === 'tree') {
135 $path = rtrim($path, '/').'/';
136 } else {
137 $path = rtrim($path, '/');
138 }
139
140 list($stdout) = $repository->execxLocalCommand(
141 'ls-tree -z -l %s -- %s',
142 gitsprintf('%s', $commit),
143 $path);
144 }
145
146 $submodules = array();
147
148 $count = 0;
149 $results = array();
150 $lines = empty($stdout)
151 ? array()
152 : explode("\0", rtrim($stdout));
153
154 foreach ($lines as $line) {
155 // NOTE: Limit to 5 components so we parse filenames with spaces in them
156 // correctly.
157 // NOTE: The output uses a mixture of tabs and one-or-more spaces to
158 // delimit fields.
159 $parts = preg_split('/\s+/', $line, 5);
160 if (count($parts) < 5) {
161 throw new Exception(
162 pht(
163 'Expected "<mode> <type> <hash> <size>\t<name>", for ls-tree of '.
164 '"%s:%s", got: %s',
165 $commit,
166 $path,
167 $line));
168 }
169
170 list($mode, $type, $hash, $size, $full_path) = $parts;
171
172 $path_result = new DiffusionRepositoryPath();
173
174 if ($type == 'tree') {
175 $file_type = DifferentialChangeType::FILE_DIRECTORY;
176 } else if ($type == 'commit') {
177 $file_type = DifferentialChangeType::FILE_SUBMODULE;
178 $submodules[] = $path_result;
179 } else {
180 $mode = intval($mode, 8);
181 if (($mode & 0120000) == 0120000) {
182 $file_type = DifferentialChangeType::FILE_SYMLINK;
183 } else {
184 $file_type = DifferentialChangeType::FILE_NORMAL;
185 }
186 }
187
188 if ($path === null) {
189 $local_path = $full_path;
190 } else {
191 $local_path = basename($full_path);
192 }
193
194 $path_result->setFullPath($full_path);
195 $path_result->setPath($local_path);
196 $path_result->setHash($hash);
197 $path_result->setFileType($file_type);
198 $path_result->setFileSize($size);
199
200 if ($count >= $offset) {
201 $results[] = $path_result;
202 }
203
204 $count++;
205
206 if ($limit && $count >= ($offset + $limit)) {
207 break;
208 }
209 }
210
211 // If we identified submodules, lookup the module info at this commit to
212 // find their source URIs.
213
214 if ($submodules) {
215
216 // NOTE: We need to read the file out of git and write it to a temporary
217 // location because "git config -f" doesn't accept a "commit:path"-style
218 // argument.
219
220 // NOTE: This file may not exist, e.g. because the commit author removed
221 // it when they added the submodule. See T1448. If it's not present, just
222 // show the submodule without enriching it. If ".gitmodules" was removed
223 // it seems to partially break submodules, but the repository as a whole
224 // continues to work fine and we've seen at least two cases of this in
225 // the wild.
226
227 list($err, $contents) = $repository->execLocalCommand(
228 'cat-file blob -- %s:.gitmodules',
229 $commit);
230
231 if (!$err) {
232
233 // NOTE: After T13673, the user executing "git" may not be the same
234 // as the user this process is running as (usually the webserver user),
235 // so we can't reliably use a temporary file: the daemon user may not
236 // be able to use it.
237
238 // Use "--file -" to read from stdin instead. If this fails in some
239 // older versions of Git, we could exempt this particular command from
240 // sudoing to the daemon user.
241
242 $future = $repository->getLocalCommandFuture('config -l --file - --');
243 $future->write($contents);
244 list($module_info) = $future->resolvex();
245
246 $dict = array();
247 $lines = explode("\n", trim($module_info));
248 foreach ($lines as $line) {
249 list($key, $value) = explode('=', $line, 2);
250 $parts = explode('.', $key);
251 $dict[$key] = $value;
252 }
253
254 foreach ($submodules as $submodule_path) {
255 $full_path = $submodule_path->getFullPath();
256 $key = 'submodule.'.$full_path.'.url';
257 if (isset($dict[$key])) {
258 $submodule_path->setExternalURI($dict[$key]);
259 }
260 }
261 }
262 }
263
264 return $result->setPaths($results);
265 }
266
267 protected function getMercurialResult(ConduitAPIRequest $request) {
268 $drequest = $this->getDiffusionRequest();
269 $repository = $drequest->getRepository();
270 $path = $request->getValue('path');
271 $commit = $request->getValue('commit');
272 $offset = (int)$request->getValue('offset');
273 $limit = (int)$request->getValue('limit');
274 $result = $this->getEmptyResultSet();
275
276
277 $entire_manifest = id(new DiffusionLowLevelMercurialPathsQuery())
278 ->setRepository($repository)
279 ->withCommit($commit)
280 ->withPath($path)
281 ->execute();
282
283 $results = array();
284
285 $match_against = trim($path, '/');
286 $match_len = strlen($match_against);
287
288 // For the root, don't trim. For other paths, trim the "/" after we match.
289 // We need this because Mercurial's canonical paths have no leading "/",
290 // but ours do.
291 $trim_len = $match_len ? $match_len + 1 : 0;
292
293 $count = 0;
294 foreach ($entire_manifest as $path) {
295 if (strncmp($path, $match_against, $match_len)) {
296 continue;
297 }
298 if (!strlen($path)) {
299 continue;
300 }
301 $remainder = substr($path, $trim_len);
302 if (!strlen($remainder)) {
303 // There is a file with this exact name in the manifest, so clearly
304 // it's a file.
305 $result->setReasonForEmptyResultSet(
306 DiffusionBrowseResultSet::REASON_IS_FILE);
307 return $result;
308 }
309
310 $parts = explode('/', $remainder);
311 $name = reset($parts);
312
313 // If we've already seen this path component, we're looking at a file
314 // inside a directory we already processed. Just move on.
315 if (isset($results[$name])) {
316 continue;
317 }
318
319 if (count($parts) == 1) {
320 $type = DifferentialChangeType::FILE_NORMAL;
321 } else {
322 $type = DifferentialChangeType::FILE_DIRECTORY;
323 }
324
325 if ($count >= $offset) {
326 $results[$name] = $type;
327 }
328
329 $count++;
330
331 if ($limit && ($count >= ($offset + $limit))) {
332 break;
333 }
334 }
335
336 foreach ($results as $key => $type) {
337 $path_result = new DiffusionRepositoryPath();
338 $path_result->setPath($key);
339 $path_result->setFileType($type);
340 $path_result->setFullPath(ltrim($match_against.'/', '/').$key);
341
342 $results[$key] = $path_result;
343 }
344
345 $valid_results = true;
346 if (empty($results)) {
347 // TODO: Detect "deleted" by issuing "hg log"?
348 $result->setReasonForEmptyResultSet(
349 DiffusionBrowseResultSet::REASON_IS_NONEXISTENT);
350 $valid_results = false;
351 }
352
353 return $result
354 ->setPaths($results)
355 ->setIsValidResults($valid_results);
356 }
357
358 protected function getSVNResult(ConduitAPIRequest $request) {
359 $drequest = $this->getDiffusionRequest();
360 $repository = $drequest->getRepository();
361 $path = $request->getValue('path');
362 $commit = $request->getValue('commit');
363 $offset = (int)$request->getValue('offset');
364 $limit = (int)$request->getValue('limit');
365 $result = $this->getEmptyResultSet();
366
367 $subpath = $repository->getDetail('svn-subpath');
368 if ($subpath && strncmp($subpath, $path, strlen($subpath))) {
369 // If we have a subpath and the path isn't a child of it, it (almost
370 // certainly) won't exist since we don't track commits which affect
371 // it. (Even if it exists, return a consistent result.)
372 $result->setReasonForEmptyResultSet(
373 DiffusionBrowseResultSet::REASON_IS_UNTRACKED_PARENT);
374 return $result;
375 }
376
377 $conn_r = $repository->establishConnection('r');
378
379 $parent_path = DiffusionPathIDQuery::getParentPath($path);
380 $path_query = new DiffusionPathIDQuery(
381 array(
382 $path,
383 $parent_path,
384 ));
385 $path_map = $path_query->loadPathIDs();
386
387 $path_id = $path_map[$path];
388 $parent_path_id = $path_map[$parent_path];
389
390 if (empty($path_id)) {
391 $result->setReasonForEmptyResultSet(
392 DiffusionBrowseResultSet::REASON_IS_NONEXISTENT);
393 return $result;
394 }
395
396 if ($commit) {
397 $slice_clause = qsprintf($conn_r, 'AND svnCommit <= %d', $commit);
398 } else {
399 $slice_clause = qsprintf($conn_r, '');
400 }
401
402 $index = queryfx_all(
403 $conn_r,
404 'SELECT pathID, max(svnCommit) maxCommit FROM %T WHERE
405 repositoryID = %d AND parentID = %d
406 %Q GROUP BY pathID',
407 PhabricatorRepository::TABLE_FILESYSTEM,
408 $repository->getID(),
409 $path_id,
410 $slice_clause);
411
412 if (!$index) {
413 if ($path == '/') {
414 $result->setReasonForEmptyResultSet(
415 DiffusionBrowseResultSet::REASON_IS_EMPTY);
416 } else {
417
418 // NOTE: The parent path ID is included so this query can take
419 // advantage of the table's primary key; it is uniquely determined by
420 // the pathID but if we don't do the lookup ourselves MySQL doesn't have
421 // the information it needs to avoid a table scan.
422
423 $reasons = queryfx_all(
424 $conn_r,
425 'SELECT * FROM %T WHERE repositoryID = %d
426 AND parentID = %d
427 AND pathID = %d
428 %Q ORDER BY svnCommit DESC LIMIT 2',
429 PhabricatorRepository::TABLE_FILESYSTEM,
430 $repository->getID(),
431 $parent_path_id,
432 $path_id,
433 $slice_clause);
434
435 $reason = reset($reasons);
436
437 if (!$reason) {
438 $result->setReasonForEmptyResultSet(
439 DiffusionBrowseResultSet::REASON_IS_NONEXISTENT);
440 } else {
441 $file_type = $reason['fileType'];
442 if (empty($reason['existed'])) {
443 $result->setReasonForEmptyResultSet(
444 DiffusionBrowseResultSet::REASON_IS_DELETED);
445 $result->setDeletedAtCommit($reason['svnCommit']);
446 if (!empty($reasons[1])) {
447 $result->setExistedAtCommit($reasons[1]['svnCommit']);
448 }
449 } else if ($file_type == DifferentialChangeType::FILE_DIRECTORY) {
450 $result->setReasonForEmptyResultSet(
451 DiffusionBrowseResultSet::REASON_IS_EMPTY);
452 } else {
453 $result->setReasonForEmptyResultSet(
454 DiffusionBrowseResultSet::REASON_IS_FILE);
455 }
456 }
457 }
458 return $result;
459 }
460
461 $result->setIsValidResults(true);
462 if ($this->shouldOnlyTestValidity($request)) {
463 return $result;
464 }
465
466 $sql = array();
467 foreach ($index as $row) {
468 $sql[] = qsprintf(
469 $conn_r,
470 '(pathID = %d AND svnCommit = %d)',
471 $row['pathID'],
472 $row['maxCommit']);
473 }
474
475 $browse = queryfx_all(
476 $conn_r,
477 'SELECT *, p.path pathName
478 FROM %T f JOIN %T p ON f.pathID = p.id
479 WHERE repositoryID = %d
480 AND parentID = %d
481 AND existed = 1
482 AND (%LO)
483 ORDER BY pathName',
484 PhabricatorRepository::TABLE_FILESYSTEM,
485 PhabricatorRepository::TABLE_PATH,
486 $repository->getID(),
487 $path_id,
488 $sql);
489
490 $loadable_commits = array();
491 foreach ($browse as $key => $file) {
492 // We need to strip out directories because we don't store last-modified
493 // in the filesystem table.
494 if ($file['fileType'] != DifferentialChangeType::FILE_DIRECTORY) {
495 $loadable_commits[] = $file['svnCommit'];
496 $browse[$key]['hasCommit'] = true;
497 }
498 }
499
500 $commits = array();
501 $commit_data = array();
502 if ($loadable_commits) {
503 // NOTE: Even though these are integers, use '%Ls' because MySQL doesn't
504 // use the second part of the key otherwise!
505 $commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
506 'repositoryID = %d AND commitIdentifier IN (%Ls)',
507 $repository->getID(),
508 $loadable_commits);
509 $commits = mpull($commits, null, 'getCommitIdentifier');
510 if ($commits) {
511 $commit_data = id(new PhabricatorRepositoryCommitData())->loadAllWhere(
512 'commitID in (%Ld)',
513 mpull($commits, 'getID'));
514 $commit_data = mpull($commit_data, null, 'getCommitID');
515 } else {
516 $commit_data = array();
517 }
518 }
519
520 $path_normal = DiffusionPathIDQuery::normalizePath($path);
521
522 $results = array();
523 $count = 0;
524 foreach ($browse as $file) {
525
526 $full_path = $file['pathName'];
527 $file_path = ltrim(substr($full_path, strlen($path_normal)), '/');
528 $full_path = ltrim($full_path, '/');
529
530 $result_path = new DiffusionRepositoryPath();
531 $result_path->setPath($file_path);
532 $result_path->setFullPath($full_path);
533 $result_path->setFileType($file['fileType']);
534
535 if (!empty($file['hasCommit'])) {
536 $commit = idx($commits, $file['svnCommit']);
537 if ($commit) {
538 $data = idx($commit_data, $commit->getID());
539 $result_path->setLastModifiedCommit($commit);
540 $result_path->setLastCommitData($data);
541 }
542 }
543
544 if ($count >= $offset) {
545 $results[] = $result_path;
546 }
547
548 $count++;
549
550 if ($limit && ($count >= ($offset + $limit))) {
551 break;
552 }
553 }
554
555 if (empty($results)) {
556 $result->setReasonForEmptyResultSet(
557 DiffusionBrowseResultSet::REASON_IS_EMPTY);
558 }
559
560 return $result->setPaths($results);
561 }
562
563 private function getEmptyResultSet() {
564 return id(new DiffusionBrowseResultSet())
565 ->setPaths(array())
566 ->setReasonForEmptyResultSet(null)
567 ->setIsValidResults(false);
568 }
569
570 private function shouldOnlyTestValidity(ConduitAPIRequest $request) {
571 return $request->getValue('needValidityOnly', false);
572 }
573
574}