git_hyper_blame_test.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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"
  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. def testBasicBlame(self):
  54. """Tests the main function (simple end-to-end test with no ignores)."""
  55. expected_output = [self.blame_line('C', '1) line 1.1'),
  56. self.blame_line('B', '2) line 2.1')]
  57. stdout = StringIO.StringIO()
  58. stderr = StringIO.StringIO()
  59. retval = self.repo.run(self.git_hyper_blame.main,
  60. args=['tag_C', 'some/files/file'], stdout=stdout,
  61. stderr=stderr)
  62. self.assertEqual(0, retval)
  63. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  64. self.assertEqual('', stderr.getvalue())
  65. def testIgnoreSimple(self):
  66. """Tests the main function (simple end-to-end test with ignores)."""
  67. expected_output = [self.blame_line('C', ' 1) line 1.1'),
  68. self.blame_line('A', '2*) line 2.1')]
  69. stdout = StringIO.StringIO()
  70. stderr = StringIO.StringIO()
  71. retval = self.repo.run(self.git_hyper_blame.main,
  72. args=['-i', 'tag_B', 'tag_C', 'some/files/file'],
  73. stdout=stdout, stderr=stderr)
  74. self.assertEqual(0, retval)
  75. self.assertEqual(expected_output, stdout.getvalue().rstrip().split('\n'))
  76. self.assertEqual('', stderr.getvalue())
  77. def testBadRepo(self):
  78. """Tests the main function (not in a repo)."""
  79. # Make a temp dir that has no .git directory.
  80. curdir = os.getcwd()
  81. tempdir = tempfile.mkdtemp(suffix='_nogit', prefix='git_repo')
  82. try:
  83. os.chdir(tempdir)
  84. stdout = StringIO.StringIO()
  85. stderr = StringIO.StringIO()
  86. retval = self.git_hyper_blame.main(
  87. args=['-i', 'tag_B', 'tag_C', 'some/files/file'], stdout=stdout,
  88. stderr=stderr)
  89. finally:
  90. shutil.rmtree(tempdir)
  91. os.chdir(curdir)
  92. self.assertNotEqual(0, retval)
  93. self.assertEqual('', stdout.getvalue())
  94. self.assertRegexpMatches(stderr.getvalue(), '^fatal: Not a git repository')
  95. def testBadFilename(self):
  96. """Tests the main function (bad filename)."""
  97. stdout = StringIO.StringIO()
  98. stderr = StringIO.StringIO()
  99. retval = self.repo.run(self.git_hyper_blame.main,
  100. args=['-i', 'tag_B', 'tag_C', 'some/files/xxxx'],
  101. stdout=stdout, stderr=stderr)
  102. self.assertNotEqual(0, retval)
  103. self.assertEqual('', stdout.getvalue())
  104. self.assertEqual('fatal: no such path some/files/xxxx in %s\n' %
  105. self.repo['C'], stderr.getvalue())
  106. def testBadRevision(self):
  107. """Tests the main function (bad revision to blame from)."""
  108. stdout = StringIO.StringIO()
  109. stderr = StringIO.StringIO()
  110. retval = self.repo.run(self.git_hyper_blame.main,
  111. args=['-i', 'tag_B', 'xxxx', 'some/files/file'],
  112. stdout=stdout, stderr=stderr)
  113. self.assertNotEqual(0, retval)
  114. self.assertEqual('', stdout.getvalue())
  115. self.assertRegexpMatches(stderr.getvalue(),
  116. '^fatal: ambiguous argument \'xxxx\': unknown '
  117. 'revision or path not in the working tree.')
  118. def testBadIgnore(self):
  119. """Tests the main function (bad revision passed to -i)."""
  120. stdout = StringIO.StringIO()
  121. stderr = StringIO.StringIO()
  122. retval = self.repo.run(self.git_hyper_blame.main,
  123. args=['-i', 'xxxx', 'tag_C', 'some/files/file'],
  124. stdout=stdout, stderr=stderr)
  125. self.assertNotEqual(0, retval)
  126. self.assertEqual('', stdout.getvalue())
  127. self.assertEqual('fatal: unknown revision \'xxxx\'.\n', stderr.getvalue())
  128. class GitHyperBlameSimpleTest(GitHyperBlameTestBase):
  129. REPO_SCHEMA = """
  130. A B D E F G H
  131. A C D
  132. """
  133. COMMIT_A = {
  134. 'some/files/file1': {'data': 'file1'},
  135. 'some/files/file2': {'data': 'file2'},
  136. 'some/files/empty': {'data': ''},
  137. 'some/other/file': {'data': 'otherfile'},
  138. }
  139. COMMIT_B = {
  140. 'some/files/file2': {
  141. 'mode': 0755,
  142. 'data': 'file2 - vanilla\n'},
  143. 'some/files/empty': {'data': 'not anymore'},
  144. 'some/files/file3': {'data': 'file3'},
  145. }
  146. COMMIT_C = {
  147. 'some/files/file2': {'data': 'file2 - merged\n'},
  148. }
  149. COMMIT_D = {
  150. 'some/files/file2': {'data': 'file2 - vanilla\nfile2 - merged\n'},
  151. }
  152. COMMIT_E = {
  153. 'some/files/file2': {'data': 'file2 - vanilla\nfile_x - merged\n'},
  154. }
  155. COMMIT_F = {
  156. 'some/files/file2': {'data': 'file2 - vanilla\nfile_y - merged\n'},
  157. }
  158. # Move file2 from files to other.
  159. COMMIT_G = {
  160. 'some/files/file2': {'data': None},
  161. 'some/other/file2': {'data': 'file2 - vanilla\nfile_y - merged\n'},
  162. }
  163. COMMIT_H = {
  164. 'some/other/file2': {'data': 'file2 - vanilla\nfile_z - merged\n'},
  165. }
  166. def testBlameError(self):
  167. """Tests a blame on a non-existent file."""
  168. expected_output = ['']
  169. retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_D')
  170. self.assertNotEqual(0, retval)
  171. self.assertEqual(expected_output, output)
  172. def testBlameEmpty(self):
  173. """Tests a blame of an empty file with no ignores."""
  174. expected_output = ['']
  175. retval, output = self.run_hyperblame([], 'some/files/empty', 'tag_A')
  176. self.assertEqual(0, retval)
  177. self.assertEqual(expected_output, output)
  178. def testBasicBlame(self):
  179. """Tests a basic blame with no ignores."""
  180. # Expect to blame line 1 on B, line 2 on C.
  181. expected_output = [self.blame_line('B', '1) file2 - vanilla'),
  182. self.blame_line('C', '2) file2 - merged')]
  183. retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_D')
  184. self.assertEqual(0, retval)
  185. self.assertEqual(expected_output, output)
  186. def testBlameRenamed(self):
  187. """Tests a blame with no ignores on a renamed file."""
  188. # Expect to blame line 1 on B, line 2 on H.
  189. # Because the file has a different name than it had when (some of) these
  190. # lines were changed, expect the filenames to be displayed.
  191. expected_output = [self.blame_line('B', '1) file2 - vanilla',
  192. filename='some/files/file2'),
  193. self.blame_line('H', '2) file_z - merged',
  194. filename='some/other/file2')]
  195. retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_H')
  196. self.assertEqual(0, retval)
  197. self.assertEqual(expected_output, output)
  198. def testIgnoreSimpleEdits(self):
  199. """Tests a blame with simple (line-level changes) commits ignored."""
  200. # Expect to blame line 1 on B, line 2 on E.
  201. expected_output = [self.blame_line('B', '1) file2 - vanilla'),
  202. self.blame_line('E', '2) file_x - merged')]
  203. retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_E')
  204. self.assertEqual(0, retval)
  205. self.assertEqual(expected_output, output)
  206. # Ignore E; blame line 1 on B, line 2 on C.
  207. expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
  208. self.blame_line('C', '2*) file_x - merged')]
  209. retval, output = self.run_hyperblame(['E'], 'some/files/file2', 'tag_E')
  210. self.assertEqual(0, retval)
  211. self.assertEqual(expected_output, output)
  212. # Ignore E and F; blame line 1 on B, line 2 on C.
  213. expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
  214. self.blame_line('C', '2*) file_y - merged')]
  215. retval, output = self.run_hyperblame(['E', 'F'], 'some/files/file2',
  216. 'tag_F')
  217. self.assertEqual(0, retval)
  218. self.assertEqual(expected_output, output)
  219. def testIgnoreInitialCommit(self):
  220. """Tests a blame with the initial commit ignored."""
  221. # Ignore A. Expect A to get blamed anyway.
  222. expected_output = [self.blame_line('A', '1) file1')]
  223. retval, output = self.run_hyperblame(['A'], 'some/files/file1', 'tag_A')
  224. self.assertEqual(0, retval)
  225. self.assertEqual(expected_output, output)
  226. def testIgnoreFileAdd(self):
  227. """Tests a blame ignoring the commit that added this file."""
  228. # Ignore A. Expect A to get blamed anyway.
  229. expected_output = [self.blame_line('B', '1) file3')]
  230. retval, output = self.run_hyperblame(['B'], 'some/files/file3', 'tag_B')
  231. self.assertEqual(0, retval)
  232. self.assertEqual(expected_output, output)
  233. def testIgnoreFilePopulate(self):
  234. """Tests a blame ignoring the commit that added data to an empty file."""
  235. # Ignore A. Expect A to get blamed anyway.
  236. expected_output = [self.blame_line('B', '1) not anymore')]
  237. retval, output = self.run_hyperblame(['B'], 'some/files/empty', 'tag_B')
  238. self.assertEqual(0, retval)
  239. self.assertEqual(expected_output, output)
  240. class GitHyperBlameLineMotionTest(GitHyperBlameTestBase):
  241. REPO_SCHEMA = """
  242. A B C D E F
  243. """
  244. COMMIT_A = {
  245. 'file': {'data': 'A\ngreen\nblue\n'},
  246. }
  247. # Change "green" to "yellow".
  248. COMMIT_B = {
  249. 'file': {'data': 'A\nyellow\nblue\n'},
  250. }
  251. # Insert 2 lines at the top,
  252. # Change "yellow" to "red".
  253. # Insert 1 line at the bottom.
  254. COMMIT_C = {
  255. 'file': {'data': 'X\nY\nA\nred\nblue\nZ\n'},
  256. }
  257. # Insert 2 more lines at the top.
  258. COMMIT_D = {
  259. 'file': {'data': 'earth\nfire\nX\nY\nA\nred\nblue\nZ\n'},
  260. }
  261. # Insert a line before "red", and indent "red" and "blue".
  262. COMMIT_E = {
  263. 'file': {'data': 'earth\nfire\nX\nY\nA\ncolors:\n red\n blue\nZ\n'},
  264. }
  265. # Insert a line between "A" and "colors".
  266. COMMIT_F = {
  267. 'file': {'data': 'earth\nfire\nX\nY\nA\nB\ncolors:\n red\n blue\nZ\n'},
  268. }
  269. def testCacheDiffHunks(self):
  270. """Tests the cache_diff_hunks internal function."""
  271. expected_hunks = [((0, 0), (1, 2)),
  272. ((2, 1), (4, 1)),
  273. ((3, 0), (6, 1)),
  274. ]
  275. hunks = self.repo.run(self.git_hyper_blame.cache_diff_hunks, 'tag_B',
  276. 'tag_C')
  277. self.assertEqual(expected_hunks, hunks)
  278. def testApproxLinenoAcrossRevs(self):
  279. """Tests the approx_lineno_across_revs internal function."""
  280. # Note: For all of these tests, the "old revision" and "new revision" are
  281. # reversed, which matches the usage by hyper_blame.
  282. # Test an unchanged line before any hunks in the diff. Should be unchanged.
  283. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  284. 'file', 'file', 'tag_B', 'tag_A', 1)
  285. self.assertEqual(1, lineno)
  286. # Test an unchanged line after all hunks in the diff. Should be matched to
  287. # the line's previous position in the file.
  288. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  289. 'file', 'file', 'tag_D', 'tag_C', 6)
  290. self.assertEqual(4, lineno)
  291. # Test a line added in a new hunk. Should be matched to the line *before*
  292. # where the hunk was inserted in the old version of the file.
  293. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  294. 'file', 'file', 'tag_F', 'tag_E', 6)
  295. self.assertEqual(5, lineno)
  296. # Test lines added in a new hunk at the very start of the file. This tests
  297. # an edge case: normally it would be matched to the line *before* where the
  298. # hunk was inserted (Line 0), but since the hunk is at the start of the
  299. # file, we match to Line 1.
  300. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  301. 'file', 'file', 'tag_C', 'tag_B', 1)
  302. self.assertEqual(1, lineno)
  303. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  304. 'file', 'file', 'tag_C', 'tag_B', 2)
  305. self.assertEqual(1, lineno)
  306. # Test an unchanged line in between hunks in the diff. Should be matched to
  307. # the line's previous position in the file.
  308. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  309. 'file', 'file', 'tag_C', 'tag_B', 3)
  310. self.assertEqual(1, lineno)
  311. # Test a changed line. Should be matched to the hunk's previous position in
  312. # the file.
  313. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  314. 'file', 'file', 'tag_C', 'tag_B', 4)
  315. self.assertEqual(2, lineno)
  316. # Test a line added in a new hunk at the very end of the file. Should be
  317. # matched to the line *before* where the hunk was inserted (the last line of
  318. # the file). Technically same as the case above but good to boundary test.
  319. lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
  320. 'file', 'file', 'tag_C', 'tag_B', 6)
  321. self.assertEqual(3, lineno)
  322. def testInterHunkLineMotion(self):
  323. """Tests a blame with line motion in another hunk in the ignored commit."""
  324. # Blame from D, ignoring C.
  325. # Lines 1, 2 were added by D.
  326. # Lines 3, 4 were added by C (but ignored, so blame A).
  327. # Line 5 was added by A.
  328. # Line 6 was modified by C (but ignored, so blame B). (Note: This requires
  329. # the algorithm to figure out that Line 6 in D == Line 4 in C ~= Line 2 in
  330. # B, so it blames B. Otherwise, it would blame A.)
  331. # Line 7 was added by A.
  332. # Line 8 was added by C (but ignored, so blame A).
  333. expected_output = [self.blame_line('D', ' 1) earth'),
  334. self.blame_line('D', ' 2) fire'),
  335. self.blame_line('A', '3*) X'),
  336. self.blame_line('A', '4*) Y'),
  337. self.blame_line('A', ' 5) A'),
  338. self.blame_line('B', '6*) red'),
  339. self.blame_line('A', ' 7) blue'),
  340. self.blame_line('A', '8*) Z'),
  341. ]
  342. retval, output = self.run_hyperblame(['C'], 'file', 'tag_D')
  343. self.assertEqual(0, retval)
  344. self.assertEqual(expected_output, output)
  345. def testIntraHunkLineMotion(self):
  346. """Tests a blame with line motion in the same hunk in the ignored commit."""
  347. # This test was mostly written as a demonstration of the limitations of the
  348. # current algorithm (it exhibits non-ideal behaviour).
  349. # Blame from E, ignoring E.
  350. # Line 6 was added by E (but ignored, so blame C).
  351. # Lines 7, 8 were modified by E (but ignored, so blame A).
  352. # TODO(mgiuca): Ideally, this would blame Line 7 on C, because the line
  353. # "red" was added by C, and this is just a small change to that line. But
  354. # the current algorithm can't deal with line motion within a hunk, so it
  355. # just assumes Line 7 in E ~= Line 7 in D == Line 3 in A (which was "blue").
  356. expected_output = [self.blame_line('D', ' 1) earth'),
  357. self.blame_line('D', ' 2) fire'),
  358. self.blame_line('C', ' 3) X'),
  359. self.blame_line('C', ' 4) Y'),
  360. self.blame_line('A', ' 5) A'),
  361. self.blame_line('C', '6*) colors:'),
  362. self.blame_line('A', '7*) red'),
  363. self.blame_line('A', '8*) blue'),
  364. self.blame_line('C', ' 9) Z'),
  365. ]
  366. retval, output = self.run_hyperblame(['E'], 'file', 'tag_E')
  367. self.assertEqual(0, retval)
  368. self.assertEqual(expected_output, output)
  369. if __name__ == '__main__':
  370. sys.exit(coverage_utils.covered_main(
  371. os.path.join(DEPOT_TOOLS_ROOT, 'git_hyper_blame.py')))