git_hyper_blame_test.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. #!/usr/bin/env python
  2. # Copyright 2016 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Tests for git_dates."""
  6. import datetime
  7. import os
  8. import shutil
  9. import StringIO
  10. import sys
  11. import tempfile
  12. import unittest
  13. DEPOT_TOOLS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  14. sys.path.insert(0, DEPOT_TOOLS_ROOT)
  15. from testing_support import coverage_utils
  16. from testing_support import git_test_utils
  17. import git_common
  18. class GitHyperBlameTestBase(git_test_utils.GitRepoReadOnlyTestBase):
  19. @classmethod
  20. def setUpClass(cls):
  21. super(GitHyperBlameTestBase, cls).setUpClass()
  22. import git_hyper_blame
  23. cls.git_hyper_blame = git_hyper_blame
  24. def run_hyperblame(self, ignored, filename, revision):
  25. stdout = StringIO.StringIO()
  26. stderr = StringIO.StringIO()
  27. ignored = [self.repo[c] for c in ignored]
  28. retval = self.repo.run(self.git_hyper_blame.hyper_blame, ignored, filename,
  29. revision=revision, out=stdout, err=stderr)
  30. return retval, stdout.getvalue().rstrip().split('\n')
  31. def blame_line(self, commit_name, rest, filename=None):
  32. """Generate a blame line from a commit.
  33. Args:
  34. commit_name: The commit's schema name.
  35. rest: The blame line after the timestamp. e.g., '2) file2 - merged'.
  36. """
  37. short = self.repo[commit_name][:8]
  38. start = '%s %s' % (short, filename) if filename else short
  39. author = self.repo.show_commit(commit_name, format_string='%an %ai')
  40. return '%s (%s %s' % (start, author, rest)
  41. class GitHyperBlameMainTest(GitHyperBlameTestBase):
  42. """End-to-end tests on a very simple repo."""
  43. REPO_SCHEMA = "A B C D"
  44. COMMIT_A = {
  45. 'some/files/file': {'data': 'line 1\nline 2\n'},
  46. }
  47. COMMIT_B = {
  48. 'some/files/file': {'data': 'line 1\nline 2.1\n'},
  49. }
  50. COMMIT_C = {
  51. 'some/files/file': {'data': 'line 1.1\nline 2.1\n'},
  52. }
  53. COMMIT_D = {
  54. # This file should be automatically considered for ignore.
  55. '.git-blame-ignore-revs': {'data': 'tag_C'},
  56. # This file should not be considered.
  57. 'some/files/.git-blame-ignore-revs': {'data': 'tag_B'},
  58. }
  59. def setUp(self):
  60. super(GitHyperBlameMainTest, self).setUp()
  61. # Most tests want to check out C (so the .git-blame-ignore-revs is not
  62. # used).
  63. self.repo.git('checkout', '-f', 'tag_C')
  64. def testBasicBlame(self):
  65. """Tests the main function (simple end-to-end test with no ignores)."""
  66. expected_output = [self.blame_line('C', '1) line 1.1'),
  67. self.blame_line('B', '2) line 2.1')]
  68. stdout = StringIO.StringIO()
  69. stderr = StringIO.StringIO()
  70. retval = self.repo.run(self.git_hyper_blame.main,
  71. args=['tag_C', 'some/files/file'], stdout=stdout,
  72. stderr=stderr)
  73. self.assertEqual(0, retval)
  74. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  75. self.assertEqual('', stderr.getvalue())
  76. def testIgnoreSimple(self):
  77. """Tests the main function (simple end-to-end test with ignores)."""
  78. expected_output = [self.blame_line('C', ' 1) line 1.1'),
  79. self.blame_line('A', '2*) line 2.1')]
  80. stdout = StringIO.StringIO()
  81. stderr = StringIO.StringIO()
  82. retval = self.repo.run(self.git_hyper_blame.main,
  83. args=['-i', 'tag_B', 'tag_C', 'some/files/file'],
  84. stdout=stdout, stderr=stderr)
  85. self.assertEqual(0, retval)
  86. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  87. self.assertEqual('', stderr.getvalue())
  88. def testBadRepo(self):
  89. """Tests the main function (not in a repo)."""
  90. # Make a temp dir that has no .git directory.
  91. curdir = os.getcwd()
  92. tempdir = tempfile.mkdtemp(suffix='_nogit', prefix='git_repo')
  93. try:
  94. os.chdir(tempdir)
  95. stdout = StringIO.StringIO()
  96. stderr = StringIO.StringIO()
  97. retval = self.git_hyper_blame.main(
  98. args=['-i', 'tag_B', 'tag_C', 'some/files/file'], stdout=stdout,
  99. stderr=stderr)
  100. finally:
  101. shutil.rmtree(tempdir)
  102. os.chdir(curdir)
  103. self.assertNotEqual(0, retval)
  104. self.assertEqual('', stdout.getvalue())
  105. self.assertRegexpMatches(stderr.getvalue(), '^fatal: Not a git repository')
  106. def testBadFilename(self):
  107. """Tests the main function (bad filename)."""
  108. stdout = StringIO.StringIO()
  109. stderr = StringIO.StringIO()
  110. retval = self.repo.run(self.git_hyper_blame.main,
  111. args=['-i', 'tag_B', 'tag_C', 'some/files/xxxx'],
  112. stdout=stdout, stderr=stderr)
  113. self.assertNotEqual(0, retval)
  114. self.assertEqual('', stdout.getvalue())
  115. self.assertEqual('fatal: no such path some/files/xxxx in %s\n' %
  116. self.repo['C'], stderr.getvalue())
  117. def testBadRevision(self):
  118. """Tests the main function (bad revision to blame from)."""
  119. stdout = StringIO.StringIO()
  120. stderr = StringIO.StringIO()
  121. retval = self.repo.run(self.git_hyper_blame.main,
  122. args=['-i', 'tag_B', 'xxxx', 'some/files/file'],
  123. stdout=stdout, stderr=stderr)
  124. self.assertNotEqual(0, retval)
  125. self.assertEqual('', stdout.getvalue())
  126. self.assertRegexpMatches(stderr.getvalue(),
  127. '^fatal: ambiguous argument \'xxxx\': unknown '
  128. 'revision or path not in the working tree.')
  129. def testBadIgnore(self):
  130. """Tests the main function (bad revision passed to -i)."""
  131. expected_output = [self.blame_line('C', '1) line 1.1'),
  132. self.blame_line('B', '2) line 2.1')]
  133. stdout = StringIO.StringIO()
  134. stderr = StringIO.StringIO()
  135. retval = self.repo.run(self.git_hyper_blame.main,
  136. args=['-i', 'xxxx', 'tag_C', 'some/files/file'],
  137. stdout=stdout, stderr=stderr)
  138. self.assertEqual(0, retval)
  139. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  140. self.assertEqual('warning: unknown revision \'xxxx\'.\n', stderr.getvalue())
  141. def testIgnoreFile(self):
  142. """Tests passing the ignore list in a file."""
  143. expected_output = [self.blame_line('C', ' 1) line 1.1'),
  144. self.blame_line('A', '2*) line 2.1')]
  145. stdout = StringIO.StringIO()
  146. stderr = StringIO.StringIO()
  147. with tempfile.NamedTemporaryFile(mode='w+', prefix='ignore') as ignore_file:
  148. ignore_file.write('# Line comments are allowed.\n'.format(self.repo['B']))
  149. ignore_file.write('\n')
  150. ignore_file.write('{}\n'.format(self.repo['B']))
  151. # A revision that is not in the repo (should be ignored).
  152. ignore_file.write('xxxx\n')
  153. ignore_file.flush()
  154. retval = self.repo.run(self.git_hyper_blame.main,
  155. args=['--ignore-file', ignore_file.name, 'tag_C',
  156. 'some/files/file'],
  157. stdout=stdout, stderr=stderr)
  158. self.assertEqual(0, retval)
  159. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  160. self.assertEqual('warning: unknown revision \'xxxx\'.\n', stderr.getvalue())
  161. def testDefaultIgnoreFile(self):
  162. """Tests automatically using a default ignore list."""
  163. # Check out revision D. We expect the script to use the default ignore list
  164. # that is checked out, *not* the one committed at the given revision.
  165. self.repo.git('checkout', '-f', 'tag_D')
  166. expected_output = [self.blame_line('A', '1*) line 1.1'),
  167. self.blame_line('B', ' 2) line 2.1')]
  168. stdout = StringIO.StringIO()
  169. stderr = StringIO.StringIO()
  170. retval = self.repo.run(self.git_hyper_blame.main,
  171. args=['tag_D', 'some/files/file'],
  172. stdout=stdout, stderr=stderr)
  173. self.assertEqual(0, retval)
  174. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  175. self.assertEqual('', stderr.getvalue())
  176. # Test blame from a different revision. Despite the default ignore file
  177. # *not* being committed at that revision, it should still be picked up
  178. # because D is currently checked out.
  179. stdout = StringIO.StringIO()
  180. stderr = StringIO.StringIO()
  181. retval = self.repo.run(self.git_hyper_blame.main,
  182. args=['tag_C', 'some/files/file'],
  183. stdout=stdout, stderr=stderr)
  184. self.assertEqual(0, retval)
  185. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  186. self.assertEqual('', stderr.getvalue())
  187. class GitHyperBlameSimpleTest(GitHyperBlameTestBase):
  188. REPO_SCHEMA = """
  189. A B D E F G H
  190. A C D
  191. """
  192. COMMIT_A = {
  193. 'some/files/file1': {'data': 'file1'},
  194. 'some/files/file2': {'data': 'file2'},
  195. 'some/files/empty': {'data': ''},
  196. 'some/other/file': {'data': 'otherfile'},
  197. }
  198. COMMIT_B = {
  199. 'some/files/file2': {
  200. 'mode': 0o755,
  201. 'data': 'file2 - vanilla\n'},
  202. 'some/files/empty': {'data': 'not anymore'},
  203. 'some/files/file3': {'data': 'file3'},
  204. }
  205. COMMIT_C = {
  206. 'some/files/file2': {'data': 'file2 - merged\n'},
  207. }
  208. COMMIT_D = {
  209. 'some/files/file2': {'data': 'file2 - vanilla\nfile2 - merged\n'},
  210. }
  211. COMMIT_E = {
  212. 'some/files/file2': {'data': 'file2 - vanilla\nfile_x - merged\n'},
  213. }
  214. COMMIT_F = {
  215. 'some/files/file2': {'data': 'file2 - vanilla\nfile_y - merged\n'},
  216. }
  217. # Move file2 from files to other.
  218. COMMIT_G = {
  219. 'some/files/file2': {'data': None},
  220. 'some/other/file2': {'data': 'file2 - vanilla\nfile_y - merged\n'},
  221. }
  222. COMMIT_H = {
  223. 'some/other/file2': {'data': 'file2 - vanilla\nfile_z - merged\n'},
  224. }
  225. def testBlameError(self):
  226. """Tests a blame on a non-existent file."""
  227. expected_output = ['']
  228. retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_D')
  229. self.assertNotEqual(0, retval)
  230. self.assertEqual(expected_output, output)
  231. def testBlameEmpty(self):
  232. """Tests a blame of an empty file with no ignores."""
  233. expected_output = ['']
  234. retval, output = self.run_hyperblame([], 'some/files/empty', 'tag_A')
  235. self.assertEqual(0, retval)
  236. self.assertEqual(expected_output, output)
  237. def testBasicBlame(self):
  238. """Tests a basic blame with no ignores."""
  239. # Expect to blame line 1 on B, line 2 on C.
  240. expected_output = [self.blame_line('B', '1) file2 - vanilla'),
  241. self.blame_line('C', '2) file2 - merged')]
  242. retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_D')
  243. self.assertEqual(0, retval)
  244. self.assertEqual(expected_output, output)
  245. def testBlameRenamed(self):
  246. """Tests a blame with no ignores on a renamed file."""
  247. # Expect to blame line 1 on B, line 2 on H.
  248. # Because the file has a different name than it had when (some of) these
  249. # lines were changed, expect the filenames to be displayed.
  250. expected_output = [self.blame_line('B', '1) file2 - vanilla',
  251. filename='some/files/file2'),
  252. self.blame_line('H', '2) file_z - merged',
  253. filename='some/other/file2')]
  254. retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_H')
  255. self.assertEqual(0, retval)
  256. self.assertEqual(expected_output, output)
  257. def testIgnoreSimpleEdits(self):
  258. """Tests a blame with simple (line-level changes) commits ignored."""
  259. # Expect to blame line 1 on B, line 2 on E.
  260. expected_output = [self.blame_line('B', '1) file2 - vanilla'),
  261. self.blame_line('E', '2) file_x - merged')]
  262. retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_E')
  263. self.assertEqual(0, retval)
  264. self.assertEqual(expected_output, output)
  265. # Ignore E; blame line 1 on B, line 2 on C.
  266. expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
  267. self.blame_line('C', '2*) file_x - merged')]
  268. retval, output = self.run_hyperblame(['E'], 'some/files/file2', 'tag_E')
  269. self.assertEqual(0, retval)
  270. self.assertEqual(expected_output, output)
  271. # Ignore E and F; blame line 1 on B, line 2 on C.
  272. expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
  273. self.blame_line('C', '2*) file_y - merged')]
  274. retval, output = self.run_hyperblame(['E', 'F'], 'some/files/file2',
  275. 'tag_F')
  276. self.assertEqual(0, retval)
  277. self.assertEqual(expected_output, output)
  278. def testIgnoreInitialCommit(self):
  279. """Tests a blame with the initial commit ignored."""
  280. # Ignore A. Expect A to get blamed anyway.
  281. expected_output = [self.blame_line('A', '1) file1')]
  282. retval, output = self.run_hyperblame(['A'], 'some/files/file1', 'tag_A')
  283. self.assertEqual(0, retval)
  284. self.assertEqual(expected_output, output)
  285. def testIgnoreFileAdd(self):
  286. """Tests a blame ignoring the commit that added this file."""
  287. # Ignore A. Expect A to get blamed anyway.
  288. expected_output = [self.blame_line('B', '1) file3')]
  289. retval, output = self.run_hyperblame(['B'], 'some/files/file3', 'tag_B')
  290. self.assertEqual(0, retval)
  291. self.assertEqual(expected_output, output)
  292. def testIgnoreFilePopulate(self):
  293. """Tests a blame ignoring the commit that added data to an empty file."""
  294. # Ignore A. Expect A to get blamed anyway.
  295. expected_output = [self.blame_line('B', '1) not anymore')]
  296. retval, output = self.run_hyperblame(['B'], 'some/files/empty', 'tag_B')
  297. self.assertEqual(0, retval)
  298. self.assertEqual(expected_output, output)
  299. class GitHyperBlameLineMotionTest(GitHyperBlameTestBase):
  300. REPO_SCHEMA = """
  301. A B C D E F
  302. """
  303. COMMIT_A = {
  304. 'file': {'data': 'A\ngreen\nblue\n'},
  305. }
  306. # Change "green" to "yellow".
  307. COMMIT_B = {
  308. 'file': {'data': 'A\nyellow\nblue\n'},
  309. }
  310. # Insert 2 lines at the top,
  311. # Change "yellow" to "red".
  312. # Insert 1 line at the bottom.
  313. COMMIT_C = {
  314. 'file': {'data': 'X\nY\nA\nred\nblue\nZ\n'},
  315. }
  316. # Insert 2 more lines at the top.
  317. COMMIT_D = {
  318. 'file': {'data': 'earth\nfire\nX\nY\nA\nred\nblue\nZ\n'},
  319. }
  320. # Insert a line before "red", and indent "red" and "blue".
  321. COMMIT_E = {
  322. 'file': {'data': 'earth\nfire\nX\nY\nA\ncolors:\n red\n blue\nZ\n'},
  323. }
  324. # Insert a line between "A" and "colors".
  325. COMMIT_F = {
  326. 'file': {'data': 'earth\nfire\nX\nY\nA\nB\ncolors:\n red\n blue\nZ\n'},
  327. }
  328. def testCacheDiffHunks(self):
  329. """Tests the cache_diff_hunks internal function."""
  330. expected_hunks = [((0, 0), (1, 2)),
  331. ((2, 1), (4, 1)),
  332. ((3, 0), (6, 1)),
  333. ]
  334. hunks = self.repo.run(self.git_hyper_blame.cache_diff_hunks, 'tag_B',
  335. 'tag_C')
  336. self.assertEqual(expected_hunks, hunks)
  337. def testApproxLinenoAcrossRevs(self):
  338. """Tests the approx_lineno_across_revs internal function."""
  339. # Note: For all of these tests, the "old revision" and "new revision" are
  340. # reversed, which matches the usage by hyper_blame.
  341. # Test an unchanged line before any hunks in the diff. Should be unchanged.
  342. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  343. 'file', 'file', 'tag_B', 'tag_A', 1)
  344. self.assertEqual(1, lineno)
  345. # Test an unchanged line after all hunks in the diff. Should be matched to
  346. # the line's previous position in the file.
  347. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  348. 'file', 'file', 'tag_D', 'tag_C', 6)
  349. self.assertEqual(4, lineno)
  350. # Test a line added in a new hunk. Should be matched to the line *before*
  351. # where the hunk was inserted in the old version of the file.
  352. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  353. 'file', 'file', 'tag_F', 'tag_E', 6)
  354. self.assertEqual(5, lineno)
  355. # Test lines added in a new hunk at the very start of the file. This tests
  356. # an edge case: normally it would be matched to the line *before* where the
  357. # hunk was inserted (Line 0), but since the hunk is at the start of the
  358. # file, we match to Line 1.
  359. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  360. 'file', 'file', 'tag_C', 'tag_B', 1)
  361. self.assertEqual(1, lineno)
  362. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  363. 'file', 'file', 'tag_C', 'tag_B', 2)
  364. self.assertEqual(1, lineno)
  365. # Test an unchanged line in between hunks in the diff. Should be matched to
  366. # the line's previous position in the file.
  367. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  368. 'file', 'file', 'tag_C', 'tag_B', 3)
  369. self.assertEqual(1, lineno)
  370. # Test a changed line. Should be matched to the hunk's previous position in
  371. # the file.
  372. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  373. 'file', 'file', 'tag_C', 'tag_B', 4)
  374. self.assertEqual(2, lineno)
  375. # Test a line added in a new hunk at the very end of the file. Should be
  376. # matched to the line *before* where the hunk was inserted (the last line of
  377. # the file). Technically same as the case above but good to boundary test.
  378. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  379. 'file', 'file', 'tag_C', 'tag_B', 6)
  380. self.assertEqual(3, lineno)
  381. def testInterHunkLineMotion(self):
  382. """Tests a blame with line motion in another hunk in the ignored commit."""
  383. # Blame from D, ignoring C.
  384. # Lines 1, 2 were added by D.
  385. # Lines 3, 4 were added by C (but ignored, so blame A).
  386. # Line 5 was added by A.
  387. # Line 6 was modified by C (but ignored, so blame B). (Note: This requires
  388. # the algorithm to figure out that Line 6 in D == Line 4 in C ~= Line 2 in
  389. # B, so it blames B. Otherwise, it would blame A.)
  390. # Line 7 was added by A.
  391. # Line 8 was added by C (but ignored, so blame A).
  392. expected_output = [self.blame_line('D', ' 1) earth'),
  393. self.blame_line('D', ' 2) fire'),
  394. self.blame_line('A', '3*) X'),
  395. self.blame_line('A', '4*) Y'),
  396. self.blame_line('A', ' 5) A'),
  397. self.blame_line('B', '6*) red'),
  398. self.blame_line('A', ' 7) blue'),
  399. self.blame_line('A', '8*) Z'),
  400. ]
  401. retval, output = self.run_hyperblame(['C'], 'file', 'tag_D')
  402. self.assertEqual(0, retval)
  403. self.assertEqual(expected_output, output)
  404. def testIntraHunkLineMotion(self):
  405. """Tests a blame with line motion in the same hunk in the ignored commit."""
  406. # This test was mostly written as a demonstration of the limitations of the
  407. # current algorithm (it exhibits non-ideal behaviour).
  408. # Blame from E, ignoring E.
  409. # Line 6 was added by E (but ignored, so blame C).
  410. # Lines 7, 8 were modified by E (but ignored, so blame A).
  411. # TODO(mgiuca): Ideally, this would blame Line 7 on C, because the line
  412. # "red" was added by C, and this is just a small change to that line. But
  413. # the current algorithm can't deal with line motion within a hunk, so it
  414. # just assumes Line 7 in E ~= Line 7 in D == Line 3 in A (which was "blue").
  415. expected_output = [self.blame_line('D', ' 1) earth'),
  416. self.blame_line('D', ' 2) fire'),
  417. self.blame_line('C', ' 3) X'),
  418. self.blame_line('C', ' 4) Y'),
  419. self.blame_line('A', ' 5) A'),
  420. self.blame_line('C', '6*) colors:'),
  421. self.blame_line('A', '7*) red'),
  422. self.blame_line('A', '8*) blue'),
  423. self.blame_line('C', ' 9) Z'),
  424. ]
  425. retval, output = self.run_hyperblame(['E'], 'file', 'tag_E')
  426. self.assertEqual(0, retval)
  427. self.assertEqual(expected_output, output)
  428. if __name__ == '__main__':
  429. sys.exit(coverage_utils.covered_main(
  430. os.path.join(DEPOT_TOOLS_ROOT, 'git_hyper_blame.py')))