2
0

test_index.py 82 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413
  1. # test_index.py -- Tests for the git index
  2. # Copyright (C) 2008-2009 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. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for the index."""
  22. import os
  23. import shutil
  24. import stat
  25. import struct
  26. import sys
  27. import tempfile
  28. from io import BytesIO
  29. from dulwich.index import (
  30. Index,
  31. IndexEntry,
  32. SerializedIndexEntry,
  33. _compress_path,
  34. _decode_varint,
  35. _decompress_path,
  36. _encode_varint,
  37. _fs_to_tree_path,
  38. _tree_to_fs_path,
  39. build_index_from_tree,
  40. cleanup_mode,
  41. commit_tree,
  42. get_unstaged_changes,
  43. index_entry_from_directory,
  44. index_entry_from_path,
  45. index_entry_from_stat,
  46. iter_fresh_entries,
  47. read_index,
  48. read_index_dict,
  49. update_working_tree,
  50. validate_path_element_default,
  51. validate_path_element_ntfs,
  52. write_cache_time,
  53. write_index,
  54. write_index_dict,
  55. )
  56. from dulwich.object_store import MemoryObjectStore
  57. from dulwich.objects import S_IFGITLINK, Blob, Commit, Tree
  58. from dulwich.repo import Repo
  59. from . import TestCase, skipIf
  60. def can_symlink() -> bool:
  61. """Return whether running process can create symlinks."""
  62. if sys.platform != "win32":
  63. # Platforms other than Windows should allow symlinks without issues.
  64. return True
  65. test_source = tempfile.mkdtemp()
  66. test_target = test_source + "can_symlink"
  67. try:
  68. os.symlink(test_source, test_target)
  69. except (NotImplementedError, OSError):
  70. return False
  71. return True
  72. class IndexTestCase(TestCase):
  73. datadir = os.path.join(os.path.dirname(__file__), "../testdata/indexes")
  74. def get_simple_index(self, name):
  75. return Index(os.path.join(self.datadir, name))
  76. class SimpleIndexTestCase(IndexTestCase):
  77. def test_len(self) -> None:
  78. self.assertEqual(1, len(self.get_simple_index("index")))
  79. def test_iter(self) -> None:
  80. self.assertEqual([b"bla"], list(self.get_simple_index("index")))
  81. def test_iter_skip_hash(self) -> None:
  82. self.assertEqual([b"bla"], list(self.get_simple_index("index_skip_hash")))
  83. def test_iterobjects(self) -> None:
  84. self.assertEqual(
  85. [(b"bla", b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", 33188)],
  86. list(self.get_simple_index("index").iterobjects()),
  87. )
  88. def test_getitem(self) -> None:
  89. self.assertEqual(
  90. IndexEntry(
  91. (1230680220, 0),
  92. (1230680220, 0),
  93. 2050,
  94. 3761020,
  95. 33188,
  96. 1000,
  97. 1000,
  98. 0,
  99. b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
  100. 0,
  101. 0,
  102. ),
  103. self.get_simple_index("index")[b"bla"],
  104. )
  105. def test_empty(self) -> None:
  106. i = self.get_simple_index("notanindex")
  107. self.assertEqual(0, len(i))
  108. self.assertFalse(os.path.exists(i._filename))
  109. def test_against_empty_tree(self) -> None:
  110. i = self.get_simple_index("index")
  111. changes = list(i.changes_from_tree(MemoryObjectStore(), None))
  112. self.assertEqual(1, len(changes))
  113. (oldname, newname), (oldmode, newmode), (oldsha, newsha) = changes[0]
  114. self.assertEqual(b"bla", newname)
  115. self.assertEqual(b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", newsha)
  116. def test_index_pathlib(self) -> None:
  117. import tempfile
  118. from pathlib import Path
  119. # Create a temporary index file
  120. with tempfile.NamedTemporaryFile(suffix=".index", delete=False) as f:
  121. temp_path = f.name
  122. try:
  123. # Test creating Index with pathlib.Path
  124. path_obj = Path(temp_path)
  125. index = Index(path_obj, read=False)
  126. self.assertEqual(str(path_obj), index.path)
  127. # Add an entry and write
  128. index[b"test"] = IndexEntry(
  129. ctime=(0, 0),
  130. mtime=(0, 0),
  131. dev=0,
  132. ino=0,
  133. mode=33188,
  134. uid=0,
  135. gid=0,
  136. size=0,
  137. sha=b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
  138. )
  139. index.write()
  140. # Read it back with pathlib.Path
  141. index2 = Index(path_obj)
  142. self.assertIn(b"test", index2)
  143. finally:
  144. # Clean up
  145. os.unlink(temp_path)
  146. class SimpleIndexWriterTestCase(IndexTestCase):
  147. def setUp(self) -> None:
  148. IndexTestCase.setUp(self)
  149. self.tempdir = tempfile.mkdtemp()
  150. def tearDown(self) -> None:
  151. IndexTestCase.tearDown(self)
  152. shutil.rmtree(self.tempdir)
  153. def test_simple_write(self) -> None:
  154. entries = [
  155. (
  156. SerializedIndexEntry(
  157. b"barbla",
  158. (1230680220, 0),
  159. (1230680220, 0),
  160. 2050,
  161. 3761020,
  162. 33188,
  163. 1000,
  164. 1000,
  165. 0,
  166. b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
  167. 0,
  168. 0,
  169. )
  170. )
  171. ]
  172. filename = os.path.join(self.tempdir, "test-simple-write-index")
  173. with open(filename, "wb+") as x:
  174. write_index(x, entries)
  175. with open(filename, "rb") as x:
  176. self.assertEqual(entries, list(read_index(x)))
  177. class ReadIndexDictTests(IndexTestCase):
  178. def setUp(self) -> None:
  179. IndexTestCase.setUp(self)
  180. self.tempdir = tempfile.mkdtemp()
  181. def tearDown(self) -> None:
  182. IndexTestCase.tearDown(self)
  183. shutil.rmtree(self.tempdir)
  184. def test_simple_write(self) -> None:
  185. entries = {
  186. b"barbla": IndexEntry(
  187. (1230680220, 0),
  188. (1230680220, 0),
  189. 2050,
  190. 3761020,
  191. 33188,
  192. 1000,
  193. 1000,
  194. 0,
  195. b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
  196. 0,
  197. 0,
  198. )
  199. }
  200. filename = os.path.join(self.tempdir, "test-simple-write-index")
  201. with open(filename, "wb+") as x:
  202. write_index_dict(x, entries)
  203. with open(filename, "rb") as x:
  204. self.assertEqual(entries, read_index_dict(x))
  205. class CommitTreeTests(TestCase):
  206. def setUp(self) -> None:
  207. super().setUp()
  208. self.store = MemoryObjectStore()
  209. def test_single_blob(self) -> None:
  210. blob = Blob()
  211. blob.data = b"foo"
  212. self.store.add_object(blob)
  213. blobs = [(b"bla", blob.id, stat.S_IFREG)]
  214. rootid = commit_tree(self.store, blobs)
  215. self.assertEqual(rootid, b"1a1e80437220f9312e855c37ac4398b68e5c1d50")
  216. self.assertEqual((stat.S_IFREG, blob.id), self.store[rootid][b"bla"])
  217. self.assertEqual({rootid, blob.id}, set(self.store._data.keys()))
  218. def test_nested(self) -> None:
  219. blob = Blob()
  220. blob.data = b"foo"
  221. self.store.add_object(blob)
  222. blobs = [(b"bla/bar", blob.id, stat.S_IFREG)]
  223. rootid = commit_tree(self.store, blobs)
  224. self.assertEqual(rootid, b"d92b959b216ad0d044671981196781b3258fa537")
  225. dirid = self.store[rootid][b"bla"][1]
  226. self.assertEqual(dirid, b"c1a1deb9788150829579a8b4efa6311e7b638650")
  227. self.assertEqual((stat.S_IFDIR, dirid), self.store[rootid][b"bla"])
  228. self.assertEqual((stat.S_IFREG, blob.id), self.store[dirid][b"bar"])
  229. self.assertEqual({rootid, dirid, blob.id}, set(self.store._data.keys()))
  230. class CleanupModeTests(TestCase):
  231. def assertModeEqual(self, expected, got) -> None:
  232. self.assertEqual(expected, got, f"{expected:o} != {got:o}")
  233. def test_file(self) -> None:
  234. self.assertModeEqual(0o100644, cleanup_mode(0o100000))
  235. def test_executable(self) -> None:
  236. self.assertModeEqual(0o100755, cleanup_mode(0o100711))
  237. self.assertModeEqual(0o100755, cleanup_mode(0o100700))
  238. def test_symlink(self) -> None:
  239. self.assertModeEqual(0o120000, cleanup_mode(0o120711))
  240. def test_dir(self) -> None:
  241. self.assertModeEqual(0o040000, cleanup_mode(0o40531))
  242. def test_submodule(self) -> None:
  243. self.assertModeEqual(0o160000, cleanup_mode(0o160744))
  244. class WriteCacheTimeTests(TestCase):
  245. def test_write_string(self) -> None:
  246. f = BytesIO()
  247. self.assertRaises(TypeError, write_cache_time, f, "foo")
  248. def test_write_int(self) -> None:
  249. f = BytesIO()
  250. write_cache_time(f, 434343)
  251. self.assertEqual(struct.pack(">LL", 434343, 0), f.getvalue())
  252. def test_write_tuple(self) -> None:
  253. f = BytesIO()
  254. write_cache_time(f, (434343, 21))
  255. self.assertEqual(struct.pack(">LL", 434343, 21), f.getvalue())
  256. def test_write_float(self) -> None:
  257. f = BytesIO()
  258. write_cache_time(f, 434343.000000021)
  259. self.assertEqual(struct.pack(">LL", 434343, 21), f.getvalue())
  260. class IndexEntryFromStatTests(TestCase):
  261. def test_simple(self) -> None:
  262. st = os.stat_result(
  263. (
  264. 16877,
  265. 131078,
  266. 64769,
  267. 154,
  268. 1000,
  269. 1000,
  270. 12288,
  271. 1323629595,
  272. 1324180496,
  273. 1324180496,
  274. )
  275. )
  276. entry = index_entry_from_stat(st, b"22" * 20)
  277. self.assertEqual(
  278. entry,
  279. IndexEntry(
  280. 1324180496,
  281. 1324180496,
  282. 64769,
  283. 131078,
  284. 16384,
  285. 1000,
  286. 1000,
  287. 12288,
  288. b"2222222222222222222222222222222222222222",
  289. 0,
  290. 0,
  291. ),
  292. )
  293. def test_override_mode(self) -> None:
  294. st = os.stat_result(
  295. (
  296. stat.S_IFREG + 0o644,
  297. 131078,
  298. 64769,
  299. 154,
  300. 1000,
  301. 1000,
  302. 12288,
  303. 1323629595,
  304. 1324180496,
  305. 1324180496,
  306. )
  307. )
  308. entry = index_entry_from_stat(st, b"22" * 20, mode=stat.S_IFREG + 0o755)
  309. self.assertEqual(
  310. entry,
  311. IndexEntry(
  312. 1324180496,
  313. 1324180496,
  314. 64769,
  315. 131078,
  316. 33261,
  317. 1000,
  318. 1000,
  319. 12288,
  320. b"2222222222222222222222222222222222222222",
  321. 0,
  322. 0,
  323. ),
  324. )
  325. class BuildIndexTests(TestCase):
  326. def assertReasonableIndexEntry(self, index_entry, mode, filesize, sha) -> None:
  327. self.assertEqual(index_entry.mode, mode) # mode
  328. self.assertEqual(index_entry.size, filesize) # filesize
  329. self.assertEqual(index_entry.sha, sha) # sha
  330. def assertFileContents(self, path, contents, symlink=False) -> None:
  331. if symlink:
  332. self.assertEqual(os.readlink(path), contents)
  333. else:
  334. with open(path, "rb") as f:
  335. self.assertEqual(f.read(), contents)
  336. def test_empty(self) -> None:
  337. repo_dir = tempfile.mkdtemp()
  338. self.addCleanup(shutil.rmtree, repo_dir)
  339. with Repo.init(repo_dir) as repo:
  340. tree = Tree()
  341. repo.object_store.add_object(tree)
  342. build_index_from_tree(
  343. repo.path, repo.index_path(), repo.object_store, tree.id
  344. )
  345. # Verify index entries
  346. index = repo.open_index()
  347. self.assertEqual(len(index), 0)
  348. # Verify no files
  349. self.assertEqual([".git"], os.listdir(repo.path))
  350. def test_git_dir(self) -> None:
  351. repo_dir = tempfile.mkdtemp()
  352. self.addCleanup(shutil.rmtree, repo_dir)
  353. with Repo.init(repo_dir) as repo:
  354. # Populate repo
  355. filea = Blob.from_string(b"file a")
  356. filee = Blob.from_string(b"d")
  357. tree = Tree()
  358. tree[b".git/a"] = (stat.S_IFREG | 0o644, filea.id)
  359. tree[b"c/e"] = (stat.S_IFREG | 0o644, filee.id)
  360. repo.object_store.add_objects([(o, None) for o in [filea, filee, tree]])
  361. build_index_from_tree(
  362. repo.path, repo.index_path(), repo.object_store, tree.id
  363. )
  364. # Verify index entries
  365. index = repo.open_index()
  366. self.assertEqual(len(index), 1)
  367. # filea
  368. apath = os.path.join(repo.path, ".git", "a")
  369. self.assertFalse(os.path.exists(apath))
  370. # filee
  371. epath = os.path.join(repo.path, "c", "e")
  372. self.assertTrue(os.path.exists(epath))
  373. self.assertReasonableIndexEntry(
  374. index[b"c/e"], stat.S_IFREG | 0o644, 1, filee.id
  375. )
  376. self.assertFileContents(epath, b"d")
  377. def test_nonempty(self) -> None:
  378. repo_dir = tempfile.mkdtemp()
  379. self.addCleanup(shutil.rmtree, repo_dir)
  380. with Repo.init(repo_dir) as repo:
  381. # Populate repo
  382. filea = Blob.from_string(b"file a")
  383. fileb = Blob.from_string(b"file b")
  384. filed = Blob.from_string(b"file d")
  385. tree = Tree()
  386. tree[b"a"] = (stat.S_IFREG | 0o644, filea.id)
  387. tree[b"b"] = (stat.S_IFREG | 0o644, fileb.id)
  388. tree[b"c/d"] = (stat.S_IFREG | 0o644, filed.id)
  389. repo.object_store.add_objects(
  390. [(o, None) for o in [filea, fileb, filed, tree]]
  391. )
  392. build_index_from_tree(
  393. repo.path, repo.index_path(), repo.object_store, tree.id
  394. )
  395. # Verify index entries
  396. index = repo.open_index()
  397. self.assertEqual(len(index), 3)
  398. # filea
  399. apath = os.path.join(repo.path, "a")
  400. self.assertTrue(os.path.exists(apath))
  401. self.assertReasonableIndexEntry(
  402. index[b"a"], stat.S_IFREG | 0o644, 6, filea.id
  403. )
  404. self.assertFileContents(apath, b"file a")
  405. # fileb
  406. bpath = os.path.join(repo.path, "b")
  407. self.assertTrue(os.path.exists(bpath))
  408. self.assertReasonableIndexEntry(
  409. index[b"b"], stat.S_IFREG | 0o644, 6, fileb.id
  410. )
  411. self.assertFileContents(bpath, b"file b")
  412. # filed
  413. dpath = os.path.join(repo.path, "c", "d")
  414. self.assertTrue(os.path.exists(dpath))
  415. self.assertReasonableIndexEntry(
  416. index[b"c/d"], stat.S_IFREG | 0o644, 6, filed.id
  417. )
  418. self.assertFileContents(dpath, b"file d")
  419. # Verify no extra files
  420. self.assertEqual([".git", "a", "b", "c"], sorted(os.listdir(repo.path)))
  421. self.assertEqual(["d"], sorted(os.listdir(os.path.join(repo.path, "c"))))
  422. @skipIf(not getattr(os, "sync", None), "Requires sync support")
  423. def test_norewrite(self) -> None:
  424. repo_dir = tempfile.mkdtemp()
  425. self.addCleanup(shutil.rmtree, repo_dir)
  426. with Repo.init(repo_dir) as repo:
  427. # Populate repo
  428. filea = Blob.from_string(b"file a")
  429. filea_path = os.path.join(repo_dir, "a")
  430. tree = Tree()
  431. tree[b"a"] = (stat.S_IFREG | 0o644, filea.id)
  432. repo.object_store.add_objects([(o, None) for o in [filea, tree]])
  433. # First Write
  434. build_index_from_tree(
  435. repo.path, repo.index_path(), repo.object_store, tree.id
  436. )
  437. # Use sync as metadata can be cached on some FS
  438. os.sync()
  439. mtime = os.stat(filea_path).st_mtime
  440. # Test Rewrite
  441. build_index_from_tree(
  442. repo.path, repo.index_path(), repo.object_store, tree.id
  443. )
  444. os.sync()
  445. self.assertEqual(mtime, os.stat(filea_path).st_mtime)
  446. # Modify content
  447. with open(filea_path, "wb") as fh:
  448. fh.write(b"test a")
  449. os.sync()
  450. mtime = os.stat(filea_path).st_mtime
  451. # Test rewrite
  452. build_index_from_tree(
  453. repo.path, repo.index_path(), repo.object_store, tree.id
  454. )
  455. os.sync()
  456. with open(filea_path, "rb") as fh:
  457. self.assertEqual(b"file a", fh.read())
  458. @skipIf(not can_symlink(), "Requires symlink support")
  459. def test_symlink(self) -> None:
  460. repo_dir = tempfile.mkdtemp()
  461. self.addCleanup(shutil.rmtree, repo_dir)
  462. with Repo.init(repo_dir) as repo:
  463. # Populate repo
  464. filed = Blob.from_string(b"file d")
  465. filee = Blob.from_string(b"d")
  466. tree = Tree()
  467. tree[b"c/d"] = (stat.S_IFREG | 0o644, filed.id)
  468. tree[b"c/e"] = (stat.S_IFLNK, filee.id) # symlink
  469. repo.object_store.add_objects([(o, None) for o in [filed, filee, tree]])
  470. build_index_from_tree(
  471. repo.path, repo.index_path(), repo.object_store, tree.id
  472. )
  473. # Verify index entries
  474. index = repo.open_index()
  475. # symlink to d
  476. epath = os.path.join(repo.path, "c", "e")
  477. self.assertTrue(os.path.exists(epath))
  478. self.assertReasonableIndexEntry(
  479. index[b"c/e"],
  480. stat.S_IFLNK,
  481. 0 if sys.platform == "win32" else 1,
  482. filee.id,
  483. )
  484. self.assertFileContents(epath, "d", symlink=True)
  485. def test_no_decode_encode(self) -> None:
  486. repo_dir = tempfile.mkdtemp()
  487. repo_dir_bytes = os.fsencode(repo_dir)
  488. self.addCleanup(shutil.rmtree, repo_dir)
  489. with Repo.init(repo_dir) as repo:
  490. # Populate repo
  491. file = Blob.from_string(b"foo")
  492. tree = Tree()
  493. latin1_name = "À".encode("latin1")
  494. try:
  495. latin1_path = os.path.join(repo_dir_bytes, latin1_name)
  496. except UnicodeDecodeError:
  497. self.skipTest("can not decode as latin1")
  498. utf8_name = "À".encode()
  499. utf8_path = os.path.join(repo_dir_bytes, utf8_name)
  500. tree[latin1_name] = (stat.S_IFREG | 0o644, file.id)
  501. tree[utf8_name] = (stat.S_IFREG | 0o644, file.id)
  502. repo.object_store.add_objects([(o, None) for o in [file, tree]])
  503. try:
  504. build_index_from_tree(
  505. repo.path, repo.index_path(), repo.object_store, tree.id
  506. )
  507. except OSError as e:
  508. if e.errno == 92 and sys.platform == "darwin":
  509. # Our filename isn't supported by the platform :(
  510. self.skipTest(f"can not write filename {e.filename!r}")
  511. else:
  512. raise
  513. except UnicodeDecodeError:
  514. # This happens e.g. with python3.6 on Windows.
  515. # It implicitly decodes using utf8, which doesn't work.
  516. self.skipTest("can not implicitly convert as utf8")
  517. # Verify index entries
  518. index = repo.open_index()
  519. self.assertIn(latin1_name, index)
  520. self.assertIn(utf8_name, index)
  521. self.assertTrue(os.path.exists(latin1_path))
  522. self.assertTrue(os.path.exists(utf8_path))
  523. def test_git_submodule(self) -> None:
  524. repo_dir = tempfile.mkdtemp()
  525. self.addCleanup(shutil.rmtree, repo_dir)
  526. with Repo.init(repo_dir) as repo:
  527. filea = Blob.from_string(b"file alalala")
  528. subtree = Tree()
  529. subtree[b"a"] = (stat.S_IFREG | 0o644, filea.id)
  530. c = Commit()
  531. c.tree = subtree.id
  532. c.committer = c.author = b"Somebody <somebody@example.com>"
  533. c.commit_time = c.author_time = 42342
  534. c.commit_timezone = c.author_timezone = 0
  535. c.parents = []
  536. c.message = b"Subcommit"
  537. tree = Tree()
  538. tree[b"c"] = (S_IFGITLINK, c.id)
  539. repo.object_store.add_objects([(o, None) for o in [tree]])
  540. build_index_from_tree(
  541. repo.path, repo.index_path(), repo.object_store, tree.id
  542. )
  543. # Verify index entries
  544. index = repo.open_index()
  545. self.assertEqual(len(index), 1)
  546. # filea
  547. apath = os.path.join(repo.path, "c/a")
  548. self.assertFalse(os.path.exists(apath))
  549. # dir c
  550. cpath = os.path.join(repo.path, "c")
  551. self.assertTrue(os.path.isdir(cpath))
  552. self.assertEqual(index[b"c"].mode, S_IFGITLINK) # mode
  553. self.assertEqual(index[b"c"].sha, c.id) # sha
  554. def test_git_submodule_exists(self) -> None:
  555. repo_dir = tempfile.mkdtemp()
  556. self.addCleanup(shutil.rmtree, repo_dir)
  557. with Repo.init(repo_dir) as repo:
  558. filea = Blob.from_string(b"file alalala")
  559. subtree = Tree()
  560. subtree[b"a"] = (stat.S_IFREG | 0o644, filea.id)
  561. c = Commit()
  562. c.tree = subtree.id
  563. c.committer = c.author = b"Somebody <somebody@example.com>"
  564. c.commit_time = c.author_time = 42342
  565. c.commit_timezone = c.author_timezone = 0
  566. c.parents = []
  567. c.message = b"Subcommit"
  568. tree = Tree()
  569. tree[b"c"] = (S_IFGITLINK, c.id)
  570. os.mkdir(os.path.join(repo_dir, "c"))
  571. repo.object_store.add_objects([(o, None) for o in [tree]])
  572. build_index_from_tree(
  573. repo.path, repo.index_path(), repo.object_store, tree.id
  574. )
  575. # Verify index entries
  576. index = repo.open_index()
  577. self.assertEqual(len(index), 1)
  578. # filea
  579. apath = os.path.join(repo.path, "c/a")
  580. self.assertFalse(os.path.exists(apath))
  581. # dir c
  582. cpath = os.path.join(repo.path, "c")
  583. self.assertTrue(os.path.isdir(cpath))
  584. self.assertEqual(index[b"c"].mode, S_IFGITLINK) # mode
  585. self.assertEqual(index[b"c"].sha, c.id) # sha
  586. def test_with_line_ending_normalization(self) -> None:
  587. """Test that build_index_from_tree applies line-ending normalization."""
  588. repo_dir = tempfile.mkdtemp()
  589. self.addCleanup(shutil.rmtree, repo_dir)
  590. from dulwich.line_ending import BlobNormalizer
  591. with Repo.init(repo_dir) as repo:
  592. # Set up autocrlf config
  593. config = repo.get_config()
  594. config.set((b"core",), b"autocrlf", b"true")
  595. config.write_to_path()
  596. # Create blob with LF line endings
  597. content_lf = b"line1\nline2\nline3\n"
  598. blob = Blob.from_string(content_lf)
  599. tree = Tree()
  600. tree[b"test.txt"] = (stat.S_IFREG | 0o644, blob.id)
  601. repo.object_store.add_objects([(blob, None), (tree, None)])
  602. # Create blob normalizer
  603. blob_normalizer = BlobNormalizer(config, {})
  604. # Build index with normalization
  605. build_index_from_tree(
  606. repo.path,
  607. repo.index_path(),
  608. repo.object_store,
  609. tree.id,
  610. blob_normalizer=blob_normalizer,
  611. )
  612. # On Windows with autocrlf=true, file should have CRLF line endings
  613. test_file = os.path.join(repo.path, "test.txt")
  614. with open(test_file, "rb") as f:
  615. content = f.read()
  616. # autocrlf=true means LF -> CRLF on checkout (on all platforms for testing)
  617. expected_content = b"line1\r\nline2\r\nline3\r\n"
  618. self.assertEqual(content, expected_content)
  619. class GetUnstagedChangesTests(TestCase):
  620. def test_get_unstaged_changes(self) -> None:
  621. """Unit test for get_unstaged_changes."""
  622. repo_dir = tempfile.mkdtemp()
  623. self.addCleanup(shutil.rmtree, repo_dir)
  624. with Repo.init(repo_dir) as repo:
  625. # Commit a dummy file then modify it
  626. foo1_fullpath = os.path.join(repo_dir, "foo1")
  627. with open(foo1_fullpath, "wb") as f:
  628. f.write(b"origstuff")
  629. foo2_fullpath = os.path.join(repo_dir, "foo2")
  630. with open(foo2_fullpath, "wb") as f:
  631. f.write(b"origstuff")
  632. repo.stage(["foo1", "foo2"])
  633. repo.do_commit(
  634. b"test status",
  635. author=b"author <email>",
  636. committer=b"committer <email>",
  637. )
  638. with open(foo1_fullpath, "wb") as f:
  639. f.write(b"newstuff")
  640. # modify access and modify time of path
  641. os.utime(foo1_fullpath, (0, 0))
  642. changes = get_unstaged_changes(repo.open_index(), repo_dir)
  643. self.assertEqual(list(changes), [b"foo1"])
  644. def test_get_unstaged_deleted_changes(self) -> None:
  645. """Unit test for get_unstaged_changes."""
  646. repo_dir = tempfile.mkdtemp()
  647. self.addCleanup(shutil.rmtree, repo_dir)
  648. with Repo.init(repo_dir) as repo:
  649. # Commit a dummy file then remove it
  650. foo1_fullpath = os.path.join(repo_dir, "foo1")
  651. with open(foo1_fullpath, "wb") as f:
  652. f.write(b"origstuff")
  653. repo.stage(["foo1"])
  654. repo.do_commit(
  655. b"test status",
  656. author=b"author <email>",
  657. committer=b"committer <email>",
  658. )
  659. os.unlink(foo1_fullpath)
  660. changes = get_unstaged_changes(repo.open_index(), repo_dir)
  661. self.assertEqual(list(changes), [b"foo1"])
  662. def test_get_unstaged_changes_removed_replaced_by_directory(self) -> None:
  663. """Unit test for get_unstaged_changes."""
  664. repo_dir = tempfile.mkdtemp()
  665. self.addCleanup(shutil.rmtree, repo_dir)
  666. with Repo.init(repo_dir) as repo:
  667. # Commit a dummy file then modify it
  668. foo1_fullpath = os.path.join(repo_dir, "foo1")
  669. with open(foo1_fullpath, "wb") as f:
  670. f.write(b"origstuff")
  671. repo.stage(["foo1"])
  672. repo.do_commit(
  673. b"test status",
  674. author=b"author <email>",
  675. committer=b"committer <email>",
  676. )
  677. os.remove(foo1_fullpath)
  678. os.mkdir(foo1_fullpath)
  679. changes = get_unstaged_changes(repo.open_index(), repo_dir)
  680. self.assertEqual(list(changes), [b"foo1"])
  681. @skipIf(not can_symlink(), "Requires symlink support")
  682. def test_get_unstaged_changes_removed_replaced_by_link(self) -> None:
  683. """Unit test for get_unstaged_changes."""
  684. repo_dir = tempfile.mkdtemp()
  685. self.addCleanup(shutil.rmtree, repo_dir)
  686. with Repo.init(repo_dir) as repo:
  687. # Commit a dummy file then modify it
  688. foo1_fullpath = os.path.join(repo_dir, "foo1")
  689. with open(foo1_fullpath, "wb") as f:
  690. f.write(b"origstuff")
  691. repo.stage(["foo1"])
  692. repo.do_commit(
  693. b"test status",
  694. author=b"author <email>",
  695. committer=b"committer <email>",
  696. )
  697. os.remove(foo1_fullpath)
  698. os.symlink(os.path.dirname(foo1_fullpath), foo1_fullpath)
  699. changes = get_unstaged_changes(repo.open_index(), repo_dir)
  700. self.assertEqual(list(changes), [b"foo1"])
  701. class TestValidatePathElement(TestCase):
  702. def test_default(self) -> None:
  703. self.assertTrue(validate_path_element_default(b"bla"))
  704. self.assertTrue(validate_path_element_default(b".bla"))
  705. self.assertFalse(validate_path_element_default(b".git"))
  706. self.assertFalse(validate_path_element_default(b".giT"))
  707. self.assertFalse(validate_path_element_default(b".."))
  708. self.assertTrue(validate_path_element_default(b"git~1"))
  709. def test_ntfs(self) -> None:
  710. self.assertTrue(validate_path_element_ntfs(b"bla"))
  711. self.assertTrue(validate_path_element_ntfs(b".bla"))
  712. self.assertFalse(validate_path_element_ntfs(b".git"))
  713. self.assertFalse(validate_path_element_ntfs(b".giT"))
  714. self.assertFalse(validate_path_element_ntfs(b".."))
  715. self.assertFalse(validate_path_element_ntfs(b"git~1"))
  716. class TestTreeFSPathConversion(TestCase):
  717. def test_tree_to_fs_path(self) -> None:
  718. tree_path = "délwíçh/foo".encode()
  719. fs_path = _tree_to_fs_path(b"/prefix/path", tree_path)
  720. self.assertEqual(
  721. fs_path,
  722. os.fsencode(os.path.join("/prefix/path", "délwíçh", "foo")),
  723. )
  724. def test_tree_to_fs_path_windows_separator(self) -> None:
  725. tree_path = b"path/with/slash"
  726. original_sep = os.sep.encode("ascii")
  727. try:
  728. # Temporarily modify os_sep_bytes to test Windows path conversion
  729. # This simulates Windows behavior on all platforms for testing
  730. import dulwich.index
  731. dulwich.index.os_sep_bytes = b"\\"
  732. fs_path = _tree_to_fs_path(b"/prefix/path", tree_path)
  733. # The function should join the prefix path with the converted tree path
  734. # The expected behavior is that the path separators in the tree_path are
  735. # converted to the platform-specific separator (which we've set to backslash)
  736. expected_path = os.path.join(b"/prefix/path", b"path\\with\\slash")
  737. self.assertEqual(fs_path, expected_path)
  738. finally:
  739. # Restore original value
  740. dulwich.index.os_sep_bytes = original_sep
  741. def test_fs_to_tree_path_str(self) -> None:
  742. fs_path = os.path.join(os.path.join("délwíçh", "foo"))
  743. tree_path = _fs_to_tree_path(fs_path)
  744. self.assertEqual(tree_path, "délwíçh/foo".encode())
  745. def test_fs_to_tree_path_bytes(self) -> None:
  746. fs_path = os.path.join(os.fsencode(os.path.join("délwíçh", "foo")))
  747. tree_path = _fs_to_tree_path(fs_path)
  748. self.assertEqual(tree_path, "délwíçh/foo".encode())
  749. def test_fs_to_tree_path_windows_separator(self) -> None:
  750. # Test conversion of Windows paths to tree paths
  751. fs_path = b"path\\with\\backslash"
  752. original_sep = os.sep.encode("ascii")
  753. try:
  754. # Temporarily modify os_sep_bytes to test Windows path conversion
  755. import dulwich.index
  756. dulwich.index.os_sep_bytes = b"\\"
  757. tree_path = _fs_to_tree_path(fs_path)
  758. self.assertEqual(tree_path, b"path/with/backslash")
  759. finally:
  760. # Restore original value
  761. dulwich.index.os_sep_bytes = original_sep
  762. class TestIndexEntryFromPath(TestCase):
  763. def setUp(self):
  764. self.tempdir = tempfile.mkdtemp()
  765. self.addCleanup(shutil.rmtree, self.tempdir)
  766. def test_index_entry_from_path_file(self) -> None:
  767. """Test creating index entry from a regular file."""
  768. # Create a test file
  769. test_file = os.path.join(self.tempdir, "testfile")
  770. with open(test_file, "wb") as f:
  771. f.write(b"test content")
  772. # Get the index entry
  773. entry = index_entry_from_path(os.fsencode(test_file))
  774. # Verify the entry was created with the right mode
  775. self.assertIsNotNone(entry)
  776. self.assertEqual(cleanup_mode(os.stat(test_file).st_mode), entry.mode)
  777. @skipIf(not can_symlink(), "Requires symlink support")
  778. def test_index_entry_from_path_symlink(self) -> None:
  779. """Test creating index entry from a symlink."""
  780. # Create a target file
  781. target_file = os.path.join(self.tempdir, "target")
  782. with open(target_file, "wb") as f:
  783. f.write(b"target content")
  784. # Create a symlink
  785. link_file = os.path.join(self.tempdir, "symlink")
  786. os.symlink(target_file, link_file)
  787. # Get the index entry
  788. entry = index_entry_from_path(os.fsencode(link_file))
  789. # Verify the entry was created with the right mode
  790. self.assertIsNotNone(entry)
  791. self.assertEqual(cleanup_mode(os.lstat(link_file).st_mode), entry.mode)
  792. def test_index_entry_from_path_directory(self) -> None:
  793. """Test creating index entry from a directory (should return None)."""
  794. # Create a directory
  795. test_dir = os.path.join(self.tempdir, "testdir")
  796. os.mkdir(test_dir)
  797. # Get the index entry for a directory
  798. entry = index_entry_from_path(os.fsencode(test_dir))
  799. # Should return None for regular directories
  800. self.assertIsNone(entry)
  801. def test_index_entry_from_directory_regular(self) -> None:
  802. """Test index_entry_from_directory with a regular directory."""
  803. # Create a directory
  804. test_dir = os.path.join(self.tempdir, "testdir")
  805. os.mkdir(test_dir)
  806. # Get stat for the directory
  807. st = os.lstat(test_dir)
  808. # Get the index entry for a regular directory
  809. entry = index_entry_from_directory(st, os.fsencode(test_dir))
  810. # Should return None for regular directories
  811. self.assertIsNone(entry)
  812. def test_index_entry_from_directory_git_submodule(self) -> None:
  813. """Test index_entry_from_directory with a Git submodule."""
  814. # Create a git repository that will be a submodule
  815. sub_repo_dir = os.path.join(self.tempdir, "subrepo")
  816. os.mkdir(sub_repo_dir)
  817. # Create the .git directory to make it look like a git repo
  818. git_dir = os.path.join(sub_repo_dir, ".git")
  819. os.mkdir(git_dir)
  820. # Create HEAD file with a fake commit SHA
  821. head_sha = b"1234567890" * 4 # 40-char fake SHA
  822. with open(os.path.join(git_dir, "HEAD"), "wb") as f:
  823. f.write(head_sha)
  824. # Get stat for the submodule directory
  825. st = os.lstat(sub_repo_dir)
  826. # Get the index entry for a git submodule directory
  827. entry = index_entry_from_directory(st, os.fsencode(sub_repo_dir))
  828. # Since we don't have a proper git setup, this might still return None
  829. # This test just ensures the code path is executed
  830. if entry is not None:
  831. # If an entry is returned, it should have the gitlink mode
  832. self.assertEqual(entry.mode, S_IFGITLINK)
  833. def test_index_entry_from_path_with_object_store(self) -> None:
  834. """Test creating index entry with object store."""
  835. # Create a test file
  836. test_file = os.path.join(self.tempdir, "testfile")
  837. with open(test_file, "wb") as f:
  838. f.write(b"test content")
  839. # Create a memory object store
  840. object_store = MemoryObjectStore()
  841. # Get the index entry and add to object store
  842. entry = index_entry_from_path(os.fsencode(test_file), object_store)
  843. # Verify we can access the blob from the object store
  844. self.assertIsNotNone(entry)
  845. blob = object_store[entry.sha]
  846. self.assertEqual(b"test content", blob.data)
  847. def test_iter_fresh_entries(self) -> None:
  848. """Test iterating over fresh entries."""
  849. # Create some test files
  850. file1 = os.path.join(self.tempdir, "file1")
  851. with open(file1, "wb") as f:
  852. f.write(b"file1 content")
  853. file2 = os.path.join(self.tempdir, "file2")
  854. with open(file2, "wb") as f:
  855. f.write(b"file2 content")
  856. # Create a memory object store
  857. object_store = MemoryObjectStore()
  858. # Get fresh entries
  859. paths = [b"file1", b"file2", b"nonexistent"]
  860. entries = dict(
  861. iter_fresh_entries(paths, os.fsencode(self.tempdir), object_store)
  862. )
  863. # Verify both files got entries but nonexistent file is None
  864. self.assertIn(b"file1", entries)
  865. self.assertIn(b"file2", entries)
  866. self.assertIn(b"nonexistent", entries)
  867. self.assertIsNotNone(entries[b"file1"])
  868. self.assertIsNotNone(entries[b"file2"])
  869. self.assertIsNone(entries[b"nonexistent"])
  870. # Check that blobs were added to object store
  871. blob1 = object_store[entries[b"file1"].sha]
  872. self.assertEqual(b"file1 content", blob1.data)
  873. blob2 = object_store[entries[b"file2"].sha]
  874. self.assertEqual(b"file2 content", blob2.data)
  875. def test_read_submodule_head(self) -> None:
  876. """Test reading the HEAD of a submodule."""
  877. from dulwich.index import read_submodule_head
  878. # Create a test repo that will be our "submodule"
  879. sub_repo_dir = os.path.join(self.tempdir, "subrepo")
  880. os.mkdir(sub_repo_dir)
  881. submodule_repo = Repo.init(sub_repo_dir)
  882. # Create a file and commit it to establish a HEAD
  883. test_file = os.path.join(sub_repo_dir, "testfile")
  884. with open(test_file, "wb") as f:
  885. f.write(b"test content")
  886. submodule_repo.stage(["testfile"])
  887. commit_id = submodule_repo.do_commit(b"Test commit for submodule")
  888. # Test reading the HEAD
  889. head_sha = read_submodule_head(sub_repo_dir)
  890. self.assertEqual(commit_id, head_sha)
  891. # Test with bytes path
  892. head_sha_bytes = read_submodule_head(os.fsencode(sub_repo_dir))
  893. self.assertEqual(commit_id, head_sha_bytes)
  894. # Test with non-existent path
  895. non_repo_dir = os.path.join(self.tempdir, "nonrepo")
  896. os.mkdir(non_repo_dir)
  897. self.assertIsNone(read_submodule_head(non_repo_dir))
  898. # Test with path that doesn't have a .git directory
  899. not_git_dir = os.path.join(self.tempdir, "notgit")
  900. os.mkdir(not_git_dir)
  901. self.assertIsNone(read_submodule_head(not_git_dir))
  902. def test_has_directory_changed(self) -> None:
  903. """Test checking if a directory has changed."""
  904. from dulwich.index import IndexEntry, _has_directory_changed
  905. # Setup mock IndexEntry
  906. mock_entry = IndexEntry(
  907. (1230680220, 0),
  908. (1230680220, 0),
  909. 2050,
  910. 3761020,
  911. 33188,
  912. 1000,
  913. 1000,
  914. 0,
  915. b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
  916. 0,
  917. 0,
  918. )
  919. # Test with a regular directory (not a submodule)
  920. reg_dir = os.path.join(self.tempdir, "regular_dir")
  921. os.mkdir(reg_dir)
  922. # Should return True for regular directory
  923. self.assertTrue(_has_directory_changed(os.fsencode(reg_dir), mock_entry))
  924. # Create a git repository to test submodule scenarios
  925. sub_repo_dir = os.path.join(self.tempdir, "subrepo")
  926. os.mkdir(sub_repo_dir)
  927. submodule_repo = Repo.init(sub_repo_dir)
  928. # Create a file and commit it to establish a HEAD
  929. test_file = os.path.join(sub_repo_dir, "testfile")
  930. with open(test_file, "wb") as f:
  931. f.write(b"test content")
  932. submodule_repo.stage(["testfile"])
  933. commit_id = submodule_repo.do_commit(b"Test commit for submodule")
  934. # Create an entry with the correct commit SHA
  935. correct_entry = IndexEntry(
  936. (1230680220, 0),
  937. (1230680220, 0),
  938. 2050,
  939. 3761020,
  940. 33188,
  941. 1000,
  942. 1000,
  943. 0,
  944. commit_id,
  945. 0,
  946. 0,
  947. )
  948. # Create an entry with an incorrect commit SHA
  949. incorrect_entry = IndexEntry(
  950. (1230680220, 0),
  951. (1230680220, 0),
  952. 2050,
  953. 3761020,
  954. 33188,
  955. 1000,
  956. 1000,
  957. 0,
  958. b"0000000000000000000000000000000000000000",
  959. 0,
  960. 0,
  961. )
  962. # Should return False for submodule with correct SHA
  963. self.assertFalse(
  964. _has_directory_changed(os.fsencode(sub_repo_dir), correct_entry)
  965. )
  966. # Should return True for submodule with incorrect SHA
  967. self.assertTrue(
  968. _has_directory_changed(os.fsencode(sub_repo_dir), incorrect_entry)
  969. )
  970. def test_get_unstaged_changes(self) -> None:
  971. """Test detecting unstaged changes in a working tree."""
  972. from dulwich.index import (
  973. ConflictedIndexEntry,
  974. Index,
  975. IndexEntry,
  976. get_unstaged_changes,
  977. )
  978. # Create a test repo
  979. repo_dir = tempfile.mkdtemp()
  980. self.addCleanup(shutil.rmtree, repo_dir)
  981. # Create test index
  982. index = Index(os.path.join(repo_dir, "index"))
  983. # Create an actual hash of our test content
  984. from dulwich.objects import Blob
  985. test_blob = Blob()
  986. test_blob.data = b"initial content"
  987. # Create some test files with known contents
  988. file1_path = os.path.join(repo_dir, "file1")
  989. with open(file1_path, "wb") as f:
  990. f.write(b"initial content")
  991. file2_path = os.path.join(repo_dir, "file2")
  992. with open(file2_path, "wb") as f:
  993. f.write(b"initial content")
  994. # Add them to index
  995. entry1 = IndexEntry(
  996. (1230680220, 0),
  997. (1230680220, 0),
  998. 2050,
  999. 3761020,
  1000. 33188,
  1001. 1000,
  1002. 1000,
  1003. 0,
  1004. b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", # Not matching actual content
  1005. 0,
  1006. 0,
  1007. )
  1008. entry2 = IndexEntry(
  1009. (1230680220, 0),
  1010. (1230680220, 0),
  1011. 2050,
  1012. 3761020,
  1013. 33188,
  1014. 1000,
  1015. 1000,
  1016. 0,
  1017. test_blob.id, # Will be content's real hash
  1018. 0,
  1019. 0,
  1020. )
  1021. # Add a file that has a conflict
  1022. entry_conflict = ConflictedIndexEntry(b"conflict", {0: None, 1: None, 2: None})
  1023. index._byname = {
  1024. b"file1": entry1,
  1025. b"file2": entry2,
  1026. b"file3": IndexEntry(
  1027. (1230680220, 0),
  1028. (1230680220, 0),
  1029. 2050,
  1030. 3761020,
  1031. 33188,
  1032. 1000,
  1033. 1000,
  1034. 0,
  1035. b"0000000000000000000000000000000000000000",
  1036. 0,
  1037. 0,
  1038. ),
  1039. b"conflict": entry_conflict,
  1040. }
  1041. # Get unstaged changes
  1042. changes = list(get_unstaged_changes(index, repo_dir))
  1043. # File1 should be unstaged (content doesn't match hash)
  1044. # File3 doesn't exist (deleted)
  1045. # Conflict is always unstaged
  1046. self.assertEqual(sorted(changes), [b"conflict", b"file1", b"file3"])
  1047. # Create directory where there should be a file
  1048. os.mkdir(os.path.join(repo_dir, "file4"))
  1049. index._byname[b"file4"] = entry1
  1050. # Get unstaged changes again
  1051. changes = list(get_unstaged_changes(index, repo_dir))
  1052. # Now file4 should also be unstaged because it's a directory instead of a file
  1053. self.assertEqual(sorted(changes), [b"conflict", b"file1", b"file3", b"file4"])
  1054. # Create a custom blob filter function
  1055. def filter_blob_callback(blob, path):
  1056. # Modify blob to make it look changed
  1057. blob.data = b"modified " + blob.data
  1058. return blob
  1059. # Get unstaged changes with blob filter
  1060. changes = list(get_unstaged_changes(index, repo_dir, filter_blob_callback))
  1061. # Now both file1 and file2 should be unstaged due to the filter
  1062. self.assertEqual(
  1063. sorted(changes), [b"conflict", b"file1", b"file2", b"file3", b"file4"]
  1064. )
  1065. class TestManyFilesFeature(TestCase):
  1066. """Tests for the manyFiles feature (index version 4 and skipHash)."""
  1067. def setUp(self):
  1068. self.tempdir = tempfile.mkdtemp()
  1069. self.addCleanup(shutil.rmtree, self.tempdir)
  1070. def test_index_version_4_parsing(self):
  1071. """Test that index version 4 files can be parsed."""
  1072. index_path = os.path.join(self.tempdir, "index")
  1073. # Create an index with version 4
  1074. index = Index(index_path, read=False, version=4)
  1075. # Add some entries
  1076. entry = IndexEntry(
  1077. ctime=(1234567890, 0),
  1078. mtime=(1234567890, 0),
  1079. dev=1,
  1080. ino=1,
  1081. mode=0o100644,
  1082. uid=1000,
  1083. gid=1000,
  1084. size=5,
  1085. sha=b"0" * 40,
  1086. )
  1087. index[b"test.txt"] = entry
  1088. # Write and read back
  1089. index.write()
  1090. # Read the index back
  1091. index2 = Index(index_path)
  1092. self.assertEqual(index2._version, 4)
  1093. self.assertIn(b"test.txt", index2)
  1094. def test_skip_hash_feature(self):
  1095. """Test that skipHash feature works correctly."""
  1096. index_path = os.path.join(self.tempdir, "index")
  1097. # Create an index with skipHash enabled
  1098. index = Index(index_path, read=False, skip_hash=True)
  1099. # Add some entries
  1100. entry = IndexEntry(
  1101. ctime=(1234567890, 0),
  1102. mtime=(1234567890, 0),
  1103. dev=1,
  1104. ino=1,
  1105. mode=0o100644,
  1106. uid=1000,
  1107. gid=1000,
  1108. size=5,
  1109. sha=b"0" * 40,
  1110. )
  1111. index[b"test.txt"] = entry
  1112. # Write the index
  1113. index.write()
  1114. # Verify the file was written with zero hash
  1115. with open(index_path, "rb") as f:
  1116. f.seek(-20, 2) # Seek to last 20 bytes
  1117. trailing_hash = f.read(20)
  1118. self.assertEqual(trailing_hash, b"\x00" * 20)
  1119. # Verify we can still read it back
  1120. index2 = Index(index_path)
  1121. self.assertIn(b"test.txt", index2)
  1122. def test_version_4_no_padding(self):
  1123. """Test that version 4 entries have no padding."""
  1124. # Create entries with names that would show compression benefits
  1125. entries = [
  1126. SerializedIndexEntry(
  1127. name=b"src/main/java/com/example/Service.java",
  1128. ctime=(1234567890, 0),
  1129. mtime=(1234567890, 0),
  1130. dev=1,
  1131. ino=1,
  1132. mode=0o100644,
  1133. uid=1000,
  1134. gid=1000,
  1135. size=5,
  1136. sha=b"0" * 40,
  1137. flags=0,
  1138. extended_flags=0,
  1139. ),
  1140. SerializedIndexEntry(
  1141. name=b"src/main/java/com/example/Controller.java",
  1142. ctime=(1234567890, 0),
  1143. mtime=(1234567890, 0),
  1144. dev=1,
  1145. ino=2,
  1146. mode=0o100644,
  1147. uid=1000,
  1148. gid=1000,
  1149. size=5,
  1150. sha=b"1" * 40,
  1151. flags=0,
  1152. extended_flags=0,
  1153. ),
  1154. ]
  1155. # Test version 2 (with padding, full paths)
  1156. buf_v2 = BytesIO()
  1157. from dulwich.index import write_cache_entry
  1158. previous_path = b""
  1159. for entry in entries:
  1160. # Set proper flags for v2
  1161. entry_v2 = SerializedIndexEntry(
  1162. entry.name,
  1163. entry.ctime,
  1164. entry.mtime,
  1165. entry.dev,
  1166. entry.ino,
  1167. entry.mode,
  1168. entry.uid,
  1169. entry.gid,
  1170. entry.size,
  1171. entry.sha,
  1172. len(entry.name),
  1173. entry.extended_flags,
  1174. )
  1175. write_cache_entry(buf_v2, entry_v2, version=2, previous_path=previous_path)
  1176. previous_path = entry.name
  1177. v2_data = buf_v2.getvalue()
  1178. # Test version 4 (path compression, no padding)
  1179. buf_v4 = BytesIO()
  1180. previous_path = b""
  1181. for entry in entries:
  1182. write_cache_entry(buf_v4, entry, version=4, previous_path=previous_path)
  1183. previous_path = entry.name
  1184. v4_data = buf_v4.getvalue()
  1185. # Version 4 should be shorter due to compression and no padding
  1186. self.assertLess(len(v4_data), len(v2_data))
  1187. # Both should parse correctly
  1188. buf_v2.seek(0)
  1189. from dulwich.index import read_cache_entry
  1190. previous_path = b""
  1191. parsed_v2_entries = []
  1192. for _ in entries:
  1193. parsed = read_cache_entry(buf_v2, version=2, previous_path=previous_path)
  1194. parsed_v2_entries.append(parsed)
  1195. previous_path = parsed.name
  1196. buf_v4.seek(0)
  1197. previous_path = b""
  1198. parsed_v4_entries = []
  1199. for _ in entries:
  1200. parsed = read_cache_entry(buf_v4, version=4, previous_path=previous_path)
  1201. parsed_v4_entries.append(parsed)
  1202. previous_path = parsed.name
  1203. # Both should have the same paths
  1204. for v2_entry, v4_entry in zip(parsed_v2_entries, parsed_v4_entries):
  1205. self.assertEqual(v2_entry.name, v4_entry.name)
  1206. self.assertEqual(v2_entry.sha, v4_entry.sha)
  1207. class TestManyFilesRepoIntegration(TestCase):
  1208. """Tests for manyFiles feature integration with Repo."""
  1209. def setUp(self):
  1210. self.tempdir = tempfile.mkdtemp()
  1211. self.addCleanup(shutil.rmtree, self.tempdir)
  1212. def test_repo_with_manyfiles_config(self):
  1213. """Test that a repository with feature.manyFiles=true uses the right settings."""
  1214. # Create a new repository
  1215. repo = Repo.init(self.tempdir)
  1216. # Set feature.manyFiles=true in config
  1217. config = repo.get_config()
  1218. config.set(b"feature", b"manyFiles", b"true")
  1219. config.write_to_path()
  1220. # Open the index - should have skipHash enabled and version 4
  1221. index = repo.open_index()
  1222. self.assertTrue(index._skip_hash)
  1223. self.assertEqual(index._version, 4)
  1224. def test_repo_with_explicit_index_settings(self):
  1225. """Test that explicit index.version and index.skipHash work."""
  1226. # Create a new repository
  1227. repo = Repo.init(self.tempdir)
  1228. # Set explicit index settings
  1229. config = repo.get_config()
  1230. config.set(b"index", b"version", b"3")
  1231. config.set(b"index", b"skipHash", b"false")
  1232. config.write_to_path()
  1233. # Open the index - should respect explicit settings
  1234. index = repo.open_index()
  1235. self.assertFalse(index._skip_hash)
  1236. self.assertEqual(index._version, 3)
  1237. class TestPathPrefixCompression(TestCase):
  1238. """Tests for index version 4 path prefix compression."""
  1239. def setUp(self):
  1240. self.tempdir = tempfile.mkdtemp()
  1241. self.addCleanup(shutil.rmtree, self.tempdir)
  1242. def test_varint_encoding_decoding(self):
  1243. """Test variable-width integer encoding and decoding."""
  1244. test_values = [0, 1, 127, 128, 255, 256, 16383, 16384, 65535, 65536]
  1245. for value in test_values:
  1246. encoded = _encode_varint(value)
  1247. decoded, _ = _decode_varint(encoded, 0)
  1248. self.assertEqual(value, decoded, f"Failed for value {value}")
  1249. def test_path_compression_simple(self):
  1250. """Test simple path compression cases."""
  1251. # Test case 1: No common prefix
  1252. compressed = _compress_path(b"file1.txt", b"")
  1253. decompressed, _ = _decompress_path(compressed, 0, b"")
  1254. self.assertEqual(b"file1.txt", decompressed)
  1255. # Test case 2: Common prefix
  1256. compressed = _compress_path(b"src/file2.txt", b"src/file1.txt")
  1257. decompressed, _ = _decompress_path(compressed, 0, b"src/file1.txt")
  1258. self.assertEqual(b"src/file2.txt", decompressed)
  1259. # Test case 3: Completely different paths
  1260. compressed = _compress_path(b"docs/readme.md", b"src/file1.txt")
  1261. decompressed, _ = _decompress_path(compressed, 0, b"src/file1.txt")
  1262. self.assertEqual(b"docs/readme.md", decompressed)
  1263. def test_path_compression_deep_directories(self):
  1264. """Test compression with deep directory structures."""
  1265. path1 = b"src/main/java/com/example/service/UserService.java"
  1266. path2 = b"src/main/java/com/example/service/OrderService.java"
  1267. path3 = b"src/main/java/com/example/model/User.java"
  1268. # Compress path2 relative to path1
  1269. compressed = _compress_path(path2, path1)
  1270. decompressed, _ = _decompress_path(compressed, 0, path1)
  1271. self.assertEqual(path2, decompressed)
  1272. # Compress path3 relative to path2
  1273. compressed = _compress_path(path3, path2)
  1274. decompressed, _ = _decompress_path(compressed, 0, path2)
  1275. self.assertEqual(path3, decompressed)
  1276. def test_index_version_4_with_compression(self):
  1277. """Test full index version 4 write/read with path compression."""
  1278. index_path = os.path.join(self.tempdir, "index")
  1279. # Create an index with version 4
  1280. index = Index(index_path, read=False, version=4)
  1281. # Add multiple entries with common prefixes
  1282. paths = [
  1283. b"src/main/java/App.java",
  1284. b"src/main/java/Utils.java",
  1285. b"src/main/resources/config.properties",
  1286. b"src/test/java/AppTest.java",
  1287. b"docs/README.md",
  1288. b"docs/INSTALL.md",
  1289. ]
  1290. for i, path in enumerate(paths):
  1291. entry = IndexEntry(
  1292. ctime=(1234567890, 0),
  1293. mtime=(1234567890, 0),
  1294. dev=1,
  1295. ino=i + 1,
  1296. mode=0o100644,
  1297. uid=1000,
  1298. gid=1000,
  1299. size=10,
  1300. sha=f"{i:040d}".encode(),
  1301. )
  1302. index[path] = entry
  1303. # Write and read back
  1304. index.write()
  1305. # Read the index back
  1306. index2 = Index(index_path)
  1307. self.assertEqual(index2._version, 4)
  1308. # Verify all paths were preserved correctly
  1309. for path in paths:
  1310. self.assertIn(path, index2)
  1311. # Verify the index file is smaller than version 2 would be
  1312. with open(index_path, "rb") as f:
  1313. v4_size = len(f.read())
  1314. # Create equivalent version 2 index for comparison
  1315. index_v2_path = os.path.join(self.tempdir, "index_v2")
  1316. index_v2 = Index(index_v2_path, read=False, version=2)
  1317. for path in paths:
  1318. entry = IndexEntry(
  1319. ctime=(1234567890, 0),
  1320. mtime=(1234567890, 0),
  1321. dev=1,
  1322. ino=1,
  1323. mode=0o100644,
  1324. uid=1000,
  1325. gid=1000,
  1326. size=10,
  1327. sha=b"0" * 40,
  1328. )
  1329. index_v2[path] = entry
  1330. index_v2.write()
  1331. with open(index_v2_path, "rb") as f:
  1332. v2_size = len(f.read())
  1333. # Version 4 should be smaller due to compression
  1334. self.assertLess(
  1335. v4_size, v2_size, "Version 4 index should be smaller than version 2"
  1336. )
  1337. def test_path_compression_edge_cases(self):
  1338. """Test edge cases in path compression."""
  1339. # Empty paths
  1340. compressed = _compress_path(b"", b"")
  1341. decompressed, _ = _decompress_path(compressed, 0, b"")
  1342. self.assertEqual(b"", decompressed)
  1343. # Path identical to previous
  1344. compressed = _compress_path(b"same.txt", b"same.txt")
  1345. decompressed, _ = _decompress_path(compressed, 0, b"same.txt")
  1346. self.assertEqual(b"same.txt", decompressed)
  1347. # Path shorter than previous
  1348. compressed = _compress_path(b"short", b"very/long/path/file.txt")
  1349. decompressed, _ = _decompress_path(compressed, 0, b"very/long/path/file.txt")
  1350. self.assertEqual(b"short", decompressed)
  1351. class TestUpdateWorkingTree(TestCase):
  1352. def setUp(self):
  1353. self.tempdir = tempfile.mkdtemp()
  1354. def cleanup_tempdir():
  1355. """Remove tempdir, handling read-only files on Windows."""
  1356. def remove_readonly(func, path, excinfo):
  1357. """Error handler for Windows read-only files."""
  1358. import stat
  1359. if sys.platform == "win32" and excinfo[0] is PermissionError:
  1360. os.chmod(path, stat.S_IWRITE)
  1361. func(path)
  1362. else:
  1363. raise
  1364. shutil.rmtree(self.tempdir, onerror=remove_readonly)
  1365. self.addCleanup(cleanup_tempdir)
  1366. self.repo = Repo.init(self.tempdir)
  1367. def test_update_working_tree_with_blob_normalizer(self):
  1368. """Test update_working_tree with a blob normalizer."""
  1369. # Create a simple blob normalizer that converts CRLF to LF
  1370. class TestBlobNormalizer:
  1371. def checkout_normalize(self, blob, path):
  1372. # Convert CRLF to LF during checkout
  1373. new_blob = Blob()
  1374. new_blob.data = blob.data.replace(b"\r\n", b"\n")
  1375. return new_blob
  1376. # Create a tree with a file containing CRLF
  1377. blob = Blob()
  1378. blob.data = b"Hello\r\nWorld\r\n"
  1379. self.repo.object_store.add_object(blob)
  1380. tree = Tree()
  1381. tree[b"test.txt"] = (0o100644, blob.id)
  1382. self.repo.object_store.add_object(tree)
  1383. # Update working tree with normalizer
  1384. normalizer = TestBlobNormalizer()
  1385. update_working_tree(
  1386. self.repo,
  1387. None, # old_tree_id
  1388. tree.id, # new_tree_id
  1389. blob_normalizer=normalizer,
  1390. )
  1391. # Check that the file was written with LF line endings
  1392. test_file = os.path.join(self.tempdir, "test.txt")
  1393. with open(test_file, "rb") as f:
  1394. content = f.read()
  1395. self.assertEqual(b"Hello\nWorld\n", content)
  1396. # Check that the index has the original blob SHA
  1397. index = self.repo.open_index()
  1398. self.assertEqual(blob.id, index[b"test.txt"].sha)
  1399. def test_update_working_tree_without_blob_normalizer(self):
  1400. """Test update_working_tree without a blob normalizer."""
  1401. # Create a tree with a file containing CRLF
  1402. blob = Blob()
  1403. blob.data = b"Hello\r\nWorld\r\n"
  1404. self.repo.object_store.add_object(blob)
  1405. tree = Tree()
  1406. tree[b"test.txt"] = (0o100644, blob.id)
  1407. self.repo.object_store.add_object(tree)
  1408. # Update working tree without normalizer
  1409. update_working_tree(
  1410. self.repo,
  1411. None, # old_tree_id
  1412. tree.id, # new_tree_id
  1413. blob_normalizer=None,
  1414. )
  1415. # Check that the file was written with original CRLF line endings
  1416. test_file = os.path.join(self.tempdir, "test.txt")
  1417. with open(test_file, "rb") as f:
  1418. content = f.read()
  1419. self.assertEqual(b"Hello\r\nWorld\r\n", content)
  1420. # Check that the index has the blob SHA
  1421. index = self.repo.open_index()
  1422. self.assertEqual(blob.id, index[b"test.txt"].sha)
  1423. def test_update_working_tree_remove_directory(self):
  1424. """Test that update_working_tree properly removes directories."""
  1425. # Create initial tree with a directory containing files
  1426. blob1 = Blob()
  1427. blob1.data = b"content1"
  1428. self.repo.object_store.add_object(blob1)
  1429. blob2 = Blob()
  1430. blob2.data = b"content2"
  1431. self.repo.object_store.add_object(blob2)
  1432. tree1 = Tree()
  1433. tree1[b"dir/file1.txt"] = (0o100644, blob1.id)
  1434. tree1[b"dir/file2.txt"] = (0o100644, blob2.id)
  1435. self.repo.object_store.add_object(tree1)
  1436. # Update to tree1 (create directory with files)
  1437. update_working_tree(self.repo, None, tree1.id)
  1438. # Verify directory and files exist
  1439. dir_path = os.path.join(self.tempdir, "dir")
  1440. self.assertTrue(os.path.isdir(dir_path))
  1441. self.assertTrue(os.path.exists(os.path.join(dir_path, "file1.txt")))
  1442. self.assertTrue(os.path.exists(os.path.join(dir_path, "file2.txt")))
  1443. # Create empty tree (remove everything)
  1444. tree2 = Tree()
  1445. self.repo.object_store.add_object(tree2)
  1446. # Update to empty tree
  1447. update_working_tree(self.repo, tree1.id, tree2.id)
  1448. # Verify directory was removed
  1449. self.assertFalse(os.path.exists(dir_path))
  1450. def test_update_working_tree_submodule_to_file(self):
  1451. """Test replacing a submodule directory with a file."""
  1452. # Create tree with submodule
  1453. submodule_sha = b"a" * 40
  1454. tree1 = Tree()
  1455. tree1[b"submodule"] = (S_IFGITLINK, submodule_sha)
  1456. self.repo.object_store.add_object(tree1)
  1457. # Update to tree with submodule
  1458. update_working_tree(self.repo, None, tree1.id)
  1459. # Verify submodule directory exists with .git file
  1460. submodule_path = os.path.join(self.tempdir, "submodule")
  1461. self.assertTrue(os.path.isdir(submodule_path))
  1462. self.assertTrue(os.path.exists(os.path.join(submodule_path, ".git")))
  1463. # Create tree with file at same path
  1464. blob = Blob()
  1465. blob.data = b"file content"
  1466. self.repo.object_store.add_object(blob)
  1467. tree2 = Tree()
  1468. tree2[b"submodule"] = (0o100644, blob.id)
  1469. self.repo.object_store.add_object(tree2)
  1470. # Update to tree with file (should remove submodule directory and create file)
  1471. update_working_tree(self.repo, tree1.id, tree2.id)
  1472. # Verify it's now a file
  1473. self.assertTrue(os.path.isfile(submodule_path))
  1474. with open(submodule_path, "rb") as f:
  1475. self.assertEqual(b"file content", f.read())
  1476. def test_update_working_tree_directory_with_nested_subdir(self):
  1477. """Test removing directory with nested subdirectories."""
  1478. # Create tree with nested directories
  1479. blob = Blob()
  1480. blob.data = b"deep content"
  1481. self.repo.object_store.add_object(blob)
  1482. tree1 = Tree()
  1483. tree1[b"a/b/c/file.txt"] = (0o100644, blob.id)
  1484. self.repo.object_store.add_object(tree1)
  1485. # Update to tree1
  1486. update_working_tree(self.repo, None, tree1.id)
  1487. # Verify nested structure exists
  1488. path_a = os.path.join(self.tempdir, "a")
  1489. path_b = os.path.join(path_a, "b")
  1490. path_c = os.path.join(path_b, "c")
  1491. file_path = os.path.join(path_c, "file.txt")
  1492. self.assertTrue(os.path.exists(file_path))
  1493. # Create empty tree
  1494. tree2 = Tree()
  1495. self.repo.object_store.add_object(tree2)
  1496. # Update to empty tree
  1497. update_working_tree(self.repo, tree1.id, tree2.id)
  1498. # Verify all directories were removed
  1499. self.assertFalse(os.path.exists(path_a))
  1500. def test_update_working_tree_file_replaced_by_dir_not_removed(self):
  1501. """Test that a directory replacing a git file is left alone if not empty."""
  1502. # Create tree with a file
  1503. blob = Blob()
  1504. blob.data = b"file content"
  1505. self.repo.object_store.add_object(blob)
  1506. tree1 = Tree()
  1507. tree1[b"path"] = (0o100644, blob.id)
  1508. self.repo.object_store.add_object(tree1)
  1509. # Update to tree1
  1510. update_working_tree(self.repo, None, tree1.id)
  1511. # Verify file exists
  1512. file_path = os.path.join(self.tempdir, "path")
  1513. self.assertTrue(os.path.isfile(file_path))
  1514. # Manually replace file with directory containing untracked file
  1515. os.remove(file_path)
  1516. os.mkdir(file_path)
  1517. with open(os.path.join(file_path, "untracked.txt"), "w") as f:
  1518. f.write("untracked content")
  1519. # Create empty tree
  1520. tree2 = Tree()
  1521. self.repo.object_store.add_object(tree2)
  1522. # Update should succeed but leave the directory alone
  1523. update_working_tree(self.repo, tree1.id, tree2.id)
  1524. # Directory should still exist with its contents
  1525. self.assertTrue(os.path.isdir(file_path))
  1526. self.assertTrue(os.path.exists(os.path.join(file_path, "untracked.txt")))
  1527. def test_update_working_tree_file_replaced_by_empty_dir_removed(self):
  1528. """Test that an empty directory replacing a git file is removed."""
  1529. # Create tree with a file
  1530. blob = Blob()
  1531. blob.data = b"file content"
  1532. self.repo.object_store.add_object(blob)
  1533. tree1 = Tree()
  1534. tree1[b"path"] = (0o100644, blob.id)
  1535. self.repo.object_store.add_object(tree1)
  1536. # Update to tree1
  1537. update_working_tree(self.repo, None, tree1.id)
  1538. # Verify file exists
  1539. file_path = os.path.join(self.tempdir, "path")
  1540. self.assertTrue(os.path.isfile(file_path))
  1541. # Manually replace file with empty directory
  1542. os.remove(file_path)
  1543. os.mkdir(file_path)
  1544. # Create empty tree
  1545. tree2 = Tree()
  1546. self.repo.object_store.add_object(tree2)
  1547. # Update should remove the empty directory
  1548. update_working_tree(self.repo, tree1.id, tree2.id)
  1549. # Directory should be gone
  1550. self.assertFalse(os.path.exists(file_path))
  1551. def test_update_working_tree_symlink_transitions(self):
  1552. """Test transitions involving symlinks."""
  1553. # Skip on Windows where symlinks might not be supported
  1554. if sys.platform == "win32":
  1555. self.skipTest("Symlinks not fully supported on Windows")
  1556. # Create tree with symlink
  1557. blob1 = Blob()
  1558. blob1.data = b"target/path"
  1559. self.repo.object_store.add_object(blob1)
  1560. tree1 = Tree()
  1561. tree1[b"link"] = (0o120000, blob1.id) # Symlink mode
  1562. self.repo.object_store.add_object(tree1)
  1563. # Update to tree with symlink
  1564. update_working_tree(self.repo, None, tree1.id)
  1565. link_path = os.path.join(self.tempdir, "link")
  1566. self.assertTrue(os.path.islink(link_path))
  1567. self.assertEqual(b"target/path", os.readlink(link_path).encode())
  1568. # Test 1: Replace symlink with regular file
  1569. blob2 = Blob()
  1570. blob2.data = b"file content"
  1571. self.repo.object_store.add_object(blob2)
  1572. tree2 = Tree()
  1573. tree2[b"link"] = (0o100644, blob2.id)
  1574. self.repo.object_store.add_object(tree2)
  1575. update_working_tree(self.repo, tree1.id, tree2.id)
  1576. self.assertFalse(os.path.islink(link_path))
  1577. self.assertTrue(os.path.isfile(link_path))
  1578. with open(link_path, "rb") as f:
  1579. self.assertEqual(b"file content", f.read())
  1580. # Test 2: Replace file with symlink
  1581. update_working_tree(self.repo, tree2.id, tree1.id)
  1582. self.assertTrue(os.path.islink(link_path))
  1583. self.assertEqual(b"target/path", os.readlink(link_path).encode())
  1584. # Test 3: Replace symlink with directory (manually)
  1585. os.unlink(link_path)
  1586. os.mkdir(link_path)
  1587. # Create empty tree
  1588. tree3 = Tree()
  1589. self.repo.object_store.add_object(tree3)
  1590. # Should remove empty directory
  1591. update_working_tree(self.repo, tree1.id, tree3.id)
  1592. self.assertFalse(os.path.exists(link_path))
  1593. def test_update_working_tree_modified_file_to_dir_transition(self):
  1594. """Test that modified files are not removed when they should be directories."""
  1595. # Create tree with file
  1596. blob1 = Blob()
  1597. blob1.data = b"original content"
  1598. self.repo.object_store.add_object(blob1)
  1599. tree1 = Tree()
  1600. tree1[b"path"] = (0o100644, blob1.id)
  1601. self.repo.object_store.add_object(tree1)
  1602. # Update to tree1
  1603. update_working_tree(self.repo, None, tree1.id)
  1604. file_path = os.path.join(self.tempdir, "path")
  1605. # Modify the file locally
  1606. with open(file_path, "w") as f:
  1607. f.write("modified content")
  1608. # Create tree where path is a directory with file
  1609. blob2 = Blob()
  1610. blob2.data = b"subfile content"
  1611. self.repo.object_store.add_object(blob2)
  1612. tree2 = Tree()
  1613. tree2[b"path/subfile"] = (0o100644, blob2.id)
  1614. self.repo.object_store.add_object(tree2)
  1615. # Update should fail because can't create directory where modified file exists
  1616. with self.assertRaises(IOError):
  1617. update_working_tree(self.repo, tree1.id, tree2.id)
  1618. # File should still exist with modifications
  1619. self.assertTrue(os.path.isfile(file_path))
  1620. with open(file_path) as f:
  1621. self.assertEqual("modified content", f.read())
  1622. def test_update_working_tree_executable_transitions(self):
  1623. """Test transitions involving executable bit changes."""
  1624. # Skip on Windows where executable bit is not supported
  1625. if sys.platform == "win32":
  1626. self.skipTest("Executable bit not supported on Windows")
  1627. # Create tree with non-executable file
  1628. blob = Blob()
  1629. blob.data = b"#!/bin/sh\necho hello"
  1630. self.repo.object_store.add_object(blob)
  1631. tree1 = Tree()
  1632. tree1[b"script.sh"] = (0o100644, blob.id) # Non-executable
  1633. self.repo.object_store.add_object(tree1)
  1634. # Update to tree1
  1635. update_working_tree(self.repo, None, tree1.id)
  1636. script_path = os.path.join(self.tempdir, "script.sh")
  1637. self.assertTrue(os.path.isfile(script_path))
  1638. # Check it's not executable
  1639. mode = os.stat(script_path).st_mode
  1640. self.assertFalse(mode & stat.S_IXUSR)
  1641. # Create tree with executable file (same content)
  1642. tree2 = Tree()
  1643. tree2[b"script.sh"] = (0o100755, blob.id) # Executable
  1644. self.repo.object_store.add_object(tree2)
  1645. # Update to tree2
  1646. update_working_tree(self.repo, tree1.id, tree2.id)
  1647. # Check it's now executable
  1648. mode = os.stat(script_path).st_mode
  1649. self.assertTrue(mode & stat.S_IXUSR)
  1650. def test_update_working_tree_submodule_with_untracked_files(self):
  1651. """Test that submodules with untracked files are not removed."""
  1652. from dulwich.objects import S_IFGITLINK, Tree
  1653. # Create tree with submodule
  1654. submodule_sha = b"a" * 40
  1655. tree1 = Tree()
  1656. tree1[b"submodule"] = (S_IFGITLINK, submodule_sha)
  1657. self.repo.object_store.add_object(tree1)
  1658. # Update to tree with submodule
  1659. update_working_tree(self.repo, None, tree1.id)
  1660. # Add untracked file to submodule directory
  1661. submodule_path = os.path.join(self.tempdir, "submodule")
  1662. untracked_path = os.path.join(submodule_path, "untracked.txt")
  1663. with open(untracked_path, "w") as f:
  1664. f.write("untracked content")
  1665. # Create empty tree
  1666. tree2 = Tree()
  1667. self.repo.object_store.add_object(tree2)
  1668. # Update should not remove submodule directory with untracked files
  1669. update_working_tree(self.repo, tree1.id, tree2.id)
  1670. # Directory should still exist with untracked file
  1671. self.assertTrue(os.path.isdir(submodule_path))
  1672. self.assertTrue(os.path.exists(untracked_path))
  1673. def test_update_working_tree_dir_to_file_with_subdir(self):
  1674. """Test replacing directory structure with a file."""
  1675. # Create tree with nested directory structure
  1676. blob1 = Blob()
  1677. blob1.data = b"content1"
  1678. self.repo.object_store.add_object(blob1)
  1679. blob2 = Blob()
  1680. blob2.data = b"content2"
  1681. self.repo.object_store.add_object(blob2)
  1682. tree1 = Tree()
  1683. tree1[b"dir/subdir/file1"] = (0o100644, blob1.id)
  1684. tree1[b"dir/subdir/file2"] = (0o100644, blob2.id)
  1685. self.repo.object_store.add_object(tree1)
  1686. # Update to tree1
  1687. update_working_tree(self.repo, None, tree1.id)
  1688. # Verify structure exists
  1689. dir_path = os.path.join(self.tempdir, "dir")
  1690. self.assertTrue(os.path.isdir(dir_path))
  1691. # Add an untracked file to make directory truly non-empty
  1692. untracked_path = os.path.join(dir_path, "untracked.txt")
  1693. with open(untracked_path, "w") as f:
  1694. f.write("untracked content")
  1695. # Create tree with file at "dir" path
  1696. blob3 = Blob()
  1697. blob3.data = b"replacement file"
  1698. self.repo.object_store.add_object(blob3)
  1699. tree2 = Tree()
  1700. tree2[b"dir"] = (0o100644, blob3.id)
  1701. self.repo.object_store.add_object(tree2)
  1702. # Update should fail because directory is not empty
  1703. with self.assertRaises(IsADirectoryError):
  1704. update_working_tree(self.repo, tree1.id, tree2.id)
  1705. # Directory should still exist
  1706. self.assertTrue(os.path.isdir(dir_path))
  1707. def test_update_working_tree_case_sensitivity(self):
  1708. """Test handling of case-sensitive filename changes."""
  1709. # Create tree with lowercase file
  1710. blob1 = Blob()
  1711. blob1.data = b"lowercase content"
  1712. self.repo.object_store.add_object(blob1)
  1713. tree1 = Tree()
  1714. tree1[b"readme.txt"] = (0o100644, blob1.id)
  1715. self.repo.object_store.add_object(tree1)
  1716. # Update to tree1
  1717. update_working_tree(self.repo, None, tree1.id)
  1718. # Create tree with uppercase file (different content)
  1719. blob2 = Blob()
  1720. blob2.data = b"uppercase content"
  1721. self.repo.object_store.add_object(blob2)
  1722. tree2 = Tree()
  1723. tree2[b"README.txt"] = (0o100644, blob2.id)
  1724. self.repo.object_store.add_object(tree2)
  1725. # Update to tree2
  1726. update_working_tree(self.repo, tree1.id, tree2.id)
  1727. # Check what exists (behavior depends on filesystem)
  1728. lowercase_path = os.path.join(self.tempdir, "readme.txt")
  1729. uppercase_path = os.path.join(self.tempdir, "README.txt")
  1730. # On case-insensitive filesystems, one will overwrite the other
  1731. # On case-sensitive filesystems, both may exist
  1732. self.assertTrue(
  1733. os.path.exists(lowercase_path) or os.path.exists(uppercase_path)
  1734. )
  1735. def test_update_working_tree_deeply_nested_removal(self):
  1736. """Test removal of deeply nested directory structures."""
  1737. # Create deeply nested structure
  1738. blob = Blob()
  1739. blob.data = b"deep content"
  1740. self.repo.object_store.add_object(blob)
  1741. tree1 = Tree()
  1742. # Create a very deep path
  1743. deep_path = b"/".join([b"level%d" % i for i in range(10)])
  1744. tree1[deep_path + b"/file.txt"] = (0o100644, blob.id)
  1745. self.repo.object_store.add_object(tree1)
  1746. # Update to tree1
  1747. update_working_tree(self.repo, None, tree1.id)
  1748. # Verify deep structure exists
  1749. current_path = self.tempdir
  1750. for i in range(10):
  1751. current_path = os.path.join(current_path, f"level{i}")
  1752. self.assertTrue(os.path.isdir(current_path))
  1753. # Create empty tree
  1754. tree2 = Tree()
  1755. self.repo.object_store.add_object(tree2)
  1756. # Update should remove all empty directories
  1757. update_working_tree(self.repo, tree1.id, tree2.id)
  1758. # Verify top level directory is gone
  1759. top_level = os.path.join(self.tempdir, "level0")
  1760. self.assertFalse(os.path.exists(top_level))
  1761. def test_update_working_tree_read_only_files(self):
  1762. """Test handling of read-only files during updates."""
  1763. # Create tree with file
  1764. blob1 = Blob()
  1765. blob1.data = b"original content"
  1766. self.repo.object_store.add_object(blob1)
  1767. tree1 = Tree()
  1768. tree1[b"readonly.txt"] = (0o100644, blob1.id)
  1769. self.repo.object_store.add_object(tree1)
  1770. # Update to tree1
  1771. update_working_tree(self.repo, None, tree1.id)
  1772. # Make file read-only
  1773. file_path = os.path.join(self.tempdir, "readonly.txt")
  1774. os.chmod(file_path, 0o444) # Read-only
  1775. # Create tree with modified file
  1776. blob2 = Blob()
  1777. blob2.data = b"new content"
  1778. self.repo.object_store.add_object(blob2)
  1779. tree2 = Tree()
  1780. tree2[b"readonly.txt"] = (0o100644, blob2.id)
  1781. self.repo.object_store.add_object(tree2)
  1782. # Update should handle read-only file
  1783. update_working_tree(self.repo, tree1.id, tree2.id)
  1784. # Verify content was updated
  1785. with open(file_path, "rb") as f:
  1786. self.assertEqual(b"new content", f.read())
  1787. def test_update_working_tree_invalid_filenames(self):
  1788. """Test handling of invalid filenames for the platform."""
  1789. # Create tree with potentially problematic filenames
  1790. blob = Blob()
  1791. blob.data = b"content"
  1792. self.repo.object_store.add_object(blob)
  1793. tree = Tree()
  1794. # Add files with names that might be invalid on some platforms
  1795. tree[b"valid.txt"] = (0o100644, blob.id)
  1796. if sys.platform != "win32":
  1797. # These are invalid on Windows but valid on Unix
  1798. tree[b"file:with:colons.txt"] = (0o100644, blob.id)
  1799. tree[b"file<with>brackets.txt"] = (0o100644, blob.id)
  1800. self.repo.object_store.add_object(tree)
  1801. # Update should skip invalid files based on validation
  1802. update_working_tree(self.repo, None, tree.id)
  1803. # Valid file should exist
  1804. self.assertTrue(os.path.exists(os.path.join(self.tempdir, "valid.txt")))
  1805. def test_update_working_tree_symlink_to_directory(self):
  1806. """Test replacing a symlink pointing to a directory with a real directory."""
  1807. if sys.platform == "win32":
  1808. self.skipTest("Symlinks not fully supported on Windows")
  1809. # Create a target directory
  1810. target_dir = os.path.join(self.tempdir, "target")
  1811. os.mkdir(target_dir)
  1812. with open(os.path.join(target_dir, "file.txt"), "w") as f:
  1813. f.write("target file")
  1814. # Create tree with symlink pointing to directory
  1815. blob1 = Blob()
  1816. blob1.data = b"target" # Relative path to target directory
  1817. self.repo.object_store.add_object(blob1)
  1818. tree1 = Tree()
  1819. tree1[b"link"] = (0o120000, blob1.id)
  1820. self.repo.object_store.add_object(tree1)
  1821. # Update to tree1
  1822. update_working_tree(self.repo, None, tree1.id)
  1823. link_path = os.path.join(self.tempdir, "link")
  1824. self.assertTrue(os.path.islink(link_path))
  1825. # Create tree with actual directory at same path
  1826. blob2 = Blob()
  1827. blob2.data = b"new file content"
  1828. self.repo.object_store.add_object(blob2)
  1829. tree2 = Tree()
  1830. tree2[b"link/newfile.txt"] = (0o100644, blob2.id)
  1831. self.repo.object_store.add_object(tree2)
  1832. # Update should replace symlink with actual directory
  1833. update_working_tree(self.repo, tree1.id, tree2.id)
  1834. self.assertFalse(os.path.islink(link_path))
  1835. self.assertTrue(os.path.isdir(link_path))
  1836. self.assertTrue(os.path.exists(os.path.join(link_path, "newfile.txt")))
  1837. def test_update_working_tree_comprehensive_transitions(self):
  1838. """Test all possible file type transitions comprehensively."""
  1839. # Skip on Windows where symlinks might not be supported
  1840. if sys.platform == "win32":
  1841. self.skipTest("Symlinks not fully supported on Windows")
  1842. # Create blobs for different file types
  1843. file_blob = Blob()
  1844. file_blob.data = b"regular file content"
  1845. self.repo.object_store.add_object(file_blob)
  1846. exec_blob = Blob()
  1847. exec_blob.data = b"#!/bin/sh\necho executable"
  1848. self.repo.object_store.add_object(exec_blob)
  1849. link_blob = Blob()
  1850. link_blob.data = b"target/path"
  1851. self.repo.object_store.add_object(link_blob)
  1852. submodule_sha = b"a" * 40
  1853. # Test 1: Regular file → Submodule
  1854. tree1 = Tree()
  1855. tree1[b"item"] = (0o100644, file_blob.id)
  1856. self.repo.object_store.add_object(tree1)
  1857. tree2 = Tree()
  1858. tree2[b"item"] = (S_IFGITLINK, submodule_sha)
  1859. self.repo.object_store.add_object(tree2)
  1860. update_working_tree(self.repo, None, tree1.id)
  1861. self.assertTrue(os.path.isfile(os.path.join(self.tempdir, "item")))
  1862. update_working_tree(self.repo, tree1.id, tree2.id)
  1863. self.assertTrue(os.path.isdir(os.path.join(self.tempdir, "item")))
  1864. # Test 2: Submodule → Executable file
  1865. tree3 = Tree()
  1866. tree3[b"item"] = (0o100755, exec_blob.id)
  1867. self.repo.object_store.add_object(tree3)
  1868. update_working_tree(self.repo, tree2.id, tree3.id)
  1869. item_path = os.path.join(self.tempdir, "item")
  1870. self.assertTrue(os.path.isfile(item_path))
  1871. if sys.platform != "win32":
  1872. self.assertTrue(os.access(item_path, os.X_OK))
  1873. # Test 3: Executable file → Symlink
  1874. tree4 = Tree()
  1875. tree4[b"item"] = (0o120000, link_blob.id)
  1876. self.repo.object_store.add_object(tree4)
  1877. update_working_tree(self.repo, tree3.id, tree4.id)
  1878. self.assertTrue(os.path.islink(item_path))
  1879. # Test 4: Symlink → Submodule
  1880. tree5 = Tree()
  1881. tree5[b"item"] = (S_IFGITLINK, submodule_sha)
  1882. self.repo.object_store.add_object(tree5)
  1883. update_working_tree(self.repo, tree4.id, tree5.id)
  1884. self.assertTrue(os.path.isdir(item_path))
  1885. # Test 5: Clean up - Submodule → absent
  1886. tree6 = Tree()
  1887. self.repo.object_store.add_object(tree6)
  1888. update_working_tree(self.repo, tree5.id, tree6.id)
  1889. self.assertFalse(os.path.exists(item_path))
  1890. # Test 6: Symlink → Executable file
  1891. tree7 = Tree()
  1892. tree7[b"item2"] = (0o120000, link_blob.id)
  1893. self.repo.object_store.add_object(tree7)
  1894. update_working_tree(self.repo, tree6.id, tree7.id)
  1895. item2_path = os.path.join(self.tempdir, "item2")
  1896. self.assertTrue(os.path.islink(item2_path))
  1897. tree8 = Tree()
  1898. tree8[b"item2"] = (0o100755, exec_blob.id)
  1899. self.repo.object_store.add_object(tree8)
  1900. update_working_tree(self.repo, tree7.id, tree8.id)
  1901. self.assertTrue(os.path.isfile(item2_path))
  1902. if sys.platform != "win32":
  1903. self.assertTrue(os.access(item2_path, os.X_OK))
  1904. def test_update_working_tree_partial_update_failure(self):
  1905. """Test handling when update fails partway through."""
  1906. # Create initial tree
  1907. blob1 = Blob()
  1908. blob1.data = b"file1 content"
  1909. self.repo.object_store.add_object(blob1)
  1910. blob2 = Blob()
  1911. blob2.data = b"file2 content"
  1912. self.repo.object_store.add_object(blob2)
  1913. tree1 = Tree()
  1914. tree1[b"file1.txt"] = (0o100644, blob1.id)
  1915. tree1[b"file2.txt"] = (0o100644, blob2.id)
  1916. self.repo.object_store.add_object(tree1)
  1917. # Update to tree1
  1918. update_working_tree(self.repo, None, tree1.id)
  1919. # Create a directory where file2.txt is, to cause a conflict
  1920. file2_path = os.path.join(self.tempdir, "file2.txt")
  1921. os.remove(file2_path)
  1922. os.mkdir(file2_path)
  1923. # Add untracked file to prevent removal
  1924. with open(os.path.join(file2_path, "blocker.txt"), "w") as f:
  1925. f.write("blocking content")
  1926. # Create tree with updates to both files
  1927. blob3 = Blob()
  1928. blob3.data = b"file1 updated"
  1929. self.repo.object_store.add_object(blob3)
  1930. blob4 = Blob()
  1931. blob4.data = b"file2 updated"
  1932. self.repo.object_store.add_object(blob4)
  1933. tree2 = Tree()
  1934. tree2[b"file1.txt"] = (0o100644, blob3.id)
  1935. tree2[b"file2.txt"] = (0o100644, blob4.id)
  1936. self.repo.object_store.add_object(tree2)
  1937. # Update should partially succeed - file1 updated, file2 blocked
  1938. try:
  1939. update_working_tree(self.repo, tree1.id, tree2.id)
  1940. except IsADirectoryError:
  1941. # Expected to fail on file2 because it's a directory
  1942. pass
  1943. # file1 should be updated
  1944. with open(os.path.join(self.tempdir, "file1.txt"), "rb") as f:
  1945. self.assertEqual(b"file1 updated", f.read())
  1946. # file2 should still be a directory
  1947. self.assertTrue(os.path.isdir(file2_path))