2
0

test_commit_graph.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
  1. # test_commit_graph.py -- Tests for commit graph functionality
  2. # Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as public by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. """Tests for Git commit graph functionality."""
  10. import io
  11. import os
  12. import struct
  13. import tempfile
  14. import unittest
  15. from dulwich.commit_graph import (
  16. CHUNK_COMMIT_DATA,
  17. CHUNK_OID_FANOUT,
  18. CHUNK_OID_LOOKUP,
  19. COMMIT_GRAPH_SIGNATURE,
  20. COMMIT_GRAPH_VERSION,
  21. HASH_VERSION_SHA1,
  22. CommitGraph,
  23. CommitGraphChunk,
  24. CommitGraphEntry,
  25. find_commit_graph_file,
  26. generate_commit_graph,
  27. get_reachable_commits,
  28. read_commit_graph,
  29. )
  30. class CommitGraphEntryTests(unittest.TestCase):
  31. """Tests for CommitGraphEntry."""
  32. def test_init(self):
  33. commit_id = b"a" * 40
  34. tree_id = b"b" * 40
  35. parents = [b"c" * 40, b"d" * 40]
  36. generation = 42
  37. commit_time = 1234567890
  38. entry = CommitGraphEntry(commit_id, tree_id, parents, generation, commit_time)
  39. self.assertEqual(entry.commit_id, commit_id)
  40. self.assertEqual(entry.tree_id, tree_id)
  41. self.assertEqual(entry.parents, parents)
  42. self.assertEqual(entry.generation, generation)
  43. self.assertEqual(entry.commit_time, commit_time)
  44. def test_repr(self):
  45. entry = CommitGraphEntry(b"a" * 40, b"b" * 40, [], 1, 1000)
  46. repr_str = repr(entry)
  47. self.assertIn("CommitGraphEntry", repr_str)
  48. self.assertIn("generation=1", repr_str)
  49. class CommitGraphChunkTests(unittest.TestCase):
  50. """Tests for CommitGraphChunk."""
  51. def test_init(self):
  52. chunk = CommitGraphChunk(b"TEST", b"test data")
  53. self.assertEqual(chunk.chunk_id, b"TEST")
  54. self.assertEqual(chunk.data, b"test data")
  55. def test_repr(self):
  56. chunk = CommitGraphChunk(b"TEST", b"x" * 100)
  57. repr_str = repr(chunk)
  58. self.assertIn("CommitGraphChunk", repr_str)
  59. self.assertIn("size=100", repr_str)
  60. class CommitGraphTests(unittest.TestCase):
  61. """Tests for CommitGraph."""
  62. def test_init(self):
  63. graph = CommitGraph()
  64. self.assertEqual(graph.hash_version, HASH_VERSION_SHA1)
  65. self.assertEqual(len(graph.entries), 0)
  66. self.assertEqual(len(graph.chunks), 0)
  67. def test_len(self):
  68. graph = CommitGraph()
  69. self.assertEqual(len(graph), 0)
  70. # Add a dummy entry
  71. entry = CommitGraphEntry(b"a" * 40, b"b" * 40, [], 1, 1000)
  72. graph.entries.append(entry)
  73. self.assertEqual(len(graph), 1)
  74. def test_iter(self):
  75. graph = CommitGraph()
  76. entry1 = CommitGraphEntry(b"a" * 40, b"b" * 40, [], 1, 1000)
  77. entry2 = CommitGraphEntry(b"c" * 40, b"d" * 40, [], 2, 2000)
  78. graph.entries.extend([entry1, entry2])
  79. entries = list(graph)
  80. self.assertEqual(len(entries), 2)
  81. self.assertEqual(entries[0], entry1)
  82. self.assertEqual(entries[1], entry2)
  83. def test_get_entry_by_oid_missing(self):
  84. graph = CommitGraph()
  85. result = graph.get_entry_by_oid(b"f" * 40)
  86. self.assertIsNone(result)
  87. def test_get_generation_number_missing(self):
  88. graph = CommitGraph()
  89. result = graph.get_generation_number(b"f" * 40)
  90. self.assertIsNone(result)
  91. def test_get_parents_missing(self):
  92. graph = CommitGraph()
  93. result = graph.get_parents(b"f" * 40)
  94. self.assertIsNone(result)
  95. def test_from_invalid_signature(self):
  96. data = b"XXXX" + b"\\x00" * 100
  97. f = io.BytesIO(data)
  98. with self.assertRaises(ValueError) as cm:
  99. CommitGraph.from_file(f)
  100. self.assertIn("Invalid commit graph signature", str(cm.exception))
  101. def test_from_invalid_version(self):
  102. data = COMMIT_GRAPH_SIGNATURE + struct.pack(">B", 99) + b"\\x00" * 100
  103. f = io.BytesIO(data)
  104. with self.assertRaises(ValueError) as cm:
  105. CommitGraph.from_file(f)
  106. self.assertIn("Unsupported commit graph version", str(cm.exception))
  107. def test_from_invalid_hash_version(self):
  108. data = (
  109. COMMIT_GRAPH_SIGNATURE
  110. + struct.pack(">B", COMMIT_GRAPH_VERSION)
  111. + struct.pack(">B", 99) # Invalid hash version
  112. + b"\\x00" * 100
  113. )
  114. f = io.BytesIO(data)
  115. with self.assertRaises(ValueError) as cm:
  116. CommitGraph.from_file(f)
  117. self.assertIn("Unsupported hash version", str(cm.exception))
  118. def create_minimal_commit_graph_data(self):
  119. """Create minimal valid commit graph data for testing."""
  120. # Create the data in order and calculate offsets properly
  121. # Header: signature + version + hash_version + num_chunks + base_graph_count
  122. header = (
  123. COMMIT_GRAPH_SIGNATURE
  124. + struct.pack(">B", COMMIT_GRAPH_VERSION)
  125. + struct.pack(">B", HASH_VERSION_SHA1)
  126. + struct.pack(">B", 3) # 3 chunks
  127. + struct.pack(">B", 0)
  128. ) # 0 base graphs
  129. # Table of contents: 4 entries (3 chunks + terminator) = 4 * 12 = 48 bytes
  130. toc_size = 4 * 12
  131. # Calculate chunk offsets from start of file
  132. header_size = 8
  133. chunk1_offset = header_size + toc_size # OID Fanout
  134. chunk2_offset = chunk1_offset + 256 * 4 # OID Lookup (after fanout)
  135. chunk3_offset = chunk2_offset + 20 # Commit Data (after 1 commit * 20 bytes)
  136. terminator_offset = (
  137. chunk3_offset + 36
  138. ) # After commit data (1 commit * 36 bytes)
  139. # Build table of contents
  140. toc = (
  141. CHUNK_OID_FANOUT
  142. + struct.pack(">Q", chunk1_offset)
  143. + CHUNK_OID_LOOKUP
  144. + struct.pack(">Q", chunk2_offset)
  145. + CHUNK_COMMIT_DATA
  146. + struct.pack(">Q", chunk3_offset)
  147. + b"\x00\x00\x00\x00"
  148. + struct.pack(">Q", terminator_offset)
  149. )
  150. # OID Fanout chunk (256 * 4 bytes)
  151. fanout = b""
  152. for i in range(256):
  153. if i < 0xAA: # Our test commit starts with 0xaa
  154. fanout += struct.pack(">L", 0)
  155. else:
  156. fanout += struct.pack(">L", 1) # 1 commit total
  157. # OID Lookup chunk (1 commit = 20 bytes)
  158. commit_oid = b"\xaa" + b"\x00" * 19
  159. oid_lookup = commit_oid
  160. # Commit Data chunk (1 commit = 20 + 16 = 36 bytes)
  161. tree_oid = b"\xbb" + b"\x00" * 19
  162. parent1_pos = 0x70000000 # GRAPH_PARENT_MISSING
  163. parent2_pos = 0x70000000 # GRAPH_PARENT_MISSING
  164. generation = 1
  165. commit_time = 1234567890
  166. gen_and_time = (generation << 2) | (commit_time >> 32)
  167. commit_data = (
  168. tree_oid
  169. + struct.pack(">LL", parent1_pos, parent2_pos)
  170. + struct.pack(">LL", gen_and_time, commit_time & 0xFFFFFFFF)
  171. )
  172. return header + toc + fanout + oid_lookup + commit_data
  173. def test_from_minimal_valid_file(self):
  174. """Test parsing a minimal but valid commit graph file."""
  175. data = self.create_minimal_commit_graph_data()
  176. f = io.BytesIO(data)
  177. graph = CommitGraph.from_file(f)
  178. self.assertEqual(graph.hash_version, HASH_VERSION_SHA1)
  179. self.assertEqual(len(graph), 1)
  180. # Check the parsed entry
  181. entry = graph.entries[0]
  182. self.assertEqual(entry.commit_id, b"aa" + b"00" * 19)
  183. self.assertEqual(entry.tree_id, b"bb" + b"00" * 19)
  184. self.assertEqual(entry.parents, []) # No parents
  185. self.assertEqual(entry.generation, 1)
  186. self.assertEqual(entry.commit_time, 1234567890)
  187. # Test lookup methods
  188. commit_oid = b"aa" + b"00" * 19
  189. self.assertEqual(graph.get_generation_number(commit_oid), 1)
  190. self.assertEqual(graph.get_parents(commit_oid), [])
  191. self.assertIsNotNone(graph.get_entry_by_oid(commit_oid))
  192. def test_missing_required_chunks(self):
  193. """Test error handling for missing required chunks."""
  194. # Create data with header but no chunks
  195. header = (
  196. COMMIT_GRAPH_SIGNATURE
  197. + struct.pack(">B", COMMIT_GRAPH_VERSION)
  198. + struct.pack(">B", HASH_VERSION_SHA1)
  199. + struct.pack(">B", 0) # 0 chunks
  200. + struct.pack(">B", 0)
  201. )
  202. # TOC with just terminator
  203. toc = b"\\x00\\x00\\x00\\x00" + struct.pack(">Q", 12)
  204. data = header + toc
  205. f = io.BytesIO(data)
  206. with self.assertRaises(ValueError) as cm:
  207. CommitGraph.from_file(f)
  208. self.assertIn("Missing required OID lookup chunk", str(cm.exception))
  209. def test_write_empty_graph_raises(self):
  210. """Test that writing empty graph raises ValueError."""
  211. graph = CommitGraph()
  212. f = io.BytesIO()
  213. with self.assertRaises(ValueError):
  214. graph.write_to_file(f)
  215. def test_write_and_read_round_trip(self):
  216. """Test writing and reading a commit graph."""
  217. # Create a simple commit graph
  218. graph = CommitGraph()
  219. entry = CommitGraphEntry(
  220. commit_id=b"aa" + b"00" * 19,
  221. tree_id=b"bb" + b"00" * 19,
  222. parents=[],
  223. generation=1,
  224. commit_time=1234567890,
  225. )
  226. graph.entries.append(entry)
  227. graph._oid_to_index = {bytes.fromhex(entry.commit_id.decode()): 0}
  228. # Write to bytes
  229. f = io.BytesIO()
  230. graph.write_to_file(f)
  231. # Read back
  232. f.seek(0)
  233. read_graph = CommitGraph.from_file(f)
  234. # Verify
  235. self.assertEqual(len(read_graph), 1)
  236. read_entry = read_graph.entries[0]
  237. self.assertEqual(read_entry.commit_id, entry.commit_id)
  238. self.assertEqual(read_entry.tree_id, entry.tree_id)
  239. self.assertEqual(read_entry.parents, entry.parents)
  240. self.assertEqual(read_entry.generation, entry.generation)
  241. self.assertEqual(read_entry.commit_time, entry.commit_time)
  242. class CommitGraphFileOperationsTests(unittest.TestCase):
  243. """Tests for commit graph file operations."""
  244. def setUp(self):
  245. self.tempdir = tempfile.mkdtemp()
  246. def tearDown(self):
  247. import shutil
  248. shutil.rmtree(self.tempdir, ignore_errors=True)
  249. def test_read_commit_graph_missing_file(self):
  250. """Test reading from non-existent file."""
  251. missing_path = os.path.join(self.tempdir, "missing.graph")
  252. result = read_commit_graph(missing_path)
  253. self.assertIsNone(result)
  254. def test_read_commit_graph_invalid_file(self):
  255. """Test reading from invalid file."""
  256. invalid_path = os.path.join(self.tempdir, "invalid.graph")
  257. with open(invalid_path, "wb") as f:
  258. f.write(b"invalid data")
  259. with self.assertRaises(ValueError):
  260. read_commit_graph(invalid_path)
  261. def test_find_commit_graph_file_missing(self):
  262. """Test finding commit graph file when it doesn't exist."""
  263. result = find_commit_graph_file(self.tempdir)
  264. self.assertIsNone(result)
  265. def test_find_commit_graph_file_standard_location(self):
  266. """Test finding commit graph file in standard location."""
  267. # Create .git/objects/info/commit-graph
  268. objects_dir = os.path.join(self.tempdir, "objects")
  269. info_dir = os.path.join(objects_dir, "info")
  270. os.makedirs(info_dir)
  271. graph_path = os.path.join(info_dir, "commit-graph")
  272. with open(graph_path, "wb") as f:
  273. f.write(b"dummy")
  274. result = find_commit_graph_file(self.tempdir)
  275. self.assertEqual(result, graph_path.encode())
  276. def test_find_commit_graph_file_chain_location(self):
  277. """Test finding commit graph file in chain location."""
  278. # Create .git/objects/info/commit-graphs/graph-{hash}.graph
  279. objects_dir = os.path.join(self.tempdir, "objects")
  280. info_dir = os.path.join(objects_dir, "info")
  281. graphs_dir = os.path.join(info_dir, "commit-graphs")
  282. os.makedirs(graphs_dir)
  283. graph_path = os.path.join(graphs_dir, "graph-abc123.graph")
  284. with open(graph_path, "wb") as f:
  285. f.write(b"dummy")
  286. result = find_commit_graph_file(self.tempdir)
  287. self.assertEqual(result, graph_path.encode())
  288. def test_find_commit_graph_file_prefers_standard(self):
  289. """Test that standard location is preferred over chain location."""
  290. # Create both locations
  291. objects_dir = os.path.join(self.tempdir, "objects")
  292. info_dir = os.path.join(objects_dir, "info")
  293. graphs_dir = os.path.join(info_dir, "commit-graphs")
  294. os.makedirs(info_dir)
  295. os.makedirs(graphs_dir)
  296. # Standard location
  297. standard_path = os.path.join(info_dir, "commit-graph")
  298. with open(standard_path, "wb") as f:
  299. f.write(b"standard")
  300. # Chain location
  301. chain_path = os.path.join(graphs_dir, "graph-abc123.graph")
  302. with open(chain_path, "wb") as f:
  303. f.write(b"chain")
  304. result = find_commit_graph_file(self.tempdir)
  305. self.assertEqual(result, standard_path.encode())
  306. class CommitGraphGenerationTests(unittest.TestCase):
  307. """Tests for commit graph generation functionality."""
  308. def setUp(self):
  309. self.tempdir = tempfile.mkdtemp()
  310. def tearDown(self):
  311. import shutil
  312. shutil.rmtree(self.tempdir, ignore_errors=True)
  313. def test_generate_commit_graph_empty(self):
  314. """Test generating commit graph with no commits."""
  315. from dulwich.object_store import MemoryObjectStore
  316. object_store = MemoryObjectStore()
  317. graph = generate_commit_graph(object_store, [])
  318. self.assertEqual(len(graph), 0)
  319. def test_generate_commit_graph_single_commit(self):
  320. """Test generating commit graph with single commit."""
  321. from dulwich.object_store import MemoryObjectStore
  322. from dulwich.objects import Commit, Tree
  323. object_store = MemoryObjectStore()
  324. # Create a tree and commit
  325. tree = Tree()
  326. object_store.add_object(tree)
  327. commit = Commit()
  328. commit.tree = tree.id
  329. commit.author = b"Test Author <test@example.com>"
  330. commit.committer = b"Test Author <test@example.com>"
  331. commit.commit_time = commit.author_time = 1234567890
  332. commit.commit_timezone = commit.author_timezone = 0
  333. commit.message = b"Test commit"
  334. object_store.add_object(commit)
  335. # Generate graph
  336. graph = generate_commit_graph(object_store, [commit.id])
  337. self.assertEqual(len(graph), 1)
  338. entry = graph.entries[0]
  339. self.assertEqual(entry.commit_id, commit.id)
  340. self.assertEqual(entry.tree_id, commit.tree)
  341. self.assertEqual(entry.parents, [])
  342. self.assertEqual(entry.generation, 1)
  343. self.assertEqual(entry.commit_time, 1234567890)
  344. def test_get_reachable_commits(self):
  345. """Test getting reachable commits."""
  346. from dulwich.object_store import MemoryObjectStore
  347. from dulwich.objects import Commit, Tree
  348. object_store = MemoryObjectStore()
  349. # Create tree
  350. tree = Tree()
  351. object_store.add_object(tree)
  352. # Create commit chain: commit1 -> commit2
  353. commit1 = Commit()
  354. commit1.tree = tree.id
  355. commit1.author = commit1.committer = b"Test <test@example.com>"
  356. commit1.commit_time = commit1.author_time = 1234567890
  357. commit1.commit_timezone = commit1.author_timezone = 0
  358. commit1.message = b"First commit"
  359. object_store.add_object(commit1)
  360. commit2 = Commit()
  361. commit2.tree = tree.id
  362. commit2.parents = [commit1.id]
  363. commit2.author = commit2.committer = b"Test <test@example.com>"
  364. commit2.commit_time = commit2.author_time = 1234567891
  365. commit2.commit_timezone = commit2.author_timezone = 0
  366. commit2.message = b"Second commit"
  367. object_store.add_object(commit2)
  368. # Get reachable commits from commit2
  369. reachable = get_reachable_commits(object_store, [commit2.id])
  370. # Should include both commits
  371. self.assertEqual(len(reachable), 2)
  372. self.assertIn(commit1.id, reachable)
  373. self.assertIn(commit2.id, reachable)
  374. def test_write_commit_graph_to_file(self):
  375. """Test writing commit graph to file."""
  376. from dulwich.object_store import DiskObjectStore
  377. from dulwich.objects import Commit, Tree
  378. # Create a disk object store
  379. object_store_path = os.path.join(self.tempdir, "objects")
  380. os.makedirs(object_store_path, exist_ok=True)
  381. object_store = DiskObjectStore(object_store_path)
  382. # Create a tree and commit
  383. tree = Tree()
  384. object_store.add_object(tree)
  385. commit = Commit()
  386. commit.tree = tree.id
  387. commit.author = b"Test Author <test@example.com>"
  388. commit.committer = b"Test Author <test@example.com>"
  389. commit.commit_time = commit.author_time = 1234567890
  390. commit.commit_timezone = commit.author_timezone = 0
  391. commit.message = b"Test commit"
  392. object_store.add_object(commit)
  393. # Write commit graph using ObjectStore method
  394. object_store.write_commit_graph([commit.id], reachable=False)
  395. # Verify file was created
  396. graph_path = os.path.join(object_store_path, "info", "commit-graph")
  397. self.assertTrue(os.path.exists(graph_path))
  398. # Read back and verify
  399. graph = read_commit_graph(graph_path)
  400. self.assertIsNotNone(graph)
  401. self.assertEqual(len(graph), 1)
  402. entry = graph.entries[0]
  403. self.assertEqual(entry.commit_id, commit.id)
  404. self.assertEqual(entry.tree_id, commit.tree)
  405. def test_object_store_commit_graph_methods(self):
  406. """Test ObjectStore commit graph methods."""
  407. from dulwich.object_store import DiskObjectStore
  408. from dulwich.objects import Commit, Tree
  409. # Create a disk object store
  410. object_store_path = os.path.join(self.tempdir, "objects")
  411. os.makedirs(object_store_path, exist_ok=True)
  412. object_store = DiskObjectStore(object_store_path)
  413. # Initially no commit graph
  414. self.assertIsNone(object_store.get_commit_graph())
  415. # Create a tree and commit
  416. tree = Tree()
  417. object_store.add_object(tree)
  418. commit = Commit()
  419. commit.tree = tree.id
  420. commit.author = b"Test Author <test@example.com>"
  421. commit.committer = b"Test Author <test@example.com>"
  422. commit.commit_time = commit.author_time = 1234567890
  423. commit.commit_timezone = commit.author_timezone = 0
  424. commit.message = b"Test commit"
  425. object_store.add_object(commit)
  426. # Write commit graph (disable reachable to avoid traversal issue)
  427. object_store.write_commit_graph([commit.id], reachable=False)
  428. # Now should have commit graph
  429. self.assertIsNotNone(object_store.get_commit_graph())
  430. # Test update (should still have commit graph)
  431. object_store.write_commit_graph()
  432. self.assertIsNot(None, object_store.get_commit_graph())
  433. def test_parents_provider_commit_graph_integration(self):
  434. """Test that ParentsProvider uses commit graph when available."""
  435. from dulwich.object_store import DiskObjectStore
  436. from dulwich.objects import Commit, Tree
  437. from dulwich.repo import ParentsProvider
  438. # Create a disk object store
  439. object_store_path = os.path.join(self.tempdir, "objects")
  440. os.makedirs(object_store_path, exist_ok=True)
  441. object_store = DiskObjectStore(object_store_path)
  442. # Create a tree and two commits
  443. tree = Tree()
  444. object_store.add_object(tree)
  445. # First commit (no parents)
  446. commit1 = Commit()
  447. commit1.tree = tree.id
  448. commit1.author = commit1.committer = b"Test <test@example.com>"
  449. commit1.commit_time = commit1.author_time = 1234567890
  450. commit1.commit_timezone = commit1.author_timezone = 0
  451. commit1.message = b"First commit"
  452. object_store.add_object(commit1)
  453. # Second commit (child of first)
  454. commit2 = Commit()
  455. commit2.tree = tree.id
  456. commit2.parents = [commit1.id]
  457. commit2.author = commit2.committer = b"Test <test@example.com>"
  458. commit2.commit_time = commit2.author_time = 1234567891
  459. commit2.commit_timezone = commit2.author_timezone = 0
  460. commit2.message = b"Second commit"
  461. object_store.add_object(commit2)
  462. # Write commit graph
  463. object_store.write_commit_graph([commit1.id, commit2.id], reachable=False)
  464. # Test ParentsProvider with commit graph
  465. provider = ParentsProvider(object_store)
  466. # Verify commit graph is loaded
  467. self.assertIsNotNone(provider.commit_graph)
  468. # Test parent lookups
  469. parents1 = provider.get_parents(commit1.id)
  470. self.assertEqual(parents1, [])
  471. parents2 = provider.get_parents(commit2.id)
  472. self.assertEqual(parents2, [commit1.id])
  473. # Test fallback behavior by creating provider without commit graph
  474. object_store_no_graph_path = os.path.join(self.tempdir, "objects2")
  475. os.makedirs(object_store_no_graph_path, exist_ok=True)
  476. object_store_no_graph = DiskObjectStore(object_store_no_graph_path)
  477. object_store_no_graph.add_object(tree)
  478. object_store_no_graph.add_object(commit1)
  479. object_store_no_graph.add_object(commit2)
  480. provider_no_graph = ParentsProvider(object_store_no_graph)
  481. self.assertIsNone(provider_no_graph.commit_graph)
  482. # Should still work via commit object fallback
  483. parents1_fallback = provider_no_graph.get_parents(commit1.id)
  484. self.assertEqual(parents1_fallback, [])
  485. parents2_fallback = provider_no_graph.get_parents(commit2.id)
  486. self.assertEqual(parents2_fallback, [commit1.id])
  487. def test_graph_operations_use_commit_graph(self):
  488. """Test that graph operations use commit graph when available."""
  489. from dulwich.graph import can_fast_forward, find_merge_base
  490. from dulwich.object_store import DiskObjectStore
  491. from dulwich.objects import Commit, Tree
  492. from dulwich.repo import Repo
  493. # Create a disk object store
  494. object_store_path = os.path.join(self.tempdir, "objects")
  495. os.makedirs(object_store_path, exist_ok=True)
  496. object_store = DiskObjectStore(object_store_path)
  497. # Create a tree and a more complex commit graph for testing
  498. tree = Tree()
  499. object_store.add_object(tree)
  500. # Create commit chain: commit1 -> commit2 -> commit3
  501. # \-> commit4 -> commit5 (merge)
  502. commit1 = Commit()
  503. commit1.tree = tree.id
  504. commit1.author = commit1.committer = b"Test <test@example.com>"
  505. commit1.commit_time = commit1.author_time = 1234567890
  506. commit1.commit_timezone = commit1.author_timezone = 0
  507. commit1.message = b"First commit"
  508. object_store.add_object(commit1)
  509. commit2 = Commit()
  510. commit2.tree = tree.id
  511. commit2.parents = [commit1.id]
  512. commit2.author = commit2.committer = b"Test <test@example.com>"
  513. commit2.commit_time = commit2.author_time = 1234567891
  514. commit2.commit_timezone = commit2.author_timezone = 0
  515. commit2.message = b"Second commit"
  516. object_store.add_object(commit2)
  517. commit3 = Commit()
  518. commit3.tree = tree.id
  519. commit3.parents = [commit2.id]
  520. commit3.author = commit3.committer = b"Test <test@example.com>"
  521. commit3.commit_time = commit3.author_time = 1234567892
  522. commit3.commit_timezone = commit3.author_timezone = 0
  523. commit3.message = b"Third commit"
  524. object_store.add_object(commit3)
  525. # Branch from commit2
  526. commit4 = Commit()
  527. commit4.tree = tree.id
  528. commit4.parents = [commit2.id]
  529. commit4.author = commit4.committer = b"Test <test@example.com>"
  530. commit4.commit_time = commit4.author_time = 1234567893
  531. commit4.commit_timezone = commit4.author_timezone = 0
  532. commit4.message = b"Fourth commit (branch)"
  533. object_store.add_object(commit4)
  534. # Merge commit
  535. commit5 = Commit()
  536. commit5.tree = tree.id
  537. commit5.parents = [commit3.id, commit4.id]
  538. commit5.author = commit5.committer = b"Test <test@example.com>"
  539. commit5.commit_time = commit5.author_time = 1234567894
  540. commit5.commit_timezone = commit5.author_timezone = 0
  541. commit5.message = b"Merge commit"
  542. object_store.add_object(commit5)
  543. # Create refs
  544. refs_path = os.path.join(self.tempdir, "refs")
  545. os.makedirs(refs_path, exist_ok=True)
  546. repo_path = self.tempdir
  547. repo = Repo.init(repo_path)
  548. repo.object_store = object_store
  549. # Test graph operations WITHOUT commit graph first
  550. merge_base_no_graph = find_merge_base(repo, [commit3.id, commit4.id])
  551. can_ff_no_graph = can_fast_forward(repo, commit1.id, commit3.id)
  552. # Now write commit graph
  553. object_store.write_commit_graph(
  554. [commit1.id, commit2.id, commit3.id, commit4.id, commit5.id],
  555. reachable=False,
  556. )
  557. # Verify commit graph is loaded by creating new repo instance
  558. repo2 = Repo(repo_path)
  559. repo2.object_store = object_store
  560. # Verify commit graph is available
  561. commit_graph = repo2.object_store.get_commit_graph()
  562. self.assertIsNotNone(commit_graph)
  563. # Test graph operations WITH commit graph
  564. merge_base_with_graph = find_merge_base(repo2, [commit3.id, commit4.id])
  565. can_ff_with_graph = can_fast_forward(repo2, commit1.id, commit3.id)
  566. # Results should be identical
  567. self.assertEqual(
  568. merge_base_no_graph,
  569. merge_base_with_graph,
  570. "Merge base should be same with and without commit graph",
  571. )
  572. self.assertEqual(
  573. can_ff_no_graph,
  574. can_ff_with_graph,
  575. "Fast-forward detection should be same with and without commit graph",
  576. )
  577. # Expected results
  578. self.assertEqual(
  579. merge_base_with_graph,
  580. [commit2.id],
  581. "Merge base of commit3 and commit4 should be commit2",
  582. )
  583. self.assertTrue(
  584. can_ff_with_graph, "Should be able to fast-forward from commit1 to commit3"
  585. )
  586. # Test that ParentsProvider in the repo uses commit graph
  587. parents_provider = repo2.parents_provider()
  588. self.assertIsNotNone(
  589. parents_provider.commit_graph,
  590. "Repository's parents provider should have commit graph",
  591. )
  592. # Verify parent lookups work through the provider
  593. self.assertEqual(parents_provider.get_parents(commit1.id), [])
  594. self.assertEqual(parents_provider.get_parents(commit2.id), [commit1.id])
  595. self.assertEqual(
  596. parents_provider.get_parents(commit5.id), [commit3.id, commit4.id]
  597. )
  598. def test_performance_with_commit_graph(self):
  599. """Test that using commit graph provides performance benefits."""
  600. from dulwich.graph import find_merge_base
  601. from dulwich.object_store import DiskObjectStore
  602. from dulwich.objects import Commit, Tree
  603. from dulwich.repo import Repo
  604. # Create a larger commit history to better measure performance
  605. object_store_path = os.path.join(self.tempdir, "objects")
  606. os.makedirs(object_store_path, exist_ok=True)
  607. object_store = DiskObjectStore(object_store_path)
  608. tree = Tree()
  609. object_store.add_object(tree)
  610. # Create a chain of 20 commits
  611. commits = []
  612. for i in range(20):
  613. commit = Commit()
  614. commit.tree = tree.id
  615. if i > 0:
  616. commit.parents = [commits[i - 1].id]
  617. commit.author = commit.committer = b"Test <test@example.com>"
  618. commit.commit_time = commit.author_time = 1234567890 + i
  619. commit.commit_timezone = commit.author_timezone = 0
  620. commit.message = f"Commit {i}".encode()
  621. object_store.add_object(commit)
  622. commits.append(commit)
  623. # Create repository
  624. repo_path = self.tempdir
  625. repo = Repo.init(repo_path)
  626. repo.object_store = object_store
  627. # Time operations without commit graph
  628. for _ in range(10): # Run multiple times for better measurement
  629. find_merge_base(repo, [commits[0].id, commits[-1].id])
  630. # Write commit graph
  631. object_store.write_commit_graph([c.id for c in commits], reachable=False)
  632. # Create new repo instance to pick up commit graph
  633. repo2 = Repo(repo_path)
  634. repo2.object_store = object_store
  635. # Verify commit graph is loaded
  636. self.assertIsNotNone(repo2.object_store.get_commit_graph())
  637. # Time operations with commit graph
  638. for _ in range(10): # Run multiple times for better measurement
  639. find_merge_base(repo2, [commits[0].id, commits[-1].id])
  640. # With commit graph should be at least as fast (usually faster)
  641. # We don't assert a specific speedup since it depends on the machine
  642. # But we verify both approaches give the same result
  643. result_no_graph = find_merge_base(repo, [commits[0].id, commits[-1].id])
  644. result_with_graph = find_merge_base(repo2, [commits[0].id, commits[-1].id])
  645. self.assertEqual(
  646. result_no_graph,
  647. result_with_graph,
  648. "Results should be identical with and without commit graph",
  649. )
  650. self.assertEqual(
  651. result_with_graph, [commits[0].id], "Merge base should be the first commit"
  652. )
  653. if __name__ == "__main__":
  654. unittest.main()