summaryrefslogtreecommitdiffstats
path: root/modules/git
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /modules/git
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/git/README.md3
-rw-r--r--modules/git/batch.go46
-rw-r--r--modules/git/batch_reader.go347
-rw-r--r--modules/git/blame.go211
-rw-r--r--modules/git/blame_sha256_test.go148
-rw-r--r--modules/git/blame_test.go147
-rw-r--r--modules/git/blob.go228
-rw-r--r--modules/git/blob_test.go59
-rw-r--r--modules/git/command.go473
-rw-r--r--modules/git/command_race_test.go38
-rw-r--r--modules/git/command_test.go70
-rw-r--r--modules/git/commit.go587
-rw-r--r--modules/git/commit_info.go176
-rw-r--r--modules/git/commit_info_test.go175
-rw-r--r--modules/git/commit_reader.go110
-rw-r--r--modules/git/commit_sha256_test.go211
-rw-r--r--modules/git/commit_test.go401
-rw-r--r--modules/git/diff.go328
-rw-r--r--modules/git/diff_test.go169
-rw-r--r--modules/git/error.go187
-rw-r--r--modules/git/foreachref/format.go83
-rw-r--r--modules/git/foreachref/format_test.go66
-rw-r--r--modules/git/foreachref/parser.go128
-rw-r--r--modules/git/foreachref/parser_test.go227
-rw-r--r--modules/git/git.go422
-rw-r--r--modules/git/git_test.go96
-rw-r--r--modules/git/grep.go195
-rw-r--r--modules/git/grep_test.go203
-rw-r--r--modules/git/hook.go143
-rw-r--r--modules/git/internal/cmdarg.go9
-rw-r--r--modules/git/last_commit_cache.go159
-rw-r--r--modules/git/log_name_status.go437
-rw-r--r--modules/git/notes.go99
-rw-r--r--modules/git/notes_test.go53
-rw-r--r--modules/git/object_format.go143
-rw-r--r--modules/git/object_id.go114
-rw-r--r--modules/git/object_id_test.go49
-rw-r--r--modules/git/object_signature.go11
-rw-r--r--modules/git/parse.go137
-rw-r--r--modules/git/parse_test.go103
-rw-r--r--modules/git/pipeline/catfile.go108
-rw-r--r--modules/git/pipeline/lfs.go254
-rw-r--r--modules/git/pipeline/namerev.go33
-rw-r--r--modules/git/pipeline/revlist.go86
-rw-r--r--modules/git/pushoptions/pushoptions.go113
-rw-r--r--modules/git/pushoptions/pushoptions_test.go125
-rw-r--r--modules/git/ref.go214
-rw-r--r--modules/git/ref_test.go38
-rw-r--r--modules/git/remote.go39
-rw-r--r--modules/git/repo.go342
-rw-r--r--modules/git/repo_archive.go80
-rw-r--r--modules/git/repo_attribute.go286
-rw-r--r--modules/git/repo_attribute_test.go351
-rw-r--r--modules/git/repo_base.go124
-rw-r--r--modules/git/repo_base_test.go163
-rw-r--r--modules/git/repo_blame.go23
-rw-r--r--modules/git/repo_blob_test.go70
-rw-r--r--modules/git/repo_branch.go349
-rw-r--r--modules/git/repo_branch_test.go197
-rw-r--r--modules/git/repo_commit.go677
-rw-r--r--modules/git/repo_commit_test.go103
-rw-r--r--modules/git/repo_commitgraph.go20
-rw-r--r--modules/git/repo_compare.go345
-rw-r--r--modules/git/repo_compare_test.go164
-rw-r--r--modules/git/repo_gpg.go58
-rw-r--r--modules/git/repo_hook.go14
-rw-r--r--modules/git/repo_index.go159
-rw-r--r--modules/git/repo_language_stats.go251
-rw-r--r--modules/git/repo_language_stats_test.go42
-rw-r--r--modules/git/repo_object.go101
-rw-r--r--modules/git/repo_ref.go157
-rw-r--r--modules/git/repo_ref_test.go56
-rw-r--r--modules/git/repo_stats.go151
-rw-r--r--modules/git/repo_stats_test.go37
-rw-r--r--modules/git/repo_tag.go366
-rw-r--r--modules/git/repo_tag_test.go364
-rw-r--r--modules/git/repo_test.go57
-rw-r--r--modules/git/repo_tree.go156
-rw-r--r--modules/git/signature.go67
-rw-r--r--modules/git/signature_test.go47
-rw-r--r--modules/git/submodule.go119
-rw-r--r--modules/git/submodule_test.go42
-rw-r--r--modules/git/tag.go129
-rw-r--r--modules/git/tag_test.go109
-rw-r--r--modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG3
-rw-r--r--modules/git/tests/repos/language_stats_repo/HEAD1
-rw-r--r--modules/git/tests/repos/language_stats_repo/config5
-rw-r--r--modules/git/tests/repos/language_stats_repo/description1
-rw-r--r--modules/git/tests/repos/language_stats_repo/indexbin0 -> 553 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/info/exclude6
-rw-r--r--modules/git/tests/repos/language_stats_repo/logs/HEAD2
-rw-r--r--modules/git/tests/repos/language_stats_repo/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756cbin0 -> 63 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bbbin0 -> 200 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97bin0 -> 172 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085bin0 -> 116 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03dbin0 -> 73 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640ebin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adcbin0 -> 371 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cdbin0 -> 201 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3bin0 -> 828 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bbbin0 -> 64 bytes
-rw-r--r--modules/git/tests/repos/language_stats_repo/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo1_bare/HEAD1
-rw-r--r--modules/git/tests/repos/repo1_bare/config4
-rw-r--r--modules/git/tests/repos/repo1_bare/description1
-rw-r--r--modules/git/tests/repos/repo1_bare/indexbin0 -> 65 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/info/exclude6
-rw-r--r--modules/git/tests/repos/repo1_bare/logs/HEAD2
-rw-r--r--modules/git/tests/repos/repo1_bare/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0bin0 -> 23 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310bin0 -> 31 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6bin0 -> 56 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fbbin0 -> 813 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86bbin0 -> 28 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e31
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32bin0 -> 818 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752ebin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5bin0 -> 23 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64bin0 -> 770 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f91231
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702bin0 -> 134 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8abin0 -> 638 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904bin0 -> 15 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7fbin0 -> 31 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9cabin0 -> 144 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee6583
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80ebin0 -> 32 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd11
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8bin0 -> 85 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0bin0 -> 176 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631bin0 -> 71 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfebin0 -> 115 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2bin0 -> 153 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4bbin0 -> 770 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653bin0 -> 121 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56bin0 -> 56 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c301852
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13bin0 -> 30 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351bin0 -> 18 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3bin0 -> 176 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24bin0 -> 80 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c4
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612bin0 -> 815 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2dbin0 -> 827 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023dabin0 -> 50 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930bin0 -> 111 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2bin0 -> 817 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare/pulls/1.patch0
-rw-r--r--modules/git/tests/repos/repo1_bare/pulls/2.patch39
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/branch11
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/branch21
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/notes/commits1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/tags/signed-tag1
-rw-r--r--modules/git/tests/repos/repo1_bare/refs/tags/test1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/config6
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/description1
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/info/refs7
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graphbin0 -> 2048 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmapbin0 -> 710 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idxbin0 -> 2576 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.packbin0 -> 5656 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.revbin0 -> 224 bytes
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/packed-refs8
-rw-r--r--modules/git/tests/repos/repo1_bare_sha256/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo2_empty/HEAD1
-rw-r--r--modules/git/tests/repos/repo2_empty/config6
-rw-r--r--modules/git/tests/repos/repo2_empty/description1
-rw-r--r--modules/git/tests/repos/repo2_empty/info/exclude6
-rw-r--r--modules/git/tests/repos/repo2_empty/objects/info/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep0
-rw-r--r--modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG1
-rw-r--r--modules/git/tests/repos/repo3_notes/HEAD1
-rw-r--r--modules/git/tests/repos/repo3_notes/config7
-rw-r--r--modules/git/tests/repos/repo3_notes/description1
-rw-r--r--modules/git/tests/repos/repo3_notes/indexbin0 -> 145 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/logs/HEAD2
-rw-r--r--modules/git/tests/repos/repo3_notes/logs/refs/heads/master2
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6bin0 -> 55 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9efbin0 -> 81 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba43
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02bin0 -> 44 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1debin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d371
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47bin0 -> 125 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225bin0 -> 55 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4bin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907bin0 -> 21 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2ebin0 -> 113 bytes
-rw-r--r--modules/git/tests/repos/repo3_notes/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo3_notes/refs/notes/commits1
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/HEAD1
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/config7
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/logs/HEAD4
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main4
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82eabin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1debin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf3443
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7cabin0 -> 160 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5bin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098bin0 -> 18 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4bin0 -> 16 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684bin0 -> 53 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78bin0 -> 126 bytes
-rw-r--r--modules/git/tests/repos/repo4_commitsbetween/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo5_pulls/HEAD1
-rw-r--r--modules/git/tests/repos/repo5_pulls/config6
-rw-r--r--modules/git/tests/repos/repo5_pulls/description1
-rw-r--r--modules/git/tests/repos/repo5_pulls/info/exclude6
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51dfbin0 -> 119 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbfbin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af1
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470abbin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb22
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd2
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17fbin0 -> 660 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f3
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640bin0 -> 650 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idxbin0 -> 1408 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.packbin0 -> 2363 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls/packed-refs5
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/master-clone1
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-11
-rw-r--r--modules/git/tests/repos/repo5_pulls/refs/pull/4/head1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/config6
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/description1
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/info/refs4
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graphbin0 -> 1544 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmapbin0 -> 414 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idxbin0 -> 1736 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.packbin0 -> 3140 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.revbin0 -> 140 bytes
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/packed-refs5
-rw-r--r--modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo6_blame/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_blame/config4
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643cbin0 -> 98 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1bin0 -> 35 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376bin0 -> 167 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9bin0 -> 24 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7bin0 -> 175 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044bin0 -> 57 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93bin0 -> 134 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8bin0 -> 54 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame/refs/heads/master1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/config6
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/description1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/info/refs1
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graphbin0 -> 1376 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/info/packs2
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmapbin0 -> 318 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idxbin0 -> 1456 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.packbin0 -> 904 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.revbin0 -> 112 bytes
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/packed-refs2
-rw-r--r--modules/git/tests/repos/repo6_blame_sha256/refs/refs/main1
-rw-r--r--modules/git/tests/repos/repo6_merge/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699bin0 -> 280 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5feccbin0 -> 152 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576bin0 -> 88 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e94112
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f414392
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482bbin0 -> 122 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597bin0 -> 26 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e25
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3abin0 -> 20 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831bin0 -> 26 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2bin0 -> 120 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77bin0 -> 145 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2bin0 -> 33 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92bin0 -> 24 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/main1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file1
-rw-r--r--modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/HEAD1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/config6
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/description1
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/info/exclude6
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/info/refs4
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graphbin0 -> 1564 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/info/packs3
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmapbin0 -> 410 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idxbin0 -> 1696 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.packbin0 -> 1556 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.revbin0 -> 136 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idxbin0 -> 1176 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimesbin0 -> 84 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.packbin0 -> 447 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.revbin0 -> 84 bytes
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/packed-refs5
-rw-r--r--modules/git/tests/repos/repo6_merge_sha256/refs/heads/main1
-rw-r--r--modules/git/tree.go178
-rw-r--r--modules/git/tree_blob.go81
-rw-r--r--modules/git/tree_entry.go277
-rw-r--r--modules/git/tree_entry_mode.go35
-rw-r--r--modules/git/tree_test.go28
-rw-r--r--modules/git/url/url.go89
-rw-r--r--modules/git/url/url_test.go167
-rw-r--r--modules/git/utils.go138
-rw-r--r--modules/git/utils_test.go26
-rw-r--r--modules/gitgraph/graph.go116
-rw-r--r--modules/gitgraph/graph_models.go256
-rw-r--r--modules/gitgraph/graph_test.go714
-rw-r--r--modules/gitgraph/parser.go336
-rw-r--r--modules/gitrepo/branch.go49
-rw-r--r--modules/gitrepo/gitrepo.go103
-rw-r--r--modules/gitrepo/walk.go15
332 files changed, 16685 insertions, 0 deletions
diff --git a/modules/git/README.md b/modules/git/README.md
new file mode 100644
index 0000000..4418c1b
--- /dev/null
+++ b/modules/git/README.md
@@ -0,0 +1,3 @@
+# Git Module
+
+This module is merged from https://github.com/go-gitea/git which is a Go module to access Git through shell commands. Now it's a part of gitea's main repository for easier pull request.
diff --git a/modules/git/batch.go b/modules/git/batch.go
new file mode 100644
index 0000000..3ec4f1d
--- /dev/null
+++ b/modules/git/batch.go
@@ -0,0 +1,46 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+)
+
+type Batch struct {
+ cancel context.CancelFunc
+ Reader *bufio.Reader
+ Writer WriteCloserError
+}
+
+func (repo *Repository) NewBatch(ctx context.Context) (*Batch, error) {
+ // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
+ if err := ensureValidGitRepository(ctx, repo.Path); err != nil {
+ return nil, err
+ }
+
+ var batch Batch
+ batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repo.Path)
+ return &batch, nil
+}
+
+func (repo *Repository) NewBatchCheck(ctx context.Context) (*Batch, error) {
+ // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
+ if err := ensureValidGitRepository(ctx, repo.Path); err != nil {
+ return nil, err
+ }
+
+ var check Batch
+ check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repo.Path)
+ return &check, nil
+}
+
+func (b *Batch) Close() {
+ if b.cancel != nil {
+ b.cancel()
+ b.Reader = nil
+ b.Writer = nil
+ b.cancel = nil
+ }
+}
diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go
new file mode 100644
index 0000000..3b1a466
--- /dev/null
+++ b/modules/git/batch_reader.go
@@ -0,0 +1,347 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "math"
+ "runtime"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/djherbis/buffer"
+ "github.com/djherbis/nio/v3"
+)
+
+// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function
+type WriteCloserError interface {
+ io.WriteCloser
+ CloseWithError(err error) error
+}
+
+// ensureValidGitRepository runs git rev-parse in the repository path - thus ensuring that the repository is a valid repository.
+// Run before opening git cat-file.
+// This is needed otherwise the git cat-file will hang for invalid repositories.
+func ensureValidGitRepository(ctx context.Context, repoPath string) error {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "rev-parse").
+ SetDescription(fmt.Sprintf("%s rev-parse [repo_path: %s]", GitExecutable, repoPath)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ return ConcatenateError(err, (&stderr).String())
+ }
+ return nil
+}
+
+// catFileBatchCheck opens git cat-file --batch-check in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func catFileBatchCheck(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
+ batchStdinReader, batchStdinWriter := io.Pipe()
+ batchStdoutReader, batchStdoutWriter := io.Pipe()
+ ctx, ctxCancel := context.WithCancel(ctx)
+ closed := make(chan struct{})
+ cancel := func() {
+ ctxCancel()
+ _ = batchStdoutReader.Close()
+ _ = batchStdinWriter.Close()
+ <-closed
+ }
+
+ // Ensure cancel is called as soon as the provided context is cancelled
+ go func() {
+ <-ctx.Done()
+ cancel()
+ }()
+
+ _, filename, line, _ := runtime.Caller(2)
+ filename = strings.TrimPrefix(filename, callerPrefix)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "cat-file", "--batch-check").
+ SetDescription(fmt.Sprintf("%s cat-file --batch-check [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stdin: batchStdinReader,
+ Stdout: batchStdoutWriter,
+ Stderr: &stderr,
+
+ UseContextTimeout: true,
+ })
+ if err != nil {
+ _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ _ = batchStdinReader.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = batchStdoutWriter.Close()
+ _ = batchStdinReader.Close()
+ }
+ close(closed)
+ }()
+
+ // For simplicities sake we'll use a buffered reader to read from the cat-file --batch-check
+ batchReader := bufio.NewReader(batchStdoutReader)
+
+ return batchStdinWriter, batchReader, cancel
+}
+
+// catFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func catFileBatch(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
+ // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinReader, batchStdinWriter := io.Pipe()
+ batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(32 * 1024))
+ ctx, ctxCancel := context.WithCancel(ctx)
+ closed := make(chan struct{})
+ cancel := func() {
+ ctxCancel()
+ _ = batchStdinWriter.Close()
+ _ = batchStdoutReader.Close()
+ <-closed
+ }
+
+ // Ensure cancel is called as soon as the provided context is cancelled
+ go func() {
+ <-ctx.Done()
+ cancel()
+ }()
+
+ _, filename, line, _ := runtime.Caller(2)
+ filename = strings.TrimPrefix(filename, callerPrefix)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := NewCommand(ctx, "cat-file", "--batch").
+ SetDescription(fmt.Sprintf("%s cat-file --batch [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)).
+ Run(&RunOpts{
+ Dir: repoPath,
+ Stdin: batchStdinReader,
+ Stdout: batchStdoutWriter,
+ Stderr: &stderr,
+
+ UseContextTimeout: true,
+ })
+ if err != nil {
+ _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ _ = batchStdinReader.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = batchStdoutWriter.Close()
+ _ = batchStdinReader.Close()
+ }
+ close(closed)
+ }()
+
+ // For simplicities sake we'll us a buffered reader to read from the cat-file --batch
+ batchReader := bufio.NewReaderSize(batchStdoutReader, 32*1024)
+
+ return batchStdinWriter, batchReader, cancel
+}
+
+// ReadBatchLine reads the header line from cat-file --batch
+// We expect:
+// <sha> SP <type> SP <size> LF
+// sha is a hex encoded here
+func ReadBatchLine(rd *bufio.Reader) (sha []byte, typ string, size int64, err error) {
+ typ, err = rd.ReadString('\n')
+ if err != nil {
+ return sha, typ, size, err
+ }
+ if len(typ) == 1 {
+ typ, err = rd.ReadString('\n')
+ if err != nil {
+ return sha, typ, size, err
+ }
+ }
+ idx := strings.IndexByte(typ, ' ')
+ if idx < 0 {
+ log.Debug("missing space typ: %s", typ)
+ return sha, typ, size, ErrNotExist{ID: string(sha)}
+ }
+ sha = []byte(typ[:idx])
+ typ = typ[idx+1:]
+
+ idx = strings.IndexByte(typ, ' ')
+ if idx < 0 {
+ return sha, typ, size, ErrNotExist{ID: string(sha)}
+ }
+
+ sizeStr := typ[idx+1 : len(typ)-1]
+ typ = typ[:idx]
+
+ size, err = strconv.ParseInt(sizeStr, 10, 64)
+ return sha, typ, size, err
+}
+
+// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream.
+func ReadTagObjectID(rd *bufio.Reader, size int64) (string, error) {
+ var id string
+ var n int64
+headerLoop:
+ for {
+ line, err := rd.ReadBytes('\n')
+ if err != nil {
+ return "", err
+ }
+ n += int64(len(line))
+ idx := bytes.Index(line, []byte{' '})
+ if idx < 0 {
+ continue
+ }
+
+ if string(line[:idx]) == "object" {
+ id = string(line[idx+1 : len(line)-1])
+ break headerLoop
+ }
+ }
+
+ // Discard the rest of the tag
+ return id, DiscardFull(rd, size-n+1)
+}
+
+// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream.
+func ReadTreeID(rd *bufio.Reader, size int64) (string, error) {
+ var id string
+ var n int64
+headerLoop:
+ for {
+ line, err := rd.ReadBytes('\n')
+ if err != nil {
+ return "", err
+ }
+ n += int64(len(line))
+ idx := bytes.Index(line, []byte{' '})
+ if idx < 0 {
+ continue
+ }
+
+ if string(line[:idx]) == "tree" {
+ id = string(line[idx+1 : len(line)-1])
+ break headerLoop
+ }
+ }
+
+ // Discard the rest of the commit
+ return id, DiscardFull(rd, size-n+1)
+}
+
+// git tree files are a list:
+// <mode-in-ascii> SP <fname> NUL <binary Hash>
+//
+// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools
+// Therefore we need some method to convert these binary hashes to hex hashes
+
+// constant hextable to help quickly convert between binary and hex representation
+const hextable = "0123456789abcdef"
+
+// BinToHexHeash converts a binary Hash into a hex encoded one. Input and output can be the
+// same byte slice to support in place conversion without allocations.
+// This is at least 100x quicker that hex.EncodeToString
+func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte {
+ for i := objectFormat.FullLength()/2 - 1; i >= 0; i-- {
+ v := sha[i]
+ vhi, vlo := v>>4, v&0x0f
+ shi, slo := hextable[vhi], hextable[vlo]
+ out[i*2], out[i*2+1] = shi, slo
+ }
+ return out
+}
+
+// ParseTreeLine reads an entry from a tree in a cat-file --batch stream
+// This carefully avoids allocations - except where fnameBuf is too small.
+// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations
+//
+// Each line is composed of:
+// <mode-in-ascii-dropping-initial-zeros> SP <fname> NUL <binary HASH>
+//
+// We don't attempt to convert the raw HASH to save a lot of time
+func ParseTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) {
+ var readBytes []byte
+
+ // Read the Mode & fname
+ readBytes, err = rd.ReadSlice('\x00')
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ idx := bytes.IndexByte(readBytes, ' ')
+ if idx < 0 {
+ log.Debug("missing space in readBytes ParseTreeLine: %s", readBytes)
+ return mode, fname, sha, n, &ErrNotExist{}
+ }
+
+ n += idx + 1
+ copy(modeBuf, readBytes[:idx])
+ if len(modeBuf) >= idx {
+ modeBuf = modeBuf[:idx]
+ } else {
+ modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...)
+ }
+ mode = modeBuf
+
+ readBytes = readBytes[idx+1:]
+
+ // Deal with the fname
+ copy(fnameBuf, readBytes)
+ if len(fnameBuf) > len(readBytes) {
+ fnameBuf = fnameBuf[:len(readBytes)]
+ } else {
+ fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...)
+ }
+ for err == bufio.ErrBufferFull {
+ readBytes, err = rd.ReadSlice('\x00')
+ fnameBuf = append(fnameBuf, readBytes...)
+ }
+ n += len(fnameBuf)
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ fnameBuf = fnameBuf[:len(fnameBuf)-1]
+ fname = fnameBuf
+
+ // Deal with the binary hash
+ idx = 0
+ length := objectFormat.FullLength() / 2
+ for idx < length {
+ var read int
+ read, err = rd.Read(shaBuf[idx:length])
+ n += read
+ if err != nil {
+ return mode, fname, sha, n, err
+ }
+ idx += read
+ }
+ sha = shaBuf
+ return mode, fname, sha, n, err
+}
+
+var callerPrefix string
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ callerPrefix = strings.TrimSuffix(filename, "modules/git/batch_reader.go")
+}
+
+func DiscardFull(rd *bufio.Reader, discard int64) error {
+ if discard > math.MaxInt32 {
+ n, err := rd.Discard(math.MaxInt32)
+ discard -= int64(n)
+ if err != nil {
+ return err
+ }
+ }
+ for discard > 0 {
+ n, err := rd.Discard(int(discard))
+ discard -= int64(n)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/modules/git/blame.go b/modules/git/blame.go
new file mode 100644
index 0000000..69e1b08
--- /dev/null
+++ b/modules/git/blame.go
@@ -0,0 +1,211 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// BlamePart represents block of blame - continuous lines with one sha
+type BlamePart struct {
+ Sha string
+ Lines []string
+ PreviousSha string
+ PreviousPath string
+}
+
+// BlameReader returns part of file blame one by one
+type BlameReader struct {
+ output io.WriteCloser
+ reader io.ReadCloser
+ bufferedReader *bufio.Reader
+ done chan error
+ lastSha *string
+ ignoreRevsFile *string
+ objectFormat ObjectFormat
+}
+
+func (r *BlameReader) UsesIgnoreRevs() bool {
+ return r.ignoreRevsFile != nil
+}
+
+// NextPart returns next part of blame (sequential code lines with the same commit)
+func (r *BlameReader) NextPart() (*BlamePart, error) {
+ var blamePart *BlamePart
+
+ if r.lastSha != nil {
+ blamePart = &BlamePart{
+ Sha: *r.lastSha,
+ Lines: make([]string, 0),
+ }
+ }
+
+ const previousHeader = "previous "
+ var lineBytes []byte
+ var isPrefix bool
+ var err error
+
+ for err != io.EOF {
+ lineBytes, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+
+ if len(lineBytes) == 0 {
+ // isPrefix will be false
+ continue
+ }
+
+ var objectID string
+ objectFormatLength := r.objectFormat.FullLength()
+
+ if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) {
+ objectID = string(lineBytes[0:objectFormatLength])
+ }
+ if len(objectID) > 0 {
+ if blamePart == nil {
+ blamePart = &BlamePart{
+ Sha: objectID,
+ Lines: make([]string, 0),
+ }
+ }
+
+ if blamePart.Sha != objectID {
+ r.lastSha = &objectID
+ // need to munch to end of line...
+ for isPrefix {
+ _, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+ }
+ return blamePart, nil
+ }
+ } else if lineBytes[0] == '\t' {
+ blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:]))
+ } else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) {
+ offset := len(previousHeader) // already includes a space
+ blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength])
+ offset += objectFormatLength + 1 // +1 for space
+ blamePart.PreviousPath = string(lineBytes[offset:])
+ }
+
+ // need to munch to end of line...
+ for isPrefix {
+ _, isPrefix, err = r.bufferedReader.ReadLine()
+ if err != nil && err != io.EOF {
+ return blamePart, err
+ }
+ }
+ }
+
+ r.lastSha = nil
+
+ return blamePart, nil
+}
+
+// Close BlameReader - don't run NextPart after invoking that
+func (r *BlameReader) Close() error {
+ if r.bufferedReader == nil {
+ return nil
+ }
+
+ err := <-r.done
+ r.bufferedReader = nil
+ _ = r.reader.Close()
+ _ = r.output.Close()
+ if r.ignoreRevsFile != nil {
+ _ = util.Remove(*r.ignoreRevsFile)
+ }
+ return err
+}
+
+// CreateBlameReader creates reader for given repository, commit and file
+func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
+ var ignoreRevsFile *string
+ if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore {
+ ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
+ }
+
+ cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain")
+ if ignoreRevsFile != nil {
+ // Possible improvement: use --ignore-revs-file /dev/stdin on unix
+ // There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
+ cmd.AddOptionValues("--ignore-revs-file", *ignoreRevsFile)
+ }
+ cmd.AddDynamicArguments(commit.ID.String()).
+ AddDashesAndList(file).
+ SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath))
+ reader, stdout, err := os.Pipe()
+ if err != nil {
+ if ignoreRevsFile != nil {
+ _ = util.Remove(*ignoreRevsFile)
+ }
+ return nil, err
+ }
+
+ done := make(chan error, 1)
+
+ go func() {
+ stderr := bytes.Buffer{}
+ // TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
+ err := cmd.Run(&RunOpts{
+ UseContextTimeout: true,
+ Dir: repoPath,
+ Stdout: stdout,
+ Stderr: &stderr,
+ })
+ done <- err
+ _ = stdout.Close()
+ if err != nil {
+ log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
+ }
+ }()
+
+ bufferedReader := bufio.NewReader(reader)
+
+ return &BlameReader{
+ output: stdout,
+ reader: reader,
+ bufferedReader: bufferedReader,
+ done: done,
+ ignoreRevsFile: ignoreRevsFile,
+ objectFormat: objectFormat,
+ }, nil
+}
+
+func tryCreateBlameIgnoreRevsFile(commit *Commit) *string {
+ entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
+ if err != nil {
+ return nil
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil
+ }
+ defer r.Close()
+
+ f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs")
+ if err != nil {
+ return nil
+ }
+
+ _, err = io.Copy(f, r)
+ _ = f.Close()
+ if err != nil {
+ _ = util.Remove(f.Name())
+ return nil
+ }
+
+ return util.ToPointer(f.Name())
+}
diff --git a/modules/git/blame_sha256_test.go b/modules/git/blame_sha256_test.go
new file mode 100644
index 0000000..eeeeb9f
--- /dev/null
+++ b/modules/git/blame_sha256_test.go
@@ -0,0 +1,148 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingBlameOutputSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345")
+ require.NoError(t, err)
+
+ parts := []*BlamePart{
+ {
+ Sha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
+ Lines: []string{
+ "# test_repo",
+ "Test repository for testing migration from github to gitea",
+ },
+ },
+ {
+ Sha: "0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345",
+ Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
+ PreviousSha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
+ PreviousPath: "README.md",
+ },
+ }
+
+ for _, bypass := range []bool{false, true} {
+ blameReader, err := CreateBlameReader(ctx, Sha256ObjectFormat, "./tests/repos/repo5_pulls_sha256", commit, "README.md", bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.False(t, blameReader.UsesIgnoreRevs())
+
+ for _, part := range parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+
+ t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ full := []*BlamePart{
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line"},
+ },
+ {
+ Sha: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ Lines: []string{"changed line"},
+ PreviousSha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ PreviousPath: "blame.txt",
+ },
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line", ""},
+ },
+ }
+
+ cases := []struct {
+ CommitID string
+ UsesIgnoreRevs bool
+ Bypass bool
+ Parts []*BlamePart
+ }{
+ {
+ CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
+ UsesIgnoreRevs: true,
+ Bypass: false,
+ Parts: []*BlamePart{
+ {
+ Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
+ Lines: []string{"line", "line", "changed line", "line", "line", ""},
+ },
+ },
+ },
+ {
+ CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
+ UsesIgnoreRevs: false,
+ Bypass: true,
+ Parts: full,
+ },
+ {
+ CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ {
+ CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ }
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+ for _, c := range cases {
+ commit, err := repo.GetCommit(c.CommitID)
+ require.NoError(t, err)
+ blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", commit, "blame.txt", c.Bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
+
+ for _, part := range c.Parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+}
diff --git a/modules/git/blame_test.go b/modules/git/blame_test.go
new file mode 100644
index 0000000..65320c7
--- /dev/null
+++ b/modules/git/blame_test.go
@@ -0,0 +1,147 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingBlameOutput(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2")
+ require.NoError(t, err)
+
+ parts := []*BlamePart{
+ {
+ Sha: "72866af952e98d02a73003501836074b286a78f6",
+ Lines: []string{
+ "# test_repo",
+ "Test repository for testing migration from github to gitea",
+ },
+ },
+ {
+ Sha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+ Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
+ PreviousSha: "72866af952e98d02a73003501836074b286a78f6",
+ PreviousPath: "README.md",
+ },
+ }
+
+ for _, bypass := range []bool{false, true} {
+ blameReader, err := CreateBlameReader(ctx, Sha1ObjectFormat, "./tests/repos/repo5_pulls", commit, "README.md", bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.False(t, blameReader.UsesIgnoreRevs())
+
+ for _, part := range parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+
+ t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
+ repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ full := []*BlamePart{
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line"},
+ },
+ {
+ Sha: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ Lines: []string{"changed line"},
+ PreviousSha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ PreviousPath: "blame.txt",
+ },
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line", ""},
+ },
+ }
+
+ cases := []struct {
+ CommitID string
+ UsesIgnoreRevs bool
+ Bypass bool
+ Parts []*BlamePart
+ }{
+ {
+ CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
+ UsesIgnoreRevs: true,
+ Bypass: false,
+ Parts: []*BlamePart{
+ {
+ Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
+ Lines: []string{"line", "line", "changed line", "line", "line", ""},
+ },
+ },
+ },
+ {
+ CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
+ UsesIgnoreRevs: false,
+ Bypass: true,
+ Parts: full,
+ },
+ {
+ CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ {
+ CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
+ UsesIgnoreRevs: false,
+ Bypass: false,
+ Parts: full,
+ },
+ }
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+ for _, c := range cases {
+ commit, err := repo.GetCommit(c.CommitID)
+ require.NoError(t, err)
+
+ blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass)
+ require.NoError(t, err)
+ assert.NotNil(t, blameReader)
+ defer blameReader.Close()
+
+ assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
+
+ for _, part := range c.Parts {
+ actualPart, err := blameReader.NextPart()
+ require.NoError(t, err)
+ assert.Equal(t, part, actualPart)
+ }
+
+ // make sure all parts have been read
+ actualPart, err := blameReader.NextPart()
+ assert.Nil(t, actualPart)
+ require.NoError(t, err)
+ }
+ })
+}
diff --git a/modules/git/blob.go b/modules/git/blob.go
new file mode 100644
index 0000000..2f02693
--- /dev/null
+++ b/modules/git/blob.go
@@ -0,0 +1,228 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "io"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Blob represents a Git object.
+type Blob struct {
+ ID ObjectID
+
+ gotSize bool
+ size int64
+ name string
+ repo *Repository
+}
+
+// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
+// Calling the Close function on the result will discard all unread output.
+func (b *Blob) DataAsync() (io.ReadCloser, error) {
+ wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = wr.Write([]byte(b.ID.String() + "\n"))
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ _, _, size, err := ReadBatchLine(rd)
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ b.gotSize = true
+ b.size = size
+
+ if size < 4096 {
+ bs, err := io.ReadAll(io.LimitReader(rd, size))
+ defer cancel()
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ return io.NopCloser(bytes.NewReader(bs)), err
+ }
+
+ return &blobReader{
+ rd: rd,
+ n: size,
+ cancel: cancel,
+ }, nil
+}
+
+// Size returns the uncompressed size of the blob
+func (b *Blob) Size() int64 {
+ if b.gotSize {
+ return b.size
+ }
+
+ wr, rd, cancel, err := b.repo.CatFileBatchCheck(b.repo.Ctx)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(b.ID.String() + "\n"))
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+ _, _, b.size, err = ReadBatchLine(rd)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
+ return 0
+ }
+
+ b.gotSize = true
+
+ return b.size
+}
+
+type blobReader struct {
+ rd *bufio.Reader
+ n int64
+ cancel func()
+}
+
+func (b *blobReader) Read(p []byte) (n int, err error) {
+ if b.n <= 0 {
+ return 0, io.EOF
+ }
+ if int64(len(p)) > b.n {
+ p = p[0:b.n]
+ }
+ n, err = b.rd.Read(p)
+ b.n -= int64(n)
+ return n, err
+}
+
+// Close implements io.Closer
+func (b *blobReader) Close() error {
+ if b.rd == nil {
+ return nil
+ }
+
+ defer b.cancel()
+
+ if err := DiscardFull(b.rd, b.n+1); err != nil {
+ return err
+ }
+
+ b.rd = nil
+
+ return nil
+}
+
+// Name returns name of the tree entry this blob object was created from (or empty string)
+func (b *Blob) Name() string {
+ return b.name
+}
+
+// GetBlobContent Gets the limited content of the blob as raw text
+func (b *Blob) GetBlobContent(limit int64) (string, error) {
+ if limit <= 0 {
+ return "", nil
+ }
+ dataRc, err := b.DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer dataRc.Close()
+ buf, err := util.ReadWithLimit(dataRc, int(limit))
+ return string(buf), err
+}
+
+// GetBlobLineCount gets line count of the blob
+func (b *Blob) GetBlobLineCount() (int, error) {
+ reader, err := b.DataAsync()
+ if err != nil {
+ return 0, err
+ }
+ defer reader.Close()
+ buf := make([]byte, 32*1024)
+ count := 1
+ lineSep := []byte{'\n'}
+
+ c, err := reader.Read(buf)
+ if c == 0 && err == io.EOF {
+ return 0, nil
+ }
+ for {
+ count += bytes.Count(buf[:c], lineSep)
+ switch {
+ case err == io.EOF:
+ return count, nil
+ case err != nil:
+ return count, err
+ }
+ c, err = reader.Read(buf)
+ }
+}
+
+// GetBlobContentBase64 Reads the content of the blob with a base64 encode and returns the encoded string
+func (b *Blob) GetBlobContentBase64() (string, error) {
+ dataRc, err := b.DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer dataRc.Close()
+
+ pr, pw := io.Pipe()
+ encoder := base64.NewEncoder(base64.StdEncoding, pw)
+
+ go func() {
+ _, err := io.Copy(encoder, dataRc)
+ _ = encoder.Close()
+
+ if err != nil {
+ _ = pw.CloseWithError(err)
+ } else {
+ _ = pw.Close()
+ }
+ }()
+
+ out, err := io.ReadAll(pr)
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
+
+// GuessContentType guesses the content type of the blob.
+func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) {
+ r, err := b.DataAsync()
+ if err != nil {
+ return typesniffer.SniffedType{}, err
+ }
+ defer r.Close()
+
+ return typesniffer.DetectContentTypeFromReader(r)
+}
+
+// GetBlob finds the blob object in the repository.
+func (repo *Repository) GetBlob(idStr string) (*Blob, error) {
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+ if id.IsZero() {
+ return nil, ErrNotExist{id.String(), ""}
+ }
+ return &Blob{
+ ID: id,
+ repo: repo,
+ }, nil
+}
diff --git a/modules/git/blob_test.go b/modules/git/blob_test.go
new file mode 100644
index 0000000..810964b
--- /dev/null
+++ b/modules/git/blob_test.go
@@ -0,0 +1,59 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBlob_Data(t *testing.T) {
+ output := "file2\n"
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+
+ defer repo.Close()
+
+ testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
+ require.NoError(t, err)
+
+ r, err := testBlob.DataAsync()
+ require.NoError(t, err)
+ require.NotNil(t, r)
+
+ data, err := io.ReadAll(r)
+ require.NoError(t, r.Close())
+
+ require.NoError(t, err)
+ assert.Equal(t, output, string(data))
+}
+
+func Benchmark_Blob_Data(b *testing.B) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer repo.Close()
+
+ testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ for i := 0; i < b.N; i++ {
+ r, err := testBlob.DataAsync()
+ if err != nil {
+ b.Fatal(err)
+ }
+ io.ReadAll(r)
+ _ = r.Close()
+ }
+}
diff --git a/modules/git/command.go b/modules/git/command.go
new file mode 100644
index 0000000..a3d43aa
--- /dev/null
+++ b/modules/git/command.go
@@ -0,0 +1,473 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// TrustedCmdArgs returns the trusted arguments for git command.
+// It's mainly for passing user-provided and trusted arguments to git command
+// In most cases, it shouldn't be used. Use AddXxx function instead
+type TrustedCmdArgs []internal.CmdArg
+
+var (
+ // globalCommandArgs global command args for external package setting
+ globalCommandArgs TrustedCmdArgs
+
+ // defaultCommandExecutionTimeout default command execution timeout duration
+ defaultCommandExecutionTimeout = 360 * time.Second
+)
+
+// DefaultLocale is the default LC_ALL to run git commands in.
+const DefaultLocale = "C"
+
+// Command represents a command with its subcommands or arguments.
+type Command struct {
+ prog string
+ args []string
+ parentContext context.Context
+ desc string
+ globalArgsLength int
+ brokenArgs []string
+}
+
+func (c *Command) String() string {
+ return c.toString(false)
+}
+
+func (c *Command) toString(sanitizing bool) string {
+ // WARNING: this function is for debugging purposes only. It's much better than old code (which only joins args with space),
+ // It's impossible to make a simple and 100% correct implementation of argument quoting for different platforms.
+ debugQuote := func(s string) string {
+ if strings.ContainsAny(s, " `'\"\t\r\n") {
+ return fmt.Sprintf("%q", s)
+ }
+ return s
+ }
+ a := make([]string, 0, len(c.args)+1)
+ a = append(a, debugQuote(c.prog))
+ for _, arg := range c.args {
+ if sanitizing && (strings.Contains(arg, "://") && strings.Contains(arg, "@")) {
+ a = append(a, debugQuote(util.SanitizeCredentialURLs(arg)))
+ } else {
+ a = append(a, debugQuote(arg))
+ }
+ }
+ return strings.Join(a, " ")
+}
+
+// NewCommand creates and returns a new Git Command based on given command and arguments.
+// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
+func NewCommand(ctx context.Context, args ...internal.CmdArg) *Command {
+ // Make an explicit copy of globalCommandArgs, otherwise append might overwrite it
+ cargs := make([]string, 0, len(globalCommandArgs)+len(args))
+ for _, arg := range globalCommandArgs {
+ cargs = append(cargs, string(arg))
+ }
+ for _, arg := range args {
+ cargs = append(cargs, string(arg))
+ }
+ return &Command{
+ prog: GitExecutable,
+ args: cargs,
+ parentContext: ctx,
+ globalArgsLength: len(globalCommandArgs),
+ }
+}
+
+// NewCommandContextNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
+// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
+func NewCommandContextNoGlobals(ctx context.Context, args ...internal.CmdArg) *Command {
+ cargs := make([]string, 0, len(args))
+ for _, arg := range args {
+ cargs = append(cargs, string(arg))
+ }
+ return &Command{
+ prog: GitExecutable,
+ args: cargs,
+ parentContext: ctx,
+ }
+}
+
+// SetParentContext sets the parent context for this command
+func (c *Command) SetParentContext(ctx context.Context) *Command {
+ c.parentContext = ctx
+ return c
+}
+
+// SetDescription sets the description for this command which be returned on c.String()
+func (c *Command) SetDescription(desc string) *Command {
+ c.desc = desc
+ return c
+}
+
+// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option)
+func isSafeArgumentValue(s string) bool {
+ return s == "" || s[0] != '-'
+}
+
+// isValidArgumentOption checks if the argument is a valid option (starting with '-').
+// It doesn't check whether the option is supported or not
+func isValidArgumentOption(s string) bool {
+ return s != "" && s[0] == '-'
+}
+
+// AddArguments adds new git arguments (option/value) to the command. It only accepts string literals, or trusted CmdArg.
+// Type CmdArg is in the internal package, so it can not be used outside of this package directly,
+// it makes sure that user-provided arguments won't cause RCE risks.
+// User-provided arguments should be passed by other AddXxx functions
+func (c *Command) AddArguments(args ...internal.CmdArg) *Command {
+ for _, arg := range args {
+ c.args = append(c.args, string(arg))
+ }
+ return c
+}
+
+// AddOptionValues adds a new option with a list of non-option values
+// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}.
+// The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val).
+func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command {
+ if !isValidArgumentOption(string(opt)) {
+ c.brokenArgs = append(c.brokenArgs, string(opt))
+ return c
+ }
+ c.args = append(c.args, string(opt))
+ c.AddDynamicArguments(args...)
+ return c
+}
+
+// AddGitGrepExpression adds an expression option (-e) to git-grep command
+// It is different from AddOptionValues in that it allows the actual expression
+// to not be filtered out for leading dashes (which is otherwise a security feature
+// of AddOptionValues).
+func (c *Command) AddGitGrepExpression(exp string) *Command {
+ if c.args[len(globalCommandArgs)] != "grep" {
+ panic("function called on a non-grep git program: " + c.args[0])
+ }
+ c.args = append(c.args, "-e", exp)
+ return c
+}
+
+// AddOptionFormat adds a new option with a format string and arguments
+// For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}.
+func (c *Command) AddOptionFormat(opt string, args ...any) *Command {
+ if !isValidArgumentOption(opt) {
+ c.brokenArgs = append(c.brokenArgs, opt)
+ return c
+ }
+ // a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP
+ if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) {
+ c.brokenArgs = append(c.brokenArgs, opt)
+ return c
+ }
+ s := fmt.Sprintf(opt, args...)
+ c.args = append(c.args, s)
+ return c
+}
+
+// AddDynamicArguments adds new dynamic argument values to the command.
+// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options.
+// TODO: in the future, this function can be renamed to AddArgumentValues
+func (c *Command) AddDynamicArguments(args ...string) *Command {
+ for _, arg := range args {
+ if !isSafeArgumentValue(arg) {
+ c.brokenArgs = append(c.brokenArgs, arg)
+ }
+ }
+ if len(c.brokenArgs) != 0 {
+ return c
+ }
+ c.args = append(c.args, args...)
+ return c
+}
+
+// AddDashesAndList adds the "--" and then add the list as arguments, it's usually for adding file list
+// At the moment, this function can be only called once, maybe in future it can be refactored to support multiple calls (if necessary)
+func (c *Command) AddDashesAndList(list ...string) *Command {
+ c.args = append(c.args, "--")
+ // Some old code also checks `arg != ""`, IMO it's not necessary.
+ // If the check is needed, the list should be prepared before the call to this function
+ c.args = append(c.args, list...)
+ return c
+}
+
+// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
+// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead
+func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
+ ret := make(TrustedCmdArgs, len(args))
+ for i, arg := range args {
+ ret[i] = internal.CmdArg(arg)
+ }
+ return ret
+}
+
+// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
+type RunOpts struct {
+ Env []string
+ Timeout time.Duration
+ UseContextTimeout bool
+
+ // Dir is the working dir for the git command, however:
+ // FIXME: this could be incorrect in many cases, for example:
+ // * /some/path/.git
+ // * /some/path/.git/gitea-data/data/repositories/user/repo.git
+ // If "user/repo.git" is invalid/broken, then running git command in it will use "/some/path/.git", and produce unexpected results
+ // The correct approach is to use `--git-dir" global argument
+ Dir string
+
+ Stdout, Stderr io.Writer
+
+ // Stdin is used for passing input to the command
+ // The caller must make sure the Stdin writer is closed properly to finish the Run function.
+ // Otherwise, the Run function may hang for long time or forever, especially when the Git's context deadline is not the same as the caller's.
+ // Some common mistakes:
+ // * `defer stdinWriter.Close()` then call `cmd.Run()`: the Run() would never return if the command is killed by timeout
+ // * `go { case <- parentContext.Done(): stdinWriter.Close() }` with `cmd.Run(DefaultTimeout)`: the command would have been killed by timeout but the Run doesn't return until stdinWriter.Close()
+ // * `go { if stdoutReader.Read() err != nil: stdinWriter.Close() }` with `cmd.Run()`: the stdoutReader may never return error if the command is killed by timeout
+ // In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture.
+ Stdin io.Reader
+
+ PipelineFunc func(context.Context, context.CancelFunc) error
+}
+
+func commonBaseEnvs() []string {
+ // at the moment, do not set "GIT_CONFIG_NOSYSTEM", users may have put some configs like "receive.certNonceSeed" in it
+ envs := []string{
+ "HOME=" + HomeDir(), // make Gitea use internal git config only, to prevent conflicts with user's git config
+ "GIT_NO_REPLACE_OBJECTS=1", // ignore replace references (https://git-scm.com/docs/git-replace)
+ }
+
+ // some environment variables should be passed to git command
+ passThroughEnvKeys := []string{
+ "GNUPGHOME", // git may call gnupg to do commit signing
+ }
+ for _, key := range passThroughEnvKeys {
+ if val, ok := os.LookupEnv(key); ok {
+ envs = append(envs, key+"="+val)
+ }
+ }
+ return envs
+}
+
+// CommonGitCmdEnvs returns the common environment variables for a "git" command.
+func CommonGitCmdEnvs() []string {
+ return append(commonBaseEnvs(), []string{
+ "LC_ALL=" + DefaultLocale,
+ "GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3
+ }...)
+}
+
+// CommonCmdServEnvs is like CommonGitCmdEnvs, but it only returns minimal required environment variables for the "gitea serv" command
+func CommonCmdServEnvs() []string {
+ return commonBaseEnvs()
+}
+
+var ErrBrokenCommand = errors.New("git command is broken")
+
+// Run runs the command with the RunOpts
+func (c *Command) Run(opts *RunOpts) error {
+ if len(c.brokenArgs) != 0 {
+ log.Error("git command is broken: %s, broken args: %s", c.String(), strings.Join(c.brokenArgs, " "))
+ return ErrBrokenCommand
+ }
+ if opts == nil {
+ opts = &RunOpts{}
+ }
+
+ // We must not change the provided options
+ timeout := opts.Timeout
+ if timeout <= 0 {
+ timeout = defaultCommandExecutionTimeout
+ }
+
+ if len(opts.Dir) == 0 {
+ log.Debug("git.Command.Run: %s", c)
+ } else {
+ log.Debug("git.Command.RunDir(%s): %s", opts.Dir, c)
+ }
+
+ desc := c.desc
+ if desc == "" {
+ if opts.Dir == "" {
+ desc = fmt.Sprintf("git: %s", c.toString(true))
+ } else {
+ desc = fmt.Sprintf("git(dir:%s): %s", opts.Dir, c.toString(true))
+ }
+ }
+
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var finished context.CancelFunc
+
+ if opts.UseContextTimeout {
+ ctx, cancel, finished = process.GetManager().AddContext(c.parentContext, desc)
+ } else {
+ ctx, cancel, finished = process.GetManager().AddContextTimeout(c.parentContext, timeout, desc)
+ }
+ defer finished()
+
+ startTime := time.Now()
+
+ cmd := exec.CommandContext(ctx, c.prog, c.args...)
+ if opts.Env == nil {
+ cmd.Env = os.Environ()
+ } else {
+ cmd.Env = opts.Env
+ }
+
+ process.SetSysProcAttribute(cmd)
+ cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
+ cmd.Dir = opts.Dir
+ cmd.Stdout = opts.Stdout
+ cmd.Stderr = opts.Stderr
+ cmd.Stdin = opts.Stdin
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ if opts.PipelineFunc != nil {
+ err := opts.PipelineFunc(ctx, cancel)
+ if err != nil {
+ cancel()
+ _ = cmd.Wait()
+ return err
+ }
+ }
+
+ err := cmd.Wait()
+ elapsed := time.Since(startTime)
+ if elapsed > time.Second {
+ log.Debug("slow git.Command.Run: %s (%s)", c, elapsed)
+ }
+
+ // We need to check if the context is canceled by the program on Windows.
+ // This is because Windows does not have signal checking when terminating the process.
+ // It always returns exit code 1, unlike Linux, which has many exit codes for signals.
+ if runtime.GOOS == "windows" &&
+ err != nil &&
+ err.Error() == "" &&
+ cmd.ProcessState.ExitCode() == 1 &&
+ ctx.Err() == context.Canceled {
+ return ctx.Err()
+ }
+
+ if err != nil && ctx.Err() != context.DeadlineExceeded {
+ return err
+ }
+
+ return ctx.Err()
+}
+
+type RunStdError interface {
+ error
+ Unwrap() error
+ Stderr() string
+}
+
+type runStdError struct {
+ err error
+ stderr string
+ errMsg string
+}
+
+func (r *runStdError) Error() string {
+ // the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
+ if r.errMsg == "" {
+ r.errMsg = ConcatenateError(r.err, r.stderr).Error()
+ }
+ return r.errMsg
+}
+
+func (r *runStdError) Unwrap() error {
+ return r.err
+}
+
+func (r *runStdError) Stderr() string {
+ return r.stderr
+}
+
+func IsErrorExitCode(err error, code int) bool {
+ var exitError *exec.ExitError
+ if errors.As(err, &exitError) {
+ return exitError.ExitCode() == code
+ }
+ return false
+}
+
+// RunStdString runs the command with options and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
+func (c *Command) RunStdString(opts *RunOpts) (stdout, stderr string, runErr RunStdError) {
+ stdoutBytes, stderrBytes, err := c.RunStdBytes(opts)
+ stdout = util.UnsafeBytesToString(stdoutBytes)
+ stderr = util.UnsafeBytesToString(stderrBytes)
+ if err != nil {
+ return stdout, stderr, &runStdError{err: err, stderr: stderr}
+ }
+ // even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
+ return stdout, stderr, nil
+}
+
+// RunStdBytes runs the command with options and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
+func (c *Command) RunStdBytes(opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
+ if opts == nil {
+ opts = &RunOpts{}
+ }
+ if opts.Stdout != nil || opts.Stderr != nil {
+ // we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
+ panic("stdout and stderr field must be nil when using RunStdBytes")
+ }
+ stdoutBuf := &bytes.Buffer{}
+ stderrBuf := &bytes.Buffer{}
+
+ // We must not change the provided options as it could break future calls - therefore make a copy.
+ newOpts := &RunOpts{
+ Env: opts.Env,
+ Timeout: opts.Timeout,
+ UseContextTimeout: opts.UseContextTimeout,
+ Dir: opts.Dir,
+ Stdout: stdoutBuf,
+ Stderr: stderrBuf,
+ Stdin: opts.Stdin,
+ PipelineFunc: opts.PipelineFunc,
+ }
+
+ err := c.Run(newOpts)
+ stderr = stderrBuf.Bytes()
+ if err != nil {
+ return nil, stderr, &runStdError{err: err, stderr: util.UnsafeBytesToString(stderr)}
+ }
+ // even if there is no err, there could still be some stderr output
+ return stdoutBuf.Bytes(), stderr, nil
+}
+
+// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests
+func AllowLFSFiltersArgs() TrustedCmdArgs {
+ // Now here we should explicitly allow lfs filters to run
+ filteredLFSGlobalArgs := make(TrustedCmdArgs, len(globalCommandArgs))
+ j := 0
+ for _, arg := range globalCommandArgs {
+ if strings.Contains(string(arg), "lfs") {
+ j--
+ } else {
+ filteredLFSGlobalArgs[j] = arg
+ j++
+ }
+ }
+ return filteredLFSGlobalArgs[:j]
+}
diff --git a/modules/git/command_race_test.go b/modules/git/command_race_test.go
new file mode 100644
index 0000000..f567406
--- /dev/null
+++ b/modules/git/command_race_test.go
@@ -0,0 +1,38 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build race
+
+package git
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestRunWithContextNoTimeout(t *testing.T) {
+ maxLoops := 10
+
+ // 'git --version' does not block so it must be finished before the timeout triggered.
+ cmd := NewCommand(context.Background(), "--version")
+ for i := 0; i < maxLoops; i++ {
+ if err := cmd.Run(&RunOpts{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestRunWithContextTimeout(t *testing.T) {
+ maxLoops := 10
+
+ // 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
+ cmd := NewCommand(context.Background(), "hash-object", "--stdin")
+ for i := 0; i < maxLoops; i++ {
+ if err := cmd.Run(&RunOpts{Timeout: 1 * time.Millisecond}); err != nil {
+ if err != context.DeadlineExceeded {
+ t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
+ }
+ }
+ }
+}
diff --git a/modules/git/command_test.go b/modules/git/command_test.go
new file mode 100644
index 0000000..d3b8338
--- /dev/null
+++ b/modules/git/command_test.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRunWithContextStd(t *testing.T) {
+ cmd := NewCommand(context.Background(), "--version")
+ stdout, stderr, err := cmd.RunStdString(&RunOpts{})
+ require.NoError(t, err)
+ assert.Empty(t, stderr)
+ assert.Contains(t, stdout, "git version")
+
+ cmd = NewCommand(context.Background(), "--no-such-arg")
+ stdout, stderr, err = cmd.RunStdString(&RunOpts{})
+ if assert.Error(t, err) {
+ assert.Equal(t, stderr, err.Stderr())
+ assert.Contains(t, err.Stderr(), "unknown option:")
+ assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
+ assert.Empty(t, stdout)
+ }
+
+ cmd = NewCommand(context.Background())
+ cmd.AddDynamicArguments("-test")
+ require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand)
+
+ cmd = NewCommand(context.Background())
+ cmd.AddDynamicArguments("--test")
+ require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand)
+
+ subCmd := "version"
+ cmd = NewCommand(context.Background()).AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production
+ stdout, stderr, err = cmd.RunStdString(&RunOpts{})
+ require.NoError(t, err)
+ assert.Empty(t, stderr)
+ assert.Contains(t, stdout, "git version")
+}
+
+func TestGitArgument(t *testing.T) {
+ assert.True(t, isValidArgumentOption("-x"))
+ assert.True(t, isValidArgumentOption("--xx"))
+ assert.False(t, isValidArgumentOption(""))
+ assert.False(t, isValidArgumentOption("x"))
+
+ assert.True(t, isSafeArgumentValue(""))
+ assert.True(t, isSafeArgumentValue("x"))
+ assert.False(t, isSafeArgumentValue("-x"))
+}
+
+func TestCommandString(t *testing.T) {
+ cmd := NewCommandContextNoGlobals(context.Background(), "a", "-m msg", "it's a test", `say "hello"`)
+ assert.EqualValues(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.String())
+
+ cmd = NewCommandContextNoGlobals(context.Background(), "url: https://a:b@c/")
+ assert.EqualValues(t, cmd.prog+` "url: https://sanitized-credential@c/"`, cmd.toString(true))
+}
+
+func TestGrepOnlyFunction(t *testing.T) {
+ cmd := NewCommand(context.Background(), "anything-but-grep")
+ assert.Panics(t, func() {
+ cmd.AddGitGrepExpression("whatever")
+ })
+}
diff --git a/modules/git/commit.go b/modules/git/commit.go
new file mode 100644
index 0000000..78468b9
--- /dev/null
+++ b/modules/git/commit.go
@@ -0,0 +1,587 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/go-git/go-git/v5/config"
+)
+
+// Commit represents a git commit.
+type Commit struct {
+ Tree
+ ID ObjectID // The ID of this commit object
+ Author *Signature
+ Committer *Signature
+ CommitMessage string
+ Signature *ObjectSignature
+
+ Parents []ObjectID // ID strings
+ submoduleCache *ObjectCache
+}
+
+// Message returns the commit message. Same as retrieving CommitMessage directly.
+func (c *Commit) Message() string {
+ return c.CommitMessage
+}
+
+// Summary returns first line of commit message.
+// The string is forced to be valid UTF8
+func (c *Commit) Summary() string {
+ return strings.ToValidUTF8(strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0], "?")
+}
+
+// ParentID returns oid of n-th parent (0-based index).
+// It returns nil if no such parent exists.
+func (c *Commit) ParentID(n int) (ObjectID, error) {
+ if n >= len(c.Parents) {
+ return nil, ErrNotExist{"", ""}
+ }
+ return c.Parents[n], nil
+}
+
+// Parent returns n-th parent (0-based index) of the commit.
+func (c *Commit) Parent(n int) (*Commit, error) {
+ id, err := c.ParentID(n)
+ if err != nil {
+ return nil, err
+ }
+ parent, err := c.repo.getCommit(id)
+ if err != nil {
+ return nil, err
+ }
+ return parent, nil
+}
+
+// ParentCount returns number of parents of the commit.
+// 0 if this is the root commit, otherwise 1,2, etc.
+func (c *Commit) ParentCount() int {
+ return len(c.Parents)
+}
+
+// GetCommitByPath return the commit of relative path object.
+func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
+ if c.repo.LastCommitCache != nil {
+ return c.repo.LastCommitCache.GetCommitByPath(c.ID.String(), relpath)
+ }
+ return c.repo.getCommitByPathWithID(c.ID, relpath)
+}
+
+// AddChanges marks local changes to be ready for commit.
+func AddChanges(repoPath string, all bool, files ...string) error {
+ return AddChangesWithArgs(repoPath, globalCommandArgs, all, files...)
+}
+
+// AddChangesWithArgs marks local changes to be ready for commit.
+func AddChangesWithArgs(repoPath string, globalArgs TrustedCmdArgs, all bool, files ...string) error {
+ cmd := NewCommandContextNoGlobals(DefaultContext, globalArgs...).AddArguments("add")
+ if all {
+ cmd.AddArguments("--all")
+ }
+ cmd.AddDashesAndList(files...)
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ return err
+}
+
+// CommitChangesOptions the options when a commit created
+type CommitChangesOptions struct {
+ Committer *Signature
+ Author *Signature
+ Message string
+}
+
+// CommitChanges commits local changes with given committer, author and message.
+// If author is nil, it will be the same as committer.
+func CommitChanges(repoPath string, opts CommitChangesOptions) error {
+ cargs := make(TrustedCmdArgs, len(globalCommandArgs))
+ copy(cargs, globalCommandArgs)
+ return CommitChangesWithArgs(repoPath, cargs, opts)
+}
+
+// CommitChangesWithArgs commits local changes with given committer, author and message.
+// If author is nil, it will be the same as committer.
+func CommitChangesWithArgs(repoPath string, args TrustedCmdArgs, opts CommitChangesOptions) error {
+ cmd := NewCommandContextNoGlobals(DefaultContext, args...)
+ if opts.Committer != nil {
+ cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name)
+ cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email)
+ }
+ cmd.AddArguments("commit")
+
+ if opts.Author == nil {
+ opts.Author = opts.Committer
+ }
+ if opts.Author != nil {
+ cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)
+ }
+ cmd.AddOptionFormat("--message=%s", opts.Message)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ // No stderr but exit status 1 means nothing to commit.
+ if err != nil && err.Error() == "exit status 1" {
+ return nil
+ }
+ return err
+}
+
+// AllCommitsCount returns count of all commits in repository
+func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) {
+ cmd := NewCommand(ctx, "rev-list")
+ if hidePRRefs {
+ cmd.AddArguments("--exclude=" + PullPrefix + "*")
+ }
+ cmd.AddArguments("--all", "--count")
+ if len(files) > 0 {
+ cmd.AddDashesAndList(files...)
+ }
+
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return 0, err
+ }
+
+ return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+}
+
+// CommitsCountOptions the options when counting commits
+type CommitsCountOptions struct {
+ RepoPath string
+ Not string
+ Revision []string
+ RelPath []string
+}
+
+// CommitsCount returns number of total commits of until given revision.
+func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) {
+ cmd := NewCommand(ctx, "rev-list", "--count")
+
+ cmd.AddDynamicArguments(opts.Revision...)
+
+ if opts.Not != "" {
+ cmd.AddOptionValues("--not", opts.Not)
+ }
+
+ if len(opts.RelPath) > 0 {
+ cmd.AddDashesAndList(opts.RelPath...)
+ }
+
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: opts.RepoPath})
+ if err != nil {
+ return 0, err
+ }
+
+ return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+}
+
+// CommitsCount returns number of total commits of until current revision.
+func (c *Commit) CommitsCount() (int64, error) {
+ return CommitsCount(c.repo.Ctx, CommitsCountOptions{
+ RepoPath: c.repo.Path,
+ Revision: []string{c.ID.String()},
+ })
+}
+
+// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
+func (c *Commit) CommitsByRange(page, pageSize int, not string) ([]*Commit, error) {
+ return c.repo.commitsByRange(c.ID, page, pageSize, not)
+}
+
+// CommitsBefore returns all the commits before current revision
+func (c *Commit) CommitsBefore() ([]*Commit, error) {
+ return c.repo.getCommitsBefore(c.ID)
+}
+
+// HasPreviousCommit returns true if a given commitHash is contained in commit's parents
+func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) {
+ this := c.ID.String()
+ that := objectID.String()
+
+ if this == that {
+ return false, nil
+ }
+
+ _, _, err := NewCommand(c.repo.Ctx, "merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(&RunOpts{Dir: c.repo.Path})
+ if err == nil {
+ return true, nil
+ }
+ var exitError *exec.ExitError
+ if errors.As(err, &exitError) {
+ if exitError.ProcessState.ExitCode() == 1 && len(exitError.Stderr) == 0 {
+ return false, nil
+ }
+ }
+ return false, err
+}
+
+// IsForcePush returns true if a push from oldCommitHash to this is a force push
+func (c *Commit) IsForcePush(oldCommitID string) (bool, error) {
+ objectFormat, err := c.repo.GetObjectFormat()
+ if err != nil {
+ return false, err
+ }
+ if oldCommitID == objectFormat.EmptyObjectID().String() {
+ return false, nil
+ }
+
+ oldCommit, err := c.repo.GetCommit(oldCommitID)
+ if err != nil {
+ return false, err
+ }
+ hasPreviousCommit, err := c.HasPreviousCommit(oldCommit.ID)
+ return !hasPreviousCommit, err
+}
+
+// CommitsBeforeLimit returns num commits before current revision
+func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) {
+ return c.repo.getCommitsBeforeLimit(c.ID, num)
+}
+
+// CommitsBeforeUntil returns the commits between commitID to current revision
+func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) {
+ endCommit, err := c.repo.GetCommit(commitID)
+ if err != nil {
+ return nil, err
+ }
+ return c.repo.CommitsBetween(c, endCommit)
+}
+
+// SearchCommitsOptions specify the parameters for SearchCommits
+type SearchCommitsOptions struct {
+ Keywords []string
+ Authors, Committers []string
+ After, Before string
+ All bool
+}
+
+// NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string
+func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions {
+ var keywords, authors, committers []string
+ var after, before string
+
+ fields := strings.Fields(searchString)
+ for _, k := range fields {
+ switch {
+ case strings.HasPrefix(k, "author:"):
+ authors = append(authors, strings.TrimPrefix(k, "author:"))
+ case strings.HasPrefix(k, "committer:"):
+ committers = append(committers, strings.TrimPrefix(k, "committer:"))
+ case strings.HasPrefix(k, "after:"):
+ after = strings.TrimPrefix(k, "after:")
+ case strings.HasPrefix(k, "before:"):
+ before = strings.TrimPrefix(k, "before:")
+ default:
+ keywords = append(keywords, k)
+ }
+ }
+
+ return SearchCommitsOptions{
+ Keywords: keywords,
+ Authors: authors,
+ Committers: committers,
+ After: after,
+ Before: before,
+ All: forAllRefs,
+ }
+}
+
+// SearchCommits returns the commits match the keyword before current revision
+func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) {
+ return c.repo.searchCommits(c.ID, opts)
+}
+
+// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
+func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) {
+ return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String())
+}
+
+// FileChangedSinceCommit Returns true if the file given has changed since the past commit
+// YOU MUST ENSURE THAT pastCommit is a valid commit ID.
+func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) {
+ return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String())
+}
+
+// HasFile returns true if the file given exists on this commit
+// This does only mean it's there - it does not mean the file was changed during the commit.
+func (c *Commit) HasFile(filename string) (bool, error) {
+ _, err := c.GetBlobByPath(filename)
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// GetFileContent reads a file content as a string or returns false if this was not possible
+func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
+ entry, err := c.GetTreeEntryByPath(filename)
+ if err != nil {
+ return "", err
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return "", err
+ }
+ defer r.Close()
+
+ if limit > 0 {
+ bs := make([]byte, limit)
+ n, err := util.ReadAtMost(r, bs)
+ if err != nil {
+ return "", err
+ }
+ return string(bs[:n]), nil
+ }
+
+ bytes, err := io.ReadAll(r)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes), nil
+}
+
+// GetSubModules get all the sub modules of current revision git tree
+func (c *Commit) GetSubModules() (*ObjectCache, error) {
+ if c.submoduleCache != nil {
+ return c.submoduleCache, nil
+ }
+
+ entry, err := c.GetTreeEntryByPath(".gitmodules")
+ if err != nil {
+ if _, ok := err.(ErrNotExist); ok {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ content, err := entry.Blob().GetBlobContent(10 * 1024)
+ if err != nil {
+ return nil, err
+ }
+
+ c.submoduleCache, err = parseSubmoduleContent([]byte(content))
+ if err != nil {
+ return nil, err
+ }
+ return c.submoduleCache, nil
+}
+
+func parseSubmoduleContent(bs []byte) (*ObjectCache, error) {
+ cfg := config.NewModules()
+ if err := cfg.Unmarshal(bs); err != nil {
+ return nil, err
+ }
+ submoduleCache := newObjectCache()
+ if len(cfg.Submodules) == 0 {
+ return nil, fmt.Errorf("no submodules found")
+ }
+ for _, subModule := range cfg.Submodules {
+ submoduleCache.Set(subModule.Path, subModule.URL)
+ }
+
+ return submoduleCache, nil
+}
+
+// GetSubModule returns the URL to the submodule according entryname
+func (c *Commit) GetSubModule(entryname string) (string, error) {
+ modules, err := c.GetSubModules()
+ if err != nil {
+ return "", err
+ }
+
+ if modules != nil {
+ module, has := modules.Get(entryname)
+ if has {
+ return module.(string), nil
+ }
+ }
+ return "", nil
+}
+
+// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
+func (c *Commit) GetBranchName() (string, error) {
+ cmd := NewCommand(c.repo.Ctx, "name-rev")
+ if CheckGitVersionAtLeast("2.13.0") == nil {
+ cmd.AddArguments("--exclude", "refs/tags/*")
+ }
+ cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String())
+ data, _, err := cmd.RunStdString(&RunOpts{Dir: c.repo.Path})
+ if err != nil {
+ // handle special case where git can not describe commit
+ if strings.Contains(err.Error(), "cannot describe") {
+ return "", nil
+ }
+
+ return "", err
+ }
+
+ // name-rev commitID output will be "master" or "master~12"
+ return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil
+}
+
+// CommitFileStatus represents status of files in a commit.
+type CommitFileStatus struct {
+ Added []string
+ Removed []string
+ Modified []string
+}
+
+// NewCommitFileStatus creates a CommitFileStatus
+func NewCommitFileStatus() *CommitFileStatus {
+ return &CommitFileStatus{
+ []string{}, []string{}, []string{},
+ }
+}
+
+func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) {
+ rd := bufio.NewReader(stdout)
+ peek, err := rd.Peek(1)
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ if peek[0] == '\n' || peek[0] == '\x00' {
+ _, _ = rd.Discard(1)
+ }
+ for {
+ modifier, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ file, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ file = file[:len(file)-1]
+ switch modifier[0] {
+ case 'A':
+ fileStatus.Added = append(fileStatus.Added, file)
+ case 'D':
+ fileStatus.Removed = append(fileStatus.Removed, file)
+ case 'M':
+ fileStatus.Modified = append(fileStatus.Modified, file)
+ }
+ }
+}
+
+// GetCommitFileStatus returns file status of commit in given repository.
+func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) {
+ stdout, w := io.Pipe()
+ done := make(chan struct{})
+ fileStatus := NewCommitFileStatus()
+ go func() {
+ parseCommitFileStatus(fileStatus, stdout)
+ close(done)
+ }()
+
+ stderr := new(bytes.Buffer)
+ err := NewCommand(ctx, "log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ w.Close() // Close writer to exit parsing goroutine
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+
+ <-done
+ return fileStatus, nil
+}
+
+func parseCommitRenames(renames *[][2]string, stdout io.Reader) {
+ rd := bufio.NewReader(stdout)
+ for {
+ // Skip (R || three digits || NULL byte)
+ _, err := rd.Discard(5)
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ oldFileName, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ newFileName, err := rd.ReadString('\x00')
+ if err != nil {
+ if err != io.EOF {
+ log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
+ }
+ return
+ }
+ oldFileName = strings.TrimSuffix(oldFileName, "\x00")
+ newFileName = strings.TrimSuffix(newFileName, "\x00")
+ *renames = append(*renames, [2]string{oldFileName, newFileName})
+ }
+}
+
+// GetCommitFileRenames returns the renames that the commit contains.
+func GetCommitFileRenames(ctx context.Context, repoPath, commitID string) ([][2]string, error) {
+ renames := [][2]string{}
+ stdout, w := io.Pipe()
+ done := make(chan struct{})
+ go func() {
+ parseCommitRenames(&renames, stdout)
+ close(done)
+ }()
+
+ stderr := new(bytes.Buffer)
+ err := NewCommand(ctx, "show", "--name-status", "--pretty=format:", "-z", "--diff-filter=R").AddDynamicArguments(commitID).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ w.Close() // Close writer to exit parsing goroutine
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+
+ <-done
+ return renames, nil
+}
+
+// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
+func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
+ commitID, _, err := NewCommand(ctx, "rev-parse").AddDynamicArguments(shortID).RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ if strings.Contains(err.Error(), "exit status 128") {
+ return "", ErrNotExist{shortID, ""}
+ }
+ return "", err
+ }
+ return strings.TrimSpace(commitID), nil
+}
+
+// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit
+func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
+ if c.repo == nil {
+ return nil, nil
+ }
+ return c.repo.GetDefaultPublicGPGKey(forceUpdate)
+}
diff --git a/modules/git/commit_info.go b/modules/git/commit_info.go
new file mode 100644
index 0000000..39e30b1
--- /dev/null
+++ b/modules/git/commit_info.go
@@ -0,0 +1,176 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "path"
+ "sort"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// CommitInfo describes the first commit with the provided entry
+type CommitInfo struct {
+ Entry *TreeEntry
+ Commit *Commit
+ SubModuleFile *SubModuleFile
+}
+
+// GetCommitsInfo gets information of all commits that are corresponding to these entries
+func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
+ entryPaths := make([]string, len(tes)+1)
+ // Get the commit for the treePath itself
+ entryPaths[0] = ""
+ for i, entry := range tes {
+ entryPaths[i+1] = entry.Name()
+ }
+
+ var err error
+
+ var revs map[string]*Commit
+ if commit.repo.LastCommitCache != nil {
+ var unHitPaths []string
+ revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, commit.repo.LastCommitCache)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(unHitPaths) > 0 {
+ sort.Strings(unHitPaths)
+ commits, err := GetLastCommitForPaths(ctx, commit, treePath, unHitPaths)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for pth, found := range commits {
+ revs[pth] = found
+ }
+ }
+ } else {
+ sort.Strings(entryPaths)
+ revs, err = GetLastCommitForPaths(ctx, commit, treePath, entryPaths)
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+
+ commitsInfo := make([]CommitInfo, len(tes))
+ for i, entry := range tes {
+ commitsInfo[i] = CommitInfo{
+ Entry: entry,
+ }
+
+ // Check if we have found a commit for this entry in time
+ if entryCommit, ok := revs[entry.Name()]; ok {
+ commitsInfo[i].Commit = entryCommit
+ } else {
+ log.Debug("missing commit for %s", entry.Name())
+ }
+
+ // If the entry if a submodule add a submodule file for this
+ if entry.IsSubModule() {
+ var fullPath string
+ if len(treePath) > 0 {
+ fullPath = treePath + "/" + entry.Name()
+ } else {
+ fullPath = entry.Name()
+ }
+ subModuleURL, err := commit.GetSubModule(fullPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ subModuleFile := NewSubModuleFile(commitsInfo[i].Commit, subModuleURL, entry.ID.String())
+ commitsInfo[i].SubModuleFile = subModuleFile
+ }
+ }
+
+ // Retrieve the commit for the treePath itself (see above). We basically
+ // get it for free during the tree traversal and it's used for listing
+ // pages to display information about newest commit for a given path.
+ var treeCommit *Commit
+ var ok bool
+ if treePath == "" {
+ treeCommit = commit
+ } else if treeCommit, ok = revs[""]; ok {
+ treeCommit.repo = commit.repo
+ }
+ return commitsInfo, treeCommit, nil
+}
+
+func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) {
+ var unHitEntryPaths []string
+ results := make(map[string]*Commit)
+ for _, p := range paths {
+ lastCommit, err := cache.Get(commitID, path.Join(treePath, p))
+ if err != nil {
+ return nil, nil, err
+ }
+ if lastCommit != nil {
+ results[p] = lastCommit
+ continue
+ }
+
+ unHitEntryPaths = append(unHitEntryPaths, p)
+ }
+
+ return results, unHitEntryPaths, nil
+}
+
+// GetLastCommitForPaths returns last commit information
+func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
+ // We read backwards from the commit to obtain all of the commits
+ revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
+ if err != nil {
+ return nil, err
+ }
+
+ batchStdinWriter, batchReader, cancel, err := commit.repo.CatFileBatch(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ commitsMap := map[string]*Commit{}
+ commitsMap[commit.ID.String()] = commit
+
+ commitCommits := map[string]*Commit{}
+ for path, commitID := range revs {
+ c, ok := commitsMap[commitID]
+ if ok {
+ commitCommits[path] = c
+ continue
+ }
+
+ if len(commitID) == 0 {
+ continue
+ }
+
+ _, err := batchStdinWriter.Write([]byte(commitID + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ _, typ, size, err := ReadBatchLine(batchReader)
+ if err != nil {
+ return nil, err
+ }
+ if typ != "commit" {
+ if err := DiscardFull(batchReader, size+1); err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("unexpected type: %s for commit id: %s", typ, commitID)
+ }
+ c, err = CommitFromReader(commit.repo, MustIDFromString(commitID), io.LimitReader(batchReader, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+ commitCommits[path] = c
+ }
+
+ return commitCommits, nil
+}
diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go
new file mode 100644
index 0000000..dbe9ab5
--- /dev/null
+++ b/modules/git/commit_info_test.go
@@ -0,0 +1,175 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testReposDir = "tests/repos/"
+)
+
+func cloneRepo(tb testing.TB, url string) (string, error) {
+ repoDir := tb.TempDir()
+ if err := Clone(DefaultContext, url, repoDir, CloneRepoOptions{
+ Mirror: false,
+ Bare: false,
+ Quiet: true,
+ Timeout: 5 * time.Minute,
+ }); err != nil {
+ return "", err
+ }
+ return repoDir, nil
+}
+
+func testGetCommitsInfo(t *testing.T, repo1 *Repository) {
+ // these test case are specific to the repo1 test repo
+ testCases := []struct {
+ CommitID string
+ Path string
+ ExpectedIDs map[string]string
+ ExpectedTreeCommit string
+ }{
+ {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{
+ "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
+ "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ }, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"},
+ {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{
+ "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3",
+ "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185",
+ }, "2839944139e0de9737a044f78b0e4b40d989a9e3"},
+ {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{
+ "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658",
+ }, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"},
+ {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{
+ "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
+ "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ "foo": "37991dec2c8e592043f47155ce4808d4580f9123",
+ }, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"},
+ }
+ for _, testCase := range testCases {
+ commit, err := repo1.GetCommit(testCase.CommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to get commit: %s from testcase due to error: %v", testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+ assert.NotNil(t, commit)
+ assert.NotNil(t, commit.Tree)
+ assert.NotNil(t, commit.Tree.repo)
+
+ tree, err := commit.Tree.SubTree(testCase.Path)
+ if err != nil {
+ require.NoError(t, err, "Unable to get subtree: %s of commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+
+ assert.NotNil(t, tree, "tree is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
+ assert.NotNil(t, tree.repo, "repo is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ require.NoError(t, err, "Unable to get entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ // no point trying to do anything else for this test.
+ continue
+ }
+
+ // FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain.
+ commitsInfo, treeCommit, err := entries.GetCommitsInfo(context.TODO(), commit, testCase.Path)
+ require.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
+ if err != nil {
+ t.FailNow()
+ }
+ assert.Equal(t, testCase.ExpectedTreeCommit, treeCommit.ID.String())
+ assert.Len(t, commitsInfo, len(testCase.ExpectedIDs))
+ for _, commitInfo := range commitsInfo {
+ entry := commitInfo.Entry
+ commit := commitInfo.Commit
+ expectedID, ok := testCase.ExpectedIDs[entry.Name()]
+ if !assert.True(t, ok) {
+ continue
+ }
+ assert.Equal(t, expectedID, commit.ID.String())
+ }
+ }
+}
+
+func TestEntries_GetCommitsInfo(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ testGetCommitsInfo(t, bareRepo1)
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ clonedRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ defer clonedRepo1.Close()
+
+ testGetCommitsInfo(t, clonedRepo1)
+}
+
+func BenchmarkEntries_GetCommitsInfo(b *testing.B) {
+ type benchmarkType struct {
+ url string
+ name string
+ }
+
+ benchmarks := []benchmarkType{
+ {url: "https://github.com/go-gitea/gitea.git", name: "gitea"},
+ {url: "https://github.com/ethantkoenig/manyfiles.git", name: "manyfiles"},
+ {url: "https://github.com/moby/moby.git", name: "moby"},
+ {url: "https://github.com/golang/go.git", name: "go"},
+ {url: "https://github.com/torvalds/linux.git", name: "linux"},
+ }
+
+ doBenchmark := func(benchmark benchmarkType) {
+ var commit *Commit
+ var entries Entries
+ var repo *Repository
+ repoPath, err := cloneRepo(b, benchmark.url)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ if repo, err = openRepositoryWithDefaultContext(repoPath); err != nil {
+ b.Fatal(err)
+ }
+ defer repo.Close()
+
+ if commit, err = repo.GetBranchCommit("master"); err != nil {
+ b.Fatal(err)
+ } else if entries, err = commit.Tree.ListEntries(); err != nil {
+ b.Fatal(err)
+ }
+ entries.Sort()
+ b.ResetTimer()
+ b.Run(benchmark.name, func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, err := entries.GetCommitsInfo(context.Background(), commit, "")
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+
+ for _, benchmark := range benchmarks {
+ doBenchmark(benchmark)
+ }
+}
diff --git a/modules/git/commit_reader.go b/modules/git/commit_reader.go
new file mode 100644
index 0000000..8e2523d
--- /dev/null
+++ b/modules/git/commit_reader.go
@@ -0,0 +1,110 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "io"
+ "strings"
+)
+
+// CommitFromReader will generate a Commit from a provided reader
+// We need this to interpret commits from cat-file or cat-file --batch
+//
+// If used as part of a cat-file --batch stream you need to limit the reader to the correct size
+func CommitFromReader(gitRepo *Repository, objectID ObjectID, reader io.Reader) (*Commit, error) {
+ commit := &Commit{
+ ID: objectID,
+ Author: &Signature{},
+ Committer: &Signature{},
+ }
+
+ payloadSB := new(strings.Builder)
+ signatureSB := new(strings.Builder)
+ messageSB := new(strings.Builder)
+ message := false
+ pgpsig := false
+
+ bufReader, ok := reader.(*bufio.Reader)
+ if !ok {
+ bufReader = bufio.NewReader(reader)
+ }
+
+readLoop:
+ for {
+ line, err := bufReader.ReadBytes('\n')
+ if err != nil {
+ if err == io.EOF {
+ if message {
+ _, _ = messageSB.Write(line)
+ }
+ _, _ = payloadSB.Write(line)
+ break readLoop
+ }
+ return nil, err
+ }
+ if pgpsig {
+ if len(line) > 0 && line[0] == ' ' {
+ _, _ = signatureSB.Write(line[1:])
+ continue
+ }
+ pgpsig = false
+ }
+
+ if !message {
+ // This is probably not correct but is copied from go-gits interpretation...
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 {
+ message = true
+ _, _ = payloadSB.Write(line)
+ continue
+ }
+
+ split := bytes.SplitN(trimmed, []byte{' '}, 2)
+ var data []byte
+ if len(split) > 1 {
+ data = split[1]
+ }
+
+ switch string(split[0]) {
+ case "tree":
+ commit.Tree = *NewTree(gitRepo, MustIDFromString(string(data)))
+ _, _ = payloadSB.Write(line)
+ case "parent":
+ commit.Parents = append(commit.Parents, MustIDFromString(string(data)))
+ _, _ = payloadSB.Write(line)
+ case "author":
+ commit.Author = &Signature{}
+ commit.Author.Decode(data)
+ _, _ = payloadSB.Write(line)
+ case "committer":
+ commit.Committer = &Signature{}
+ commit.Committer.Decode(data)
+ _, _ = payloadSB.Write(line)
+ case "encoding":
+ _, _ = payloadSB.Write(line)
+ case "gpgsig":
+ fallthrough
+ case "gpgsig-sha256": // FIXME: no intertop, so only 1 exists at present.
+ _, _ = signatureSB.Write(data)
+ _ = signatureSB.WriteByte('\n')
+ pgpsig = true
+ }
+ } else {
+ _, _ = messageSB.Write(line)
+ _, _ = payloadSB.Write(line)
+ }
+ }
+ commit.CommitMessage = messageSB.String()
+ commit.Signature = &ObjectSignature{
+ Signature: signatureSB.String(),
+ Payload: payloadSB.String(),
+ }
+ if len(commit.Signature.Signature) == 0 {
+ commit.Signature = nil
+ }
+
+ return commit, nil
+}
diff --git a/modules/git/commit_sha256_test.go b/modules/git/commit_sha256_test.go
new file mode 100644
index 0000000..9e56829
--- /dev/null
+++ b/modules/git/commit_sha256_test.go
@@ -0,0 +1,211 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCommitsCountSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), commitsCount)
+}
+
+func TestCommitsCountWithoutBaseSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Not: "main",
+ Revision: []string{"branch1"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), commitsCount)
+}
+
+func TestGetFullCommitIDSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "f004f4")
+ require.NoError(t, err)
+ assert.Equal(t, "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc", id)
+}
+
+func TestGetFullCommitIDErrorSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown")
+ assert.Empty(t, id)
+ if assert.Error(t, err) {
+ assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
+ }
+}
+
+func TestCommitFromReaderSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ commitString := `9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 commit 1114
+tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
+parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
+author Adam Majer <amajer@suse.de> 1698676906 +0100
+committer Adam Majer <amajer@suse.de> 1698676906 +0100
+gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
+ dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
+ aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
+ WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
+ 1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
+ JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
+ oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
+ U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
+ zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
+ VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
+ HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
+ 8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
+ =xybZ
+ -----END PGP SIGNATURE-----
+
+signed commit`
+
+ sha := &Sha256Hash{
+ 0x94, 0x33, 0xb2, 0xa6, 0x2b, 0x96, 0x4c, 0x17, 0xa4, 0x48, 0x5a, 0xe1, 0x80, 0xf4, 0x5f, 0x59,
+ 0x5d, 0x3e, 0x69, 0xd3, 0x1b, 0x78, 0x60, 0x87, 0x77, 0x5e, 0x28, 0xc6, 0xb6, 0x39, 0x9d, 0xf0,
+ }
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare_sha256"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ if !assert.NotNil(t, commitFromReader) {
+ return
+ }
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
+dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
+aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
+WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
+1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
+JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
+oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
+U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
+zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
+VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
+HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
+8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
+=xybZ
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
+parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
+author Adam Majer <amajer@suse.de> 1698676906 +0100
+committer Adam Majer <amajer@suse.de> 1698676906 +0100
+
+signed commit`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "Adam Majer <amajer@suse.de>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestHasPreviousCommitSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
+
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc")
+ require.NoError(t, err)
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+
+ parentSHA := MustIDFromString("b0ec7af4547047f12d5093e37ef8f1b3b5415ed8ee17894d43a34d7d34212e9c")
+ notParentSHA := MustIDFromString("42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236")
+ assert.Equal(t, parentSHA.Type(), objectFormat)
+ assert.Equal(t, "sha256", objectFormat.Name())
+
+ haz, err := commit.HasPreviousCommit(parentSHA)
+ require.NoError(t, err)
+ assert.True(t, haz)
+
+ hazNot, err := commit.HasPreviousCommit(notParentSHA)
+ require.NoError(t, err)
+ assert.False(t, hazNot)
+
+ selfNot, err := commit.HasPreviousCommit(commit.ID)
+ require.NoError(t, err)
+ assert.False(t, selfNot)
+}
+
+func TestGetCommitFileStatusMergesSha256(t *testing.T) {
+ skipIfSHA256NotSupported(t)
+
+ bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256")
+
+ commitFileStatus, err := GetCommitFileStatus(DefaultContext, bareRepo1Path, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1")
+ require.NoError(t, err)
+
+ expected := CommitFileStatus{
+ []string{
+ "add_file.txt",
+ },
+ []string{},
+ []string{
+ "to_modify.txt",
+ },
+ }
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+
+ expected = CommitFileStatus{
+ []string{},
+ []string{
+ "to_remove.txt",
+ },
+ []string{},
+ }
+
+ commitFileStatus, err = GetCommitFileStatus(DefaultContext, bareRepo1Path, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172")
+ require.NoError(t, err)
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+}
diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go
new file mode 100644
index 0000000..6bb7d77
--- /dev/null
+++ b/modules/git/commit_test.go
@@ -0,0 +1,401 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCommitsCount(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), commitsCount)
+}
+
+func TestCommitsCountWithoutBase(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ commitsCount, err := CommitsCount(DefaultContext,
+ CommitsCountOptions{
+ RepoPath: bareRepo1Path,
+ Not: "master",
+ Revision: []string{"branch1"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), commitsCount)
+}
+
+func TestGetFullCommitID(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "8006ff9a")
+ require.NoError(t, err)
+ assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id)
+}
+
+func TestGetFullCommitIDError(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown")
+ assert.Empty(t, id)
+ if assert.Error(t, err) {
+ assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
+ }
+}
+
+func TestCommitFromReader(t *testing.T) {
+ commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074
+tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
+parent 37991dec2c8e592043f47155ce4808d4580f9123
+author silverwind <me@silverwind.io> 1563741793 +0200
+committer silverwind <me@silverwind.io> 1563741793 +0200
+gpgsig -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
+ lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
+ xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
+ vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
+ R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
+ FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
+ /maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
+ S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
+ sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
+ 1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
+ mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
+ 1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
+ =FRsO
+ -----END PGP SIGNATURE-----
+
+empty commit`
+
+ sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ require.NotNil(t, commitFromReader)
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
+lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
+xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
+vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
+R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
+FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
+/maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
+S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
+sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
+1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
+mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
+1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
+=FRsO
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
+parent 37991dec2c8e592043f47155ce4808d4580f9123
+author silverwind <me@silverwind.io> 1563741793 +0200
+committer silverwind <me@silverwind.io> 1563741793 +0200
+
+empty commit`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "silverwind <me@silverwind.io>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestCommitWithEncodingFromReader(t *testing.T) {
+ commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074
+tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
+parent 47b24e7ab977ed31c5a39989d570847d6d0052af
+author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+encoding ISO-8859-1
+gpgsig -----BEGIN PGP SIGNATURE-----
+` + " " + `
+ iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
+ Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
+ gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
+ zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
+ frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
+ FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
+ G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
+ SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
+ yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
+ jw4YcO5u
+ =r3UU
+ -----END PGP SIGNATURE-----
+
+ISO-8859-1`
+
+ sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
+ gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ assert.NotNil(t, gitRepo)
+ defer gitRepo.Close()
+
+ commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
+ require.NoError(t, err)
+ require.NotNil(t, commitFromReader)
+ assert.EqualValues(t, sha, commitFromReader.ID)
+ assert.EqualValues(t, `-----BEGIN PGP SIGNATURE-----
+
+iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
+Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
+gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
+zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
+frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
+FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
+G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
+SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
+yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
+jw4YcO5u
+=r3UU
+-----END PGP SIGNATURE-----
+`, commitFromReader.Signature.Signature)
+ assert.EqualValues(t, `tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
+parent 47b24e7ab977ed31c5a39989d570847d6d0052af
+author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
+encoding ISO-8859-1
+
+ISO-8859-1`, commitFromReader.Signature.Payload)
+ assert.EqualValues(t, "KN4CK3R <admin@oldschoolhack.me>", commitFromReader.Author.String())
+
+ commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
+ require.NoError(t, err)
+ commitFromReader.CommitMessage += "\n\n"
+ commitFromReader.Signature.Payload += "\n\n"
+ assert.EqualValues(t, commitFromReader, commitFromReader2)
+}
+
+func TestHasPreviousCommit(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0")
+ require.NoError(t, err)
+
+ parentSHA := MustIDFromString("8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2")
+ notParentSHA := MustIDFromString("2839944139e0de9737a044f78b0e4b40d989a9e3")
+
+ haz, err := commit.HasPreviousCommit(parentSHA)
+ require.NoError(t, err)
+ assert.True(t, haz)
+
+ hazNot, err := commit.HasPreviousCommit(notParentSHA)
+ require.NoError(t, err)
+ assert.False(t, hazNot)
+
+ selfNot, err := commit.HasPreviousCommit(commit.ID)
+ require.NoError(t, err)
+ assert.False(t, selfNot)
+}
+
+func TestParseCommitFileStatus(t *testing.T) {
+ type testcase struct {
+ output string
+ added []string
+ removed []string
+ modified []string
+ }
+
+ kases := []testcase{
+ {
+ // Merge commit
+ output: "MM\x00options/locale/locale_en-US.ini\x00",
+ modified: []string{
+ "options/locale/locale_en-US.ini",
+ },
+ added: []string{},
+ removed: []string{},
+ },
+ {
+ // Spaces commit
+ output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
+ removed: []string{
+ "b",
+ "b b/b",
+ },
+ modified: []string{},
+ added: []string{
+ "b b/b b/b b/b",
+ "b b/b b/b b/b b/b",
+ },
+ },
+ {
+ // larger commit
+ output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00",
+ modified: []string{
+ "go.mod",
+ "go.sum",
+ "modules/ssh/ssh.go",
+ "vendor/github.com/gliderlabs/ssh/circle.yml",
+ "vendor/github.com/gliderlabs/ssh/context.go",
+ "vendor/github.com/gliderlabs/ssh/server.go",
+ "vendor/github.com/gliderlabs/ssh/session.go",
+ "vendor/github.com/gliderlabs/ssh/ssh.go",
+ "vendor/golang.org/x/sys/unix/mkerrors.sh",
+ "vendor/golang.org/x/sys/unix/syscall_darwin.go",
+ "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go",
+ "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go",
+ "vendor/golang.org/x/sys/unix/zerrors_linux.go",
+ "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go",
+ "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go",
+ "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go",
+ "vendor/modules.txt",
+ },
+ added: []string{
+ "vendor/github.com/gliderlabs/ssh/go.mod",
+ "vendor/github.com/gliderlabs/ssh/go.sum",
+ },
+ removed: []string{},
+ },
+ {
+ // git 1.7.2 adds an unnecessary \x00 on merge commit
+ output: "\x00MM\x00options/locale/locale_en-US.ini\x00",
+ modified: []string{
+ "options/locale/locale_en-US.ini",
+ },
+ added: []string{},
+ removed: []string{},
+ },
+ {
+ // git 1.7.2 adds an unnecessary \n on normal commit
+ output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
+ removed: []string{
+ "b",
+ "b b/b",
+ },
+ modified: []string{},
+ added: []string{
+ "b b/b b/b b/b",
+ "b b/b b/b b/b b/b",
+ },
+ },
+ }
+
+ for _, kase := range kases {
+ fileStatus := NewCommitFileStatus()
+ parseCommitFileStatus(fileStatus, strings.NewReader(kase.output))
+
+ assert.Equal(t, kase.added, fileStatus.Added)
+ assert.Equal(t, kase.removed, fileStatus.Removed)
+ assert.Equal(t, kase.modified, fileStatus.Modified)
+ }
+}
+
+func TestGetCommitFileStatusMerges(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo6_merge")
+
+ commitFileStatus, err := GetCommitFileStatus(DefaultContext, bareRepo1Path, "022f4ce6214973e018f02bf363bf8a2e3691f699")
+ require.NoError(t, err)
+
+ expected := CommitFileStatus{
+ []string{
+ "add_file.txt",
+ },
+ []string{
+ "to_remove.txt",
+ },
+ []string{
+ "to_modify.txt",
+ },
+ }
+
+ assert.Equal(t, expected.Added, commitFileStatus.Added)
+ assert.Equal(t, expected.Removed, commitFileStatus.Removed)
+ assert.Equal(t, expected.Modified, commitFileStatus.Modified)
+}
+
+func TestParseCommitRenames(t *testing.T) {
+ testcases := []struct {
+ output string
+ renames [][2]string
+ }{
+ {
+ output: "R090\x00renamed.txt\x00history.txt\x00",
+ renames: [][2]string{{"renamed.txt", "history.txt"}},
+ },
+ {
+ output: "R090\x00renamed.txt\x00history.txt\x00R000\x00corruptedstdouthere",
+ renames: [][2]string{{"renamed.txt", "history.txt"}},
+ },
+ {
+ output: "R100\x00renamed.txt\x00history.txt\x00R001\x00readme.md\x00README.md\x00",
+ renames: [][2]string{{"renamed.txt", "history.txt"}, {"readme.md", "README.md"}},
+ },
+ }
+
+ for _, testcase := range testcases {
+ renames := [][2]string{}
+ parseCommitRenames(&renames, strings.NewReader(testcase.output))
+
+ assert.Equal(t, testcase.renames, renames)
+ }
+}
+
+func Test_parseSubmoduleContent(t *testing.T) {
+ submoduleFiles := []struct {
+ fileContent string
+ expectedPath string
+ expectedURL string
+ }{
+ {
+ fileContent: `[submodule "jakarta-servlet"]
+url = ../../ALP-pool/jakarta-servlet
+path = jakarta-servlet`,
+ expectedPath: "jakarta-servlet",
+ expectedURL: "../../ALP-pool/jakarta-servlet",
+ },
+ {
+ fileContent: `[submodule "jakarta-servlet"]
+path = jakarta-servlet
+url = ../../ALP-pool/jakarta-servlet`,
+ expectedPath: "jakarta-servlet",
+ expectedURL: "../../ALP-pool/jakarta-servlet",
+ },
+ }
+ for _, kase := range submoduleFiles {
+ submodule, err := parseSubmoduleContent([]byte(kase.fileContent))
+ require.NoError(t, err)
+ v, ok := submodule.Get(kase.expectedPath)
+ assert.True(t, ok)
+ assert.Equal(t, kase.expectedURL, v)
+ }
+}
diff --git a/modules/git/diff.go b/modules/git/diff.go
new file mode 100644
index 0000000..d9f3f6d
--- /dev/null
+++ b/modules/git/diff.go
@@ -0,0 +1,328 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// RawDiffType type of a raw diff.
+type RawDiffType string
+
+// RawDiffType possible values.
+const (
+ RawDiffNormal RawDiffType = "diff"
+ RawDiffPatch RawDiffType = "patch"
+)
+
+// GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
+func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) error {
+ return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer)
+}
+
+// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
+func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error {
+ stderr := new(bytes.Buffer)
+ cmd := NewCommand(ctx, "show", "--pretty=format:revert %H%n", "-R").AddDynamicArguments(commitID)
+ if err := cmd.Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: writer,
+ Stderr: stderr,
+ }); err != nil {
+ return fmt.Errorf("Run: %w - %s", err, stderr)
+ }
+ return nil
+}
+
+// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository
+func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error {
+ commit, err := repo.GetCommit(endCommit)
+ if err != nil {
+ return err
+ }
+ var files []string
+ if len(file) > 0 {
+ files = append(files, file)
+ }
+
+ cmd := NewCommand(repo.Ctx)
+ switch diffType {
+ case RawDiffNormal:
+ if len(startCommit) != 0 {
+ cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...)
+ } else if commit.ParentCount() == 0 {
+ cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...)
+ } else {
+ c, _ := commit.Parent(0)
+ cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...)
+ }
+ case RawDiffPatch:
+ if len(startCommit) != 0 {
+ query := fmt.Sprintf("%s...%s", endCommit, startCommit)
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(query).AddDashesAndList(files...)
+ } else if commit.ParentCount() == 0 {
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(endCommit).AddDashesAndList(files...)
+ } else {
+ c, _ := commit.Parent(0)
+ query := fmt.Sprintf("%s...%s", endCommit, c.ID.String())
+ cmd.AddArguments("format-patch", "--no-signature", "--stdout").AddDynamicArguments(query).AddDashesAndList(files...)
+ }
+ default:
+ return fmt.Errorf("invalid diffType: %s", diffType)
+ }
+
+ stderr := new(bytes.Buffer)
+ if err = cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: writer,
+ Stderr: stderr,
+ }); err != nil {
+ return fmt.Errorf("Run: %w - %s", err, stderr)
+ }
+ return nil
+}
+
+// ParseDiffHunkString parse the diffhunk content and return
+func ParseDiffHunkString(diffhunk string) (leftLine, leftHunk, rightLine, righHunk int) {
+ ss := strings.Split(diffhunk, "@@")
+ ranges := strings.Split(ss[1][1:], " ")
+ leftRange := strings.Split(ranges[0], ",")
+ leftLine, _ = strconv.Atoi(leftRange[0][1:])
+ if len(leftRange) > 1 {
+ leftHunk, _ = strconv.Atoi(leftRange[1])
+ }
+ if len(ranges) > 1 {
+ rightRange := strings.Split(ranges[1], ",")
+ rightLine, _ = strconv.Atoi(rightRange[0])
+ if len(rightRange) > 1 {
+ righHunk, _ = strconv.Atoi(rightRange[1])
+ }
+ } else {
+ log.Debug("Parse line number failed: %v", diffhunk)
+ rightLine = leftLine
+ righHunk = leftHunk
+ }
+ return leftLine, leftHunk, rightLine, righHunk
+}
+
+// Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9]
+var hunkRegex = regexp.MustCompile(`^@@ -(?P<beginOld>[0-9]+)(,(?P<endOld>[0-9]+))? \+(?P<beginNew>[0-9]+)(,(?P<endNew>[0-9]+))? @@`)
+
+const cmdDiffHead = "diff --git "
+
+func isHeader(lof string, inHunk bool) bool {
+ return strings.HasPrefix(lof, cmdDiffHead) || (!inHunk && (strings.HasPrefix(lof, "---") || strings.HasPrefix(lof, "+++")))
+}
+
+// CutDiffAroundLine cuts a diff of a file in way that only the given line + numberOfLine above it will be shown
+// it also recalculates hunks and adds the appropriate headers to the new diff.
+// Warning: Only one-file diffs are allowed.
+func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLine int) (string, error) {
+ if line == 0 || numbersOfLine == 0 {
+ // no line or num of lines => no diff
+ return "", nil
+ }
+
+ scanner := bufio.NewScanner(originalDiff)
+ hunk := make([]string, 0)
+
+ // begin is the start of the hunk containing searched line
+ // end is the end of the hunk ...
+ // currentLine is the line number on the side of the searched line (differentiated by old)
+ // otherLine is the line number on the opposite side of the searched line (differentiated by old)
+ var begin, end, currentLine, otherLine int64
+ var headerLines int
+
+ inHunk := false
+
+ for scanner.Scan() {
+ lof := scanner.Text()
+ // Add header to enable parsing
+
+ if isHeader(lof, inHunk) {
+ if strings.HasPrefix(lof, cmdDiffHead) {
+ inHunk = false
+ }
+ hunk = append(hunk, lof)
+ headerLines++
+ }
+ if currentLine > line {
+ break
+ }
+ // Detect "hunk" with contains commented lof
+ if strings.HasPrefix(lof, "@@") {
+ inHunk = true
+ // Already got our hunk. End of hunk detected!
+ if len(hunk) > headerLines {
+ break
+ }
+ // A map with named groups of our regex to recognize them later more easily
+ submatches := hunkRegex.FindStringSubmatch(lof)
+ groups := make(map[string]string)
+ for i, name := range hunkRegex.SubexpNames() {
+ if i != 0 && name != "" {
+ groups[name] = submatches[i]
+ }
+ }
+ if old {
+ begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
+ end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
+ // init otherLine with begin of opposite side
+ otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
+ } else {
+ begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
+ if groups["endNew"] != "" {
+ end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
+ } else {
+ end = 0
+ }
+ // init otherLine with begin of opposite side
+ otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
+ }
+ end += begin // end is for real only the number of lines in hunk
+ // lof is between begin and end
+ if begin <= line && end >= line {
+ hunk = append(hunk, lof)
+ currentLine = begin
+ continue
+ }
+ } else if len(hunk) > headerLines {
+ hunk = append(hunk, lof)
+ // Count lines in context
+ switch lof[0] {
+ case '+':
+ if !old {
+ currentLine++
+ } else {
+ otherLine++
+ }
+ case '-':
+ if old {
+ currentLine++
+ } else {
+ otherLine++
+ }
+ case '\\':
+ // FIXME: handle `\ No newline at end of file`
+ default:
+ currentLine++
+ otherLine++
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+
+ // No hunk found
+ if currentLine == 0 {
+ return "", nil
+ }
+ // headerLines + hunkLine (1) = totalNonCodeLines
+ if len(hunk)-headerLines-1 <= numbersOfLine {
+ // No need to cut the hunk => return existing hunk
+ return strings.Join(hunk, "\n"), nil
+ }
+ var oldBegin, oldNumOfLines, newBegin, newNumOfLines int64
+ if old {
+ oldBegin = currentLine
+ newBegin = otherLine
+ } else {
+ oldBegin = otherLine
+ newBegin = currentLine
+ }
+ // headers + hunk header
+ newHunk := make([]string, headerLines)
+ // transfer existing headers
+ copy(newHunk, hunk[:headerLines])
+ // transfer last n lines
+ newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
+ // calculate newBegin, ... by counting lines
+ for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
+ switch hunk[i][0] {
+ case '+':
+ newBegin--
+ newNumOfLines++
+ case '-':
+ oldBegin--
+ oldNumOfLines++
+ default:
+ oldBegin--
+ newBegin--
+ newNumOfLines++
+ oldNumOfLines++
+ }
+ }
+ // construct the new hunk header
+ newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
+ oldBegin, oldNumOfLines, newBegin, newNumOfLines)
+ return strings.Join(newHunk, "\n"), nil
+}
+
+// GetAffectedFiles returns the affected files between two commits
+func GetAffectedFiles(repo *Repository, oldCommitID, newCommitID string, env []string) ([]string, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+
+ // If the oldCommitID is empty, then we must assume its a new branch, so diff
+ // against the empty tree. So all changes of this new branch are included.
+ if oldCommitID == objectFormat.EmptyObjectID().String() {
+ oldCommitID = objectFormat.EmptyTree().String()
+ }
+
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ log.Error("Unable to create os.Pipe for %s", repo.Path)
+ return nil, err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ affectedFiles := make([]string, 0, 32)
+
+ // Run `git diff --name-only` to get the names of the changed files
+ err = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID).
+ Run(&RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ // Close the writer end of the pipe to begin processing
+ _ = stdoutWriter.Close()
+ defer func() {
+ // Close the reader on return to terminate the git command if necessary
+ _ = stdoutReader.Close()
+ }()
+ // Now scan the output from the command
+ scanner := bufio.NewScanner(stdoutReader)
+ for scanner.Scan() {
+ path := strings.TrimSpace(scanner.Text())
+ if len(path) == 0 {
+ continue
+ }
+ affectedFiles = append(affectedFiles, path)
+ }
+ return scanner.Err()
+ },
+ })
+ if err != nil {
+ log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
+ }
+
+ return affectedFiles, err
+}
diff --git a/modules/git/diff_test.go b/modules/git/diff_test.go
new file mode 100644
index 0000000..0855a7d
--- /dev/null
+++ b/modules/git/diff_test.go
@@ -0,0 +1,169 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const exampleDiff = `diff --git a/README.md b/README.md
+--- a/README.md
++++ b/README.md
+@@ -1,3 +1,6 @@
+ # gitea-github-migrator
++
++ Build Status
+- Latest Release
+ Docker Pulls
++ cut off
++ cut off`
+
+const breakingDiff = `diff --git a/aaa.sql b/aaa.sql
+index d8e4c92..19dc8ad 100644
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2
++-- some comment 3
+ create or replace procedure test(p1 varchar2)
+ is
+ begin
+---new comment
+ dbms_output.put_line(p1);
++--some other comment
+ end;
+ /
+`
+
+var issue17875Diff = `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
+index d46c152..a7d2d55 100644
+--- a/Geschäftsordnung.md
++++ b/Geschäftsordnung.md
+@@ -1,5 +1,5 @@
+ ---
+-date: "23.01.2021"
++date: "30.11.2021"
+ ...
+ ` + `
+ # Geschäftsordnung
+@@ -16,4 +16,22 @@ Diese Geschäftsordnung regelt alle Prozesse des Vereins, solange diese nicht du
+ ` + `
+ ## § 3 Datenschutzverantwortlichkeit
+ ` + `
+-1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
+\ No newline at end of file
++1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
++
++## §4 Umgang mit der SARS-Cov-2-Pandemie
++
++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.
++
++2. Die Einführung, Änderung und Abschaffung dieser Maßnahmen sind nur zum Zweck der Eindämmung der SARS-Cov-2-Pandemie zulässig.
++
++3. Die Einführung, Änderung und Abschaffung von Maßnahmen nach Abs. 2 bedarf einer wissenschaftlichen Grundlage.
++
++4. Die Maßnahmen nach Abs. 2 setzen sich aus den folgenden Bausteinen inklusive einer ihrer Ausprägungen zusammen.
++
++ 1. Maskenpflicht: Keine; Maskenpflicht, außer am Platz, oder wo Abstände nicht eingehalten werden können; Maskenpflicht, wenn Abstände nicht eingehalten werden können; Maskenpflicht
++
++ 2. Geimpft-, Genesen- oder Testnachweis: Kein Nachweis notwendig; Nachweis, dass Person geimpft, genesen oder tagesaktuell getestet ist (3G); Nachweis, dass Person geimpft oder genesen ist (2G); Nachweis, dass Person geimpft bzw. genesen und tagesaktuell getestet ist (2G+)
++
++ 3. Online-Veranstaltung: Keine, parallele Online-Veranstaltung, ausschließlich Online-Veranstaltung
++
++5. Bei Präsenzveranstungen gelten außerdem die Hygienevorschriften des Veranstaltungsorts. Bei Regelkollision greift die restriktivere Regel.
+\ No newline at end of file`
+
+func TestCutDiffAroundLineIssue17875(t *testing.T) {
+ result, err := CutDiffAroundLine(strings.NewReader(issue17875Diff), 23, false, 3)
+ require.NoError(t, err)
+ expected := `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
+--- a/Geschäftsordnung.md
++++ b/Geschäftsordnung.md
+@@ -20,0 +21,3 @@
++## §4 Umgang mit der SARS-Cov-2-Pandemie
++
++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.`
+ assert.Equal(t, expected, result)
+}
+
+func TestCutDiffAroundLine(t *testing.T) {
+ result, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 4, false, 3)
+ require.NoError(t, err)
+ resultByLine := strings.Split(result, "\n")
+ assert.Len(t, resultByLine, 7)
+ // Check if headers got transferred
+ assert.Equal(t, "diff --git a/README.md b/README.md", resultByLine[0])
+ assert.Equal(t, "--- a/README.md", resultByLine[1])
+ assert.Equal(t, "+++ b/README.md", resultByLine[2])
+ // Check if hunk header is calculated correctly
+ assert.Equal(t, "@@ -2,2 +3,2 @@", resultByLine[3])
+ // Check if line got transferred
+ assert.Equal(t, "+ Build Status", resultByLine[4])
+
+ // Must be same result as before since old line 3 == new line 5
+ newResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
+ require.NoError(t, err)
+ assert.Equal(t, result, newResult, "Must be same result as before since old line 3 == new line 5")
+
+ newResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 300)
+ require.NoError(t, err)
+ assert.Equal(t, exampleDiff, newResult)
+
+ emptyResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 0)
+ require.NoError(t, err)
+ assert.Empty(t, emptyResult)
+
+ // Line is out of scope
+ emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
+ require.NoError(t, err)
+ assert.Empty(t, emptyResult)
+
+ // Handle minus diffs properly
+ minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
+ require.NoError(t, err)
+
+ expected := `diff --git a/aaa.sql b/aaa.sql
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2`
+ assert.Equal(t, expected, minusDiff)
+
+ // Handle minus diffs properly
+ minusDiff, err = CutDiffAroundLine(strings.NewReader(breakingDiff), 3, false, 4)
+ require.NoError(t, err)
+
+ expected = `diff --git a/aaa.sql b/aaa.sql
+--- a/aaa.sql
++++ b/aaa.sql
+@@ -1,9 +1,10 @@
+ --some comment
+--- some comment 5
++--some coment 2
++-- some comment 3`
+
+ assert.Equal(t, expected, minusDiff)
+}
+
+func BenchmarkCutDiffAroundLine(b *testing.B) {
+ for n := 0; n < b.N; n++ {
+ CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
+ }
+}
+
+func TestParseDiffHunkString(t *testing.T) {
+ leftLine, leftHunk, rightLine, rightHunk := ParseDiffHunkString("@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER")
+ assert.EqualValues(t, 19, leftLine)
+ assert.EqualValues(t, 3, leftHunk)
+ assert.EqualValues(t, 19, rightLine)
+ assert.EqualValues(t, 5, rightHunk)
+}
diff --git a/modules/git/error.go b/modules/git/error.go
new file mode 100644
index 0000000..91d25ec
--- /dev/null
+++ b/modules/git/error.go
@@ -0,0 +1,187 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ErrExecTimeout error when exec timed out
+type ErrExecTimeout struct {
+ Duration time.Duration
+}
+
+// IsErrExecTimeout if some error is ErrExecTimeout
+func IsErrExecTimeout(err error) bool {
+ _, ok := err.(ErrExecTimeout)
+ return ok
+}
+
+func (err ErrExecTimeout) Error() string {
+ return fmt.Sprintf("execution is timeout [duration: %v]", err.Duration)
+}
+
+// ErrNotExist commit not exist error
+type ErrNotExist struct {
+ ID string
+ RelPath string
+}
+
+// IsErrNotExist if some error is ErrNotExist
+func IsErrNotExist(err error) bool {
+ _, ok := err.(ErrNotExist)
+ return ok
+}
+
+func (err ErrNotExist) Error() string {
+ return fmt.Sprintf("object does not exist [id: %s, rel_path: %s]", err.ID, err.RelPath)
+}
+
+func (err ErrNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrBadLink entry.FollowLink error
+type ErrBadLink struct {
+ Name string
+ Message string
+}
+
+func (err ErrBadLink) Error() string {
+ return fmt.Sprintf("%s: %s", err.Name, err.Message)
+}
+
+// IsErrBadLink if some error is ErrBadLink
+func IsErrBadLink(err error) bool {
+ _, ok := err.(ErrBadLink)
+ return ok
+}
+
+// ErrUnsupportedVersion error when required git version not matched
+type ErrUnsupportedVersion struct {
+ Required string
+}
+
+// IsErrUnsupportedVersion if some error is ErrUnsupportedVersion
+func IsErrUnsupportedVersion(err error) bool {
+ _, ok := err.(ErrUnsupportedVersion)
+ return ok
+}
+
+func (err ErrUnsupportedVersion) Error() string {
+ return fmt.Sprintf("Operation requires higher version [required: %s]", err.Required)
+}
+
+// ErrBranchNotExist represents a "BranchNotExist" kind of error.
+type ErrBranchNotExist struct {
+ Name string
+}
+
+// IsErrBranchNotExist checks if an error is a ErrBranchNotExist.
+func IsErrBranchNotExist(err error) bool {
+ _, ok := err.(ErrBranchNotExist)
+ return ok
+}
+
+func (err ErrBranchNotExist) Error() string {
+ return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
+}
+
+func (err ErrBranchNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrPushOutOfDate represents an error if merging fails due to the base branch being updated
+type ErrPushOutOfDate struct {
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrPushOutOfDate checks if an error is a ErrPushOutOfDate.
+func IsErrPushOutOfDate(err error) bool {
+ _, ok := err.(*ErrPushOutOfDate)
+ return ok
+}
+
+func (err *ErrPushOutOfDate) Error() string {
+ return fmt.Sprintf("PushOutOfDate Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
+
+// Unwrap unwraps the underlying error
+func (err *ErrPushOutOfDate) Unwrap() error {
+ return fmt.Errorf("%w - %s", err.Err, err.StdErr)
+}
+
+// ErrPushRejected represents an error if merging fails due to rejection from a hook
+type ErrPushRejected struct {
+ Message string
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrPushRejected checks if an error is a ErrPushRejected.
+func IsErrPushRejected(err error) bool {
+ _, ok := err.(*ErrPushRejected)
+ return ok
+}
+
+func (err *ErrPushRejected) Error() string {
+ return fmt.Sprintf("PushRejected Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
+
+// Unwrap unwraps the underlying error
+func (err *ErrPushRejected) Unwrap() error {
+ return fmt.Errorf("%w - %s", err.Err, err.StdErr)
+}
+
+// GenerateMessage generates the remote message from the stderr
+func (err *ErrPushRejected) GenerateMessage() {
+ messageBuilder := &strings.Builder{}
+ i := strings.Index(err.StdErr, "remote: ")
+ if i < 0 {
+ err.Message = ""
+ return
+ }
+ for {
+ if len(err.StdErr) <= i+8 {
+ break
+ }
+ if err.StdErr[i:i+8] != "remote: " {
+ break
+ }
+ i += 8
+ nl := strings.IndexByte(err.StdErr[i:], '\n')
+ if nl >= 0 {
+ messageBuilder.WriteString(err.StdErr[i : i+nl+1])
+ i = i + nl + 1
+ } else {
+ messageBuilder.WriteString(err.StdErr[i:])
+ i = len(err.StdErr)
+ }
+ }
+ err.Message = strings.TrimSpace(messageBuilder.String())
+}
+
+// ErrMoreThanOne represents an error if pull request fails when there are more than one sources (branch, tag) with the same name
+type ErrMoreThanOne struct {
+ StdOut string
+ StdErr string
+ Err error
+}
+
+// IsErrMoreThanOne checks if an error is a ErrMoreThanOne
+func IsErrMoreThanOne(err error) bool {
+ _, ok := err.(*ErrMoreThanOne)
+ return ok
+}
+
+func (err *ErrMoreThanOne) Error() string {
+ return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
+}
diff --git a/modules/git/foreachref/format.go b/modules/git/foreachref/format.go
new file mode 100644
index 0000000..97e8ee4
--- /dev/null
+++ b/modules/git/foreachref/format.go
@@ -0,0 +1,83 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref
+
+import (
+ "encoding/hex"
+ "fmt"
+ "io"
+ "strings"
+)
+
+var (
+ nullChar = []byte("\x00")
+ dualNullChar = []byte("\x00\x00")
+)
+
+// Format supports specifying and parsing an output format for 'git
+// for-each-ref'. See See git-for-each-ref(1) for available fields.
+type Format struct {
+ // fieldNames hold %(fieldname)s to be passed to the '--format' flag of
+ // for-each-ref. See git-for-each-ref(1) for available fields.
+ fieldNames []string
+
+ // fieldDelim is the character sequence that is used to separate fields
+ // for each reference. fieldDelim and refDelim should be selected to not
+ // interfere with each other and to not be present in field values.
+ fieldDelim []byte
+ // fieldDelimStr is a string representation of fieldDelim. Used to save
+ // us from repetitive reallocation whenever we need the delimiter as a
+ // string.
+ fieldDelimStr string
+ // refDelim is the character sequence used to separate reference from
+ // each other in the output. fieldDelim and refDelim should be selected
+ // to not interfere with each other and to not be present in field
+ // values.
+ refDelim []byte
+}
+
+// NewFormat creates a forEachRefFormat using the specified fieldNames. See
+// git-for-each-ref(1) for available fields.
+func NewFormat(fieldNames ...string) Format {
+ return Format{
+ fieldNames: fieldNames,
+ fieldDelim: nullChar,
+ fieldDelimStr: string(nullChar),
+ refDelim: dualNullChar,
+ }
+}
+
+// Flag returns a for-each-ref --format flag value that captures the fieldNames.
+func (f Format) Flag() string {
+ var formatFlag strings.Builder
+ for i, field := range f.fieldNames {
+ // field key and field value
+ formatFlag.WriteString(fmt.Sprintf("%s %%(%s)", field, field))
+
+ if i < len(f.fieldNames)-1 {
+ // note: escape delimiters to allow control characters as
+ // delimiters. For example, '%00' for null character or '%0a'
+ // for newline.
+ formatFlag.WriteString(f.hexEscaped(f.fieldDelim))
+ }
+ }
+ formatFlag.WriteString(f.hexEscaped(f.refDelim))
+ return formatFlag.String()
+}
+
+// Parser returns a Parser capable of parsing 'git for-each-ref' output produced
+// with this Format.
+func (f Format) Parser(r io.Reader) *Parser {
+ return NewParser(r, f)
+}
+
+// hexEscaped produces hex-escpaed characters from a string. For example, "\n\0"
+// would turn into "%0a%00".
+func (f Format) hexEscaped(delim []byte) string {
+ escaped := ""
+ for i := 0; i < len(delim); i++ {
+ escaped += "%" + hex.EncodeToString([]byte{delim[i]})
+ }
+ return escaped
+}
diff --git a/modules/git/foreachref/format_test.go b/modules/git/foreachref/format_test.go
new file mode 100644
index 0000000..8ff2393
--- /dev/null
+++ b/modules/git/foreachref/format_test.go
@@ -0,0 +1,66 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFormat_Flag(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenFormat foreachref.Format
+
+ wantFlag string
+ }{
+ {
+ name: "references are delimited by dual null chars",
+
+ // no reference fields requested
+ givenFormat: foreachref.NewFormat(),
+
+ // only a reference delimiter field in --format
+ wantFlag: "%00%00",
+ },
+
+ {
+ name: "a field is a space-separated key-value pair",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+
+ // only a reference delimiter field
+ wantFlag: "refname:short %(refname:short)%00%00",
+ },
+
+ {
+ name: "fields are separated by a null char field-delimiter",
+
+ givenFormat: foreachref.NewFormat("refname:short", "author"),
+
+ wantFlag: "refname:short %(refname:short)%00author %(author)%00%00",
+ },
+
+ {
+ name: "multiple fields",
+
+ givenFormat: foreachref.NewFormat("refname:lstrip=2", "objecttype", "objectname"),
+
+ wantFlag: "refname:lstrip=2 %(refname:lstrip=2)%00objecttype %(objecttype)%00objectname %(objectname)%00%00",
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ gotFlag := tc.givenFormat.Flag()
+
+ require.Equal(t, tc.wantFlag, gotFlag, "unexpected for-each-ref --format string. wanted: '%s', got: '%s'", tc.wantFlag, gotFlag)
+ })
+ }
+}
diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go
new file mode 100644
index 0000000..de69eaa
--- /dev/null
+++ b/modules/git/foreachref/parser.go
@@ -0,0 +1,128 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// Parser parses 'git for-each-ref' output according to a given output Format.
+type Parser struct {
+ // tokenizes 'git for-each-ref' output into "reference paragraphs".
+ scanner *bufio.Scanner
+
+ // format represents the '--format' string that describes the expected
+ // 'git for-each-ref' output structure.
+ format Format
+
+ // err holds the last encountered error during parsing.
+ err error
+}
+
+// NewParser creates a 'git for-each-ref' output parser that will parse all
+// references in the provided Reader. The references in the output are assumed
+// to follow the specified Format.
+func NewParser(r io.Reader, format Format) *Parser {
+ scanner := bufio.NewScanner(r)
+
+ // in addition to the reference delimiter we specified in the --format,
+ // `git for-each-ref` will always add a newline after every reference.
+ refDelim := make([]byte, 0, len(format.refDelim)+1)
+ refDelim = append(refDelim, format.refDelim...)
+ refDelim = append(refDelim, '\n')
+
+ // Split input into delimiter-separated "reference blocks".
+ scanner.Split(
+ func(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ // Scan until delimiter, marking end of reference.
+ delimIdx := bytes.Index(data, refDelim)
+ if delimIdx >= 0 {
+ token := data[:delimIdx]
+ advance := delimIdx + len(refDelim)
+ return advance, token, nil
+ }
+ // If we're at EOF, we have a final, non-terminated reference. Return it.
+ if atEOF {
+ return len(data), data, nil
+ }
+ // Not yet a full field. Request more data.
+ return 0, nil, nil
+ })
+
+ return &Parser{
+ scanner: scanner,
+ format: format,
+ err: nil,
+ }
+}
+
+// Next returns the next reference as a collection of key-value pairs. nil
+// denotes EOF but is also returned on errors. The Err method should always be
+// consulted after Next returning nil.
+//
+// It could, for example return something like:
+//
+// { "objecttype": "tag", "refname:short": "v1.16.4", "object": "f460b7543ed500e49c133c2cd85c8c55ee9dbe27" }
+func (p *Parser) Next() map[string]string {
+ if !p.scanner.Scan() {
+ return nil
+ }
+ fields, err := p.parseRef(p.scanner.Text())
+ if err != nil {
+ p.err = err
+ return nil
+ }
+ return fields
+}
+
+// Err returns the latest encountered parsing error.
+func (p *Parser) Err() error {
+ return p.err
+}
+
+// parseRef parses out all key-value pairs from a single reference block, such as
+//
+// "objecttype tag\0refname:short v1.16.4\0object f460b7543ed500e49c133c2cd85c8c55ee9dbe27"
+func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
+ if refBlock == "" {
+ // must be at EOF
+ return nil, nil
+ }
+
+ fieldValues := make(map[string]string)
+
+ fields := strings.Split(refBlock, p.format.fieldDelimStr)
+ if len(fields) != len(p.format.fieldNames) {
+ return nil, fmt.Errorf("unexpected number of reference fields: wanted %d, was %d",
+ len(fields), len(p.format.fieldNames))
+ }
+ for i, field := range fields {
+ field = strings.TrimSpace(field)
+
+ var fieldKey string
+ var fieldVal string
+ firstSpace := strings.Index(field, " ")
+ if firstSpace > 0 {
+ fieldKey = field[:firstSpace]
+ fieldVal = field[firstSpace+1:]
+ } else {
+ // could be the case if the requested field had no value
+ fieldKey = field
+ }
+
+ // enforce the format order of fields
+ if p.format.fieldNames[i] != fieldKey {
+ return nil, fmt.Errorf("unexpected field name at position %d: wanted: '%s', was: '%s'",
+ i, p.format.fieldNames[i], fieldKey)
+ }
+
+ fieldValues[fieldKey] = fieldVal
+ }
+
+ return fieldValues, nil
+}
diff --git a/modules/git/foreachref/parser_test.go b/modules/git/foreachref/parser_test.go
new file mode 100644
index 0000000..7a37ced
--- /dev/null
+++ b/modules/git/foreachref/parser_test.go
@@ -0,0 +1,227 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package foreachref_test
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+ "code.gitea.io/gitea/modules/json"
+
+ "github.com/stretchr/testify/require"
+)
+
+type refSlice = []map[string]string
+
+func TestParser(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenFormat foreachref.Format
+ givenInput io.Reader
+
+ wantRefs refSlice
+ wantErr bool
+ expectedErr error
+ }{
+ // this would, for example, be the result when running `git
+ // for-each-ref refs/tags` on a repo without tags.
+ {
+ name: "no references on empty input",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader(``),
+
+ wantRefs: []map[string]string{},
+ },
+
+ // note: `git for-each-ref` will add a newline between every
+ // reference (in addition to the ref-delimiter we've chosen)
+ {
+ name: "single field requested, single reference in output",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader("refname:short v0.0.1\x00\x00" + "\n"),
+
+ wantRefs: []map[string]string{
+ {"refname:short": "v0.0.1"},
+ },
+ },
+ {
+ name: "single field requested, multiple references in output",
+
+ givenFormat: foreachref.NewFormat("refname:short"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00\x00" + "\n"),
+
+ wantRefs: []map[string]string{
+ {"refname:short": "v0.0.1"},
+ {"refname:short": "v0.0.2"},
+ {"refname:short": "v0.0.3"},
+ },
+ },
+
+ {
+ name: "multiple fields requested for each reference",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
+ givenInput: strings.NewReader(
+
+ "refname:short v0.0.1\x00objecttype commit\x00objectname 7b2c5ac9fc04fc5efafb60700713d4fa609b777b\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00objectname a1f051bc3eba734da4772d60e2d677f47cf93ef4\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00objectname ef82de70bb3f60c65fb8eebacbb2d122ef517385\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "objecttype": "commit",
+ "objectname": "7b2c5ac9fc04fc5efafb60700713d4fa609b777b",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "objecttype": "commit",
+ "objectname": "a1f051bc3eba734da4772d60e2d677f47cf93ef4",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "objecttype": "commit",
+ "objectname": "ef82de70bb3f60c65fb8eebacbb2d122ef517385",
+ },
+ },
+ },
+
+ {
+ name: "must handle multi-line fields such as 'content'",
+
+ givenFormat: foreachref.NewFormat("refname:short", "contents", "author"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00contents Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.\x00author Foo Bar <foo@bar.com> 1507832733 +0200\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00contents Update CI config (#651)\n\n\x00author John Doe <john.doe@foo.com> 1521643174 +0000\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00contents Fixed code sample for bash completion (#687)\n\n\x00author Foo Baz <foo@baz.com> 1524836750 +0200\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "contents": "Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.",
+ "author": "Foo Bar <foo@bar.com> 1507832733 +0200",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "contents": "Update CI config (#651)",
+ "author": "John Doe <john.doe@foo.com> 1521643174 +0000",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "contents": "Fixed code sample for bash completion (#687)",
+ "author": "Foo Baz <foo@baz.com> 1524836750 +0200",
+ },
+ },
+ },
+
+ {
+ name: "must handle fields without values",
+
+ givenFormat: foreachref.NewFormat("refname:short", "object", "objecttype"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00object \x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00object \x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00object \x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantRefs: []map[string]string{
+ {
+ "refname:short": "v0.0.1",
+ "object": "",
+ "objecttype": "commit",
+ },
+ {
+ "refname:short": "v0.0.2",
+ "object": "",
+ "objecttype": "commit",
+ },
+ {
+ "refname:short": "v0.0.3",
+ "object": "",
+ "objecttype": "commit",
+ },
+ },
+ },
+
+ {
+ name: "must fail when the number of fields in the input doesn't match expected format",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantErr: true,
+ expectedErr: errors.New("unexpected number of reference fields: wanted 2, was 3"),
+ },
+
+ {
+ name: "must fail input fields don't match expected format",
+
+ givenFormat: foreachref.NewFormat("refname:short", "objectname"),
+ givenInput: strings.NewReader(
+ "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
+ "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
+ ),
+
+ wantErr: true,
+ expectedErr: errors.New("unexpected field name at position 1: wanted: 'objectname', was: 'objecttype'"),
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ parser := tc.givenFormat.Parser(tc.givenInput)
+
+ //
+ // parse references from input
+ //
+ gotRefs := make([]map[string]string, 0)
+ for {
+ ref := parser.Next()
+ if ref == nil {
+ break
+ }
+ gotRefs = append(gotRefs, ref)
+ }
+ err := parser.Err()
+
+ //
+ // verify expectations
+ //
+ if tc.wantErr {
+ require.Error(t, err)
+ require.EqualError(t, err, tc.expectedErr.Error())
+ } else {
+ require.NoError(t, err, "for-each-ref parser unexpectedly failed with: %v", err)
+ require.Equal(t, tc.wantRefs, gotRefs, "for-each-ref parser produced unexpected reference set. wanted: %v, got: %v", pretty(tc.wantRefs), pretty(gotRefs))
+ }
+ })
+ }
+}
+
+func pretty(v any) string {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ // shouldn't happen
+ panic(fmt.Sprintf("json-marshalling failed: %v", err))
+ }
+ return string(data)
+}
diff --git a/modules/git/git.go b/modules/git/git.go
new file mode 100644
index 0000000..f1174e6
--- /dev/null
+++ b/modules/git/git.go
@@ -0,0 +1,422 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/hashicorp/go-version"
+)
+
+// RequiredVersion is the minimum Git version required
+const RequiredVersion = "2.0.0"
+
+var (
+ // GitExecutable is the command name of git
+ // Could be updated to an absolute path while initialization
+ GitExecutable = "git"
+
+ // DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx
+ DefaultContext context.Context
+
+ SupportProcReceive bool // >= 2.29
+ SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
+ InvertedGitFlushEnv bool // 2.43.1
+ SupportCheckAttrOnBare bool // >= 2.40
+
+ HasSSHExecutable bool
+
+ gitVersion *version.Version
+)
+
+// loadGitVersion returns current Git version from shell. Internal usage only.
+func loadGitVersion() error {
+ // doesn't need RWMutex because it's executed by Init()
+ if gitVersion != nil {
+ return nil
+ }
+ stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil)
+ if runErr != nil {
+ return runErr
+ }
+
+ fields := strings.Fields(stdout)
+ if len(fields) < 3 {
+ return fmt.Errorf("invalid git version output: %s", stdout)
+ }
+
+ var versionString string
+
+ // Handle special case on Windows.
+ i := strings.Index(fields[2], "windows")
+ if i >= 1 {
+ versionString = fields[2][:i-1]
+ } else {
+ versionString = fields[2]
+ }
+
+ var err error
+ gitVersion, err = version.NewVersion(versionString)
+ return err
+}
+
+// SetExecutablePath changes the path of git executable and checks the file permission and version.
+func SetExecutablePath(path string) error {
+ // If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
+ if path != "" {
+ GitExecutable = path
+ }
+ absPath, err := exec.LookPath(GitExecutable)
+ if err != nil {
+ return fmt.Errorf("git not found: %w", err)
+ }
+ GitExecutable = absPath
+
+ err = loadGitVersion()
+ if err != nil {
+ return fmt.Errorf("unable to load git version: %w", err)
+ }
+
+ versionRequired, err := version.NewVersion(RequiredVersion)
+ if err != nil {
+ return err
+ }
+
+ if gitVersion.LessThan(versionRequired) {
+ moreHint := "get git: https://git-scm.com/downloads"
+ if runtime.GOOS == "linux" {
+ // there are a lot of CentOS/RHEL users using old git, so we add a special hint for them
+ if _, err = os.Stat("/etc/redhat-release"); err == nil {
+ // ius.io is the recommended official(git-scm.com) method to install git
+ moreHint = "get git: https://git-scm.com/downloads/linux and https://ius.io"
+ }
+ }
+ return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", gitVersion.Original(), RequiredVersion, moreHint)
+ }
+
+ return nil
+}
+
+// VersionInfo returns git version information
+func VersionInfo() string {
+ if gitVersion == nil {
+ return "(git not found)"
+ }
+ format := "%s"
+ args := []any{gitVersion.Original()}
+ // Since git wire protocol has been released from git v2.18
+ if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
+ format += ", Wire Protocol %s Enabled"
+ args = append(args, "Version 2") // for focus color
+ }
+
+ return fmt.Sprintf(format, args...)
+}
+
+func checkInit() error {
+ if setting.Git.HomePath == "" {
+ return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
+ }
+ if DefaultContext != nil {
+ log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
+ }
+ return nil
+}
+
+// HomeDir is the home dir for git to store the global config file used by Gitea internally
+func HomeDir() string {
+ if setting.Git.HomePath == "" {
+ // strict check, make sure the git module is initialized correctly.
+ // attention: when the git module is called in gitea sub-command (serv/hook), the log module might not obviously show messages to users/developers.
+ // for example: if there is gitea git hook code calling git.NewCommand before git.InitXxx, the integration test won't show the real failure reasons.
+ log.Fatal("Unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
+ return ""
+ }
+ return setting.Git.HomePath
+}
+
+// InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
+// This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands.
+func InitSimple(ctx context.Context) error {
+ if err := checkInit(); err != nil {
+ return err
+ }
+
+ DefaultContext = ctx
+ globalCommandArgs = nil
+
+ if setting.Git.Timeout.Default > 0 {
+ defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
+ }
+
+ return SetExecutablePath(setting.Git.Path)
+}
+
+// InitFull initializes git module with version check and change global variables, sync gitconfig.
+// It should only be called once at the beginning of the program initialization (TestMain/GlobalInitInstalled) as this code makes unsynchronized changes to variables.
+func InitFull(ctx context.Context) (err error) {
+ if err = InitSimple(ctx); err != nil {
+ return err
+ }
+
+ // when git works with gnupg (commit signing), there should be a stable home for gnupg commands
+ if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
+ _ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg"))
+ }
+
+ // Since git wire protocol has been released from git v2.18
+ if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
+ globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2")
+ }
+
+ // Explicitly disable credential helper, otherwise Git credentials might leak
+ if CheckGitVersionAtLeast("2.9") == nil {
+ globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=")
+ }
+ SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil
+ SupportHashSha256 = CheckGitVersionAtLeast("2.42") == nil
+ SupportCheckAttrOnBare = CheckGitVersionAtLeast("2.40") == nil
+ if SupportHashSha256 {
+ SupportedObjectFormats = append(SupportedObjectFormats, Sha256ObjectFormat)
+ } else {
+ log.Warn("sha256 hash support is disabled - requires Git >= 2.42")
+ }
+
+ InvertedGitFlushEnv = CheckGitVersionEqual("2.43.1") == nil
+
+ if setting.LFS.StartServer {
+ if CheckGitVersionAtLeast("2.1.2") != nil {
+ return errors.New("LFS server support requires Git >= 2.1.2")
+ }
+ globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=")
+ }
+
+ // Detect the presence of the ssh executable in $PATH.
+ _, err = exec.LookPath("ssh")
+ HasSSHExecutable = err == nil
+
+ return syncGitConfig()
+}
+
+// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
+func syncGitConfig() (err error) {
+ if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil {
+ return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
+ }
+
+ // first, write user's git config options to git config file
+ // user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
+ for k, v := range setting.GitConfig.Options {
+ if err = configSet(strings.ToLower(k), v); err != nil {
+ return err
+ }
+ }
+
+ // Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
+ // TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
+ // If these values are not really used, then they can be set (overwritten) directly without considering about existence.
+ for configKey, defaultValue := range map[string]string{
+ "user.name": "Gitea",
+ "user.email": "gitea@fake.local",
+ } {
+ if err := configSetNonExist(configKey, defaultValue); err != nil {
+ return err
+ }
+ }
+
+ // Set git some configurations - these must be set to these values for gitea to work correctly
+ if err := configSet("core.quotePath", "false"); err != nil {
+ return err
+ }
+
+ if CheckGitVersionAtLeast("2.10") == nil {
+ if err := configSet("receive.advertisePushOptions", "true"); err != nil {
+ return err
+ }
+ }
+
+ if CheckGitVersionAtLeast("2.18") == nil {
+ if err := configSet("core.commitGraph", "true"); err != nil {
+ return err
+ }
+ if err := configSet("gc.writeCommitGraph", "true"); err != nil {
+ return err
+ }
+ if err := configSet("fetch.writeCommitGraph", "true"); err != nil {
+ return err
+ }
+ }
+
+ if SupportProcReceive {
+ // set support for AGit flow
+ if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
+ return err
+ }
+ } else {
+ if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil {
+ return err
+ }
+ }
+
+ // Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user
+ // however, some docker users and samba users find it difficult to configure their systems so that Gitea's git repositories are owned by the Gitea user. (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
+ // see issue: https://github.com/go-gitea/gitea/issues/19455
+ // Fundamentally the problem lies with the uid-gid-mapping mechanism for filesystems in docker on windows (and to a lesser extent samba).
+ // Docker's configuration mechanism for local filesystems provides no way of setting this mapping and although there is a mechanism for setting this uid through using cifs mounting it is complicated and essentially undocumented
+ // Thus the owner uid/gid for files on these filesystems will be marked as root.
+ // As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
+ // it is now safe to set "safe.directory=*" for internal usage only.
+ // Please note: the wildcard "*" is only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later
+ // Although only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later - this setting is tolerated by earlier versions
+ if err := configAddNonExist("safe.directory", "*"); err != nil {
+ return err
+ }
+ if runtime.GOOS == "windows" {
+ if err := configSet("core.longpaths", "true"); err != nil {
+ return err
+ }
+ if setting.Git.DisableCoreProtectNTFS {
+ err = configSet("core.protectNTFS", "false")
+ } else {
+ err = configUnsetAll("core.protectNTFS", "false")
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ // By default partial clones are disabled, enable them from git v2.22
+ if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil {
+ if err = configSet("uploadpack.allowfilter", "true"); err != nil {
+ return err
+ }
+ err = configSet("uploadpack.allowAnySHA1InWant", "true")
+ } else {
+ if err = configUnsetAll("uploadpack.allowfilter", "true"); err != nil {
+ return err
+ }
+ err = configUnsetAll("uploadpack.allowAnySHA1InWant", "true")
+ }
+
+ return err
+}
+
+// CheckGitVersionAtLeast check git version is at least the constraint version
+func CheckGitVersionAtLeast(atLeast string) error {
+ if err := loadGitVersion(); err != nil {
+ return err
+ }
+ atLeastVersion, err := version.NewVersion(atLeast)
+ if err != nil {
+ return err
+ }
+ if gitVersion.Compare(atLeastVersion) < 0 {
+ return fmt.Errorf("installed git binary version %s is not at least %s", gitVersion.Original(), atLeast)
+ }
+ return nil
+}
+
+// CheckGitVersionEqual checks if the git version is equal to the constraint version.
+func CheckGitVersionEqual(equal string) error {
+ if err := loadGitVersion(); err != nil {
+ return err
+ }
+ atLeastVersion, err := version.NewVersion(equal)
+ if err != nil {
+ return err
+ }
+ if !gitVersion.Equal(atLeastVersion) {
+ return fmt.Errorf("installed git binary version %s is not equal to %s", gitVersion.Original(), equal)
+ }
+ return nil
+}
+
+func configSet(key, value string) error {
+ stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err != nil && !IsErrorExitCode(err, 1) {
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+ }
+
+ currValue := strings.TrimSpace(stdout)
+ if currValue == value {
+ return nil
+ }
+
+ _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
+ }
+
+ return nil
+}
+
+func configSetNonExist(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err == nil {
+ // already exist
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist, set new config
+ _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+func configAddNonExist(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
+ if err == nil {
+ // already exist
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist, add new config
+ _, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+func configUnsetAll(key, value string) error {
+ _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
+ if err == nil {
+ // exist, need to remove
+ _, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
+ if err != nil {
+ return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
+ }
+ return nil
+ }
+ if IsErrorExitCode(err, 1) {
+ // not exist
+ return nil
+ }
+ return fmt.Errorf("failed to get git config %s, err: %w", key, err)
+}
+
+// Fsck verifies the connectivity and validity of the objects in the database
+func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error {
+ return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
+}
diff --git a/modules/git/git_test.go b/modules/git/git_test.go
new file mode 100644
index 0000000..cdbd2a1
--- /dev/null
+++ b/modules/git/git_test.go
@@ -0,0 +1,96 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testRun(m *testing.M) error {
+ gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
+ if err != nil {
+ return fmt.Errorf("unable to create temp dir: %w", err)
+ }
+ defer util.RemoveAll(gitHomePath)
+ setting.Git.HomePath = gitHomePath
+
+ if err = InitFull(context.Background()); err != nil {
+ return fmt.Errorf("failed to call Init: %w", err)
+ }
+
+ exitCode := m.Run()
+ if exitCode != 0 {
+ return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
+ }
+ return nil
+}
+
+func TestMain(m *testing.M) {
+ if err := testRun(m); err != nil {
+ _, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
+ os.Exit(1)
+ }
+}
+
+func gitConfigContains(sub string) bool {
+ if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
+ return strings.Contains(string(b), sub)
+ }
+ return false
+}
+
+func TestGitConfig(t *testing.T) {
+ assert.False(t, gitConfigContains("key-a"))
+
+ require.NoError(t, configSetNonExist("test.key-a", "val-a"))
+ assert.True(t, gitConfigContains("key-a = val-a"))
+
+ require.NoError(t, configSetNonExist("test.key-a", "val-a-changed"))
+ assert.False(t, gitConfigContains("key-a = val-a-changed"))
+
+ require.NoError(t, configSet("test.key-a", "val-a-changed"))
+ assert.True(t, gitConfigContains("key-a = val-a-changed"))
+
+ require.NoError(t, configAddNonExist("test.key-b", "val-b"))
+ assert.True(t, gitConfigContains("key-b = val-b"))
+
+ require.NoError(t, configAddNonExist("test.key-b", "val-2b"))
+ assert.True(t, gitConfigContains("key-b = val-b"))
+ assert.True(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configUnsetAll("test.key-b", "val-b"))
+ assert.False(t, gitConfigContains("key-b = val-b"))
+ assert.True(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configUnsetAll("test.key-b", "val-2b"))
+ assert.False(t, gitConfigContains("key-b = val-2b"))
+
+ require.NoError(t, configSet("test.key-x", "*"))
+ assert.True(t, gitConfigContains("key-x = *"))
+ require.NoError(t, configSetNonExist("test.key-x", "*"))
+ require.NoError(t, configUnsetAll("test.key-x", "*"))
+ assert.False(t, gitConfigContains("key-x = *"))
+}
+
+func TestSyncConfig(t *testing.T) {
+ oldGitConfig := setting.GitConfig
+ defer func() {
+ setting.GitConfig = oldGitConfig
+ }()
+
+ setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
+ require.NoError(t, syncGitConfig())
+ assert.True(t, gitConfigContains("[sync-test]"))
+ assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
+}
diff --git a/modules/git/grep.go b/modules/git/grep.go
new file mode 100644
index 0000000..41466bf
--- /dev/null
+++ b/modules/git/grep.go
@@ -0,0 +1,195 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type GrepResult struct {
+ Filename string
+ LineNumbers []int
+ LineCodes []string
+ HighlightedRanges [][3]int
+}
+
+type GrepOptions struct {
+ RefName string
+ MaxResultLimit int
+ MatchesPerFile int // >= git 2.38
+ ContextLineNumber int
+ IsFuzzy bool
+ PathSpec []setting.Glob
+}
+
+func (opts *GrepOptions) ensureDefaults() {
+ opts.RefName = cmp.Or(opts.RefName, "HEAD")
+ opts.MaxResultLimit = cmp.Or(opts.MaxResultLimit, 50)
+ opts.MatchesPerFile = cmp.Or(opts.MatchesPerFile, 20)
+}
+
+func hasPrefixFold(s, t string) bool {
+ if len(s) < len(t) {
+ return false
+ }
+ return strings.EqualFold(s[:len(t)], t)
+}
+
+func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, fmt.Errorf("unable to create os pipe to grep: %w", err)
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ opts.ensureDefaults()
+
+ /*
+ The output is like this ("^@" means \x00; the first number denotes the line,
+ the second number denotes the column of the first match in line):
+
+ HEAD:.air.toml
+ 6^@8^@bin = "gitea"
+
+ HEAD:.changelog.yml
+ 2^@10^@repo: go-gitea/gitea
+ */
+ var results []*GrepResult
+ // -I skips binary files
+ cmd := NewCommand(ctx, "grep",
+ "-I", "--null", "--break", "--heading", "--column",
+ "--fixed-strings", "--line-number", "--ignore-case", "--full-name")
+ cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber))
+
+ // --max-count requires at least git 2.38
+ if CheckGitVersionAtLeast("2.38.0") == nil {
+ cmd.AddOptionValues("--max-count", fmt.Sprint(opts.MatchesPerFile))
+ } else {
+ log.Warn("git-grep: --max-count requires at least git 2.38")
+ }
+
+ words := []string{search}
+ if opts.IsFuzzy {
+ words = strings.Fields(search)
+ }
+ for _, word := range words {
+ cmd.AddGitGrepExpression(word)
+ }
+
+ // pathspec
+ files := make([]string, 0,
+ len(setting.Indexer.IncludePatterns)+
+ len(setting.Indexer.ExcludePatterns)+
+ len(opts.PathSpec))
+ for _, expr := range append(setting.Indexer.IncludePatterns, opts.PathSpec...) {
+ files = append(files, ":"+expr.Pattern())
+ }
+ for _, expr := range setting.Indexer.ExcludePatterns {
+ files = append(files, ":^"+expr.Pattern())
+ }
+ cmd.AddDynamicArguments(opts.RefName).AddDashesAndList(files...)
+
+ stderr := bytes.Buffer{}
+ err = cmd.Run(&RunOpts{
+ Timeout: time.Duration(setting.Git.Timeout.Grep) * time.Second,
+
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ defer stdoutReader.Close()
+
+ isInBlock := false
+ scanner := bufio.NewReader(stdoutReader)
+ var res *GrepResult
+ for {
+ line, err := scanner.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ return nil
+ }
+ return err
+ }
+ // Remove delimiter.
+ if len(line) > 0 {
+ line = line[:len(line)-1]
+ }
+
+ if !isInBlock {
+ if _ /* ref */, filename, ok := strings.Cut(line, ":"); ok {
+ isInBlock = true
+ res = &GrepResult{Filename: filename}
+ results = append(results, res)
+ }
+ continue
+ }
+ if line == "" {
+ if len(results) >= opts.MaxResultLimit {
+ cancel()
+ break
+ }
+ isInBlock = false
+ continue
+ }
+ if line == "--" {
+ continue
+ }
+ if lineNum, lineCode, ok := strings.Cut(line, "\x00"); ok {
+ lineNumInt, _ := strconv.Atoi(lineNum)
+ res.LineNumbers = append(res.LineNumbers, lineNumInt)
+ if lineCol, lineCode2, ok := strings.Cut(lineCode, "\x00"); ok {
+ lineColInt, _ := strconv.Atoi(lineCol)
+ start := lineColInt - 1
+ matchLen := len(lineCode2)
+ for _, word := range words {
+ if hasPrefixFold(lineCode2[start:], word) {
+ matchLen = len(word)
+ break
+ }
+ }
+ res.HighlightedRanges = append(res.HighlightedRanges, [3]int{
+ len(res.LineCodes),
+ start,
+ start + matchLen,
+ })
+ res.LineCodes = append(res.LineCodes, lineCode2)
+ continue
+ }
+ res.LineCodes = append(res.LineCodes, lineCode)
+ }
+ }
+ return nil
+ },
+ })
+ // git grep exits by cancel (killed), usually it is caused by the limit of results
+ if IsErrorExitCode(err, -1) && stderr.Len() == 0 {
+ return results, nil
+ }
+ // git grep exits with 1 if no results are found
+ if IsErrorExitCode(err, 1) && stderr.Len() == 0 {
+ return nil, nil
+ }
+ if err != nil && !errors.Is(err, context.Canceled) {
+ return nil, fmt.Errorf("unable to run git grep: %w, stderr: %s", err, stderr.String())
+ }
+ return results, nil
+}
diff --git a/modules/git/grep_test.go b/modules/git/grep_test.go
new file mode 100644
index 0000000..3ba7a6e
--- /dev/null
+++ b/modules/git/grep_test.go
@@ -0,0 +1,203 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGrepSearch(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "language_stats_repo"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ res, err := GrepSearch(context.Background(), repo, "public", GrepOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1, 3},
+ LineCodes: []string{
+ "public class HelloWorld",
+ " public static void main(String[] args)",
+ },
+ HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}},
+ },
+ {
+ Filename: "main.vendor.java",
+ LineNumbers: []int{1, 3},
+ LineCodes: []string{
+ "public class HelloWorld",
+ " public static void main(String[] args)",
+ },
+ HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1, ContextLineNumber: 2})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1, 2, 3, 4, 5},
+ LineCodes: []string{
+ "public class HelloWorld",
+ "{",
+ " public static void main(String[] args)",
+ " {",
+ " System.out.println(\"Hello world!\");",
+ },
+ HighlightedRanges: [][3]int{{2, 15, 19}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "world", GrepOptions{MatchesPerFile: 1})
+ require.NoError(t, err)
+ assert.Equal(t, []*GrepResult{
+ {
+ Filename: "i-am-a-python.p",
+ LineNumbers: []int{1},
+ LineCodes: []string{"## This is a simple file to do a hello world"},
+ HighlightedRanges: [][3]int{{0, 39, 44}},
+ },
+ {
+ Filename: "java-hello/main.java",
+ LineNumbers: []int{1},
+ LineCodes: []string{"public class HelloWorld"},
+ HighlightedRanges: [][3]int{{0, 18, 23}},
+ },
+ {
+ Filename: "main.vendor.java",
+ LineNumbers: []int{1},
+ LineCodes: []string{"public class HelloWorld"},
+ HighlightedRanges: [][3]int{{0, 18, 23}},
+ },
+ {
+ Filename: "python-hello/hello.py",
+ LineNumbers: []int{1},
+ LineCodes: []string{"## This is a simple file to do a hello world"},
+ HighlightedRanges: [][3]int{{0, 39, 44}},
+ },
+ }, res)
+
+ res, err = GrepSearch(context.Background(), repo, "no-such-content", GrepOptions{})
+ require.NoError(t, err)
+ assert.Empty(t, res)
+
+ res, err = GrepSearch(context.Background(), &Repository{Path: "no-such-git-repo"}, "no-such-content", GrepOptions{})
+ require.Error(t, err)
+ assert.Empty(t, res)
+}
+
+func TestGrepDashesAreFine(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "with-dashes"), []byte("--"), 0o666))
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "without-dashes"), []byte(".."), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Dashes are cool sometimes"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "--", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "with-dashes", res[0].Filename)
+}
+
+func TestGrepNoBinary(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "BINARY"), []byte("I AM BINARY\n\x00\nYOU WON'T SEE ME"), 0o666))
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "TEXT"), []byte("I AM NOT BINARY\nYOU WILL SEE ME"), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Binary and text files"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "BINARY", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "TEXT", res[0].Filename)
+}
+
+func TestGrepLongFiles(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), bytes.Repeat([]byte{'a'}, 65*1024), 0o666))
+
+ err = AddChanges(tmpDir, true)
+ require.NoError(t, err)
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Long file"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Len(t, res[0].LineCodes[0], 65*1024)
+}
+
+func TestGrepRefs(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name())
+ require.NoError(t, err)
+
+ gitRepo, err := openRepositoryWithDefaultContext(tmpDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A'}, 0o666))
+ require.NoError(t, AddChanges(tmpDir, true))
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add A"})
+ require.NoError(t, err)
+
+ require.NoError(t, gitRepo.CreateTag("v1", "HEAD"))
+
+ require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A', 'B', 'C', 'D'}, 0o666))
+ require.NoError(t, AddChanges(tmpDir, true))
+
+ err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add BCD"})
+ require.NoError(t, err)
+
+ res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{RefName: "v1"})
+ require.NoError(t, err)
+ assert.Len(t, res, 1)
+ assert.Equal(t, "A", res[0].LineCodes[0])
+}
diff --git a/modules/git/hook.go b/modules/git/hook.go
new file mode 100644
index 0000000..46f93ce
--- /dev/null
+++ b/modules/git/hook.go
@@ -0,0 +1,143 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "errors"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// hookNames is a list of Git server hooks' name that are supported.
+var hookNames = []string{
+ "pre-receive",
+ "update",
+ "post-receive",
+}
+
+// ErrNotValidHook error when a git hook is not valid
+var ErrNotValidHook = errors.New("not a valid Git hook")
+
+// IsValidHookName returns true if given name is a valid Git hook.
+func IsValidHookName(name string) bool {
+ for _, hn := range hookNames {
+ if hn == name {
+ return true
+ }
+ }
+ return false
+}
+
+// Hook represents a Git hook.
+type Hook struct {
+ name string
+ IsActive bool // Indicates whether repository has this hook.
+ Content string // Content of hook if it's active.
+ Sample string // Sample content from Git.
+ path string // Hook file path.
+}
+
+// GetHook returns a Git hook by given name and repository.
+func GetHook(repoPath, name string) (*Hook, error) {
+ if !IsValidHookName(name) {
+ return nil, ErrNotValidHook
+ }
+ h := &Hook{
+ name: name,
+ path: path.Join(repoPath, "hooks", name+".d", name),
+ }
+ samplePath := filepath.Join(repoPath, "hooks", name+".sample")
+ if isFile(h.path) {
+ data, err := os.ReadFile(h.path)
+ if err != nil {
+ return nil, err
+ }
+ h.IsActive = true
+ h.Content = string(data)
+ } else if isFile(samplePath) {
+ data, err := os.ReadFile(samplePath)
+ if err != nil {
+ return nil, err
+ }
+ h.Sample = string(data)
+ }
+ return h, nil
+}
+
+// Name return the name of the hook
+func (h *Hook) Name() string {
+ return h.name
+}
+
+// Update updates hook settings.
+func (h *Hook) Update() error {
+ if len(strings.TrimSpace(h.Content)) == 0 {
+ if isExist(h.path) {
+ err := util.Remove(h.path)
+ if err != nil {
+ return err
+ }
+ }
+ h.IsActive = false
+ return nil
+ }
+ d := filepath.Dir(h.path)
+ if err := os.MkdirAll(d, os.ModePerm); err != nil {
+ return err
+ }
+
+ err := os.WriteFile(h.path, []byte(strings.ReplaceAll(h.Content, "\r", "")), os.ModePerm)
+ if err != nil {
+ return err
+ }
+ h.IsActive = true
+ return nil
+}
+
+// ListHooks returns a list of Git hooks of given repository.
+func ListHooks(repoPath string) (_ []*Hook, err error) {
+ if !isDir(path.Join(repoPath, "hooks")) {
+ return nil, errors.New("hooks path does not exist")
+ }
+
+ hooks := make([]*Hook, len(hookNames))
+ for i, name := range hookNames {
+ hooks[i], err = GetHook(repoPath, name)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return hooks, nil
+}
+
+const (
+ // HookPathUpdate hook update path
+ HookPathUpdate = "hooks/update"
+)
+
+// SetUpdateHook writes given content to update hook of the repository.
+func SetUpdateHook(repoPath, content string) (err error) {
+ log.Debug("Setting update hook: %s", repoPath)
+ hookPath := path.Join(repoPath, HookPathUpdate)
+ isExist, err := util.IsExist(hookPath)
+ if err != nil {
+ log.Debug("Unable to check if %s exists. Error: %v", hookPath, err)
+ return err
+ }
+ if isExist {
+ err = util.Remove(hookPath)
+ } else {
+ err = os.MkdirAll(path.Dir(hookPath), os.ModePerm)
+ }
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(hookPath, []byte(content), 0o777)
+}
diff --git a/modules/git/internal/cmdarg.go b/modules/git/internal/cmdarg.go
new file mode 100644
index 0000000..f8f3c20
--- /dev/null
+++ b/modules/git/internal/cmdarg.go
@@ -0,0 +1,9 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package internal
+
+// CmdArg represents a command argument for git command, and it will be used for the git command directly without any further processing.
+// In most cases, you should use the "AddXxx" functions to add arguments, but not use this type directly.
+// Casting a risky (user-provided) string to CmdArg would cause security issues if it's injected with a "--xxx" argument.
+type CmdArg string
diff --git a/modules/git/last_commit_cache.go b/modules/git/last_commit_cache.go
new file mode 100644
index 0000000..8c7ee5a
--- /dev/null
+++ b/modules/git/last_commit_cache.go
@@ -0,0 +1,159 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Cache represents a caching interface
+type Cache interface {
+ // Put puts value into cache with key and expire time.
+ Put(key string, val any, timeout int64) error
+ // Get gets cached value by given key.
+ Get(key string) any
+}
+
+func getCacheKey(repoPath, commitID, entryPath string) string {
+ hashBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", repoPath, commitID, entryPath)))
+ return fmt.Sprintf("last_commit:%x", hashBytes)
+}
+
+// LastCommitCache represents a cache to store last commit
+type LastCommitCache struct {
+ repoPath string
+ ttl func() int64
+ repo *Repository
+ commitCache map[string]*Commit
+ cache Cache
+}
+
+// NewLastCommitCache creates a new last commit cache for repo
+func NewLastCommitCache(count int64, repoPath string, gitRepo *Repository, cache Cache) *LastCommitCache {
+ if cache == nil {
+ return nil
+ }
+ if count < setting.CacheService.LastCommit.CommitsCount {
+ return nil
+ }
+
+ return &LastCommitCache{
+ repoPath: repoPath,
+ repo: gitRepo,
+ ttl: setting.LastCommitCacheTTLSeconds,
+ cache: cache,
+ }
+}
+
+// Put put the last commit id with commit and entry path
+func (c *LastCommitCache) Put(ref, entryPath, commitID string) error {
+ if c == nil || c.cache == nil {
+ return nil
+ }
+ log.Debug("LastCommitCache save: [%s:%s:%s]", ref, entryPath, commitID)
+ return c.cache.Put(getCacheKey(c.repoPath, ref, entryPath), commitID, c.ttl())
+}
+
+// Get gets the last commit information by commit id and entry path
+func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) {
+ if c == nil || c.cache == nil {
+ return nil, nil
+ }
+
+ commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath)).(string)
+ if !ok || commitID == "" {
+ return nil, nil
+ }
+
+ log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID)
+ if c.commitCache != nil {
+ if commit, ok := c.commitCache[commitID]; ok {
+ log.Debug("LastCommitCache hit level 2: [%s:%s:%s]", ref, entryPath, commitID)
+ return commit, nil
+ }
+ }
+
+ commit, err := c.repo.GetCommit(commitID)
+ if err != nil {
+ return nil, err
+ }
+ if c.commitCache == nil {
+ c.commitCache = make(map[string]*Commit)
+ }
+ c.commitCache[commitID] = commit
+ return commit, nil
+}
+
+// GetCommitByPath gets the last commit for the entry in the provided commit
+func (c *LastCommitCache) GetCommitByPath(commitID, entryPath string) (*Commit, error) {
+ sha, err := NewIDFromString(commitID)
+ if err != nil {
+ return nil, err
+ }
+
+ lastCommit, err := c.Get(sha.String(), entryPath)
+ if err != nil || lastCommit != nil {
+ return lastCommit, err
+ }
+
+ lastCommit, err = c.repo.getCommitByPathWithID(sha, entryPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := c.Put(commitID, entryPath, lastCommit.ID.String()); err != nil {
+ log.Error("Unable to cache %s as the last commit for %q in %s %s. Error %v", lastCommit.ID.String(), entryPath, commitID, c.repoPath, err)
+ }
+
+ return lastCommit, nil
+}
+
+// CacheCommit will cache the commit from the gitRepository
+func (c *Commit) CacheCommit(ctx context.Context) error {
+ if c.repo.LastCommitCache == nil {
+ return nil
+ }
+ return c.recursiveCache(ctx, &c.Tree, "", 1)
+}
+
+func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string, level int) error {
+ if level == 0 {
+ return nil
+ }
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return err
+ }
+
+ entryPaths := make([]string, len(entries))
+ for i, entry := range entries {
+ entryPaths[i] = entry.Name()
+ }
+
+ _, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
+ if err != nil {
+ return err
+ }
+
+ for _, treeEntry := range entries {
+ // entryMap won't contain "" therefore skip this.
+ if treeEntry.IsDir() {
+ subTree, err := tree.SubTree(treeEntry.Name())
+ if err != nil {
+ return err
+ }
+ if err := c.recursiveCache(ctx, subTree, treeEntry.Name(), level-1); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/modules/git/log_name_status.go b/modules/git/log_name_status.go
new file mode 100644
index 0000000..1fd58ab
--- /dev/null
+++ b/modules/git/log_name_status.go
@@ -0,0 +1,437 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "path"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/container"
+
+ "github.com/djherbis/buffer"
+ "github.com/djherbis/nio/v3"
+)
+
+// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
+func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
+ // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ stdoutReader, stdoutWriter := nio.Pipe(buffer.New(32 * 1024))
+
+ // Lets also create a context so that we can absolutely ensure that the command should die when we're done
+ ctx, ctxCancel := context.WithCancel(ctx)
+
+ cancel := func() {
+ ctxCancel()
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }
+
+ cmd := NewCommand(ctx)
+ cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
+
+ var files []string
+ if len(paths) < 70 {
+ if treepath != "" {
+ files = append(files, treepath)
+ for _, pth := range paths {
+ if pth != "" {
+ files = append(files, path.Join(treepath, pth))
+ }
+ }
+ } else {
+ for _, pth := range paths {
+ if pth != "" {
+ files = append(files, pth)
+ }
+ }
+ }
+ } else if treepath != "" {
+ files = append(files, treepath)
+ }
+ // Use the :(literal) pathspec magic to handle edge cases with files named like ":file.txt" or "*.jpg"
+ for i, file := range files {
+ files[i] = ":(literal)" + file
+ }
+ cmd.AddDashesAndList(files...)
+
+ go func() {
+ stderr := strings.Builder{}
+ err := cmd.Run(&RunOpts{
+ Dir: repository,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ return
+ }
+
+ _ = stdoutWriter.Close()
+ }()
+
+ // For simplicities sake we'll us a buffered reader to read from the cat-file --batch
+ bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
+
+ return bufReader, cancel
+}
+
+// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
+type LogNameStatusRepoParser struct {
+ treepath string
+ paths []string
+ next []byte
+ buffull bool
+ rd *bufio.Reader
+ cancel func()
+}
+
+// NewLogNameStatusRepoParser returns a new parser for a git log raw output
+func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
+ rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
+ return &LogNameStatusRepoParser{
+ treepath: treepath,
+ paths: paths,
+ rd: rd,
+ cancel: cancel,
+ }
+}
+
+// LogNameStatusCommitData represents a commit artefact from git log raw
+type LogNameStatusCommitData struct {
+ CommitID string
+ ParentIDs []string
+ Paths []bool
+}
+
+// Next returns the next LogStatusCommitData
+func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
+ var err error
+ if len(g.next) == 0 {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+ }
+
+ ret := LogNameStatusCommitData{}
+ if bytes.Equal(g.next, []byte("commit\000")) {
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+ }
+
+ // Our "line" must look like: <commitid> SP (<parent> SP) * NUL
+ commitIDs := string(g.next)
+ if g.buffull {
+ more, err := g.rd.ReadString('\x00')
+ if err != nil {
+ return nil, err
+ }
+ commitIDs += more
+ }
+ commitIDs = commitIDs[:len(commitIDs)-1]
+ splitIDs := strings.Split(commitIDs, " ")
+ ret.CommitID = splitIDs[0]
+ if len(splitIDs) > 1 {
+ ret.ParentIDs = splitIDs[1:]
+ }
+
+ // now read the next "line"
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+
+ if err == io.EOF || !(g.next[0] == '\n' || g.next[0] == '\000') {
+ return &ret, nil
+ }
+
+ // Ok we have some changes.
+ // This line will look like: NL <fname> NUL
+ //
+ // Subsequent lines will not have the NL - so drop it here - g.bufffull must also be false at this point too.
+ if g.next[0] == '\n' {
+ g.next = g.next[1:]
+ } else {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+ if len(g.next) == 0 {
+ return &ret, nil
+ }
+ if g.next[0] == '\x00' {
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+ }
+ }
+
+ fnameBuf := make([]byte, 4096)
+
+diffloop:
+ for {
+ if err == io.EOF || bytes.Equal(g.next, []byte("commit\000")) {
+ return &ret, nil
+ }
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err == io.EOF {
+ return &ret, nil
+ } else {
+ return nil, err
+ }
+ }
+ copy(fnameBuf, g.next)
+ if len(fnameBuf) < len(g.next) {
+ fnameBuf = append(fnameBuf, g.next[len(fnameBuf):]...)
+ } else {
+ fnameBuf = fnameBuf[:len(g.next)]
+ }
+ if err != nil {
+ if err != bufio.ErrBufferFull {
+ return nil, err
+ }
+ more, err := g.rd.ReadBytes('\x00')
+ if err != nil {
+ return nil, err
+ }
+ fnameBuf = append(fnameBuf, more...)
+ }
+
+ // read the next line
+ g.buffull = false
+ g.next, err = g.rd.ReadSlice('\x00')
+ if err != nil {
+ if err == bufio.ErrBufferFull {
+ g.buffull = true
+ } else if err != io.EOF {
+ return nil, err
+ }
+ }
+
+ if treepath != "" {
+ if !bytes.HasPrefix(fnameBuf, []byte(treepath)) {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ }
+ fnameBuf = fnameBuf[len(treepath) : len(fnameBuf)-1]
+ if len(fnameBuf) > maxpathlen {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ if len(fnameBuf) > 0 {
+ if len(treepath) > 0 {
+ if fnameBuf[0] != '/' || bytes.IndexByte(fnameBuf[1:], '/') >= 0 {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ fnameBuf = fnameBuf[1:]
+ } else if bytes.IndexByte(fnameBuf, '/') >= 0 {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ }
+
+ idx, ok := paths2ids[string(fnameBuf)]
+ if !ok {
+ fnameBuf = fnameBuf[:cap(fnameBuf)]
+ continue diffloop
+ }
+ if ret.Paths == nil {
+ ret.Paths = changed
+ }
+ changed[idx] = true
+ }
+}
+
+// Close closes the parser
+func (g *LogNameStatusRepoParser) Close() {
+ g.cancel()
+}
+
+// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
+func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
+ headRef := head.ID.String()
+
+ tree, err := head.SubTree(treepath)
+ if err != nil {
+ return nil, err
+ }
+
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(paths) == 0 {
+ paths = make([]string, 0, len(entries)+1)
+ paths = append(paths, "")
+ for _, entry := range entries {
+ paths = append(paths, entry.Name())
+ }
+ } else {
+ sort.Strings(paths)
+ if paths[0] != "" {
+ paths = append([]string{""}, paths...)
+ }
+ // remove duplicates
+ for i := len(paths) - 1; i > 0; i-- {
+ if paths[i] == paths[i-1] {
+ paths = append(paths[:i-1], paths[i:]...)
+ }
+ }
+ }
+
+ path2idx := map[string]int{}
+ maxpathlen := len(treepath)
+
+ for i := range paths {
+ path2idx[paths[i]] = i
+ pthlen := len(paths[i]) + len(treepath) + 1
+ if pthlen > maxpathlen {
+ maxpathlen = pthlen
+ }
+ }
+
+ g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
+ // don't use defer g.Close() here as g may change its value - instead wrap in a func
+ defer func() {
+ g.Close()
+ }()
+
+ results := make([]string, len(paths))
+ remaining := len(paths)
+ nextRestart := (len(paths) * 3) / 4
+ if nextRestart > 70 {
+ nextRestart = 70
+ }
+ lastEmptyParent := head.ID.String()
+ commitSinceLastEmptyParent := uint64(0)
+ commitSinceNextRestart := uint64(0)
+ parentRemaining := make(container.Set[string])
+
+ changed := make([]bool, len(paths))
+
+heaploop:
+ for {
+ select {
+ case <-ctx.Done():
+ if ctx.Err() == context.DeadlineExceeded {
+ break heaploop
+ }
+ g.Close()
+ return nil, ctx.Err()
+ default:
+ }
+ current, err := g.Next(treepath, path2idx, changed, maxpathlen)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ break heaploop
+ }
+ g.Close()
+ return nil, err
+ }
+ if current == nil {
+ break heaploop
+ }
+ parentRemaining.Remove(current.CommitID)
+ for i, found := range current.Paths {
+ if !found {
+ continue
+ }
+ changed[i] = false
+ if results[i] == "" {
+ results[i] = current.CommitID
+ if err := repo.LastCommitCache.Put(headRef, path.Join(treepath, paths[i]), current.CommitID); err != nil {
+ return nil, err
+ }
+ delete(path2idx, paths[i])
+ remaining--
+ if results[0] == "" {
+ results[0] = current.CommitID
+ if err := repo.LastCommitCache.Put(headRef, treepath, current.CommitID); err != nil {
+ return nil, err
+ }
+ delete(path2idx, "")
+ remaining--
+ }
+ }
+ }
+
+ if remaining <= 0 {
+ break heaploop
+ }
+ commitSinceLastEmptyParent++
+ if len(parentRemaining) == 0 {
+ lastEmptyParent = current.CommitID
+ commitSinceLastEmptyParent = 0
+ }
+ if remaining <= nextRestart {
+ commitSinceNextRestart++
+ if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
+ g.Close()
+ remainingPaths := make([]string, 0, len(paths))
+ for i, pth := range paths {
+ if results[i] == "" {
+ remainingPaths = append(remainingPaths, pth)
+ }
+ }
+ g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
+ parentRemaining = make(container.Set[string])
+ nextRestart = (remaining * 3) / 4
+ continue heaploop
+ }
+ }
+ parentRemaining.AddMultiple(current.ParentIDs...)
+ }
+ g.Close()
+
+ resultsMap := map[string]string{}
+ for i, pth := range paths {
+ resultsMap[pth] = results[i]
+ }
+
+ return resultsMap, nil
+}
diff --git a/modules/git/notes.go b/modules/git/notes.go
new file mode 100644
index 0000000..ee628c0
--- /dev/null
+++ b/modules/git/notes.go
@@ -0,0 +1,99 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// NotesRef is the git ref where Gitea will look for git-notes data.
+// The value ("refs/notes/commits") is the default ref used by git-notes.
+const NotesRef = "refs/notes/commits"
+
+// Note stores information about a note created using git-notes.
+type Note struct {
+ Message []byte
+ Commit *Commit
+}
+
+// GetNote retrieves the git-notes data for a given commit.
+// FIXME: Add LastCommitCache support
+func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) error {
+ log.Trace("Searching for git note corresponding to the commit %q in the repository %q", commitID, repo.Path)
+ notes, err := repo.GetCommit(NotesRef)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return err
+ }
+ log.Error("Unable to get commit from ref %q. Error: %v", NotesRef, err)
+ return err
+ }
+
+ path := ""
+
+ tree := &notes.Tree
+ log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", tree.ID, commitID)
+
+ var entry *TreeEntry
+ originalCommitID := commitID
+ for len(commitID) > 2 {
+ entry, err = tree.GetTreeEntryByPath(commitID)
+ if err == nil {
+ path += commitID
+ break
+ }
+ if IsErrNotExist(err) {
+ tree, err = tree.SubTree(commitID[0:2])
+ path += commitID[0:2] + "/"
+ commitID = commitID[2:]
+ }
+ if err != nil {
+ // Err may have been updated by the SubTree we need to recheck if it's again an ErrNotExist
+ if !IsErrNotExist(err) {
+ log.Error("Unable to find git note corresponding to the commit %q. Error: %v", originalCommitID, err)
+ }
+ return err
+ }
+ }
+
+ blob := entry.Blob()
+ dataRc, err := blob.DataAsync()
+ if err != nil {
+ log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
+ return err
+ }
+ closed := false
+ defer func() {
+ if !closed {
+ _ = dataRc.Close()
+ }
+ }()
+ d, err := io.ReadAll(dataRc)
+ if err != nil {
+ log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
+ return err
+ }
+ _ = dataRc.Close()
+ closed = true
+ note.Message = d
+
+ treePath := ""
+ if idx := strings.LastIndex(path, "/"); idx > -1 {
+ treePath = path[:idx]
+ path = path[idx+1:]
+ }
+
+ lastCommits, err := GetLastCommitForPaths(ctx, notes, treePath, []string{path})
+ if err != nil {
+ log.Error("Unable to get the commit for the path %q. Error: %v", treePath, err)
+ return err
+ }
+ note.Commit = lastCommits[path]
+
+ return nil
+}
diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go
new file mode 100644
index 0000000..bbb16cc
--- /dev/null
+++ b/modules/git/notes_test.go
@@ -0,0 +1,53 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetNotes(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note contents\n"), note.Message)
+ assert.Equal(t, "Vladimir Panteleev", note.Commit.Author.Name)
+}
+
+func TestGetNestedNotes(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo3_notes")
+ repo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note 2"), note.Message)
+ err = GetNote(context.Background(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", &note)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("Note 1"), note.Message)
+}
+
+func TestGetNonExistentNotes(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ note := Note{}
+ err = GetNote(context.Background(), bareRepo1, "non_existent_sha", &note)
+ require.Error(t, err)
+ assert.IsType(t, ErrNotExist{}, err)
+}
diff --git a/modules/git/object_format.go b/modules/git/object_format.go
new file mode 100644
index 0000000..db9120d
--- /dev/null
+++ b/modules/git/object_format.go
@@ -0,0 +1,143 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "crypto/sha1"
+ "crypto/sha256"
+ "hash"
+ "regexp"
+ "strconv"
+)
+
+// sha1Pattern can be used to determine if a string is an valid sha
+var sha1Pattern = regexp.MustCompile(`^[0-9a-f]{4,40}$`)
+
+// sha256Pattern can be used to determine if a string is an valid sha
+var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`)
+
+type ObjectFormat interface {
+ // Name returns the name of the object format
+ Name() string
+ // EmptyObjectID creates a new empty ObjectID from an object format hash name
+ EmptyObjectID() ObjectID
+ // EmptyTree is the hash of an empty tree
+ EmptyTree() ObjectID
+ // FullLength is the length of the hash's hex string
+ FullLength() int
+ // IsValid returns true if the input is a valid hash
+ IsValid(input string) bool
+ // MustID creates a new ObjectID from a byte slice
+ MustID(b []byte) ObjectID
+ // ComputeHash compute the hash for a given ObjectType and content
+ ComputeHash(t ObjectType, content []byte) ObjectID
+}
+
+func computeHash(dst []byte, hasher hash.Hash, t ObjectType, content []byte) {
+ _, _ = hasher.Write(t.Bytes())
+ _, _ = hasher.Write([]byte(" "))
+ _, _ = hasher.Write([]byte(strconv.Itoa(len(content))))
+ _, _ = hasher.Write([]byte{0})
+ _, _ = hasher.Write(content)
+ hasher.Sum(dst)
+}
+
+/* SHA1 Type */
+type Sha1ObjectFormatImpl struct{}
+
+var (
+ emptySha1ObjectID = &Sha1Hash{}
+ emptySha1Tree = &Sha1Hash{
+ 0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60,
+ 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04,
+ }
+)
+
+func (Sha1ObjectFormatImpl) Name() string { return "sha1" }
+func (Sha1ObjectFormatImpl) EmptyObjectID() ObjectID {
+ return emptySha1ObjectID
+}
+
+func (Sha1ObjectFormatImpl) EmptyTree() ObjectID {
+ return emptySha1Tree
+}
+func (Sha1ObjectFormatImpl) FullLength() int { return 40 }
+func (Sha1ObjectFormatImpl) IsValid(input string) bool {
+ return sha1Pattern.MatchString(input)
+}
+
+func (Sha1ObjectFormatImpl) MustID(b []byte) ObjectID {
+ var id Sha1Hash
+ copy(id[0:20], b)
+ return &id
+}
+
+// ComputeHash compute the hash for a given ObjectType and content
+func (h Sha1ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
+ var obj Sha1Hash
+ computeHash(obj[:0], sha1.New(), t, content)
+ return &obj
+}
+
+/* SHA256 Type */
+type Sha256ObjectFormatImpl struct{}
+
+var (
+ emptySha256ObjectID = &Sha256Hash{}
+ emptySha256Tree = &Sha256Hash{
+ 0x6e, 0xf1, 0x9b, 0x41, 0x22, 0x5c, 0x53, 0x69, 0xf1, 0xc1,
+ 0x04, 0xd4, 0x5d, 0x8d, 0x85, 0xef, 0xa9, 0xb0, 0x57, 0xb5,
+ 0x3b, 0x14, 0xb4, 0xb9, 0xb9, 0x39, 0xdd, 0x74, 0xde, 0xcc,
+ 0x53, 0x21,
+ }
+)
+
+func (Sha256ObjectFormatImpl) Name() string { return "sha256" }
+func (Sha256ObjectFormatImpl) EmptyObjectID() ObjectID {
+ return emptySha256ObjectID
+}
+
+func (Sha256ObjectFormatImpl) EmptyTree() ObjectID {
+ return emptySha256Tree
+}
+func (Sha256ObjectFormatImpl) FullLength() int { return 64 }
+func (Sha256ObjectFormatImpl) IsValid(input string) bool {
+ return sha256Pattern.MatchString(input)
+}
+
+func (Sha256ObjectFormatImpl) MustID(b []byte) ObjectID {
+ var id Sha256Hash
+ copy(id[0:32], b)
+ return &id
+}
+
+// ComputeHash compute the hash for a given ObjectType and content
+func (h Sha256ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
+ var obj Sha256Hash
+ computeHash(obj[:0], sha256.New(), t, content)
+ return &obj
+}
+
+var (
+ Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
+ Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
+ // any addition must be reflected in IsEmptyCommitID
+)
+
+var SupportedObjectFormats = []ObjectFormat{
+ Sha1ObjectFormat,
+}
+
+func ObjectFormatFromName(name string) ObjectFormat {
+ for _, objectFormat := range SupportedObjectFormats {
+ if name == objectFormat.Name() {
+ return objectFormat
+ }
+ }
+ return nil
+}
+
+func IsValidObjectFormat(name string) bool {
+ return ObjectFormatFromName(name) != nil
+}
diff --git a/modules/git/object_id.go b/modules/git/object_id.go
new file mode 100644
index 0000000..ecbc158
--- /dev/null
+++ b/modules/git/object_id.go
@@ -0,0 +1,114 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "encoding/hex"
+ "fmt"
+)
+
+type ObjectID interface {
+ String() string
+ IsZero() bool
+ RawValue() []byte
+ Type() ObjectFormat
+}
+
+/* SHA1 */
+type Sha1Hash [20]byte
+
+func (h *Sha1Hash) String() string {
+ return hex.EncodeToString(h[:])
+}
+
+func (h *Sha1Hash) IsZero() bool {
+ empty := Sha1Hash{}
+ return bytes.Equal(empty[:], h[:])
+}
+func (h *Sha1Hash) RawValue() []byte { return h[:] }
+func (*Sha1Hash) Type() ObjectFormat { return Sha1ObjectFormat }
+
+var _ ObjectID = &Sha1Hash{}
+
+func MustIDFromString(hexHash string) ObjectID {
+ id, err := NewIDFromString(hexHash)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+/* SHA256 */
+type Sha256Hash [32]byte
+
+func (h *Sha256Hash) String() string {
+ return hex.EncodeToString(h[:])
+}
+
+func (h *Sha256Hash) IsZero() bool {
+ empty := Sha256Hash{}
+ return bytes.Equal(empty[:], h[:])
+}
+func (h *Sha256Hash) RawValue() []byte { return h[:] }
+func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat }
+
+/* utility */
+func NewIDFromString(hexHash string) (ObjectID, error) {
+ var theObjectFormat ObjectFormat
+ for _, objectFormat := range SupportedObjectFormats {
+ if len(hexHash) == objectFormat.FullLength() {
+ theObjectFormat = objectFormat
+ break
+ }
+ }
+
+ if theObjectFormat == nil {
+ return nil, fmt.Errorf("length %d has no matched object format: %s", len(hexHash), hexHash)
+ }
+
+ b, err := hex.DecodeString(hexHash)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(b) != theObjectFormat.FullLength()/2 {
+ return theObjectFormat.EmptyObjectID(), fmt.Errorf("length must be %d: %v", theObjectFormat.FullLength(), b)
+ }
+ return theObjectFormat.MustID(b), nil
+}
+
+// IsEmptyCommitID checks if an hexadecimal string represents an empty commit according to git (only '0').
+// If objectFormat is not nil, the length will be checked as well (otherwise the length must match the sha1 or sha256 length).
+func IsEmptyCommitID(commitID string, objectFormat ObjectFormat) bool {
+ if commitID == "" {
+ return true
+ }
+ if objectFormat == nil {
+ if Sha1ObjectFormat.FullLength() != len(commitID) && Sha256ObjectFormat.FullLength() != len(commitID) {
+ return false
+ }
+ } else if objectFormat.FullLength() != len(commitID) {
+ return false
+ }
+ for _, c := range commitID {
+ if c != '0' {
+ return false
+ }
+ }
+ return true
+}
+
+// ComputeBlobHash compute the hash for a given blob content
+func ComputeBlobHash(hashType ObjectFormat, content []byte) ObjectID {
+ return hashType.ComputeHash(ObjectBlob, content)
+}
+
+type ErrInvalidSHA struct {
+ SHA string
+}
+
+func (err ErrInvalidSHA) Error() string {
+ return fmt.Sprintf("invalid sha: %s", err.SHA)
+}
diff --git a/modules/git/object_id_test.go b/modules/git/object_id_test.go
new file mode 100644
index 0000000..00a24e3
--- /dev/null
+++ b/modules/git/object_id_test.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsValidSHAPattern(t *testing.T) {
+ h := Sha1ObjectFormat
+ assert.True(t, h.IsValid("fee1"))
+ assert.True(t, h.IsValid("abc000"))
+ assert.True(t, h.IsValid("9023902390239023902390239023902390239023"))
+ assert.False(t, h.IsValid("90239023902390239023902390239023902390239023"))
+ assert.False(t, h.IsValid("abc"))
+ assert.False(t, h.IsValid("123g"))
+ assert.False(t, h.IsValid("some random text"))
+
+ assert.Equal(t, "79ee38a6416c1ede423ec7ee0a8639ceea4aad22", ComputeBlobHash(Sha1ObjectFormat, []byte("some random blob")).String())
+ assert.Equal(t, "d5c6407415d85df49592672aa421aed39b9db5e3", ComputeBlobHash(Sha1ObjectFormat, []byte("same length blob")).String())
+ assert.Equal(t, "df0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", ComputeBlobHash(Sha256ObjectFormat, []byte("some random blob")).String())
+}
+
+func TestIsEmptyCommitID(t *testing.T) {
+ assert.True(t, IsEmptyCommitID("", nil))
+ assert.True(t, IsEmptyCommitID("", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("79ee38a6416c1ede423ec7ee0a8639ceea4aad20", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", nil))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha1ObjectFormat))
+ assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("00000000000000000000000000000000000000000", nil))
+
+ assert.False(t, IsEmptyCommitID("0f0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", nil))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", nil))
+ assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha1ObjectFormat))
+ assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha256ObjectFormat))
+
+ assert.False(t, IsEmptyCommitID("1", nil))
+ assert.False(t, IsEmptyCommitID("0", nil))
+
+ assert.False(t, IsEmptyCommitID("010", nil))
+ assert.False(t, IsEmptyCommitID("0 0", nil))
+}
diff --git a/modules/git/object_signature.go b/modules/git/object_signature.go
new file mode 100644
index 0000000..35fa671
--- /dev/null
+++ b/modules/git/object_signature.go
@@ -0,0 +1,11 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+// ObjectSignature represents a git object (commit, tag) signature part.
+type ObjectSignature struct {
+ Signature string
+ Payload string // TODO check if can be reconstruct from the rest of commit information to not have duplicate data
+}
diff --git a/modules/git/parse.go b/modules/git/parse.go
new file mode 100644
index 0000000..8c2c411
--- /dev/null
+++ b/modules/git/parse.go
@@ -0,0 +1,137 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// ParseTreeEntries parses the output of a `git ls-tree -l` command.
+func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
+ return parseTreeEntries(data, nil)
+}
+
+var sepSpace = []byte{' '}
+
+func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
+ var err error
+ entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
+ for pos := 0; pos < len(data); {
+ // expect line to be of the form:
+ // <mode> <type> <sha> <space-padded-size>\t<filename>
+ // <mode> <type> <sha>\t<filename>
+ posEnd := bytes.IndexByte(data[pos:], '\n')
+ if posEnd == -1 {
+ posEnd = len(data)
+ } else {
+ posEnd += pos
+ }
+ line := data[pos:posEnd]
+ posTab := bytes.IndexByte(line, '\t')
+ if posTab == -1 {
+ return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line)
+ }
+
+ entry := new(TreeEntry)
+ entry.ptree = ptree
+
+ entryAttrs := line[:posTab]
+ entryName := line[posTab+1:]
+
+ entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
+ _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type
+ entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
+ if len(entryAttrs) > 0 {
+ entrySize := entryAttrs // the last field is the space-padded-size
+ entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64)
+ entry.sized = true
+ }
+
+ switch string(entryMode) {
+ case "100644":
+ entry.entryMode = EntryModeBlob
+ case "100755":
+ entry.entryMode = EntryModeExec
+ case "120000":
+ entry.entryMode = EntryModeSymlink
+ case "160000":
+ entry.entryMode = EntryModeCommit
+ case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
+ entry.entryMode = EntryModeTree
+ default:
+ return nil, fmt.Errorf("unknown type: %v", string(entryMode))
+ }
+
+ entry.ID, err = NewIDFromString(string(entryObjectID))
+ if err != nil {
+ return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
+ }
+
+ if len(entryName) > 0 && entryName[0] == '"' {
+ entry.name, err = strconv.Unquote(string(entryName))
+ if err != nil {
+ return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
+ }
+ } else {
+ entry.name = string(entryName)
+ }
+
+ pos = posEnd + 1
+ entries = append(entries, entry)
+ }
+ return entries, nil
+}
+
+func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) {
+ fnameBuf := make([]byte, 4096)
+ modeBuf := make([]byte, 40)
+ shaBuf := make([]byte, objectFormat.FullLength())
+ entries := make([]*TreeEntry, 0, 10)
+
+loop:
+ for sz > 0 {
+ mode, fname, sha, count, err := ParseTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf)
+ if err != nil {
+ if err == io.EOF {
+ break loop
+ }
+ return nil, err
+ }
+ sz -= int64(count)
+ entry := new(TreeEntry)
+ entry.ptree = ptree
+
+ switch string(mode) {
+ case "100644":
+ entry.entryMode = EntryModeBlob
+ case "100755":
+ entry.entryMode = EntryModeExec
+ case "120000":
+ entry.entryMode = EntryModeSymlink
+ case "160000":
+ entry.entryMode = EntryModeCommit
+ case "40000", "40755": // git uses 40000 for tree object, but some users may get 40755 for unknown reasons
+ entry.entryMode = EntryModeTree
+ default:
+ log.Debug("Unknown mode: %v", string(mode))
+ return nil, fmt.Errorf("unknown mode: %v", string(mode))
+ }
+
+ entry.ID = objectFormat.MustID(sha)
+ entry.name = string(fname)
+ entries = append(entries, entry)
+ }
+ if _, err := rd.Discard(1); err != nil {
+ return entries, err
+ }
+
+ return entries, nil
+}
diff --git a/modules/git/parse_test.go b/modules/git/parse_test.go
new file mode 100644
index 0000000..89c6e03
--- /dev/null
+++ b/modules/git/parse_test.go
@@ -0,0 +1,103 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseTreeEntriesLong(t *testing.T) {
+ testCases := []struct {
+ Input string
+ Expected []*TreeEntry
+ }{
+ {
+ Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af 8218 README.md
+100644 blob 037f27dc9d353ae4fd50f0474b2194c593914e35 4681 README_ZH.md
+100644 blob 9846a94f7e8350a916632929d0fda38c90dd2ca8 429 SECURITY.md
+040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d - assets
+`,
+ Expected: []*TreeEntry{
+ {
+ ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
+ name: "README.md",
+ entryMode: EntryModeBlob,
+ size: 8218,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("037f27dc9d353ae4fd50f0474b2194c593914e35"),
+ name: "README_ZH.md",
+ entryMode: EntryModeBlob,
+ size: 4681,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("9846a94f7e8350a916632929d0fda38c90dd2ca8"),
+ name: "SECURITY.md",
+ entryMode: EntryModeBlob,
+ size: 429,
+ sized: true,
+ },
+ {
+ ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
+ name: "assets",
+ entryMode: EntryModeTree,
+ sized: true,
+ },
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ entries, err := ParseTreeEntries([]byte(testCase.Input))
+ require.NoError(t, err)
+ assert.Len(t, entries, len(testCase.Expected))
+ for i, entry := range entries {
+ assert.EqualValues(t, testCase.Expected[i], entry)
+ }
+ }
+}
+
+func TestParseTreeEntriesShort(t *testing.T) {
+ testCases := []struct {
+ Input string
+ Expected []*TreeEntry
+ }{
+ {
+ Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af README.md
+040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d assets
+`,
+ Expected: []*TreeEntry{
+ {
+ ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
+ name: "README.md",
+ entryMode: EntryModeBlob,
+ },
+ {
+ ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
+ name: "assets",
+ entryMode: EntryModeTree,
+ },
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ entries, err := ParseTreeEntries([]byte(testCase.Input))
+ require.NoError(t, err)
+ assert.Len(t, entries, len(testCase.Expected))
+ for i, entry := range entries {
+ assert.EqualValues(t, testCase.Expected[i], entry)
+ }
+ }
+}
+
+func TestParseTreeEntriesInvalid(t *testing.T) {
+ // there was a panic: "runtime error: slice bounds out of range" when the input was invalid: #20315
+ entries, err := ParseTreeEntries([]byte("100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af"))
+ require.Error(t, err)
+ assert.Empty(t, entries)
+}
diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go
new file mode 100644
index 0000000..4677218
--- /dev/null
+++ b/modules/git/pipeline/catfile.go
@@ -0,0 +1,108 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// CatFileBatchCheck runs cat-file with --batch-check
+func CatFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToCheckReader.Close()
+ defer catFileCheckWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "cat-file", "--batch-check")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdin: shasToCheckReader,
+ Stdout: catFileCheckWriter,
+ Stderr: stderr,
+ }); err != nil {
+ _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
+
+// CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all
+func CatFileBatchCheckAllObjects(ctx context.Context, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) {
+ defer wg.Done()
+ defer catFileCheckWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "cat-file", "--batch-check", "--batch-all-objects")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: catFileCheckWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String())
+ err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %w - %s", tmpBasePath, err, errbuf.String())
+ _ = catFileCheckWriter.CloseWithError(err)
+ errChan <- err
+ }
+}
+
+// CatFileBatch runs cat-file --batch
+func CatFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToBatchReader.Close()
+ defer catFileBatchWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ if err := git.NewCommand(ctx, "cat-file", "--batch").Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: catFileBatchWriter,
+ Stdin: shasToBatchReader,
+ Stderr: stderr,
+ }); err != nil {
+ _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
+
+// BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size
+func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) {
+ defer wg.Done()
+ defer catFileCheckReader.Close()
+ scanner := bufio.NewScanner(catFileCheckReader)
+ defer func() {
+ _ = shasToBatchWriter.CloseWithError(scanner.Err())
+ }()
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ fields := strings.Split(line, " ")
+ if len(fields) < 3 || fields[1] != "blob" {
+ continue
+ }
+ size, _ := strconv.Atoi(fields[2])
+ if size > 1024 {
+ continue
+ }
+ toWrite := []byte(fields[0] + "\n")
+ for len(toWrite) > 0 {
+ n, err := shasToBatchWriter.Write(toWrite)
+ if err != nil {
+ _ = catFileCheckReader.CloseWithError(err)
+ break
+ }
+ toWrite = toWrite[n:]
+ }
+ }
+}
diff --git a/modules/git/pipeline/lfs.go b/modules/git/pipeline/lfs.go
new file mode 100644
index 0000000..3407eb9
--- /dev/null
+++ b/modules/git/pipeline/lfs.go
@@ -0,0 +1,254 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// LFSResult represents commits found using a provided pointer file hash
+type LFSResult struct {
+ Name string
+ SHA string
+ Summary string
+ When time.Time
+ ParentHashes []git.ObjectID
+ BranchName string
+ FullCommitName string
+}
+
+type lfsResultSlice []*LFSResult
+
+func (a lfsResultSlice) Len() int { return len(a) }
+func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
+func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) }
+
+func lfsError(msg string, err error) error {
+ return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err)
+}
+
+// FindLFSFile finds commits that contain a provided pointer file hash
+func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) {
+ resultsMap := map[string]*LFSResult{}
+ results := make([]*LFSResult, 0)
+
+ basePath := repo.Path
+
+ // Use rev-list to provide us with all commits in order
+ revListReader, revListWriter := io.Pipe()
+ defer func() {
+ _ = revListWriter.Close()
+ _ = revListReader.Close()
+ }()
+
+ go func() {
+ stderr := strings.Builder{}
+ err := git.NewCommand(repo.Ctx, "rev-list", "--all").Run(&git.RunOpts{
+ Dir: repo.Path,
+ Stdout: revListWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = revListWriter.CloseWithError(git.ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = revListWriter.Close()
+ }
+ }()
+
+ // Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ // We'll use a scanner for the revList because it's simpler than a bufio.Reader
+ scan := bufio.NewScanner(revListReader)
+ trees := [][]byte{}
+ paths := []string{}
+
+ fnameBuf := make([]byte, 4096)
+ modeBuf := make([]byte, 40)
+ workingShaBuf := make([]byte, objectID.Type().FullLength()/2)
+
+ for scan.Scan() {
+ // Get the next commit ID
+ commitID := scan.Bytes()
+
+ // push the commit to the cat-file --batch process
+ _, err := batchStdinWriter.Write(commitID)
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte{'\n'})
+ if err != nil {
+ return nil, err
+ }
+
+ var curCommit *git.Commit
+ curPath := ""
+
+ commitReadingLoop:
+ for {
+ _, typ, size, err := git.ReadBatchLine(batchReader)
+ if err != nil {
+ return nil, err
+ }
+
+ switch typ {
+ case "tag":
+ // This shouldn't happen but if it does well just get the commit and try again
+ id, err := git.ReadTagObjectID(batchReader, size)
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte(id + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ continue
+ case "commit":
+ // Read in the commit to get its tree and in case this is one of the last used commits
+ curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(string(commitID)), io.LimitReader(batchReader, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+
+ if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil {
+ return nil, err
+ }
+ curPath = ""
+ case "tree":
+ var n int64
+ for n < size {
+ mode, fname, binObjectID, count, err := git.ParseTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf)
+ if err != nil {
+ return nil, err
+ }
+ n += int64(count)
+ if bytes.Equal(binObjectID, objectID.RawValue()) {
+ result := LFSResult{
+ Name: curPath + string(fname),
+ SHA: curCommit.ID.String(),
+ Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0],
+ When: curCommit.Author.When,
+ ParentHashes: curCommit.Parents,
+ }
+ resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result
+ } else if string(mode) == git.EntryModeTree.String() {
+ hexObjectID := make([]byte, objectID.Type().FullLength())
+ git.BinToHex(objectID.Type(), binObjectID, hexObjectID)
+ trees = append(trees, hexObjectID)
+ paths = append(paths, curPath+string(fname)+"/")
+ }
+ }
+ if _, err := batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+ if len(trees) > 0 {
+ _, err := batchStdinWriter.Write(trees[len(trees)-1])
+ if err != nil {
+ return nil, err
+ }
+ _, err = batchStdinWriter.Write([]byte("\n"))
+ if err != nil {
+ return nil, err
+ }
+ curPath = paths[len(paths)-1]
+ trees = trees[:len(trees)-1]
+ paths = paths[:len(paths)-1]
+ } else {
+ break commitReadingLoop
+ }
+ default:
+ if err := git.DiscardFull(batchReader, size+1); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+
+ if err := scan.Err(); err != nil {
+ return nil, err
+ }
+
+ for _, result := range resultsMap {
+ hasParent := false
+ for _, parentID := range result.ParentHashes {
+ if _, hasParent = resultsMap[parentID.String()+":"+result.Name]; hasParent {
+ break
+ }
+ }
+ if !hasParent {
+ results = append(results, result)
+ }
+ }
+
+ sort.Sort(lfsResultSlice(results))
+
+ // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
+ shasToNameReader, shasToNameWriter := io.Pipe()
+ nameRevStdinReader, nameRevStdinWriter := io.Pipe()
+ errChan := make(chan error, 1)
+ wg := sync.WaitGroup{}
+ wg.Add(3)
+
+ go func() {
+ defer wg.Done()
+ scanner := bufio.NewScanner(nameRevStdinReader)
+ i := 0
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ result := results[i]
+ result.FullCommitName = line
+ result.BranchName = strings.Split(line, "~")[0]
+ i++
+ }
+ }()
+ go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath)
+ go func() {
+ defer wg.Done()
+ defer shasToNameWriter.Close()
+ for _, result := range results {
+ _, err := shasToNameWriter.Write([]byte(result.SHA))
+ if err != nil {
+ errChan <- err
+ break
+ }
+ _, err = shasToNameWriter.Write([]byte{'\n'})
+ if err != nil {
+ errChan <- err
+ break
+ }
+ }
+ }()
+
+ wg.Wait()
+
+ select {
+ case err, has := <-errChan:
+ if has {
+ return nil, lfsError("unable to obtain name for LFS files", err)
+ }
+ default:
+ }
+
+ return results, nil
+}
diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go
new file mode 100644
index 0000000..ad583a7
--- /dev/null
+++ b/modules/git/pipeline/namerev.go
@@ -0,0 +1,33 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// NameRevStdin runs name-rev --stdin
+func NameRevStdin(ctx context.Context, shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
+ defer wg.Done()
+ defer shasToNameReader.Close()
+ defer nameRevStdinWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ if err := git.NewCommand(ctx, "name-rev", "--stdin", "--name-only", "--always").Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: nameRevStdinWriter,
+ Stdin: shasToNameReader,
+ Stderr: stderr,
+ }); err != nil {
+ _ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
+ }
+}
diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go
new file mode 100644
index 0000000..d88ebe7
--- /dev/null
+++ b/modules/git/pipeline/revlist.go
@@ -0,0 +1,86 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pipeline
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter
+func RevListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) {
+ defer wg.Done()
+ defer revListWriter.Close()
+
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "rev-list", "--objects", "--all")
+ if err := cmd.Run(&git.RunOpts{
+ Dir: basePath,
+ Stdout: revListWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String())
+ err = fmt.Errorf("git rev-list --objects --all [%s]: %w - %s", basePath, err, errbuf.String())
+ _ = revListWriter.CloseWithError(err)
+ errChan <- err
+ }
+}
+
+// RevListObjects run rev-list --objects from headSHA to baseSHA
+func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) {
+ defer wg.Done()
+ defer revListWriter.Close()
+ stderr := new(bytes.Buffer)
+ var errbuf strings.Builder
+ cmd := git.NewCommand(ctx, "rev-list", "--objects").AddDynamicArguments(headSHA)
+ if baseSHA != "" {
+ cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA)
+ }
+ if err := cmd.Run(&git.RunOpts{
+ Dir: tmpBasePath,
+ Stdout: revListWriter,
+ Stderr: stderr,
+ }); err != nil {
+ log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String())
+ errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String())
+ }
+}
+
+// BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs
+func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) {
+ defer wg.Done()
+ defer revListReader.Close()
+ scanner := bufio.NewScanner(revListReader)
+ defer func() {
+ _ = shasToCheckWriter.CloseWithError(scanner.Err())
+ }()
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) == 0 {
+ continue
+ }
+ fields := strings.Split(line, " ")
+ if len(fields) < 2 || len(fields[1]) == 0 {
+ continue
+ }
+ toWrite := []byte(fields[0] + "\n")
+ for len(toWrite) > 0 {
+ n, err := shasToCheckWriter.Write(toWrite)
+ if err != nil {
+ _ = revListReader.CloseWithError(err)
+ break
+ }
+ toWrite = toWrite[n:]
+ }
+ }
+}
diff --git a/modules/git/pushoptions/pushoptions.go b/modules/git/pushoptions/pushoptions.go
new file mode 100644
index 0000000..9709a8b
--- /dev/null
+++ b/modules/git/pushoptions/pushoptions.go
@@ -0,0 +1,113 @@
+// Copyright twenty-panda <twenty-panda@posteo.com>
+// SPDX-License-Identifier: MIT
+
+package pushoptions
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type Key string
+
+const (
+ RepoPrivate = Key("repo.private")
+ RepoTemplate = Key("repo.template")
+ AgitTopic = Key("topic")
+ AgitForcePush = Key("force-push")
+ AgitTitle = Key("title")
+ AgitDescription = Key("description")
+
+ envPrefix = "GIT_PUSH_OPTION"
+ EnvCount = envPrefix + "_COUNT"
+ EnvFormat = envPrefix + "_%d"
+)
+
+type Interface interface {
+ ReadEnv() Interface
+ Parse(string) bool
+ Map() map[string]string
+
+ ChangeRepoSettings() bool
+
+ Empty() bool
+
+ GetBool(key Key, def bool) bool
+ GetString(key Key) (val string, ok bool)
+}
+
+type gitPushOptions map[string]string
+
+func New() Interface {
+ pushOptions := gitPushOptions(make(map[string]string))
+ return &pushOptions
+}
+
+func NewFromMap(o *map[string]string) Interface {
+ return (*gitPushOptions)(o)
+}
+
+func (o *gitPushOptions) ReadEnv() Interface {
+ if pushCount, err := strconv.Atoi(os.Getenv(EnvCount)); err == nil {
+ for idx := 0; idx < pushCount; idx++ {
+ _ = o.Parse(os.Getenv(fmt.Sprintf(EnvFormat, idx)))
+ }
+ }
+ return o
+}
+
+func (o *gitPushOptions) Parse(data string) bool {
+ key, value, found := strings.Cut(data, "=")
+ if !found {
+ value = "true"
+ }
+ switch Key(key) {
+ case RepoPrivate:
+ case RepoTemplate:
+ case AgitTopic:
+ case AgitForcePush:
+ case AgitTitle:
+ case AgitDescription:
+ default:
+ return false
+ }
+ (*o)[key] = value
+ return true
+}
+
+func (o gitPushOptions) Map() map[string]string {
+ return o
+}
+
+func (o gitPushOptions) ChangeRepoSettings() bool {
+ if o.Empty() {
+ return false
+ }
+ for _, key := range []Key{RepoPrivate, RepoTemplate} {
+ _, ok := o[string(key)]
+ if ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (o gitPushOptions) Empty() bool {
+ return len(o) == 0
+}
+
+func (o gitPushOptions) GetBool(key Key, def bool) bool {
+ if val, ok := o[string(key)]; ok {
+ if b, err := strconv.ParseBool(val); err == nil {
+ return b
+ }
+ }
+ return def
+}
+
+func (o gitPushOptions) GetString(key Key) (string, bool) {
+ val, ok := o[string(key)]
+ return val, ok
+}
diff --git a/modules/git/pushoptions/pushoptions_test.go b/modules/git/pushoptions/pushoptions_test.go
new file mode 100644
index 0000000..49bf2d2
--- /dev/null
+++ b/modules/git/pushoptions/pushoptions_test.go
@@ -0,0 +1,125 @@
+// Copyright twenty-panda <twenty-panda@posteo.com>
+// SPDX-License-Identifier: MIT
+
+package pushoptions
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEmpty(t *testing.T) {
+ options := New()
+ assert.True(t, options.Empty())
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ assert.False(t, options.Empty())
+}
+
+func TestToAndFromMap(t *testing.T) {
+ options := New()
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ actual := options.Map()
+ expected := map[string]string{string(RepoPrivate): "true"}
+ assert.EqualValues(t, expected, actual)
+ assert.EqualValues(t, expected, NewFromMap(&actual).Map())
+}
+
+func TestChangeRepositorySettings(t *testing.T) {
+ options := New()
+ assert.False(t, options.ChangeRepoSettings())
+ assert.True(t, options.Parse(fmt.Sprintf("%v=description", AgitDescription)))
+ assert.False(t, options.ChangeRepoSettings())
+
+ options.Parse(fmt.Sprintf("%v", RepoPrivate))
+ assert.True(t, options.ChangeRepoSettings())
+
+ options = New()
+ options.Parse(fmt.Sprintf("%v", RepoTemplate))
+ assert.True(t, options.ChangeRepoSettings())
+}
+
+func TestParse(t *testing.T) {
+ t.Run("no key", func(t *testing.T) {
+ options := New()
+
+ val, ok := options.GetString(RepoPrivate)
+ assert.False(t, ok)
+ assert.Equal(t, "", val)
+
+ assert.True(t, options.GetBool(RepoPrivate, true))
+ assert.False(t, options.GetBool(RepoPrivate, false))
+ })
+
+ t.Run("key=value", func(t *testing.T) {
+ options := New()
+
+ topic := "TOPIC"
+ assert.True(t, options.Parse(fmt.Sprintf("%v=%s", AgitTopic, topic)))
+ val, ok := options.GetString(AgitTopic)
+ assert.True(t, ok)
+ assert.Equal(t, topic, val)
+ })
+
+ t.Run("key=true", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v=true", RepoPrivate)))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ assert.True(t, options.Parse(fmt.Sprintf("%v=TRUE", RepoTemplate)))
+ assert.True(t, options.GetBool(RepoTemplate, false))
+ })
+
+ t.Run("key=false", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v=false", RepoPrivate)))
+ assert.False(t, options.GetBool(RepoPrivate, true))
+ })
+
+ t.Run("key", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Parse(fmt.Sprintf("%v", RepoPrivate)))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ })
+
+ t.Run("unknown keys are ignored", func(t *testing.T) {
+ options := New()
+
+ assert.True(t, options.Empty())
+ assert.False(t, options.Parse("unknown=value"))
+ assert.True(t, options.Empty())
+ })
+}
+
+func TestReadEnv(t *testing.T) {
+ t.Setenv(envPrefix+"_0", fmt.Sprintf("%v=true", AgitForcePush))
+ t.Setenv(envPrefix+"_1", fmt.Sprintf("%v", RepoPrivate))
+ t.Setenv(envPrefix+"_2", fmt.Sprintf("%v=equal=in string", AgitTitle))
+ t.Setenv(envPrefix+"_3", "not=valid")
+ t.Setenv(envPrefix+"_4", fmt.Sprintf("%v=description", AgitDescription))
+ t.Setenv(EnvCount, "5")
+
+ options := New().ReadEnv()
+
+ assert.True(t, options.GetBool(AgitForcePush, false))
+ assert.True(t, options.GetBool(RepoPrivate, false))
+ assert.False(t, options.GetBool(RepoTemplate, false))
+
+ {
+ val, ok := options.GetString(AgitTitle)
+ assert.True(t, ok)
+ assert.Equal(t, "equal=in string", val)
+ }
+ {
+ val, ok := options.GetString(AgitDescription)
+ assert.True(t, ok)
+ assert.Equal(t, "description", val)
+ }
+ {
+ _, ok := options.GetString(AgitTopic)
+ assert.False(t, ok)
+ }
+}
diff --git a/modules/git/ref.go b/modules/git/ref.go
new file mode 100644
index 0000000..2db630e
--- /dev/null
+++ b/modules/git/ref.go
@@ -0,0 +1,214 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ // RemotePrefix is the base directory of the remotes information of git.
+ RemotePrefix = "refs/remotes/"
+ // PullPrefix is the base directory of the pull information of git.
+ PullPrefix = "refs/pull/"
+)
+
+// refNamePatternInvalid is regular expression with unallowed characters in git reference name
+// They cannot have ASCII control characters (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^, or colon : anywhere.
+// They cannot have question-mark ?, asterisk *, or open bracket [ anywhere
+var refNamePatternInvalid = regexp.MustCompile(
+ `[\000-\037\177 \\~^:?*[]|` + // No absolutely invalid characters
+ `(?:^[/.])|` + // Not HasPrefix("/") or "."
+ `(?:/\.)|` + // no "/."
+ `(?:\.lock$)|(?:\.lock/)|` + // No ".lock/"" or ".lock" at the end
+ `(?:\.\.)|` + // no ".." anywhere
+ `(?://)|` + // no "//" anywhere
+ `(?:@{)|` + // no "@{"
+ `(?:[/.]$)|` + // no terminal '/' or '.'
+ `(?:^@$)`) // Not "@"
+
+// IsValidRefPattern ensures that the provided string could be a valid reference
+func IsValidRefPattern(name string) bool {
+ return !refNamePatternInvalid.MatchString(name)
+}
+
+func SanitizeRefPattern(name string) string {
+ return refNamePatternInvalid.ReplaceAllString(name, "_")
+}
+
+// Reference represents a Git ref.
+type Reference struct {
+ Name string
+ repo *Repository
+ Object ObjectID // The id of this commit object
+ Type string
+}
+
+// Commit return the commit of the reference
+func (ref *Reference) Commit() (*Commit, error) {
+ return ref.repo.getCommit(ref.Object)
+}
+
+// ShortName returns the short name of the reference
+func (ref *Reference) ShortName() string {
+ return RefName(ref.Name).ShortName()
+}
+
+// RefGroup returns the group type of the reference
+func (ref *Reference) RefGroup() string {
+ return RefName(ref.Name).RefGroup()
+}
+
+// ForPrefix special ref to create a pull request: refs/for/<target-branch>/<topic-branch>
+// or refs/for/<targe-branch> -o topic='<topic-branch>'
+const ForPrefix = "refs/for/"
+
+// TODO: /refs/for-review for suggest change interface
+
+// RefName represents a full git reference name
+type RefName string
+
+func RefNameFromBranch(shortName string) RefName {
+ return RefName(BranchPrefix + shortName)
+}
+
+func RefNameFromTag(shortName string) RefName {
+ return RefName(TagPrefix + shortName)
+}
+
+func (ref RefName) String() string {
+ return string(ref)
+}
+
+func (ref RefName) IsBranch() bool {
+ return strings.HasPrefix(string(ref), BranchPrefix)
+}
+
+func (ref RefName) IsTag() bool {
+ return strings.HasPrefix(string(ref), TagPrefix)
+}
+
+func (ref RefName) IsRemote() bool {
+ return strings.HasPrefix(string(ref), RemotePrefix)
+}
+
+func (ref RefName) IsPull() bool {
+ return strings.HasPrefix(string(ref), PullPrefix) && strings.IndexByte(string(ref)[len(PullPrefix):], '/') > -1
+}
+
+func (ref RefName) IsFor() bool {
+ return strings.HasPrefix(string(ref), ForPrefix)
+}
+
+func (ref RefName) nameWithoutPrefix(prefix string) string {
+ if strings.HasPrefix(string(ref), prefix) {
+ return strings.TrimPrefix(string(ref), prefix)
+ }
+ return ""
+}
+
+// TagName returns simple tag name if it's an operation to a tag
+func (ref RefName) TagName() string {
+ return ref.nameWithoutPrefix(TagPrefix)
+}
+
+// BranchName returns simple branch name if it's an operation to branch
+func (ref RefName) BranchName() string {
+ return ref.nameWithoutPrefix(BranchPrefix)
+}
+
+// PullName returns the pull request name part of refs like refs/pull/<pull_name>/head
+func (ref RefName) PullName() string {
+ refName := string(ref)
+ lastIdx := strings.LastIndexByte(refName[len(PullPrefix):], '/')
+ if strings.HasPrefix(refName, PullPrefix) && lastIdx > -1 {
+ return refName[len(PullPrefix) : lastIdx+len(PullPrefix)]
+ }
+ return ""
+}
+
+// ForBranchName returns the branch name part of refs like refs/for/<branch_name>
+func (ref RefName) ForBranchName() string {
+ return ref.nameWithoutPrefix(ForPrefix)
+}
+
+func (ref RefName) RemoteName() string {
+ return ref.nameWithoutPrefix(RemotePrefix)
+}
+
+// ShortName returns the short name of the reference name
+func (ref RefName) ShortName() string {
+ refName := string(ref)
+ if ref.IsBranch() {
+ return ref.BranchName()
+ }
+ if ref.IsTag() {
+ return ref.TagName()
+ }
+ if ref.IsRemote() {
+ return ref.RemoteName()
+ }
+ if ref.IsPull() {
+ return ref.PullName()
+ }
+ if ref.IsFor() {
+ return ref.ForBranchName()
+ }
+
+ return refName
+}
+
+// RefGroup returns the group type of the reference
+// Using the name of the directory under .git/refs
+func (ref RefName) RefGroup() string {
+ if ref.IsBranch() {
+ return "heads"
+ }
+ if ref.IsTag() {
+ return "tags"
+ }
+ if ref.IsRemote() {
+ return "remotes"
+ }
+ if ref.IsPull() {
+ return "pull"
+ }
+ if ref.IsFor() {
+ return "for"
+ }
+ return ""
+}
+
+// RefType returns the simple ref type of the reference, e.g. branch, tag
+// It's different from RefGroup, which is using the name of the directory under .git/refs
+// Here we using branch but not heads, using tag but not tags
+func (ref RefName) RefType() string {
+ var refType string
+ if ref.IsBranch() {
+ refType = "branch"
+ } else if ref.IsTag() {
+ refType = "tag"
+ }
+ return refType
+}
+
+// RefURL returns the absolute URL for a ref in a repository
+func RefURL(repoURL, ref string) string {
+ refFullName := RefName(ref)
+ refName := util.PathEscapeSegments(refFullName.ShortName())
+ switch {
+ case refFullName.IsBranch():
+ return repoURL + "/src/branch/" + refName
+ case refFullName.IsTag():
+ return repoURL + "/src/tag/" + refName
+ case !Sha1ObjectFormat.IsValid(ref):
+ // assume they mean a branch
+ return repoURL + "/src/branch/" + refName
+ default:
+ return repoURL + "/src/commit/" + refName
+ }
+}
diff --git a/modules/git/ref_test.go b/modules/git/ref_test.go
new file mode 100644
index 0000000..58f679b
--- /dev/null
+++ b/modules/git/ref_test.go
@@ -0,0 +1,38 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRefName(t *testing.T) {
+ // Test branch names (with and without slash).
+ assert.Equal(t, "foo", RefName("refs/heads/foo").BranchName())
+ assert.Equal(t, "feature/foo", RefName("refs/heads/feature/foo").BranchName())
+
+ // Test tag names (with and without slash).
+ assert.Equal(t, "foo", RefName("refs/tags/foo").TagName())
+ assert.Equal(t, "release/foo", RefName("refs/tags/release/foo").TagName())
+
+ // Test pull names
+ assert.Equal(t, "1", RefName("refs/pull/1/head").PullName())
+ assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").PullName())
+
+ // Test for branch names
+ assert.Equal(t, "main", RefName("refs/for/main").ForBranchName())
+ assert.Equal(t, "my/branch", RefName("refs/for/my/branch").ForBranchName())
+
+ // Test commit hashes.
+ assert.Equal(t, "c0ffee", RefName("c0ffee").ShortName())
+}
+
+func TestRefURL(t *testing.T) {
+ repoURL := "/user/repo"
+ assert.Equal(t, repoURL+"/src/branch/foo", RefURL(repoURL, "refs/heads/foo"))
+ assert.Equal(t, repoURL+"/src/tag/foo", RefURL(repoURL, "refs/tags/foo"))
+ assert.Equal(t, repoURL+"/src/commit/c0ffee", RefURL(repoURL, "c0ffee"))
+}
diff --git a/modules/git/remote.go b/modules/git/remote.go
new file mode 100644
index 0000000..3585313
--- /dev/null
+++ b/modules/git/remote.go
@@ -0,0 +1,39 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+
+ giturl "code.gitea.io/gitea/modules/git/url"
+)
+
+// GetRemoteAddress returns remote url of git repository in the repoPath with special remote name
+func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) {
+ var cmd *Command
+ if CheckGitVersionAtLeast("2.7") == nil {
+ cmd = NewCommand(ctx, "remote", "get-url").AddDynamicArguments(remoteName)
+ } else {
+ cmd = NewCommand(ctx, "config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
+ }
+
+ result, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return "", err
+ }
+
+ if len(result) > 0 {
+ result = result[:len(result)-1]
+ }
+ return result, nil
+}
+
+// GetRemoteURL returns the url of a specific remote of the repository.
+func GetRemoteURL(ctx context.Context, repoPath, remoteName string) (*giturl.GitURL, error) {
+ addr, err := GetRemoteAddress(ctx, repoPath, remoteName)
+ if err != nil {
+ return nil, err
+ }
+ return giturl.Parse(addr)
+}
diff --git a/modules/git/repo.go b/modules/git/repo.go
new file mode 100644
index 0000000..84db08d
--- /dev/null
+++ b/modules/git/repo.go
@@ -0,0 +1,342 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// GPGSettings represents the default GPG settings for this repository
+type GPGSettings struct {
+ Sign bool
+ KeyID string
+ Email string
+ Name string
+ PublicKeyContent string
+}
+
+const prettyLogFormat = `--pretty=format:%H`
+
+// GetAllCommitsCount returns count of all commits in repository
+func (repo *Repository) GetAllCommitsCount() (int64, error) {
+ return AllCommitsCount(repo.Ctx, repo.Path, false)
+}
+
+func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, error) {
+ var commits []*Commit
+ if len(logs) == 0 {
+ return commits, nil
+ }
+
+ parts := bytes.Split(logs, []byte{'\n'})
+
+ for _, commitID := range parts {
+ commit, err := repo.GetCommit(string(commitID))
+ if err != nil {
+ return nil, err
+ }
+ commits = append(commits, commit)
+ }
+
+ return commits, nil
+}
+
+// IsRepoURLAccessible checks if given repository URL is accessible.
+func IsRepoURLAccessible(ctx context.Context, url string) bool {
+ _, _, err := NewCommand(ctx, "ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(nil)
+ return err == nil
+}
+
+// InitRepository initializes a new Git repository.
+func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error {
+ err := os.MkdirAll(repoPath, os.ModePerm)
+ if err != nil {
+ return err
+ }
+
+ cmd := NewCommand(ctx, "init")
+
+ if !IsValidObjectFormat(objectFormatName) {
+ return fmt.Errorf("invalid object format: %s", objectFormatName)
+ }
+ if SupportHashSha256 {
+ cmd.AddOptionValues("--object-format", objectFormatName)
+ }
+
+ if bare {
+ cmd.AddArguments("--bare")
+ }
+ _, _, err = cmd.RunStdString(&RunOpts{Dir: repoPath})
+ return err
+}
+
+// IsEmpty Check if repository is empty.
+func (repo *Repository) IsEmpty() (bool, error) {
+ var errbuf, output strings.Builder
+ if err := NewCommand(repo.Ctx).AddOptionFormat("--git-dir=%s", repo.Path).AddArguments("rev-list", "-n", "1", "--all").
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: &output,
+ Stderr: &errbuf,
+ }); err != nil {
+ if (err.Error() == "exit status 1" && strings.TrimSpace(errbuf.String()) == "") || err.Error() == "exit status 129" {
+ // git 2.11 exits with 129 if the repo is empty
+ return true, nil
+ }
+ return true, fmt.Errorf("check empty: %w - %s", err, errbuf.String())
+ }
+
+ return strings.TrimSpace(output.String()) == "", nil
+}
+
+// CloneRepoOptions options when clone a repository
+type CloneRepoOptions struct {
+ Timeout time.Duration
+ Mirror bool
+ Bare bool
+ Quiet bool
+ Branch string
+ Shared bool
+ NoCheckout bool
+ Depth int
+ Filter string
+ SkipTLSVerify bool
+}
+
+// Clone clones original repository to target path.
+func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
+ return CloneWithArgs(ctx, globalCommandArgs, from, to, opts)
+}
+
+// CloneWithArgs original repository to target path.
+func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, opts CloneRepoOptions) (err error) {
+ toDir := path.Dir(to)
+ if err = os.MkdirAll(toDir, os.ModePerm); err != nil {
+ return err
+ }
+
+ cmd := NewCommandContextNoGlobals(ctx, args...).AddArguments("clone")
+ if opts.SkipTLSVerify {
+ cmd.AddArguments("-c", "http.sslVerify=false")
+ }
+ if opts.Mirror {
+ cmd.AddArguments("--mirror")
+ }
+ if opts.Bare {
+ cmd.AddArguments("--bare")
+ }
+ if opts.Quiet {
+ cmd.AddArguments("--quiet")
+ }
+ if opts.Shared {
+ cmd.AddArguments("-s")
+ }
+ if opts.NoCheckout {
+ cmd.AddArguments("--no-checkout")
+ }
+ if opts.Depth > 0 {
+ cmd.AddArguments("--depth").AddDynamicArguments(strconv.Itoa(opts.Depth))
+ }
+ if opts.Filter != "" {
+ cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
+ }
+ if len(opts.Branch) > 0 {
+ cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
+ }
+ cmd.AddDashesAndList(from, to)
+
+ if strings.Contains(from, "://") && strings.Contains(from, "@") {
+ cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, util.SanitizeCredentialURLs(from), to, opts.Shared, opts.Mirror, opts.Depth))
+ } else {
+ cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, from, to, opts.Shared, opts.Mirror, opts.Depth))
+ }
+
+ if opts.Timeout <= 0 {
+ opts.Timeout = -1
+ }
+
+ envs := os.Environ()
+ u, err := url.Parse(from)
+ if err == nil {
+ envs = proxy.EnvWithProxy(u)
+ }
+
+ stderr := new(bytes.Buffer)
+ if err = cmd.Run(&RunOpts{
+ Timeout: opts.Timeout,
+ Env: envs,
+ Stdout: io.Discard,
+ Stderr: stderr,
+ }); err != nil {
+ return ConcatenateError(err, stderr.String())
+ }
+ return nil
+}
+
+// PushOptions options when push to remote
+type PushOptions struct {
+ Remote string
+ Branch string
+ Force bool
+ Mirror bool
+ Env []string
+ Timeout time.Duration
+ PrivateKeyPath string
+}
+
+// Push pushs local commits to given remote branch.
+func Push(ctx context.Context, repoPath string, opts PushOptions) error {
+ cmd := NewCommand(ctx, "push")
+
+ if opts.PrivateKeyPath != "" {
+ // Preserve the behavior that existing environments are used if no
+ // environments are passed.
+ if len(opts.Env) == 0 {
+ opts.Env = os.Environ()
+ }
+
+ // Use environment because it takes precedence over using -c core.sshcommand
+ // and it's possible that a system might have an existing GIT_SSH_COMMAND
+ // environment set.
+ opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
+ fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
+ " -o IdentitiesOnly=yes"+
+ // This will store new SSH host keys and verify connections to existing
+ // host keys, but it doesn't allow replacement of existing host keys. This
+ // means TOFU is used for Git over SSH pushes.
+ " -o StrictHostKeyChecking=accept-new"+
+ " -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
+ }
+
+ if opts.Force {
+ cmd.AddArguments("-f")
+ }
+ if opts.Mirror {
+ cmd.AddArguments("--mirror")
+ }
+ remoteBranchArgs := []string{opts.Remote}
+ if len(opts.Branch) > 0 {
+ remoteBranchArgs = append(remoteBranchArgs, opts.Branch)
+ }
+ cmd.AddDashesAndList(remoteBranchArgs...)
+
+ if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") {
+ cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, util.SanitizeCredentialURLs(opts.Remote), opts.Force, opts.Mirror))
+ } else {
+ cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror))
+ }
+
+ stdout, stderr, err := cmd.RunStdString(&RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
+ if err != nil {
+ if strings.Contains(stderr, "non-fast-forward") {
+ return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
+ } else if strings.Contains(stderr, "! [remote rejected]") {
+ err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err}
+ err.GenerateMessage()
+ return err
+ } else if strings.Contains(stderr, "matches more than one") {
+ return &ErrMoreThanOne{StdOut: stdout, StdErr: stderr, Err: err}
+ }
+ return fmt.Errorf("push failed: %w - %s\n%s", err, stderr, stdout)
+ }
+
+ return nil
+}
+
+// GetLatestCommitTime returns time for latest commit in repository (across all branches)
+func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) {
+ cmd := NewCommand(ctx, "for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return time.Time{}, err
+ }
+ commitTime := strings.TrimSpace(stdout)
+ return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
+}
+
+// DivergeObject represents commit count diverging commits
+type DivergeObject struct {
+ Ahead int
+ Behind int
+}
+
+// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch
+func GetDivergingCommits(ctx context.Context, repoPath, baseBranch, targetBranch string) (do DivergeObject, err error) {
+ cmd := NewCommand(ctx, "rev-list", "--count", "--left-right").
+ AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--")
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return do, err
+ }
+ left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t")
+ if !found {
+ return do, fmt.Errorf("git rev-list output is missing a tab: %q", stdout)
+ }
+
+ do.Behind, err = strconv.Atoi(left)
+ if err != nil {
+ return do, err
+ }
+ do.Ahead, err = strconv.Atoi(right)
+ if err != nil {
+ return do, err
+ }
+ return do, nil
+}
+
+// CreateBundle create bundle content to the target path
+func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error {
+ tmp, err := os.MkdirTemp(os.TempDir(), "gitea-bundle")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+
+ env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects"))
+ _, _, err = NewCommand(ctx, "init", "--bare").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ _, _, err = NewCommand(ctx, "reset", "--soft").AddDynamicArguments(commit).RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ _, _, err = NewCommand(ctx, "branch", "-m", "bundle").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ tmpFile := filepath.Join(tmp, "bundle")
+ _, _, err = NewCommand(ctx, "bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").RunStdString(&RunOpts{Dir: tmp, Env: env})
+ if err != nil {
+ return err
+ }
+
+ fi, err := os.Open(tmpFile)
+ if err != nil {
+ return err
+ }
+ defer fi.Close()
+
+ _, err = io.Copy(out, fi)
+ return err
+}
diff --git a/modules/git/repo_archive.go b/modules/git/repo_archive.go
new file mode 100644
index 0000000..1bf1aa4
--- /dev/null
+++ b/modules/git/repo_archive.go
@@ -0,0 +1,80 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ArchiveType archive types
+type ArchiveType int
+
+const (
+ // ZIP zip archive type
+ ZIP ArchiveType = iota + 1
+ // TARGZ tar gz archive type
+ TARGZ
+ // BUNDLE bundle archive type
+ BUNDLE
+)
+
+// String converts an ArchiveType to string
+func (a ArchiveType) String() string {
+ switch a {
+ case ZIP:
+ return "zip"
+ case TARGZ:
+ return "tar.gz"
+ case BUNDLE:
+ return "bundle"
+ }
+ return "unknown"
+}
+
+func ToArchiveType(s string) ArchiveType {
+ switch s {
+ case "zip":
+ return ZIP
+ case "tar.gz":
+ return TARGZ
+ case "bundle":
+ return BUNDLE
+ }
+ return 0
+}
+
+// CreateArchive create archive content to the target path
+func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, target io.Writer, usePrefix bool, commitID string) error {
+ if format.String() == "unknown" {
+ return fmt.Errorf("unknown format: %v", format)
+ }
+
+ cmd := NewCommand(ctx, "archive")
+ if usePrefix {
+ cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/")
+ }
+ cmd.AddOptionFormat("--format=%s", format.String())
+ cmd.AddDynamicArguments(commitID)
+
+ // Avoid LFS hooks getting installed because of /etc/gitconfig, which can break pull requests.
+ env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
+
+ var stderr strings.Builder
+ err := cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: target,
+ Stderr: &stderr,
+ Env: env,
+ })
+ if err != nil {
+ return ConcatenateError(err, stderr.String())
+ }
+ return nil
+}
diff --git a/modules/git/repo_attribute.go b/modules/git/repo_attribute.go
new file mode 100644
index 0000000..3ccc1b8
--- /dev/null
+++ b/modules/git/repo_attribute.go
@@ -0,0 +1,286 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync/atomic"
+
+ "code.gitea.io/gitea/modules/optional"
+)
+
+var LinguistAttributes = []string{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language", "linguist-documentation", "linguist-detectable"}
+
+// newCheckAttrStdoutReader parses the nul-byte separated output of git check-attr on each call of
+// the returned function. The first reading error will stop the reading and be returned on all
+// subsequent calls.
+func newCheckAttrStdoutReader(r io.Reader, count int) func() (map[string]GitAttribute, error) {
+ scanner := bufio.NewScanner(r)
+
+ // adapted from bufio.ScanLines to split on nul-byte \x00
+ scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ if atEOF && len(data) == 0 {
+ return 0, nil, nil
+ }
+ if i := bytes.IndexByte(data, '\x00'); i >= 0 {
+ // We have a full nul-terminated line.
+ return i + 1, data[0:i], nil
+ }
+ // If we're at EOF, we have a final, non-terminated line. Return it.
+ if atEOF {
+ return len(data), data, nil
+ }
+ // Request more data.
+ return 0, nil, nil
+ })
+
+ var err error
+ nextText := func() string {
+ if err != nil {
+ return ""
+ }
+ if !scanner.Scan() {
+ err = scanner.Err()
+ if err == nil {
+ err = io.ErrUnexpectedEOF
+ }
+ return ""
+ }
+ return scanner.Text()
+ }
+ nextAttribute := func() (string, GitAttribute, error) {
+ nextText() // discard filename
+ key := nextText()
+ value := GitAttribute(nextText())
+ return key, value, err
+ }
+ return func() (map[string]GitAttribute, error) {
+ values := make(map[string]GitAttribute, count)
+ for range count {
+ k, v, err := nextAttribute()
+ if err != nil {
+ return values, err
+ }
+ values[k] = v
+ }
+ return values, scanner.Err()
+ }
+}
+
+// GitAttribute exposes an attribute from the .gitattribute file
+type GitAttribute string //nolint:revive
+
+// IsSpecified returns true if the gitattribute is set and not empty
+func (ca GitAttribute) IsSpecified() bool {
+ return ca != "" && ca != "unspecified"
+}
+
+// String returns the value of the attribute or "" if unspecified
+func (ca GitAttribute) String() string {
+ if !ca.IsSpecified() {
+ return ""
+ }
+ return string(ca)
+}
+
+// Prefix returns the value of the attribute before any question mark '?'
+//
+// sometimes used within gitlab-language: https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
+func (ca GitAttribute) Prefix() string {
+ s := ca.String()
+ if i := strings.IndexByte(s, '?'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+// Bool returns true if "set"/"true", false if "unset"/"false", none otherwise
+func (ca GitAttribute) Bool() optional.Option[bool] {
+ switch ca {
+ case "set", "true":
+ return optional.Some(true)
+ case "unset", "false":
+ return optional.Some(false)
+ }
+ return optional.None[bool]()
+}
+
+// gitCheckAttrCommand prepares the "git check-attr" command for later use as one-shot or streaming
+// instantiation.
+func (repo *Repository) gitCheckAttrCommand(treeish string, attributes ...string) (*Command, *RunOpts, context.CancelFunc, error) {
+ if len(attributes) == 0 {
+ return nil, nil, nil, fmt.Errorf("no provided attributes to check-attr")
+ }
+
+ env := os.Environ()
+ var removeTempFiles context.CancelFunc = func() {}
+
+ // git < 2.40 cannot run check-attr on bare repo, but needs INDEX + WORK_TREE
+ hasIndex := treeish == ""
+ if !hasIndex && !SupportCheckAttrOnBare {
+ indexFilename, worktree, cancel, err := repo.ReadTreeToTemporaryIndex(treeish)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ removeTempFiles = cancel
+
+ env = append(env, "GIT_INDEX_FILE="+indexFilename, "GIT_WORK_TREE="+worktree)
+
+ hasIndex = true
+
+ // clear treeish to read from provided index/work_tree
+ treeish = ""
+ }
+
+ cmd := NewCommand(repo.Ctx, "check-attr", "-z")
+
+ if hasIndex {
+ cmd.AddArguments("--cached")
+ }
+
+ if len(treeish) > 0 {
+ cmd.AddArguments("--source")
+ cmd.AddDynamicArguments(treeish)
+ }
+ cmd.AddDynamicArguments(attributes...)
+
+ // Version 2.43.1 has a bug where the behavior of `GIT_FLUSH` is flipped.
+ // Ref: https://lore.kernel.org/git/CABn0oJvg3M_kBW-u=j3QhKnO=6QOzk-YFTgonYw_UvFS1NTX4g@mail.gmail.com
+ if InvertedGitFlushEnv {
+ env = append(env, "GIT_FLUSH=0")
+ } else {
+ env = append(env, "GIT_FLUSH=1")
+ }
+
+ return cmd, &RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ }, removeTempFiles, nil
+}
+
+// GitAttributeFirst returns the first specified attribute of the given filename.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributeFirst(treeish, filename string, attributes ...string) (GitAttribute, error) {
+ values, err := repo.GitAttributes(treeish, filename, attributes...)
+ if err != nil {
+ return "", err
+ }
+ for _, a := range attributes {
+ if values[a].IsSpecified() {
+ return values[a], nil
+ }
+ }
+ return "", nil
+}
+
+// GitAttributes returns the gitattribute of the given filename.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributes(treeish, filename string, attributes ...string) (map[string]GitAttribute, error) {
+ cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...)
+ if err != nil {
+ return nil, err
+ }
+ defer removeTempFiles()
+
+ stdOut := new(bytes.Buffer)
+ runOpts.Stdout = stdOut
+
+ stdErr := new(bytes.Buffer)
+ runOpts.Stderr = stdErr
+
+ cmd.AddDashesAndList(filename)
+
+ if err := cmd.Run(runOpts); err != nil {
+ return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String())
+ }
+
+ return newCheckAttrStdoutReader(stdOut, len(attributes))()
+}
+
+// GitAttributeChecker creates an AttributeChecker for the given repository and provided commit ID
+// to retrieve the attributes of multiple files. The AttributeChecker must be closed after use.
+//
+// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare).
+func (repo *Repository) GitAttributeChecker(treeish string, attributes ...string) (AttributeChecker, error) {
+ cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...)
+ if err != nil {
+ return AttributeChecker{}, err
+ }
+
+ cmd.AddArguments("--stdin")
+
+ // os.Pipe is needed (and not io.Pipe), otherwise cmd.Wait will wait for the stdinReader
+ // to be closed before returning (which would require another goroutine)
+ // https://go.dev/issue/23019
+ stdinReader, stdinWriter, err := os.Pipe() // reader closed in goroutine / writer closed on ac.Close
+ if err != nil {
+ return AttributeChecker{}, err
+ }
+ stdoutReader, stdoutWriter := io.Pipe() // closed in goroutine
+
+ ac := AttributeChecker{
+ removeTempFiles: removeTempFiles, // called on ac.Close
+ stdinWriter: stdinWriter,
+ readStdout: newCheckAttrStdoutReader(stdoutReader, len(attributes)),
+ err: &atomic.Value{},
+ }
+
+ go func() {
+ defer stdinReader.Close()
+ defer stdoutWriter.Close() // in case of a panic (no-op if already closed by CloseWithError at the end)
+
+ stdErr := new(bytes.Buffer)
+ runOpts.Stdin = stdinReader
+ runOpts.Stdout = stdoutWriter
+ runOpts.Stderr = stdErr
+
+ err := cmd.Run(runOpts)
+
+ // if the context was cancelled, Run error is irrelevant
+ if e := cmd.parentContext.Err(); e != nil {
+ err = e
+ }
+
+ if err != nil { // decorate the returned error
+ err = fmt.Errorf("git check-attr (stderr: %q): %w", strings.TrimSpace(stdErr.String()), err)
+ ac.err.Store(err)
+ }
+ stdoutWriter.CloseWithError(err)
+ }()
+
+ return ac, nil
+}
+
+type AttributeChecker struct {
+ removeTempFiles context.CancelFunc
+ stdinWriter io.WriteCloser
+ readStdout func() (map[string]GitAttribute, error)
+ err *atomic.Value
+}
+
+func (ac AttributeChecker) CheckPath(path string) (map[string]GitAttribute, error) {
+ if _, err := ac.stdinWriter.Write([]byte(path + "\x00")); err != nil {
+ // try to return the Run error if available, since it is likely more helpful
+ // than just "broken pipe"
+ if aerr, _ := ac.err.Load().(error); aerr != nil {
+ return nil, aerr
+ }
+ return nil, fmt.Errorf("git check-attr: %w", err)
+ }
+
+ return ac.readStdout()
+}
+
+func (ac AttributeChecker) Close() error {
+ ac.removeTempFiles()
+ return ac.stdinWriter.Close()
+}
diff --git a/modules/git/repo_attribute_test.go b/modules/git/repo_attribute_test.go
new file mode 100644
index 0000000..8b832e7
--- /dev/null
+++ b/modules/git/repo_attribute_test.go
@@ -0,0 +1,351 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewCheckAttrStdoutReader(t *testing.T) {
+ t.Run("two_times", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ ".gitignore\x00linguist-vendored\x00unspecified\x00"+
+ ".gitignore\x00linguist-vendored\x00specified",
+ ), 1)
+
+ // first read
+ attr, err := read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("unspecified"),
+ }, attr)
+
+ // second read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("specified"),
+ }, attr)
+ })
+ t.Run("incomplete", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ "filename\x00linguist-vendored",
+ ), 1)
+
+ _, err := read()
+ assert.Equal(t, io.ErrUnexpectedEOF, err)
+ })
+ t.Run("three_times", func(t *testing.T) {
+ read := newCheckAttrStdoutReader(strings.NewReader(
+ "shouldbe.vendor\x00linguist-vendored\x00set\x00"+
+ "shouldbe.vendor\x00linguist-generated\x00unspecified\x00"+
+ "shouldbe.vendor\x00linguist-language\x00unspecified\x00",
+ ), 1)
+
+ // first read
+ attr, err := read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-vendored": GitAttribute("set"),
+ }, attr)
+
+ // second read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-generated": GitAttribute("unspecified"),
+ }, attr)
+
+ // third read
+ attr, err = read()
+ require.NoError(t, err)
+ assert.Equal(t, map[string]GitAttribute{
+ "linguist-language": GitAttribute("unspecified"),
+ }, attr)
+ })
+}
+
+func TestGitAttributeBareNonBare(t *testing.T) {
+ if !SupportCheckAttrOnBare {
+ t.Skip("git check-attr supported on bare repo starting with git 2.40")
+ }
+
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ for _, commitID := range []string{
+ "8fee858da5796dfb37704761701bb8e800ad9ef3",
+ "341fca5b5ea3de596dc483e54c2db28633cd2f97",
+ } {
+ bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+
+ defer test.MockVariableValue(&SupportCheckAttrOnBare, false)()
+ cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, cloneStats, bareStats)
+ refStats := cloneStats
+
+ t.Run("GitAttributeChecker/"+commitID+"/SupportBare", func(t *testing.T) {
+ bareChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...)
+ require.NoError(t, err)
+ defer bareChecker.Close()
+
+ bareStats, err := bareChecker.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.EqualValues(t, refStats, bareStats)
+ })
+ t.Run("GitAttributeChecker/"+commitID+"/NoBareSupport", func(t *testing.T) {
+ defer test.MockVariableValue(&SupportCheckAttrOnBare, false)()
+ cloneChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...)
+ require.NoError(t, err)
+ defer cloneChecker.Close()
+
+ cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+
+ assert.EqualValues(t, refStats, cloneStats)
+ })
+ }
+}
+
+func TestGitAttributes(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ attr, err := gitRepo.GitAttributes("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+ assert.EqualValues(t, map[string]GitAttribute{
+ "gitlab-language": "unspecified",
+ "linguist-detectable": "unspecified",
+ "linguist-documentation": "unspecified",
+ "linguist-generated": "unspecified",
+ "linguist-language": "Python",
+ "linguist-vendored": "unspecified",
+ }, attr)
+
+ attr, err = gitRepo.GitAttributes("341fca5b5ea3de596dc483e54c2db28633cd2f97", "i-am-a-python.p", LinguistAttributes...)
+ require.NoError(t, err)
+ assert.EqualValues(t, map[string]GitAttribute{
+ "gitlab-language": "unspecified",
+ "linguist-detectable": "unspecified",
+ "linguist-documentation": "unspecified",
+ "linguist-generated": "unspecified",
+ "linguist-language": "Cobra",
+ "linguist-vendored": "unspecified",
+ }, attr)
+}
+
+func TestGitAttributeFirst(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ t.Run("first is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-language", "gitlab-language")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", language.String())
+ })
+
+ t.Run("second is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "gitlab-language", "linguist-language")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", language.String())
+ })
+
+ t.Run("none is specified", func(t *testing.T) {
+ language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-detectable", "gitlab-language", "non-existing")
+ require.NoError(t, err)
+ assert.Equal(t, "", language.String())
+ })
+}
+
+func TestGitAttributeStruct(t *testing.T) {
+ assert.Equal(t, "", GitAttribute("").String())
+ assert.Equal(t, "", GitAttribute("unspecified").String())
+
+ assert.Equal(t, "python", GitAttribute("python").String())
+
+ assert.Equal(t, "text?token=Error", GitAttribute("text?token=Error").String())
+ assert.Equal(t, "text", GitAttribute("text?token=Error").Prefix())
+}
+
+func TestGitAttributeCheckerError(t *testing.T) {
+ prepareRepo := func(t *testing.T) *Repository {
+ t.Helper()
+ path := t.TempDir()
+
+ // we can't use unittest.CopyDir because of an import cycle (git.Init in unittest)
+ require.NoError(t, CopyFS(path, os.DirFS(filepath.Join(testReposDir, "language_stats_repo"))))
+
+ gitRepo, err := openRepositoryWithDefaultContext(path)
+ require.NoError(t, err)
+ return gitRepo
+ }
+
+ t.Run("RemoveAll/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ require.NoError(t, os.RemoveAll(gitRepo.Path))
+
+ ac, err := gitRepo.GitAttributeChecker("", "linguist-language")
+ require.NoError(t, err)
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `git check-attr (stderr: ""):`)
+ })
+
+ t.Run("RemoveAll/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("", "linguist-language")
+ require.NoError(t, err)
+
+ // calling CheckPath before would allow git to cache part of it and successfully return later
+ require.NoError(t, os.RemoveAll(gitRepo.Path))
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ if err == nil {
+ t.Skip(
+ "git check-attr started too fast and CheckPath was successful (and likely cached)",
+ "https://codeberg.org/forgejo/forgejo/issues/2948",
+ )
+ }
+ // Depending on the order of execution, the returned error can be:
+ // - a launch error "fork/exec /usr/bin/git: no such file or directory" (when the removal happens before the Run)
+ // - a git error (stderr: "fatal: Unable to read current working directory: No such file or directory"): exit status 128 (when the removal happens after the Run)
+ // (pipe error "write |1: broken pipe" should be replaced by one of the Run errors above)
+ assert.Contains(t, err.Error(), `git check-attr`)
+ })
+
+ t.Run("Cancelled/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ var cancel context.CancelFunc
+ gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx)
+ cancel()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.Error(t, err)
+ })
+
+ t.Run("Cancelled/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ var cancel context.CancelFunc
+ gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx)
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ attr, err := ac.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", attr["linguist-language"].String())
+
+ errCh := make(chan error)
+ go func() {
+ cancel()
+
+ for err == nil {
+ _, err = ac.CheckPath("i-am-a-python.p")
+ runtime.Gosched() // the cancellation must have time to propagate
+ }
+ errCh <- err
+ }()
+
+ select {
+ case <-time.After(time.Second):
+ t.Error("CheckPath did not complete within 1s")
+ case err = <-errCh:
+ require.ErrorIs(t, err, context.Canceled)
+ }
+ })
+
+ t.Run("Closed/BeforeRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ require.NoError(t, ac.Close())
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.ErrorIs(t, err, fs.ErrClosed)
+ })
+
+ t.Run("Closed/DuringRun", func(t *testing.T) {
+ gitRepo := prepareRepo(t)
+ defer gitRepo.Close()
+
+ ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language")
+ require.NoError(t, err)
+
+ attr, err := ac.CheckPath("i-am-a-python.p")
+ require.NoError(t, err)
+ assert.Equal(t, "Python", attr["linguist-language"].String())
+
+ require.NoError(t, ac.Close())
+
+ _, err = ac.CheckPath("i-am-a-python.p")
+ require.ErrorIs(t, err, fs.ErrClosed)
+ })
+}
+
+// CopyFS is adapted from https://github.com/golang/go/issues/62484
+// which should be available with go1.23
+func CopyFS(dir string, fsys fs.FS) error {
+ return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error {
+ targ := filepath.Join(dir, filepath.FromSlash(path))
+ if d.IsDir() {
+ return os.MkdirAll(targ, 0o777)
+ }
+ r, err := fsys.Open(path)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ info, err := r.Stat()
+ if err != nil {
+ return err
+ }
+ w, err := os.OpenFile(targ, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(w, r); err != nil {
+ w.Close()
+ return fmt.Errorf("copying %s: %v", path, err)
+ }
+ return w.Close()
+ })
+}
diff --git a/modules/git/repo_base.go b/modules/git/repo_base.go
new file mode 100644
index 0000000..5f17bc1
--- /dev/null
+++ b/modules/git/repo_base.go
@@ -0,0 +1,124 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "path/filepath"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Repository represents a Git repository.
+type Repository struct {
+ Path string
+
+ tagCache *ObjectCache
+
+ gpgSettings *GPGSettings
+
+ batchInUse bool
+ batch *Batch
+
+ checkInUse bool
+ check *Batch
+
+ Ctx context.Context
+ LastCommitCache *LastCommitCache
+
+ objectFormat ObjectFormat
+}
+
+// openRepositoryWithDefaultContext opens the repository at the given path with DefaultContext.
+func openRepositoryWithDefaultContext(repoPath string) (*Repository, error) {
+ return OpenRepository(DefaultContext, repoPath)
+}
+
+// OpenRepository opens the repository at the given path with the provided context.
+func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
+ repoPath, err := filepath.Abs(repoPath)
+ if err != nil {
+ return nil, err
+ } else if !isDir(repoPath) {
+ return nil, errors.New("no such file or directory")
+ }
+
+ return &Repository{
+ Path: repoPath,
+ tagCache: newObjectCache(),
+ Ctx: ctx,
+ }, nil
+}
+
+// CatFileBatch obtains a CatFileBatch for this repository
+func (repo *Repository) CatFileBatch(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
+ if repo.batch == nil {
+ var err error
+ repo.batch, err = repo.NewBatch(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ }
+
+ if !repo.batchInUse {
+ repo.batchInUse = true
+ return repo.batch.Writer, repo.batch.Reader, func() {
+ repo.batchInUse = false
+ }, nil
+ }
+
+ log.Debug("Opening temporary cat file batch for: %s", repo.Path)
+ tempBatch, err := repo.NewBatch(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return tempBatch.Writer, tempBatch.Reader, tempBatch.Close, nil
+}
+
+// CatFileBatchCheck obtains a CatFileBatchCheck for this repository
+func (repo *Repository) CatFileBatchCheck(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
+ if repo.check == nil {
+ var err error
+ repo.check, err = repo.NewBatchCheck(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ }
+
+ if !repo.checkInUse {
+ repo.checkInUse = true
+ return repo.check.Writer, repo.check.Reader, func() {
+ repo.checkInUse = false
+ }, nil
+ }
+
+ log.Debug("Opening temporary cat file batch-check for: %s", repo.Path)
+ tempBatchCheck, err := repo.NewBatchCheck(ctx)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.Close, nil
+}
+
+func (repo *Repository) Close() error {
+ if repo == nil {
+ return nil
+ }
+ if repo.batch != nil {
+ repo.batch.Close()
+ repo.batch = nil
+ repo.batchInUse = false
+ }
+ if repo.check != nil {
+ repo.check.Close()
+ repo.check = nil
+ repo.checkInUse = false
+ }
+ repo.LastCommitCache = nil
+ repo.tagCache = nil
+ return nil
+}
diff --git a/modules/git/repo_base_test.go b/modules/git/repo_base_test.go
new file mode 100644
index 0000000..323b28f
--- /dev/null
+++ b/modules/git/repo_base_test.go
@@ -0,0 +1,163 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// This unit test relies on the implementation detail of CatFileBatch.
+func TestCatFileBatch(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ var wr WriteCloserError
+ var r *bufio.Reader
+ var cancel1 func()
+ t.Run("Request cat file batch", func(t *testing.T) {
+ assert.Nil(t, repo.batch)
+ wr, r, cancel1, err = repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.batch)
+ assert.Equal(t, repo.batch.Writer, wr)
+ assert.True(t, repo.batchInUse)
+ })
+
+ t.Run("Request temporary cat file batch", func(t *testing.T) {
+ wr, r, cancel, err := repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotEqual(t, repo.batch.Writer, wr)
+
+ t.Run("Check temporary cat file batch", func(t *testing.T) {
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "commit", typ)
+ assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha)
+ assert.EqualValues(t, 144, size)
+ })
+
+ cancel()
+ assert.True(t, repo.batchInUse)
+ })
+
+ t.Run("Check cached cat file batch", func(t *testing.T) {
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "commit", typ)
+ assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha)
+ assert.EqualValues(t, 144, size)
+ })
+
+ t.Run("Cancel cached cat file batch", func(t *testing.T) {
+ cancel1()
+ assert.False(t, repo.batchInUse)
+ assert.NotNil(t, repo.batch)
+ })
+
+ t.Run("Request cached cat file batch", func(t *testing.T) {
+ wr, _, _, err := repo.CatFileBatch(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.batch)
+ assert.Equal(t, repo.batch.Writer, wr)
+ assert.True(t, repo.batchInUse)
+
+ t.Run("Close git repo", func(t *testing.T) {
+ require.NoError(t, repo.Close())
+ assert.Nil(t, repo.batch)
+ })
+
+ _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n"))
+ require.Error(t, err)
+ })
+}
+
+// This unit test relies on the implementation detail of CatFileBatchCheck.
+func TestCatFileBatchCheck(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare")
+ require.NoError(t, err)
+ defer repo.Close()
+
+ var wr WriteCloserError
+ var r *bufio.Reader
+ var cancel1 func()
+ t.Run("Request cat file batch check", func(t *testing.T) {
+ assert.Nil(t, repo.check)
+ wr, r, cancel1, err = repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.check)
+ assert.Equal(t, repo.check.Writer, wr)
+ assert.True(t, repo.checkInUse)
+ })
+
+ t.Run("Request temporary cat file batch check", func(t *testing.T) {
+ wr, r, cancel, err := repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotEqual(t, repo.check.Writer, wr)
+
+ t.Run("Check temporary cat file batch check", func(t *testing.T) {
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "tag", typ)
+ assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha)
+ assert.EqualValues(t, 807, size)
+ })
+
+ cancel()
+ assert.True(t, repo.checkInUse)
+ })
+
+ t.Run("Check cached cat file batch check", func(t *testing.T) {
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.NoError(t, err)
+
+ sha, typ, size, err := ReadBatchLine(r)
+ require.NoError(t, err)
+ assert.Equal(t, "tag", typ)
+ assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha)
+ assert.EqualValues(t, 807, size)
+ })
+
+ t.Run("Cancel cached cat file batch check", func(t *testing.T) {
+ cancel1()
+ assert.False(t, repo.checkInUse)
+ assert.NotNil(t, repo.check)
+ })
+
+ t.Run("Request cached cat file batch check", func(t *testing.T) {
+ wr, _, _, err := repo.CatFileBatchCheck(ctx)
+ require.NoError(t, err)
+ assert.NotNil(t, repo.check)
+ assert.Equal(t, repo.check.Writer, wr)
+ assert.True(t, repo.checkInUse)
+
+ t.Run("Close git repo", func(t *testing.T) {
+ require.NoError(t, repo.Close())
+ assert.Nil(t, repo.check)
+ })
+
+ _, err = wr.Write([]byte("test" + "\n"))
+ require.Error(t, err)
+ })
+}
diff --git a/modules/git/repo_blame.go b/modules/git/repo_blame.go
new file mode 100644
index 0000000..139cdd7
--- /dev/null
+++ b/modules/git/repo_blame.go
@@ -0,0 +1,23 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+)
+
+// LineBlame returns the latest commit at the given line
+func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) {
+ res, _, err := NewCommand(repo.Ctx, "blame").
+ AddOptionFormat("-L %d,%d", line, line).
+ AddOptionValues("-p", revision).
+ AddDashesAndList(file).RunStdString(&RunOpts{Dir: path})
+ if err != nil {
+ return nil, err
+ }
+ if len(res) < 40 {
+ return nil, fmt.Errorf("invalid result of blame: %s", res)
+ }
+ return repo.GetCommit(res[:40])
+}
diff --git a/modules/git/repo_blob_test.go b/modules/git/repo_blob_test.go
new file mode 100644
index 0000000..b018479
--- /dev/null
+++ b/modules/git/repo_blob_test.go
@@ -0,0 +1,70 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetBlob_Found(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCases := []struct {
+ OID string
+ Data []byte
+ }{
+ {"e2129701f1a4d54dc44f03c93bca0a2aec7c5449", []byte("file1\n")},
+ {"6c493ff740f9380390d5c9ddef4af18697ac9375", []byte("file2\n")},
+ }
+
+ for _, testCase := range testCases {
+ blob, err := r.GetBlob(testCase.OID)
+ require.NoError(t, err)
+
+ dataReader, err := blob.DataAsync()
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(dataReader)
+ require.NoError(t, dataReader.Close())
+ require.NoError(t, err)
+ assert.Equal(t, testCase.Data, data)
+ }
+}
+
+func TestRepository_GetBlob_NotExist(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCase := "0000000000000000000000000000000000000000"
+ testError := ErrNotExist{testCase, ""}
+
+ blob, err := r.GetBlob(testCase)
+ assert.Nil(t, blob)
+ assert.EqualError(t, err, testError.Error())
+}
+
+func TestRepository_GetBlob_NoId(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "repo1_bare")
+ r, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+ defer r.Close()
+
+ testCase := ""
+ testError := fmt.Errorf("length %d has no matched object format: %s", len(testCase), testCase)
+
+ blob, err := r.GetBlob(testCase)
+ assert.Nil(t, blob)
+ assert.EqualError(t, err, testError.Error())
+}
diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go
new file mode 100644
index 0000000..7339c7d
--- /dev/null
+++ b/modules/git/repo_branch.go
@@ -0,0 +1,349 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// BranchPrefix base dir of the branch information file store on git
+const BranchPrefix = "refs/heads/"
+
+// IsReferenceExist returns true if given reference exists in the repository.
+func IsReferenceExist(ctx context.Context, repoPath, name string) bool {
+ _, _, err := NewCommand(ctx, "show-ref", "--verify").AddDashesAndList(name).RunStdString(&RunOpts{Dir: repoPath})
+ return err == nil
+}
+
+// IsBranchExist returns true if given branch exists in the repository.
+func IsBranchExist(ctx context.Context, repoPath, name string) bool {
+ return IsReferenceExist(ctx, repoPath, BranchPrefix+name)
+}
+
+// Branch represents a Git branch.
+type Branch struct {
+ Name string
+ Path string
+
+ gitRepo *Repository
+}
+
+// GetHEADBranch returns corresponding branch of HEAD.
+func (repo *Repository) GetHEADBranch() (*Branch, error) {
+ if repo == nil {
+ return nil, fmt.Errorf("nil repo")
+ }
+ stdout, _, err := NewCommand(repo.Ctx, "symbolic-ref", "HEAD").RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ stdout = strings.TrimSpace(stdout)
+
+ if !strings.HasPrefix(stdout, BranchPrefix) {
+ return nil, fmt.Errorf("invalid HEAD branch: %v", stdout)
+ }
+
+ return &Branch{
+ Name: stdout[len(BranchPrefix):],
+ Path: stdout,
+ gitRepo: repo,
+ }, nil
+}
+
+func GetDefaultBranch(ctx context.Context, repoPath string) (string, error) {
+ stdout, _, err := NewCommand(ctx, "symbolic-ref", "HEAD").RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return "", err
+ }
+ stdout = strings.TrimSpace(stdout)
+ if !strings.HasPrefix(stdout, BranchPrefix) {
+ return "", errors.New("the HEAD is not a branch: " + stdout)
+ }
+ return strings.TrimPrefix(stdout, BranchPrefix), nil
+}
+
+// GetBranch returns a branch by it's name
+func (repo *Repository) GetBranch(branch string) (*Branch, error) {
+ if !repo.IsBranchExist(branch) {
+ return nil, ErrBranchNotExist{branch}
+ }
+ return &Branch{
+ Path: repo.Path,
+ Name: branch,
+ gitRepo: repo,
+ }, nil
+}
+
+// GetBranches returns a slice of *git.Branch
+func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) {
+ brs, countAll, err := repo.GetBranchNames(skip, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ branches := make([]*Branch, len(brs))
+ for i := range brs {
+ branches[i] = &Branch{
+ Path: repo.Path,
+ Name: brs[i],
+ gitRepo: repo,
+ }
+ }
+
+ return branches, countAll, nil
+}
+
+// DeleteBranchOptions Option(s) for delete branch
+type DeleteBranchOptions struct {
+ Force bool
+}
+
+// DeleteBranch delete a branch by name on repository.
+func (repo *Repository) DeleteBranch(name string, opts DeleteBranchOptions) error {
+ cmd := NewCommand(repo.Ctx, "branch")
+
+ if opts.Force {
+ cmd.AddArguments("-D")
+ } else {
+ cmd.AddArguments("-d")
+ }
+
+ cmd.AddDashesAndList(name)
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+
+ return err
+}
+
+// CreateBranch create a new branch
+func (repo *Repository) CreateBranch(branch, oldbranchOrCommit string) error {
+ cmd := NewCommand(repo.Ctx, "branch")
+ cmd.AddDashesAndList(branch, oldbranchOrCommit)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+
+ return err
+}
+
+// AddRemote adds a new remote to repository.
+func (repo *Repository) AddRemote(name, url string, fetch bool) error {
+ cmd := NewCommand(repo.Ctx, "remote", "add")
+ if fetch {
+ cmd.AddArguments("-f")
+ }
+ cmd.AddDynamicArguments(name, url)
+
+ _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// RemoveRemote removes a remote from repository.
+func (repo *Repository) RemoveRemote(name string) error {
+ _, _, err := NewCommand(repo.Ctx, "remote", "rm").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// GetCommit returns the head commit of a branch
+func (branch *Branch) GetCommit() (*Commit, error) {
+ return branch.gitRepo.GetBranchCommit(branch.Name)
+}
+
+// RenameBranch rename a branch
+func (repo *Repository) RenameBranch(from, to string) error {
+ _, _, err := NewCommand(repo.Ctx, "branch", "-m").AddDynamicArguments(from, to).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// IsObjectExist returns true if given reference exists in the repository.
+func (repo *Repository) IsObjectExist(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ sha, _, _, err := ReadBatchLine(rd)
+ return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name)))
+}
+
+// IsReferenceExist returns true if given reference exists in the repository.
+func (repo *Repository) IsReferenceExist(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ log.Debug("Error writing to CatFileBatchCheck %v", err)
+ return false
+ }
+ _, _, _, err = ReadBatchLine(rd)
+ return err == nil
+}
+
+// IsBranchExist returns true if given branch exists in current repository.
+func (repo *Repository) IsBranchExist(name string) bool {
+ if repo == nil || name == "" {
+ return false
+ }
+
+ return repo.IsReferenceExist(BranchPrefix + name)
+}
+
+// GetBranchNames returns branches from the repository, skipping "skip" initial branches and
+// returning at most "limit" branches, or all branches if "limit" is 0.
+func (repo *Repository) GetBranchNames(skip, limit int) ([]string, int, error) {
+ return callShowRef(repo.Ctx, repo.Path, BranchPrefix, TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}, skip, limit)
+}
+
+// WalkReferences walks all the references from the repository
+// refType should be empty, ObjectTag or ObjectBranch. All other values are equivalent to empty.
+func (repo *Repository) WalkReferences(refType ObjectType, skip, limit int, walkfn func(sha1, refname string) error) (int, error) {
+ var args TrustedCmdArgs
+ switch refType {
+ case ObjectTag:
+ args = TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}
+ case ObjectBranch:
+ args = TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}
+ }
+
+ return WalkShowRef(repo.Ctx, repo.Path, args, skip, limit, walkfn)
+}
+
+// callShowRef return refs, if limit = 0 it will not limit
+func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs TrustedCmdArgs, skip, limit int) (branchNames []string, countAll int, err error) {
+ countAll, err = WalkShowRef(ctx, repoPath, extraArgs, skip, limit, func(_, branchName string) error {
+ branchName = strings.TrimPrefix(branchName, trimPrefix)
+ branchNames = append(branchNames, branchName)
+
+ return nil
+ })
+ return branchNames, countAll, err
+}
+
+func WalkShowRef(ctx context.Context, repoPath string, extraArgs TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) {
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ go func() {
+ stderrBuilder := &strings.Builder{}
+ args := TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"}
+ args = append(args, extraArgs...)
+ err := NewCommand(ctx, args...).Run(&RunOpts{
+ Dir: repoPath,
+ Stdout: stdoutWriter,
+ Stderr: stderrBuilder,
+ })
+ if err != nil {
+ if stderrBuilder.Len() == 0 {
+ _ = stdoutWriter.Close()
+ return
+ }
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderrBuilder.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ i := 0
+ bufReader := bufio.NewReader(stdoutReader)
+ for i < skip {
+ _, isPrefix, err := bufReader.ReadLine()
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if !isPrefix {
+ i++
+ }
+ }
+ for limit == 0 || i < skip+limit {
+ // The output of show-ref is simply a list:
+ // <sha> SP <ref> LF
+ sha, err := bufReader.ReadString(' ')
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+
+ branchName, err := bufReader.ReadString('\n')
+ if err == io.EOF {
+ // This shouldn't happen... but we'll tolerate it for the sake of peace
+ return i, nil
+ }
+ if err != nil {
+ return i, err
+ }
+
+ if len(branchName) > 0 {
+ branchName = branchName[:len(branchName)-1]
+ }
+
+ if len(sha) > 0 {
+ sha = sha[:len(sha)-1]
+ }
+
+ err = walkfn(sha, branchName)
+ if err != nil {
+ return i, err
+ }
+ i++
+ }
+ // count all refs
+ for limit != 0 {
+ _, isPrefix, err := bufReader.ReadLine()
+ if err == io.EOF {
+ return i, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if !isPrefix {
+ i++
+ }
+ }
+ return i, nil
+}
+
+// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash
+func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) {
+ var revList []string
+ _, err := WalkShowRef(repo.Ctx, repo.Path, nil, 0, 0, func(walkSha, refname string) error {
+ if walkSha == sha && strings.HasPrefix(refname, prefix) {
+ revList = append(revList, refname)
+ }
+ return nil
+ })
+ return revList, err
+}
diff --git a/modules/git/repo_branch_test.go b/modules/git/repo_branch_test.go
new file mode 100644
index 0000000..610c845
--- /dev/null
+++ b/modules/git/repo_branch_test.go
@@ -0,0 +1,197 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetBranches(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ branches, countAll, err := bareRepo1.GetBranchNames(0, 2)
+
+ require.NoError(t, err)
+ assert.Len(t, branches, 2)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{"master", "branch2"}, branches)
+
+ branches, countAll, err = bareRepo1.GetBranchNames(0, 0)
+
+ require.NoError(t, err)
+ assert.Len(t, branches, 3)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{"master", "branch2", "branch1"}, branches)
+
+ branches, countAll, err = bareRepo1.GetBranchNames(5, 1)
+
+ require.NoError(t, err)
+ assert.Empty(t, branches)
+ assert.EqualValues(t, 3, countAll)
+ assert.ElementsMatch(t, []string{}, branches)
+}
+
+func BenchmarkRepository_GetBranches(b *testing.B) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer bareRepo1.Close()
+
+ for i := 0; i < b.N; i++ {
+ _, _, err := bareRepo1.GetBranchNames(0, 0)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func TestGetRefsBySha(t *testing.T) {
+ bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
+ bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer bareRepo5.Close()
+
+ // do not exist
+ branches, err := bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
+ require.NoError(t, err)
+ assert.Empty(t, branches)
+
+ // refs/pull/1/head
+ branches, err = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", PullPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/pull/1/head"}, branches)
+
+ branches, err = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", BranchPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/heads/master", "refs/heads/master-clone"}, branches)
+
+ branches, err = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", BranchPrefix)
+ require.NoError(t, err)
+ assert.EqualValues(t, []string{"refs/heads/test-patch-1"}, branches)
+}
+
+func BenchmarkGetRefsBySha(b *testing.B) {
+ bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
+ bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer bareRepo5.Close()
+
+ _, _ = bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
+ _, _ = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", "")
+ _, _ = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", "")
+ _, _ = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", "")
+}
+
+func TestRepository_IsObjectExist(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ supportShortHash := true
+
+ tests := []struct {
+ name string
+ arg string
+ want bool
+ }{
+ {
+ name: "empty",
+ arg: "",
+ want: false,
+ },
+ {
+ name: "branch",
+ arg: "master",
+ want: false,
+ },
+ {
+ name: "commit hash",
+ arg: "ce064814f4a0d337b333e646ece456cd39fab612",
+ want: true,
+ },
+ {
+ name: "short commit hash",
+ arg: "ce06481",
+ want: supportShortHash,
+ },
+ {
+ name: "blob hash",
+ arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
+ want: true,
+ },
+ {
+ name: "short blob hash",
+ arg: "153f451",
+ want: supportShortHash,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, repo.IsObjectExist(tt.arg))
+ })
+ }
+}
+
+func TestRepository_IsReferenceExist(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ supportBlobHash := true
+
+ tests := []struct {
+ name string
+ arg string
+ want bool
+ }{
+ {
+ name: "empty",
+ arg: "",
+ want: false,
+ },
+ {
+ name: "branch",
+ arg: "master",
+ want: true,
+ },
+ {
+ name: "commit hash",
+ arg: "ce064814f4a0d337b333e646ece456cd39fab612",
+ want: true,
+ },
+ {
+ name: "short commit hash",
+ arg: "ce06481",
+ want: true,
+ },
+ {
+ name: "blob hash",
+ arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
+ want: supportBlobHash,
+ },
+ {
+ name: "short blob hash",
+ arg: "153f451",
+ want: supportBlobHash,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, repo.IsReferenceExist(tt.arg))
+ })
+ }
+}
diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go
new file mode 100644
index 0000000..1f3d64f
--- /dev/null
+++ b/modules/git/repo_commit.go
@@ -0,0 +1,677 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "io"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// GetBranchCommitID returns last commit ID string of given branch.
+func (repo *Repository) GetBranchCommitID(name string) (string, error) {
+ return repo.GetRefCommitID(BranchPrefix + name)
+}
+
+// GetTagCommitID returns last commit ID string of given tag.
+func (repo *Repository) GetTagCommitID(name string) (string, error) {
+ return repo.GetRefCommitID(TagPrefix + name)
+}
+
+// GetCommit returns commit object of by ID string.
+func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
+ id, err := repo.ConvertToGitID(commitID)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getCommit(id)
+}
+
+// GetBranchCommit returns the last commit of given branch.
+func (repo *Repository) GetBranchCommit(name string) (*Commit, error) {
+ commitID, err := repo.GetBranchCommitID(name)
+ if err != nil {
+ return nil, err
+ }
+ return repo.GetCommit(commitID)
+}
+
+// GetTagCommit get the commit of the specific tag via name
+func (repo *Repository) GetTagCommit(name string) (*Commit, error) {
+ commitID, err := repo.GetTagCommitID(name)
+ if err != nil {
+ return nil, err
+ }
+ return repo.GetCommit(commitID)
+}
+
+func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Commit, error) {
+ // File name starts with ':' must be escaped.
+ if relpath[0] == ':' {
+ relpath = `\` + relpath
+ }
+
+ stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDynamicArguments(id.String()).AddDashesAndList(relpath).RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ id, err := NewIDFromString(stdout)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getCommit(id)
+}
+
+// GetCommitByPath returns the last commit of relative path.
+func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
+ stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDashesAndList(relpath).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ commits, err := repo.parsePrettyFormatLogToList(stdout)
+ if err != nil {
+ return nil, err
+ }
+ if len(commits) == 0 {
+ return nil, ErrNotExist{ID: relpath}
+ }
+ return commits[0], nil
+}
+
+func (repo *Repository) commitsByRange(id ObjectID, page, pageSize int, not string) ([]*Commit, error) {
+ cmd := NewCommand(repo.Ctx, "log").
+ AddOptionFormat("--skip=%d", (page-1)*pageSize).
+ AddOptionFormat("--max-count=%d", pageSize).
+ AddArguments(prettyLogFormat).
+ AddDynamicArguments(id.String())
+
+ if not != "" {
+ cmd.AddOptionValues("--not", not)
+ }
+
+ stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.parsePrettyFormatLogToList(stdout)
+}
+
+func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([]*Commit, error) {
+ // add common arguments to git command
+ addCommonSearchArgs := func(c *Command) {
+ // ignore case
+ c.AddArguments("-i")
+
+ // add authors if present in search query
+ for _, v := range opts.Authors {
+ c.AddOptionFormat("--author=%s", v)
+ }
+
+ // add committers if present in search query
+ for _, v := range opts.Committers {
+ c.AddOptionFormat("--committer=%s", v)
+ }
+
+ // add time constraints if present in search query
+ if len(opts.After) > 0 {
+ c.AddOptionFormat("--after=%s", opts.After)
+ }
+ if len(opts.Before) > 0 {
+ c.AddOptionFormat("--before=%s", opts.Before)
+ }
+ }
+
+ // create new git log command with limit of 100 commits
+ cmd := NewCommand(repo.Ctx, "log", "-100", prettyLogFormat).AddDynamicArguments(id.String())
+
+ // pretend that all refs along with HEAD were listed on command line as <commis>
+ // https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
+ // note this is done only for command created above
+ if opts.All {
+ cmd.AddArguments("--all")
+ }
+
+ // interpret search string keywords as string instead of regex
+ cmd.AddArguments("--fixed-strings")
+
+ // add remaining keywords from search string
+ // note this is done only for command created above
+ for _, v := range opts.Keywords {
+ cmd.AddOptionFormat("--grep=%s", v)
+ }
+
+ // search for commits matching given constraints and keywords in commit msg
+ addCommonSearchArgs(cmd)
+ stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ if len(stdout) != 0 {
+ stdout = append(stdout, '\n')
+ }
+
+ // if there are any keywords (ie not committer:, author:, time:)
+ // then let's iterate over them
+ for _, v := range opts.Keywords {
+ // ignore anything not matching a valid sha pattern
+ if id.Type().IsValid(v) {
+ // create new git log command with 1 commit limit
+ hashCmd := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat)
+ // add previous arguments except for --grep and --all
+ addCommonSearchArgs(hashCmd)
+ // add keyword as <commit>
+ hashCmd.AddDynamicArguments(v)
+
+ // search with given constraints for commit matching sha hash of v
+ hashMatching, _, err := hashCmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil || bytes.Contains(stdout, hashMatching) {
+ continue
+ }
+ stdout = append(stdout, hashMatching...)
+ stdout = append(stdout, '\n')
+ }
+ }
+
+ return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'}))
+}
+
+// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
+// You must ensure that id1 and id2 are valid commit ids.
+func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only", "-z").AddDynamicArguments(id1, id2).AddDashesAndList(filename).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return false, err
+ }
+ return len(strings.TrimSpace(string(stdout))) > 0, nil
+}
+
+// FileCommitsCount return the number of files at a revision
+func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) {
+ return CommitsCount(repo.Ctx,
+ CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{revision},
+ RelPath: []string{file},
+ })
+}
+
+type CommitsByFileAndRangeOptions struct {
+ Revision string
+ File string
+ Not string
+ Page int
+}
+
+// CommitsByFileAndRange return the commits according revision file and the page
+func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
+ skip := (opts.Page - 1) * setting.Git.CommitsRangeSize
+
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+ go func() {
+ stderr := strings.Builder{}
+ gitCmd := NewCommand(repo.Ctx, "rev-list").
+ AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize*opts.Page).
+ AddOptionFormat("--skip=%d", skip)
+ gitCmd.AddDynamicArguments(opts.Revision)
+
+ if opts.Not != "" {
+ gitCmd.AddOptionValues("--not", opts.Not)
+ }
+
+ gitCmd.AddDashesAndList(opts.File)
+ err := gitCmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: &stderr,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+
+ length := objectFormat.FullLength()
+ commits := []*Commit{}
+ shaline := make([]byte, length+1)
+ for {
+ n, err := io.ReadFull(stdoutReader, shaline)
+ if err != nil || n < length {
+ if err == io.EOF {
+ err = nil
+ }
+ return commits, err
+ }
+ objectID, err := NewIDFromString(string(shaline[0:length]))
+ if err != nil {
+ return nil, err
+ }
+ commit, err := repo.getCommit(objectID)
+ if err != nil {
+ return nil, err
+ }
+ commits = append(commits, commit)
+ }
+}
+
+// FilesCountBetween return the number of files changed between two commits
+func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID + "..." + endCommitID).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated.
+ // previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID, endCommitID).RunStdString(&RunOpts{Dir: repo.Path})
+ }
+ if err != nil {
+ return 0, err
+ }
+ return len(strings.Split(stdout, "\n")) - 1, nil
+}
+
+// CommitsBetween returns a list that contains commits between [before, last).
+// If before is detached (removed by reset + push) it is not included.
+func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [before, last)
+func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip int) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list --max-count n before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").
+ AddOptionValues("--max-count", strconv.Itoa(limit)).
+ AddOptionValues("--skip", strconv.Itoa(skip)).
+ AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenNotBase returns a list that contains commits between [before, last), excluding commits in baseBranch.
+// If before is detached (removed by reset + push) it is not included.
+func (repo *Repository) CommitsBetweenNotBase(last, before *Commit, baseBranch string) ([]*Commit, error) {
+ var stdout []byte
+ var err error
+ if before == nil {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ } else {
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path})
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+}
+
+// CommitsBetweenIDs return commits between twoe commits
+func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error) {
+ lastCommit, err := repo.GetCommit(last)
+ if err != nil {
+ return nil, err
+ }
+ if before == "" {
+ return repo.CommitsBetween(lastCommit, nil)
+ }
+ beforeCommit, err := repo.GetCommit(before)
+ if err != nil {
+ return nil, err
+ }
+ return repo.CommitsBetween(lastCommit, beforeCommit)
+}
+
+// CommitsCountBetween return numbers of commits between two commits
+func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) {
+ count, err := CommitsCount(repo.Ctx, CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{start + ".." + end},
+ })
+
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
+ // previously it would return the results of git rev-list before last so let's try that...
+ return CommitsCount(repo.Ctx, CommitsCountOptions{
+ RepoPath: repo.Path,
+ Revision: []string{start, end},
+ })
+ }
+
+ return count, err
+}
+
+// commitsBefore the limit is depth, not total number of returned commits.
+func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) {
+ cmd := NewCommand(repo.Ctx, "log", prettyLogFormat)
+ if limit > 0 {
+ cmd.AddOptionFormat("-%d", limit)
+ }
+ cmd.AddDynamicArguments(id.String())
+
+ stdout, _, runErr := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
+ if err != nil {
+ return nil, err
+ }
+
+ commits := make([]*Commit, 0, len(formattedLog))
+ for _, commit := range formattedLog {
+ branches, err := repo.getBranches(commit, 2)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(branches) > 1 {
+ break
+ }
+
+ commits = append(commits, commit)
+ }
+
+ return commits, nil
+}
+
+func (repo *Repository) getCommitsBefore(id ObjectID) ([]*Commit, error) {
+ return repo.commitsBefore(id, 0)
+}
+
+func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, error) {
+ return repo.commitsBefore(id, num)
+}
+
+func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) {
+ if CheckGitVersionAtLeast("2.7.0") == nil {
+ stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)").
+ AddOptionFormat("--count=%d", limit).
+ AddOptionValues("--contains", commit.ID.String(), BranchPrefix).
+ RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ branches := strings.Fields(stdout)
+ return branches, nil
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "branch").AddOptionValues("--contains", commit.ID.String()).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ refs := strings.Split(stdout, "\n")
+
+ var max int
+ if len(refs) > limit {
+ max = limit
+ } else {
+ max = len(refs) - 1
+ }
+
+ branches := make([]string, max)
+ for i, ref := range refs[:max] {
+ parts := strings.Fields(ref)
+
+ branches[i] = parts[len(parts)-1]
+ }
+ return branches, nil
+}
+
+// GetCommitsFromIDs get commits from commit IDs
+func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit {
+ commits := make([]*Commit, 0, len(commitIDs))
+
+ for _, commitID := range commitIDs {
+ commit, err := repo.GetCommit(commitID)
+ if err == nil && commit != nil {
+ commits = append(commits, commit)
+ }
+ }
+
+ return commits
+}
+
+// IsCommitInBranch check if the commit is on the branch
+func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
+ stdout, _, err := NewCommand(repo.Ctx, "branch", "--contains").AddDynamicArguments(commitID, branch).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return false, err
+ }
+ return len(stdout) > 0, err
+}
+
+func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error {
+ if repo.LastCommitCache == nil {
+ commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) {
+ commit, err := repo.GetCommit(sha)
+ if err != nil {
+ return 0, err
+ }
+ return commit.CommitsCount()
+ })
+ if err != nil {
+ return err
+ }
+ repo.LastCommitCache = NewLastCommitCache(commitsCount, fullName, repo, cache.GetCache())
+ }
+ return nil
+}
+
+// ResolveReference resolves a name to a reference
+func (repo *Repository) ResolveReference(name string) (string, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--hash").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ if strings.Contains(err.Error(), "not a valid ref") {
+ return "", ErrNotExist{name, ""}
+ }
+ return "", err
+ }
+ stdout = strings.TrimSpace(stdout)
+ if stdout == "" {
+ return "", ErrNotExist{name, ""}
+ }
+
+ return stdout, nil
+}
+
+// GetRefCommitID returns the last commit ID string of given reference (branch or tag).
+func (repo *Repository) GetRefCommitID(name string) (string, error) {
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return "", err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(name + "\n"))
+ if err != nil {
+ return "", err
+ }
+ shaBs, _, _, err := ReadBatchLine(rd)
+ if IsErrNotExist(err) {
+ return "", ErrNotExist{name, ""}
+ }
+
+ return string(shaBs), nil
+}
+
+// SetReference sets the commit ID string of given reference (e.g. branch or tag).
+func (repo *Repository) SetReference(name, commitID string) error {
+ _, _, err := NewCommand(repo.Ctx, "update-ref").AddDynamicArguments(name, commitID).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// RemoveReference removes the given reference (e.g. branch or tag).
+func (repo *Repository) RemoveReference(name string) error {
+ _, _, err := NewCommand(repo.Ctx, "update-ref", "--no-deref", "-d").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// IsCommitExist returns true if given commit exists in current repository.
+func (repo *Repository) IsCommitExist(name string) bool {
+ if err := ensureValidGitRepository(repo.Ctx, repo.Path); err != nil {
+ log.Error("IsCommitExist: %v", err)
+ return false
+ }
+ _, _, err := NewCommand(repo.Ctx, "cat-file", "-e").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path})
+ return err == nil
+}
+
+func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(id.String() + "\n"))
+
+ return repo.getCommitFromBatchReader(rd, id)
+}
+
+func (repo *Repository) getCommitFromBatchReader(rd *bufio.Reader, id ObjectID) (*Commit, error) {
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ if errors.Is(err, io.EOF) || IsErrNotExist(err) {
+ return nil, ErrNotExist{ID: id.String()}
+ }
+ return nil, err
+ }
+
+ switch typ {
+ case "missing":
+ return nil, ErrNotExist{ID: id.String()}
+ case "tag":
+ // then we need to parse the tag
+ // and load the commit
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+ tag, err := parseTagData(id.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+
+ commit, err := tag.Commit(repo)
+ if err != nil {
+ return nil, err
+ }
+
+ return commit, nil
+ case "commit":
+ commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+
+ return commit, nil
+ default:
+ log.Debug("Unknown typ: %s", typ)
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{
+ ID: id.String(),
+ }
+ }
+}
+
+// ConvertToGitID returns a GitHash object from a potential ID string
+func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) {
+ ID, err := NewIDFromString(commitID)
+ if err == nil {
+ return ID, nil
+ }
+ }
+
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(commitID + "\n"))
+ if err != nil {
+ return nil, err
+ }
+ sha, _, _, err := ReadBatchLine(rd)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return nil, ErrNotExist{commitID, ""}
+ }
+ return nil, err
+ }
+
+ return MustIDFromString(string(sha)), nil
+}
diff --git a/modules/git/repo_commit_test.go b/modules/git/repo_commit_test.go
new file mode 100644
index 0000000..e2a9f97
--- /dev/null
+++ b/modules/git/repo_commit_test.go
@@ -0,0 +1,103 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetCommitBranches(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ // these test case are specific to the repo1_bare test repo
+ testCases := []struct {
+ CommitID string
+ ExpectedBranches []string
+ }{
+ {"2839944139e0de9737a044f78b0e4b40d989a9e3", []string{"branch1"}},
+ {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", []string{"branch2"}},
+ {"37991dec2c8e592043f47155ce4808d4580f9123", []string{"master"}},
+ {"95bb4d39648ee7e325106df01a621c530863a653", []string{"branch1", "branch2"}},
+ {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", []string{"branch2", "master"}},
+ {"master", []string{"master"}},
+ }
+ for _, testCase := range testCases {
+ commit, err := bareRepo1.GetCommit(testCase.CommitID)
+ require.NoError(t, err)
+ branches, err := bareRepo1.getBranches(commit, 2)
+ require.NoError(t, err)
+ assert.Equal(t, testCase.ExpectedBranches, branches)
+ }
+}
+
+func TestGetTagCommitWithSignature(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ // both the tag and the commit are signed here, this validates only the commit signature
+ commit, err := bareRepo1.GetCommit("28b55526e7100924d864dd89e35c1ea62e7a5a32")
+ require.NoError(t, err)
+ assert.NotNil(t, commit)
+ assert.NotNil(t, commit.Signature)
+ // test that signature is not in message
+ assert.Equal(t, "signed-commit\n", commit.CommitMessage)
+}
+
+func TestGetCommitWithBadCommitID(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ commit, err := bareRepo1.GetCommit("bad_branch")
+ assert.Nil(t, commit)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+}
+
+func TestIsCommitInBranch(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ result, err := bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch1")
+ require.NoError(t, err)
+ assert.True(t, result)
+
+ result, err = bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
+ require.NoError(t, err)
+ assert.False(t, result)
+}
+
+func TestRepository_CommitsBetweenIDs(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo4_commitsbetween")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ cases := []struct {
+ OldID string
+ NewID string
+ ExpectedCommits int
+ }{
+ {"fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", "78a445db1eac62fe15e624e1137965969addf344", 1}, // com1 -> com2
+ {"78a445db1eac62fe15e624e1137965969addf344", "fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", 0}, // reset HEAD~, com2 -> com1
+ {"78a445db1eac62fe15e624e1137965969addf344", "a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca", 1}, // com2 -> com2_new
+ }
+ for i, c := range cases {
+ commits, err := bareRepo1.CommitsBetweenIDs(c.NewID, c.OldID)
+ require.NoError(t, err)
+ assert.Len(t, commits, c.ExpectedCommits, "case %d", i)
+ }
+}
diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go
new file mode 100644
index 0000000..492438b
--- /dev/null
+++ b/modules/git/repo_commitgraph.go
@@ -0,0 +1,20 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "fmt"
+)
+
+// WriteCommitGraph write commit graph to speed up repo access
+// this requires git v2.18 to be installed
+func WriteCommitGraph(ctx context.Context, repoPath string) error {
+ if CheckGitVersionAtLeast("2.18") == nil {
+ if _, _, err := NewCommand(ctx, "commit-graph", "write").RunStdString(&RunOpts{Dir: repoPath}); err != nil {
+ return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err)
+ }
+ }
+ return nil
+}
diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go
new file mode 100644
index 0000000..b6e9d2b
--- /dev/null
+++ b/modules/git/repo_compare.go
@@ -0,0 +1,345 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ logger "code.gitea.io/gitea/modules/log"
+)
+
+// CompareInfo represents needed information for comparing references.
+type CompareInfo struct {
+ MergeBase string
+ BaseCommitID string
+ HeadCommitID string
+ Commits []*Commit
+ NumFiles int
+}
+
+// GetMergeBase checks and returns merge base of two branches and the reference used as base.
+func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, string, error) {
+ if tmpRemote == "" {
+ tmpRemote = "origin"
+ }
+
+ if tmpRemote != "origin" {
+ tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base
+ // Fetch commit into a temporary branch in order to be able to handle commits and tags
+ _, _, err := NewCommand(repo.Ctx, "fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base + ":" + tmpBaseName).RunStdString(&RunOpts{Dir: repo.Path})
+ if err == nil {
+ base = tmpBaseName
+ }
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "merge-base").AddDashesAndList(base, head).RunStdString(&RunOpts{Dir: repo.Path})
+ return strings.TrimSpace(stdout), base, err
+}
+
+// GetCompareInfo generates and returns compare information between base and head branches of repositories.
+func (repo *Repository) GetCompareInfo(basePath, baseBranch, headBranch string, directComparison, fileOnly bool) (_ *CompareInfo, err error) {
+ var (
+ remoteBranch string
+ tmpRemote string
+ )
+
+ // We don't need a temporary remote for same repository.
+ if repo.Path != basePath {
+ // Add a temporary remote
+ tmpRemote = strconv.FormatInt(time.Now().UnixNano(), 10)
+ if err = repo.AddRemote(tmpRemote, basePath, false); err != nil {
+ return nil, fmt.Errorf("AddRemote: %w", err)
+ }
+ defer func() {
+ if err := repo.RemoveRemote(tmpRemote); err != nil {
+ logger.Error("GetPullRequestInfo: RemoveRemote: %v", err)
+ }
+ }()
+ }
+
+ compareInfo := new(CompareInfo)
+
+ compareInfo.HeadCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, headBranch)
+ if err != nil {
+ compareInfo.HeadCommitID = headBranch
+ }
+
+ compareInfo.MergeBase, remoteBranch, err = repo.GetMergeBase(tmpRemote, baseBranch, headBranch)
+ if err == nil {
+ compareInfo.BaseCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch)
+ if err != nil {
+ compareInfo.BaseCommitID = remoteBranch
+ }
+ separator := "..."
+ baseCommitID := compareInfo.MergeBase
+ if directComparison {
+ separator = ".."
+ baseCommitID = compareInfo.BaseCommitID
+ }
+
+ // We have a common base - therefore we know that ... should work
+ if !fileOnly {
+ // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
+ var logs []byte
+ logs, _, err = NewCommand(repo.Ctx, "log").AddArguments(prettyLogFormat).
+ AddDynamicArguments(baseCommitID + separator + headBranch).AddArguments("--").
+ RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ compareInfo.Commits, err = repo.parsePrettyFormatLogToList(logs)
+ if err != nil {
+ return nil, fmt.Errorf("parsePrettyFormatLogToList: %w", err)
+ }
+ } else {
+ compareInfo.Commits = []*Commit{}
+ }
+ } else {
+ compareInfo.Commits = []*Commit{}
+ compareInfo.MergeBase, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch)
+ if err != nil {
+ compareInfo.MergeBase = remoteBranch
+ }
+ compareInfo.BaseCommitID = compareInfo.MergeBase
+ }
+
+ // Count number of changed files.
+ // This probably should be removed as we need to use shortstat elsewhere
+ // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly
+ compareInfo.NumFiles, err = repo.GetDiffNumChangedFiles(remoteBranch, headBranch, directComparison)
+ if err != nil {
+ return nil, err
+ }
+ return compareInfo, nil
+}
+
+type lineCountWriter struct {
+ numLines int
+}
+
+// Write counts the number of newlines in the provided bytestream
+func (l *lineCountWriter) Write(p []byte) (n int, err error) {
+ n = len(p)
+ l.numLines += bytes.Count(p, []byte{'\000'})
+ return n, err
+}
+
+// GetDiffNumChangedFiles counts the number of changed files
+// This is substantially quicker than shortstat but...
+func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparison bool) (int, error) {
+ // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly
+ w := &lineCountWriter{}
+ stderr := new(bytes.Buffer)
+
+ separator := "..."
+ if directComparison {
+ separator = ".."
+ }
+
+ // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
+ if err := NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base + separator + head).AddArguments("--").
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ }); err != nil {
+ if strings.Contains(stderr.String(), "no merge base") {
+ // git >= 2.28 now returns an error if base and head have become unrelated.
+ // previously it would return the results of git diff -z --name-only base head so let's try that...
+ w = &lineCountWriter{}
+ stderr.Reset()
+ if err = NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ }); err == nil {
+ return w.numLines, nil
+ }
+ }
+ return 0, fmt.Errorf("%w: Stderr: %s", err, stderr)
+ }
+ return w.numLines, nil
+}
+
+// GetDiffShortStat counts number of changed files, number of additions and deletions
+func (repo *Repository) GetDiffShortStat(base, head string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ numFiles, totalAdditions, totalDeletions, err = GetDiffShortStat(repo.Ctx, repo.Path, nil, base+"..."+head)
+ if err != nil && strings.Contains(err.Error(), "no merge base") {
+ return GetDiffShortStat(repo.Ctx, repo.Path, nil, base, head)
+ }
+ return numFiles, totalAdditions, totalDeletions, err
+}
+
+// GetDiffShortStat counts number of changed files, number of additions and deletions
+func GetDiffShortStat(ctx context.Context, repoPath string, trustedArgs TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ // Now if we call:
+ // $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875
+ // we get:
+ // " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n"
+ cmd := NewCommand(ctx, "diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...)
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
+ if err != nil {
+ return 0, 0, 0, err
+ }
+
+ return parseDiffStat(stdout)
+}
+
+var shortStatFormat = regexp.MustCompile(
+ `\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`)
+
+var patchCommits = regexp.MustCompile(`^From\s(\w+)\s`)
+
+func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, err error) {
+ if len(stdout) == 0 || stdout == "\n" {
+ return 0, 0, 0, nil
+ }
+ groups := shortStatFormat.FindStringSubmatch(stdout)
+ if len(groups) != 4 {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s groups: %s", stdout, groups)
+ }
+
+ numFiles, err = strconv.Atoi(groups[1])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumFiles %w", stdout, err)
+ }
+
+ if len(groups[2]) != 0 {
+ totalAdditions, err = strconv.Atoi(groups[2])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumAdditions %w", stdout, err)
+ }
+ }
+
+ if len(groups[3]) != 0 {
+ totalDeletions, err = strconv.Atoi(groups[3])
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumDeletions %w", stdout, err)
+ }
+ }
+ return numFiles, totalAdditions, totalDeletions, err
+}
+
+// GetDiffOrPatch generates either diff or formatted patch data between given revisions
+func (repo *Repository) GetDiffOrPatch(base, head string, w io.Writer, patch, binary bool) error {
+ if patch {
+ return repo.GetPatch(base, head, w)
+ }
+ if binary {
+ return repo.GetDiffBinary(base, head, w)
+ }
+ return repo.GetDiff(base, head, w)
+}
+
+// GetDiff generates and returns patch data between given revisions, optimized for human readability
+func (repo *Repository) GetDiff(base, head string, w io.Writer) error {
+ return NewCommand(repo.Ctx, "diff", "-p").AddDynamicArguments(base, head).Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+}
+
+// GetDiffBinary generates and returns patch data between given revisions, including binary diffs.
+func (repo *Repository) GetDiffBinary(base, head string, w io.Writer) error {
+ return NewCommand(repo.Ctx, "diff", "-p", "--binary", "--histogram").AddDynamicArguments(base, head).Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+}
+
+// GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply`
+func (repo *Repository) GetPatch(base, head string, w io.Writer) error {
+ stderr := new(bytes.Buffer)
+ err := NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base + "..." + head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) {
+ return NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base, head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ })
+ }
+ return err
+}
+
+// GetFilesChangedBetween returns a list of all files that have been changed between the given commits
+// If base is undefined empty SHA (zeros), it only returns the files changed in the head commit
+// If base is the SHA of an empty tree (EmptyTreeSHA), it returns the files changes from the initial commit to the head commit
+func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ cmd := NewCommand(repo.Ctx, "diff-tree", "--name-only", "--root", "--no-commit-id", "-r", "-z")
+ if base == objectFormat.EmptyObjectID().String() {
+ cmd.AddDynamicArguments(head)
+ } else {
+ cmd.AddDynamicArguments(base, head)
+ }
+ stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ split := strings.Split(stdout, "\000")
+
+ // Because Git will always emit filenames with a terminal NUL ignore the last entry in the split - which will always be empty.
+ if len(split) > 0 {
+ split = split[:len(split)-1]
+ }
+
+ return split, err
+}
+
+// GetDiffFromMergeBase generates and return patch data from merge base to head
+func (repo *Repository) GetDiffFromMergeBase(base, head string, w io.Writer) error {
+ stderr := new(bytes.Buffer)
+ err := NewCommand(repo.Ctx, "diff", "-p", "--binary").AddDynamicArguments(base + "..." + head).
+ Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: w,
+ Stderr: stderr,
+ })
+ if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) {
+ return repo.GetDiffBinary(base, head, w)
+ }
+ return err
+}
+
+// ReadPatchCommit will check if a diff patch exists and return stats
+func (repo *Repository) ReadPatchCommit(prID int64) (commitSHA string, err error) {
+ // Migrated repositories download patches to "pulls" location
+ patchFile := fmt.Sprintf("pulls/%d.patch", prID)
+ loadPatch, err := os.Open(filepath.Join(repo.Path, patchFile))
+ if err != nil {
+ return "", err
+ }
+ defer loadPatch.Close()
+ // Read only the first line of the patch - usually it contains the first commit made in patch
+ scanner := bufio.NewScanner(loadPatch)
+ scanner.Scan()
+ // Parse the Patch stats, sometimes Migration returns a 404 for the patch file
+ commitSHAGroups := patchCommits.FindStringSubmatch(scanner.Text())
+ if len(commitSHAGroups) != 0 {
+ commitSHA = commitSHAGroups[1]
+ } else {
+ return "", errors.New("patch file doesn't contain valid commit ID")
+ }
+ return commitSHA, nil
+}
diff --git a/modules/git/repo_compare_test.go b/modules/git/repo_compare_test.go
new file mode 100644
index 0000000..86bd685
--- /dev/null
+++ b/modules/git/repo_compare_test.go
@@ -0,0 +1,164 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetFormatPatch(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ repo, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+
+ rd := &bytes.Buffer{}
+ err = repo.GetPatch("8d92fc95^", "8d92fc95", rd)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ patchb, err := io.ReadAll(rd)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ patch := string(patchb)
+ assert.Regexp(t, "^From 8d92fc95", patch)
+ assert.Contains(t, patch, "Subject: [PATCH] Add file2.txt")
+}
+
+func TestReadPatch(t *testing.T) {
+ // Ensure we can read the patch files
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+ // This patch doesn't exist
+ noFile, err := repo.ReadPatchCommit(0)
+ require.Error(t, err)
+
+ // This patch is an empty one (sometimes it's a 404)
+ noCommit, err := repo.ReadPatchCommit(1)
+ require.Error(t, err)
+
+ // This patch is legit and should return a commit
+ oldCommit, err := repo.ReadPatchCommit(2)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ assert.Empty(t, noFile)
+ assert.Empty(t, noCommit)
+ assert.Len(t, oldCommit, 40)
+ assert.Equal(t, "6e8e2a6f9efd71dbe6917816343ed8415ad696c3", oldCommit)
+}
+
+func TestReadWritePullHead(t *testing.T) {
+ // Ensure we can write SHA1 head corresponding to PR and open them
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ // As we are writing we should clone the repository first
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ repo, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer repo.Close()
+
+ // Try to open non-existing Pull
+ _, err = repo.GetRefCommitID(PullPrefix + "0/head")
+ require.Error(t, err)
+
+ // Write a fake sha1 with only 40 zeros
+ newCommit := "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"
+ err = repo.SetReference(PullPrefix+"1/head", newCommit)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ // Read the file created
+ headContents, err := repo.GetRefCommitID(PullPrefix + "1/head")
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ assert.Len(t, headContents, 40)
+ assert.Equal(t, newCommit, headContents)
+
+ // Remove file after the test
+ err = repo.RemoveReference(PullPrefix + "1/head")
+ require.NoError(t, err)
+}
+
+func TestGetCommitFilesChanged(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer repo.Close()
+
+ objectFormat, err := repo.GetObjectFormat()
+ require.NoError(t, err)
+
+ testCases := []struct {
+ base, head string
+ files []string
+ }{
+ {
+ objectFormat.EmptyObjectID().String(),
+ "95bb4d39648ee7e325106df01a621c530863a653",
+ []string{"file1.txt"},
+ },
+ {
+ objectFormat.EmptyObjectID().String(),
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file2.txt"},
+ },
+ {
+ "95bb4d39648ee7e325106df01a621c530863a653",
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file2.txt"},
+ },
+ {
+ objectFormat.EmptyTree().String(),
+ "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
+ []string{"file1.txt", "file2.txt"},
+ },
+ }
+
+ for _, tc := range testCases {
+ changedFiles, err := repo.GetFilesChangedBetween(tc.base, tc.head)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, tc.files, changedFiles)
+ }
+}
diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go
new file mode 100644
index 0000000..e2b4506
--- /dev/null
+++ b/modules/git/repo_gpg.go
@@ -0,0 +1,58 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/modules/process"
+)
+
+// LoadPublicKeyContent will load the key from gpg
+func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
+ content, stderr, err := process.GetManager().Exec(
+ "gpg -a --export",
+ "gpg", "-a", "--export", gpgSettings.KeyID)
+ if err != nil {
+ return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err)
+ }
+ gpgSettings.PublicKeyContent = content
+ return nil
+}
+
+// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository
+func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
+ if repo.gpgSettings != nil && !forceUpdate {
+ return repo.gpgSettings, nil
+ }
+
+ gpgSettings := &GPGSettings{
+ Sign: true,
+ }
+
+ value, _, _ := NewCommand(repo.Ctx, "config", "--get", "commit.gpgsign").RunStdString(&RunOpts{Dir: repo.Path})
+ sign, valid := ParseBool(strings.TrimSpace(value))
+ if !sign || !valid {
+ gpgSettings.Sign = false
+ repo.gpgSettings = gpgSettings
+ return gpgSettings, nil
+ }
+
+ signingKey, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.signingkey").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.KeyID = strings.TrimSpace(signingKey)
+
+ defaultEmail, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.email").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.Email = strings.TrimSpace(defaultEmail)
+
+ defaultName, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.name").RunStdString(&RunOpts{Dir: repo.Path})
+ gpgSettings.Name = strings.TrimSpace(defaultName)
+
+ if err := gpgSettings.LoadPublicKeyContent(); err != nil {
+ return nil, err
+ }
+ repo.gpgSettings = gpgSettings
+ return repo.gpgSettings, nil
+}
diff --git a/modules/git/repo_hook.go b/modules/git/repo_hook.go
new file mode 100644
index 0000000..cdf0765
--- /dev/null
+++ b/modules/git/repo_hook.go
@@ -0,0 +1,14 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+// GetHook get one hook according the name on a repository
+func (repo *Repository) GetHook(name string) (*Hook, error) {
+ return GetHook(repo.Path, name)
+}
+
+// Hooks get all the hooks on the repository
+func (repo *Repository) Hooks() ([]*Hook, error) {
+ return ListHooks(repo.Path)
+}
diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go
new file mode 100644
index 0000000..8390570
--- /dev/null
+++ b/modules/git/repo_index.go
@@ -0,0 +1,159 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ReadTreeToIndex reads a treeish to the index
+func (repo *Repository) ReadTreeToIndex(treeish string, indexFilename ...string) error {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return err
+ }
+
+ if len(treeish) != objectFormat.FullLength() {
+ res, _, err := NewCommand(repo.Ctx, "rev-parse", "--verify").AddDynamicArguments(treeish).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return err
+ }
+ if len(res) > 0 {
+ treeish = res[:len(res)-1]
+ }
+ }
+ id, err := NewIDFromString(treeish)
+ if err != nil {
+ return err
+ }
+ return repo.readTreeToIndex(id, indexFilename...)
+}
+
+func (repo *Repository) readTreeToIndex(id ObjectID, indexFilename ...string) error {
+ var env []string
+ if len(indexFilename) > 0 {
+ env = append(os.Environ(), "GIT_INDEX_FILE="+indexFilename[0])
+ }
+ _, _, err := NewCommand(repo.Ctx, "read-tree").AddDynamicArguments(id.String()).RunStdString(&RunOpts{Dir: repo.Path, Env: env})
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// ReadTreeToTemporaryIndex reads a treeish to a temporary index file
+func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (filename, tmpDir string, cancel context.CancelFunc, err error) {
+ tmpDir, err = os.MkdirTemp("", "index")
+ if err != nil {
+ return filename, tmpDir, cancel, err
+ }
+
+ filename = filepath.Join(tmpDir, ".tmp-index")
+ cancel = func() {
+ err := util.RemoveAll(tmpDir)
+ if err != nil {
+ log.Error("failed to remove tmp index file: %v", err)
+ }
+ }
+ err = repo.ReadTreeToIndex(treeish, filename)
+ if err != nil {
+ defer cancel()
+ return "", "", func() {}, err
+ }
+ return filename, tmpDir, cancel, err
+}
+
+// EmptyIndex empties the index
+func (repo *Repository) EmptyIndex() error {
+ _, _, err := NewCommand(repo.Ctx, "read-tree", "--empty").RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// LsFiles checks if the given filenames are in the index
+func (repo *Repository) LsFiles(filenames ...string) ([]string, error) {
+ cmd := NewCommand(repo.Ctx, "ls-files", "-z").AddDashesAndList(filenames...)
+ res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ filelist := make([]string, 0, len(filenames))
+ for _, line := range bytes.Split(res, []byte{'\000'}) {
+ filelist = append(filelist, string(line))
+ }
+
+ return filelist, err
+}
+
+// RemoveFilesFromIndex removes given filenames from the index - it does not check whether they are present.
+func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return err
+ }
+ cmd := NewCommand(repo.Ctx, "update-index", "--remove", "-z", "--index-info")
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ buffer := new(bytes.Buffer)
+ for _, file := range filenames {
+ if file != "" {
+ // using format: mode SP type SP sha1 TAB path
+ buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000")
+ }
+ }
+ return cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: bytes.NewReader(buffer.Bytes()),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+}
+
+type IndexObjectInfo struct {
+ Mode string
+ Object ObjectID
+ Filename string
+}
+
+// AddObjectsToIndex adds the provided object hashes to the index at the provided filenames
+func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error {
+ cmd := NewCommand(repo.Ctx, "update-index", "--add", "--replace", "-z", "--index-info")
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ buffer := new(bytes.Buffer)
+ for _, object := range objects {
+ // using format: mode SP type SP sha1 TAB path
+ buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000")
+ }
+ return cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: bytes.NewReader(buffer.Bytes()),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+}
+
+// AddObjectToIndex adds the provided object hash to the index at the provided filename
+func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename string) error {
+ return repo.AddObjectsToIndex(IndexObjectInfo{Mode: mode, Object: object, Filename: filename})
+}
+
+// WriteTree writes the current index as a tree to the object db and returns its hash
+func (repo *Repository) WriteTree() (*Tree, error) {
+ stdout, _, runErr := NewCommand(repo.Ctx, "write-tree").RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+ id, err := NewIDFromString(strings.TrimSpace(stdout))
+ if err != nil {
+ return nil, err
+ }
+ return NewTree(repo, id), nil
+}
diff --git a/modules/git/repo_language_stats.go b/modules/git/repo_language_stats.go
new file mode 100644
index 0000000..37c23fa
--- /dev/null
+++ b/modules/git/repo_language_stats.go
@@ -0,0 +1,251 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "cmp"
+ "io"
+ "strings"
+ "unicode"
+
+ "code.gitea.io/gitea/modules/analyze"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+
+ "github.com/go-enry/go-enry/v2"
+)
+
+const (
+ fileSizeLimit int64 = 16 * 1024 // 16 KiB
+ bigFileSize int64 = 1024 * 1024 // 1 MiB
+)
+
+// mergeLanguageStats mergers language names with different cases. The name with most upper case letters is used.
+func mergeLanguageStats(stats map[string]int64) map[string]int64 {
+ names := map[string]struct {
+ uniqueName string
+ upperCount int
+ }{}
+
+ countUpper := func(s string) (count int) {
+ for _, r := range s {
+ if unicode.IsUpper(r) {
+ count++
+ }
+ }
+ return count
+ }
+
+ for name := range stats {
+ cnt := countUpper(name)
+ lower := strings.ToLower(name)
+ if cnt >= names[lower].upperCount {
+ names[lower] = struct {
+ uniqueName string
+ upperCount int
+ }{uniqueName: name, upperCount: cnt}
+ }
+ }
+
+ res := make(map[string]int64, len(names))
+ for name, num := range stats {
+ res[names[strings.ToLower(name)].uniqueName] += num
+ }
+ return res
+}
+
+// GetLanguageStats calculates language stats for git repository at specified commit
+func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, error) {
+ // We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary.
+ // so let's create a batch stdin and stdout
+ batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ writeID := func(id string) error {
+ _, err := batchStdinWriter.Write([]byte(id + "\n"))
+ return err
+ }
+
+ if err := writeID(commitID); err != nil {
+ return nil, err
+ }
+ shaBytes, typ, size, err := ReadBatchLine(batchReader)
+ if typ != "commit" {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, ErrNotExist{commitID, ""}
+ }
+
+ sha, err := NewIDFromString(string(shaBytes))
+ if err != nil {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, ErrNotExist{commitID, ""}
+ }
+
+ commit, err := CommitFromReader(repo, sha, io.LimitReader(batchReader, size))
+ if err != nil {
+ log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
+ return nil, err
+ }
+ if _, err = batchReader.Discard(1); err != nil {
+ return nil, err
+ }
+
+ tree := commit.Tree
+
+ entries, err := tree.ListEntriesRecursiveWithSize()
+ if err != nil {
+ return nil, err
+ }
+
+ checker, err := repo.GitAttributeChecker(commitID, LinguistAttributes...)
+ if err != nil {
+ return nil, err
+ }
+ defer checker.Close()
+
+ contentBuf := bytes.Buffer{}
+ var content []byte
+
+ // sizes contains the current calculated size of all files by language
+ sizes := make(map[string]int64)
+ // by default we will only count the sizes of programming languages or markup languages
+ // unless they are explicitly set using linguist-language
+ includedLanguage := map[string]bool{}
+ // or if there's only one language in the repository
+ firstExcludedLanguage := ""
+ firstExcludedLanguageSize := int64(0)
+
+ isTrue := func(v optional.Option[bool]) bool {
+ return v.ValueOrDefault(false)
+ }
+ isFalse := func(v optional.Option[bool]) bool {
+ return !v.ValueOrDefault(true)
+ }
+
+ for _, f := range entries {
+ select {
+ case <-repo.Ctx.Done():
+ return sizes, repo.Ctx.Err()
+ default:
+ }
+
+ contentBuf.Reset()
+ content = contentBuf.Bytes()
+
+ if f.Size() == 0 {
+ continue
+ }
+
+ isVendored := optional.None[bool]()
+ isGenerated := optional.None[bool]()
+ isDocumentation := optional.None[bool]()
+ isDetectable := optional.None[bool]()
+
+ attrs, err := checker.CheckPath(f.Name())
+ if err == nil {
+ isVendored = attrs["linguist-vendored"].Bool()
+ isGenerated = attrs["linguist-generated"].Bool()
+ isDocumentation = attrs["linguist-documentation"].Bool()
+ isDetectable = attrs["linguist-detectable"].Bool()
+ if language := cmp.Or(
+ attrs["linguist-language"].String(),
+ attrs["gitlab-language"].Prefix(),
+ ); language != "" {
+ // group languages, such as Pug -> HTML; SCSS -> CSS
+ group := enry.GetLanguageGroup(language)
+ if len(group) != 0 {
+ language = group
+ }
+
+ // this language will always be added to the size
+ sizes[language] += f.Size()
+ continue
+ }
+ }
+
+ if isFalse(isDetectable) || isTrue(isVendored) || isTrue(isDocumentation) ||
+ (!isFalse(isVendored) && analyze.IsVendor(f.Name())) ||
+ enry.IsDotFile(f.Name()) ||
+ enry.IsConfiguration(f.Name()) ||
+ (!isFalse(isDocumentation) && enry.IsDocumentation(f.Name())) {
+ continue
+ }
+
+ // If content can not be read or file is too big just do detection by filename
+
+ if f.Size() <= bigFileSize {
+ if err := writeID(f.ID.String()); err != nil {
+ return nil, err
+ }
+ _, _, size, err := ReadBatchLine(batchReader)
+ if err != nil {
+ log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err)
+ return nil, err
+ }
+
+ sizeToRead := size
+ discard := int64(1)
+ if size > fileSizeLimit {
+ sizeToRead = fileSizeLimit
+ discard = size - fileSizeLimit + 1
+ }
+
+ _, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead))
+ if err != nil {
+ return nil, err
+ }
+ content = contentBuf.Bytes()
+ if err := DiscardFull(batchReader, discard); err != nil {
+ return nil, err
+ }
+ }
+ if !isTrue(isGenerated) && enry.IsGenerated(f.Name(), content) {
+ continue
+ }
+
+ // FIXME: Why can't we split this and the IsGenerated tests to avoid reading the blob unless absolutely necessary?
+ // - eg. do the all the detection tests using filename first before reading content.
+ language := analyze.GetCodeLanguage(f.Name(), content)
+ if language == "" {
+ continue
+ }
+
+ // group languages, such as Pug -> HTML; SCSS -> CSS
+ group := enry.GetLanguageGroup(language)
+ if group != "" {
+ language = group
+ }
+
+ included, checked := includedLanguage[language]
+ langType := enry.GetLanguageType(language)
+ if !checked {
+ included = langType == enry.Programming || langType == enry.Markup
+ if !included && (isTrue(isDetectable) || (langType == enry.Prose && isFalse(isDocumentation))) {
+ included = true
+ }
+ includedLanguage[language] = included
+ }
+ if included {
+ sizes[language] += f.Size()
+ } else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) {
+ // Only consider Programming or Markup languages as fallback
+ if !(langType == enry.Programming || langType == enry.Markup) {
+ continue
+ }
+ firstExcludedLanguage = language
+ firstExcludedLanguageSize += f.Size()
+ }
+ }
+
+ // If there are no included languages add the first excluded language
+ if len(sizes) == 0 && firstExcludedLanguage != "" {
+ sizes[firstExcludedLanguage] = firstExcludedLanguageSize
+ }
+
+ return mergeLanguageStats(sizes), nil
+}
diff --git a/modules/git/repo_language_stats_test.go b/modules/git/repo_language_stats_test.go
new file mode 100644
index 0000000..fd80e44
--- /dev/null
+++ b/modules/git/repo_language_stats_test.go
@@ -0,0 +1,42 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetLanguageStats(t *testing.T) {
+ repoPath := filepath.Join(testReposDir, "language_stats_repo")
+ gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+ require.NoError(t, err)
+
+ defer gitRepo.Close()
+
+ stats, err := gitRepo.GetLanguageStats("8fee858da5796dfb37704761701bb8e800ad9ef3")
+ require.NoError(t, err)
+
+ assert.EqualValues(t, map[string]int64{
+ "Python": 134,
+ "Java": 112,
+ }, stats)
+}
+
+func TestMergeLanguageStats(t *testing.T) {
+ assert.EqualValues(t, map[string]int64{
+ "PHP": 1,
+ "python": 10,
+ "JAVA": 700,
+ }, mergeLanguageStats(map[string]int64{
+ "PHP": 1,
+ "python": 10,
+ "Java": 100,
+ "java": 200,
+ "JAVA": 400,
+ }))
+}
diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go
new file mode 100644
index 0000000..3d48b91
--- /dev/null
+++ b/modules/git/repo_object.go
@@ -0,0 +1,101 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "strings"
+)
+
+// ObjectType git object type
+type ObjectType string
+
+const (
+ // ObjectCommit commit object type
+ ObjectCommit ObjectType = "commit"
+ // ObjectTree tree object type
+ ObjectTree ObjectType = "tree"
+ // ObjectBlob blob object type
+ ObjectBlob ObjectType = "blob"
+ // ObjectTag tag object type
+ ObjectTag ObjectType = "tag"
+ // ObjectBranch branch object type
+ ObjectBranch ObjectType = "branch"
+)
+
+// Bytes returns the byte array for the Object Type
+func (o ObjectType) Bytes() []byte {
+ return []byte(o)
+}
+
+type EmptyReader struct{}
+
+func (EmptyReader) Read(p []byte) (int, error) {
+ return 0, io.EOF
+}
+
+func (repo *Repository) GetObjectFormat() (ObjectFormat, error) {
+ if repo != nil && repo.objectFormat != nil {
+ return repo.objectFormat, nil
+ }
+
+ str, err := repo.hashObject(EmptyReader{}, false)
+ if err != nil {
+ return nil, err
+ }
+ hash, err := NewIDFromString(str)
+ if err != nil {
+ return nil, err
+ }
+
+ repo.objectFormat = hash.Type()
+
+ return repo.objectFormat, nil
+}
+
+// HashObject takes a reader and returns hash for that reader
+func (repo *Repository) HashObject(reader io.Reader) (ObjectID, error) {
+ idStr, err := repo.hashObject(reader, true)
+ if err != nil {
+ return nil, err
+ }
+ return NewIDFromString(idStr)
+}
+
+func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) {
+ var cmd *Command
+ if save {
+ cmd = NewCommand(repo.Ctx, "hash-object", "-w", "--stdin")
+ } else {
+ cmd = NewCommand(repo.Ctx, "hash-object", "--stdin")
+ }
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ err := cmd.Run(&RunOpts{
+ Dir: repo.Path,
+ Stdin: reader,
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+// GetRefType gets the type of the ref based on the string
+func (repo *Repository) GetRefType(ref string) ObjectType {
+ if repo.IsTagExist(ref) {
+ return ObjectTag
+ } else if repo.IsBranchExist(ref) {
+ return ObjectBranch
+ } else if repo.IsCommitExist(ref) {
+ return ObjectCommit
+ } else if _, err := repo.GetBlob(ref); err == nil {
+ return ObjectBlob
+ }
+ return ObjectType("invalid")
+}
diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go
new file mode 100644
index 0000000..550c653
--- /dev/null
+++ b/modules/git/repo_ref.go
@@ -0,0 +1,157 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// GetRefs returns all references of the repository.
+func (repo *Repository) GetRefs() ([]*Reference, error) {
+ return repo.GetRefsFiltered("")
+}
+
+// ListOccurrences lists all refs of the given refType the given commit appears in sorted by creation date DESC
+// refType should only be a literal "branch" or "tag" and nothing else
+func (repo *Repository) ListOccurrences(ctx context.Context, refType, commitSHA string) ([]string, error) {
+ cmd := NewCommand(ctx)
+ if refType == "branch" {
+ cmd.AddArguments("branch")
+ } else if refType == "tag" {
+ cmd.AddArguments("tag")
+ } else {
+ return nil, util.NewInvalidArgumentErrorf(`can only use "branch" or "tag" for refType, but got %q`, refType)
+ }
+ stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains").AddDynamicArguments(commitSHA).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+
+ refs := strings.Split(strings.TrimSpace(stdout), "\n")
+ if refType == "branch" {
+ return parseBranches(refs), nil
+ }
+ return parseTags(refs), nil
+}
+
+func parseBranches(refs []string) []string {
+ results := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ if strings.HasPrefix(ref, "* ") { // current branch (main branch)
+ results = append(results, ref[len("* "):])
+ } else if strings.HasPrefix(ref, " ") { // all other branches
+ results = append(results, ref[len(" "):])
+ } else if ref != "" {
+ results = append(results, ref)
+ }
+ }
+ return results
+}
+
+func parseTags(refs []string) []string {
+ results := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ if ref != "" {
+ results = append(results, ref)
+ }
+ }
+ return results
+}
+
+// ExpandRef expands any partial reference to its full form
+func (repo *Repository) ExpandRef(ref string) (string, error) {
+ if strings.HasPrefix(ref, "refs/") {
+ return ref, nil
+ } else if strings.HasPrefix(ref, "tags/") || strings.HasPrefix(ref, "heads/") {
+ return "refs/" + ref, nil
+ } else if repo.IsTagExist(ref) {
+ return TagPrefix + ref, nil
+ } else if repo.IsBranchExist(ref) {
+ return BranchPrefix + ref, nil
+ } else if repo.IsCommitExist(ref) {
+ return ref, nil
+ }
+ return "", fmt.Errorf("could not expand reference '%s'", ref)
+}
+
+// GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with.
+func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) {
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ go func() {
+ stderrBuilder := &strings.Builder{}
+ err := NewCommand(repo.Ctx, "for-each-ref").Run(&RunOpts{
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderrBuilder,
+ })
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderrBuilder.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ refs := make([]*Reference, 0)
+ bufReader := bufio.NewReader(stdoutReader)
+ for {
+ // The output of for-each-ref is simply a list:
+ // <sha> SP <type> TAB <ref> LF
+ sha, err := bufReader.ReadString(' ')
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ sha = sha[:len(sha)-1]
+
+ typ, err := bufReader.ReadString('\t')
+ if err == io.EOF {
+ // This should not happen, but we'll tolerate it
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ typ = typ[:len(typ)-1]
+
+ refName, err := bufReader.ReadString('\n')
+ if err == io.EOF {
+ // This should not happen, but we'll tolerate it
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ refName = refName[:len(refName)-1]
+
+ // refName cannot be HEAD but can be remotes or stash
+ if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" {
+ continue
+ }
+
+ if pattern == "" || strings.HasPrefix(refName, pattern) {
+ r := &Reference{
+ Name: refName,
+ Object: MustIDFromString(sha),
+ Type: typ,
+ repo: repo,
+ }
+ refs = append(refs, r)
+ }
+ }
+
+ return refs, nil
+}
diff --git a/modules/git/repo_ref_test.go b/modules/git/repo_ref_test.go
new file mode 100644
index 0000000..609bef5
--- /dev/null
+++ b/modules/git/repo_ref_test.go
@@ -0,0 +1,56 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetRefs(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ refs, err := bareRepo1.GetRefs()
+
+ require.NoError(t, err)
+ assert.Len(t, refs, 6)
+
+ expectedRefs := []string{
+ BranchPrefix + "branch1",
+ BranchPrefix + "branch2",
+ BranchPrefix + "master",
+ TagPrefix + "test",
+ TagPrefix + "signed-tag",
+ NotesRef,
+ }
+
+ for _, ref := range refs {
+ assert.Contains(t, expectedRefs, ref.Name)
+ }
+}
+
+func TestRepository_GetRefsFiltered(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ refs, err := bareRepo1.GetRefsFiltered(TagPrefix)
+
+ require.NoError(t, err)
+ if assert.Len(t, refs, 2) {
+ assert.Equal(t, TagPrefix+"signed-tag", refs[0].Name)
+ assert.Equal(t, "tag", refs[0].Type)
+ assert.Equal(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", refs[0].Object.String())
+ assert.Equal(t, TagPrefix+"test", refs[1].Name)
+ assert.Equal(t, "tag", refs[1].Type)
+ assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[1].Object.String())
+ }
+}
diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go
new file mode 100644
index 0000000..8322010
--- /dev/null
+++ b/modules/git/repo_stats.go
@@ -0,0 +1,151 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/container"
+)
+
+// CodeActivityStats represents git statistics data
+type CodeActivityStats struct {
+ AuthorCount int64
+ CommitCount int64
+ ChangedFiles int64
+ Additions int64
+ Deletions int64
+ CommitCountInAllBranches int64
+ Authors []*CodeActivityAuthor
+}
+
+// CodeActivityAuthor represents git statistics data for commit authors
+type CodeActivityAuthor struct {
+ Name string
+ Email string
+ Commits int64
+}
+
+// GetCodeActivityStats returns code statistics for activity page
+func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) {
+ stats := &CodeActivityStats{}
+
+ since := fromTime.Format(time.RFC3339)
+
+ stdout, _, runErr := NewCommand(repo.Ctx, "rev-list", "--count", "--no-merges", "--branches=*", "--date=iso").AddOptionFormat("--since='%s'", since).RunStdString(&RunOpts{Dir: repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ c, err := strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ stats.CommitCountInAllBranches = c
+
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ gitCmd := NewCommand(repo.Ctx, "log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso").AddOptionFormat("--since='%s'", since)
+ if len(branch) == 0 {
+ gitCmd.AddArguments("--branches=*")
+ } else {
+ gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch)
+ }
+
+ stderr := new(strings.Builder)
+ err = gitCmd.Run(&RunOpts{
+ Env: []string{},
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ scanner := bufio.NewScanner(stdoutReader)
+ scanner.Split(bufio.ScanLines)
+ stats.CommitCount = 0
+ stats.Additions = 0
+ stats.Deletions = 0
+ authors := make(map[string]*CodeActivityAuthor)
+ files := make(container.Set[string])
+ var author string
+ p := 0
+ for scanner.Scan() {
+ l := strings.TrimSpace(scanner.Text())
+ if l == "---" {
+ p = 1
+ } else if p == 0 {
+ continue
+ } else {
+ p++
+ }
+ if p > 4 && len(l) == 0 {
+ continue
+ }
+ switch p {
+ case 1: // Separator
+ case 2: // Commit sha-1
+ stats.CommitCount++
+ case 3: // Author
+ author = l
+ case 4: // E-mail
+ email := strings.ToLower(l)
+ if _, ok := authors[email]; !ok {
+ authors[email] = &CodeActivityAuthor{Name: author, Email: email, Commits: 0}
+ }
+ authors[email].Commits++
+ default: // Changed file
+ if parts := strings.Fields(l); len(parts) >= 3 {
+ if parts[0] != "-" {
+ if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil {
+ stats.Additions += c
+ }
+ }
+ if parts[1] != "-" {
+ if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil {
+ stats.Deletions += c
+ }
+ }
+ files.Add(parts[2])
+ }
+ }
+ }
+ if err = scanner.Err(); err != nil {
+ _ = stdoutReader.Close()
+ return fmt.Errorf("GetCodeActivityStats scan: %w", err)
+ }
+ a := make([]*CodeActivityAuthor, 0, len(authors))
+ for _, v := range authors {
+ a = append(a, v)
+ }
+ // Sort authors descending depending on commit count
+ sort.Slice(a, func(i, j int) bool {
+ return a[i].Commits > a[j].Commits
+ })
+ stats.AuthorCount = int64(len(authors))
+ stats.ChangedFiles = int64(len(files))
+ stats.Authors = a
+ _ = stdoutReader.Close()
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr)
+ }
+
+ return stats, nil
+}
diff --git a/modules/git/repo_stats_test.go b/modules/git/repo_stats_test.go
new file mode 100644
index 0000000..2a15b6f
--- /dev/null
+++ b/modules/git/repo_stats_test.go
@@ -0,0 +1,37 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetCodeActivityStats(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+ defer bareRepo1.Close()
+
+ timeFrom, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00")
+ require.NoError(t, err)
+
+ code, err := bareRepo1.GetCodeActivityStats(timeFrom, "")
+ require.NoError(t, err)
+ assert.NotNil(t, code)
+
+ assert.EqualValues(t, 10, code.CommitCount)
+ assert.EqualValues(t, 3, code.AuthorCount)
+ assert.EqualValues(t, 10, code.CommitCountInAllBranches)
+ assert.EqualValues(t, 10, code.Additions)
+ assert.EqualValues(t, 1, code.Deletions)
+ assert.Len(t, code.Authors, 3)
+ assert.EqualValues(t, "tris.git@shoddynet.org", code.Authors[1].Email)
+ assert.EqualValues(t, 3, code.Authors[1].Commits)
+ assert.EqualValues(t, 5, code.Authors[0].Commits)
+}
diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go
new file mode 100644
index 0000000..12b0c02
--- /dev/null
+++ b/modules/git/repo_tag.go
@@ -0,0 +1,366 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git/foreachref"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// TagPrefix tags prefix path on the repository
+const TagPrefix = "refs/tags/"
+
+// IsTagExist returns true if given tag exists in the repository.
+func IsTagExist(ctx context.Context, repoPath, name string) bool {
+ return IsReferenceExist(ctx, repoPath, TagPrefix+name)
+}
+
+// CreateTag create one tag in the repository
+func (repo *Repository) CreateTag(name, revision string) error {
+ _, _, err := NewCommand(repo.Ctx, "tag").AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// CreateAnnotatedTag create one annotated tag in the repository
+func (repo *Repository) CreateAnnotatedTag(name, message, revision string) error {
+ _, _, err := NewCommand(repo.Ctx, "tag", "-a", "-m").AddDynamicArguments(message).AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path})
+ return err
+}
+
+// GetTagNameBySHA returns the name of a tag from its tag object SHA or commit SHA
+func (repo *Repository) GetTagNameBySHA(sha string) (string, error) {
+ if len(sha) < 5 {
+ return "", fmt.Errorf("SHA is too short: %s", sha)
+ }
+
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--tags", "-d").RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return "", err
+ }
+
+ tagRefs := strings.Split(stdout, "\n")
+ for _, tagRef := range tagRefs {
+ if len(strings.TrimSpace(tagRef)) > 0 {
+ fields := strings.Fields(tagRef)
+ if strings.HasPrefix(fields[0], sha) && strings.HasPrefix(fields[1], TagPrefix) {
+ name := fields[1][len(TagPrefix):]
+ // annotated tags show up twice, we should only return if is not the ^{} ref
+ if !strings.HasSuffix(name, "^{}") {
+ return name, nil
+ }
+ }
+ }
+ }
+ return "", ErrNotExist{ID: sha}
+}
+
+// GetTagID returns the object ID for a tag (annotated tags have both an object SHA AND a commit SHA)
+func (repo *Repository) GetTagID(name string) (string, error) {
+ stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--tags").AddDashesAndList(name).RunStdString(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return "", err
+ }
+ // Make sure exact match is used: "v1" != "release/v1"
+ for _, line := range strings.Split(stdout, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == "refs/tags/"+name {
+ return fields[0], nil
+ }
+ }
+ return "", ErrNotExist{ID: name}
+}
+
+// GetTag returns a Git tag by given name.
+func (repo *Repository) GetTag(name string) (*Tag, error) {
+ idStr, err := repo.GetTagID(name)
+ if err != nil {
+ return nil, err
+ }
+
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// GetTagWithID returns a Git tag by given name and ID
+func (repo *Repository) GetTagWithID(idStr, name string) (*Tag, error) {
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// GetTagInfos returns all tag infos of the repository.
+func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) {
+ // Generally, refname:short should be equal to refname:lstrip=2 except core.warnAmbiguousRefs is used to select the strict abbreviation mode.
+ // https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname
+ forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature")
+
+ stdoutReader, stdoutWriter := io.Pipe()
+ defer stdoutReader.Close()
+ defer stdoutWriter.Close()
+ stderr := strings.Builder{}
+ rc := &RunOpts{Dir: repo.Path, Stdout: stdoutWriter, Stderr: &stderr}
+
+ go func() {
+ err := NewCommand(repo.Ctx, "for-each-ref").
+ AddOptionFormat("--format=%s", forEachRefFmt.Flag()).
+ AddArguments("--sort", "-*creatordate", "refs/tags").Run(rc)
+ if err != nil {
+ _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderr.String()))
+ } else {
+ _ = stdoutWriter.Close()
+ }
+ }()
+
+ var tags []*Tag
+ parser := forEachRefFmt.Parser(stdoutReader)
+ for {
+ ref := parser.Next()
+ if ref == nil {
+ break
+ }
+
+ tag, err := parseTagRef(ref)
+ if err != nil {
+ return nil, 0, fmt.Errorf("GetTagInfos: parse tag: %w", err)
+ }
+ tags = append(tags, tag)
+ }
+ if err := parser.Err(); err != nil {
+ return nil, 0, fmt.Errorf("GetTagInfos: parse output: %w", err)
+ }
+
+ sortTagsByTime(tags)
+ tagsTotal := len(tags)
+ if page != 0 {
+ tags = util.PaginateSlice(tags, page, pageSize).([]*Tag)
+ }
+
+ return tags, tagsTotal, nil
+}
+
+// parseTagRef parses a tag from a 'git for-each-ref'-produced reference.
+func parseTagRef(ref map[string]string) (tag *Tag, err error) {
+ tag = &Tag{
+ Type: ref["objecttype"],
+ Name: ref["refname:lstrip=2"],
+ }
+
+ tag.ID, err = NewIDFromString(ref["objectname"])
+ if err != nil {
+ return nil, fmt.Errorf("parse objectname '%s': %w", ref["objectname"], err)
+ }
+
+ if tag.Type == "commit" {
+ // lightweight tag
+ tag.Object = tag.ID
+ } else {
+ // annotated tag
+ tag.Object, err = NewIDFromString(ref["object"])
+ if err != nil {
+ return nil, fmt.Errorf("parse object '%s': %w", ref["object"], err)
+ }
+ }
+
+ tag.Tagger = parseSignatureFromCommitLine(ref["creator"])
+ tag.Message = ref["contents"]
+ // strip the signature if present in contents field
+ pgpStart := strings.Index(tag.Message, beginpgp)
+ if pgpStart >= 0 {
+ tag.Message = tag.Message[0:pgpStart]
+ } else {
+ sshStart := strings.Index(tag.Message, beginssh)
+ if sshStart >= 0 {
+ tag.Message = tag.Message[0:sshStart]
+ }
+ }
+
+ // annotated tag with signature
+ if tag.Type == "tag" && ref["contents:signature"] != "" {
+ payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s\n",
+ tag.Object, tag.Name, ref["creator"], strings.TrimSpace(tag.Message))
+ tag.Signature = &ObjectSignature{
+ Signature: ref["contents:signature"],
+ Payload: payload,
+ }
+ }
+
+ return tag, nil
+}
+
+// GetAnnotatedTag returns a Git tag by its SHA, must be an annotated tag
+func (repo *Repository) GetAnnotatedTag(sha string) (*Tag, error) {
+ id, err := NewIDFromString(sha)
+ if err != nil {
+ return nil, err
+ }
+
+ // Tag type must be "tag" (annotated) and not a "commit" (lightweight) tag
+ if tagType, err := repo.GetTagType(id); err != nil {
+ return nil, err
+ } else if ObjectType(tagType) != ObjectTag {
+ // not an annotated tag
+ return nil, ErrNotExist{ID: id.String()}
+ }
+
+ // Get tag name
+ name, err := repo.GetTagNameBySHA(id.String())
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := repo.getTag(id, name)
+ if err != nil {
+ return nil, err
+ }
+ return tag, nil
+}
+
+// IsTagExist returns true if given tag exists in the repository.
+func (repo *Repository) IsTagExist(name string) bool {
+ if repo == nil || name == "" {
+ return false
+ }
+
+ return repo.IsReferenceExist(TagPrefix + name)
+}
+
+// GetTags returns all tags of the repository.
+// returning at most limit tags, or all if limit is 0.
+func (repo *Repository) GetTags(skip, limit int) (tags []string, err error) {
+ tags, _, err = callShowRef(repo.Ctx, repo.Path, TagPrefix, TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}, skip, limit)
+ return tags, err
+}
+
+// GetTagType gets the type of the tag, either commit (simple) or tag (annotated)
+func (repo *Repository) GetTagType(id ObjectID) (string, error) {
+ wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
+ if err != nil {
+ return "", err
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(id.String() + "\n"))
+ if err != nil {
+ return "", err
+ }
+ _, typ, _, err := ReadBatchLine(rd)
+ if IsErrNotExist(err) {
+ return "", ErrNotExist{ID: id.String()}
+ }
+ return typ, nil
+}
+
+func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
+ t, ok := repo.tagCache.Get(tagID.String())
+ if ok {
+ log.Debug("Hit cache: %s", tagID)
+ tagClone := *t.(*Tag)
+ tagClone.Name = name // This is necessary because lightweight tags may have same id
+ return &tagClone, nil
+ }
+
+ tp, err := repo.GetTagType(tagID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the commit ID and tag ID (may be different for annotated tag) for the returned tag object
+ commitIDStr, err := repo.GetTagCommitID(name)
+ if err != nil {
+ // every tag should have a commit ID so return all errors
+ return nil, err
+ }
+ commitID, err := NewIDFromString(commitIDStr)
+ if err != nil {
+ return nil, err
+ }
+
+ // If type is "commit, the tag is a lightweight tag
+ if ObjectType(tp) == ObjectCommit {
+ commit, err := repo.GetCommit(commitIDStr)
+ if err != nil {
+ return nil, err
+ }
+ tag := &Tag{
+ Name: name,
+ ID: tagID,
+ Object: commitID,
+ Type: tp,
+ Tagger: commit.Committer,
+ Message: commit.Message(),
+ }
+
+ repo.tagCache.Set(tagID.String(), tag)
+ return tag, nil
+ }
+
+ // The tag is an annotated tag with a message.
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ if _, err := wr.Write([]byte(tagID.String() + "\n")); err != nil {
+ return nil, err
+ }
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ if errors.Is(err, io.EOF) || IsErrNotExist(err) {
+ return nil, ErrNotExist{ID: tagID.String()}
+ }
+ return nil, err
+ }
+ if typ != "tag" {
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{ID: tagID.String()}
+ }
+
+ // then we need to parse the tag
+ // and load the commit
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ if err != nil {
+ return nil, err
+ }
+
+ tag, err := parseTagData(tagID.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+
+ tag.Name = name
+ tag.ID = tagID
+ tag.Type = tp
+
+ repo.tagCache.Set(tagID.String(), tag)
+ return tag, nil
+}
diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go
new file mode 100644
index 0000000..1cf420a
--- /dev/null
+++ b/modules/git/repo_tag_test.go
@@ -0,0 +1,364 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepository_GetTags(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ tags, total, err := bareRepo1.GetTagInfos(0, 0)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.Len(t, tags, 2)
+ assert.Len(t, tags, total)
+ assert.EqualValues(t, "signed-tag", tags[0].Name)
+ assert.EqualValues(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", tags[0].ID.String())
+ assert.EqualValues(t, "tag", tags[0].Type)
+ assert.EqualValues(t, "test", tags[1].Name)
+ assert.EqualValues(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", tags[1].ID.String())
+ assert.EqualValues(t, "tag", tags[1].Type)
+}
+
+func TestRepository_GetTag(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ bareRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ // LIGHTWEIGHT TAGS
+ lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1"
+ lTagName := "lightweightTag"
+
+ // Create the lightweight tag
+ err = bareRepo1.CreateTag(lTagName, lTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the lightweight tag: %s for ID: %s. Error: %v", lTagName, lTagCommitID, err)
+ return
+ }
+
+ // and try to get the Tag for lightweight tag
+ lTag, err := bareRepo1.GetTag(lTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ if lTag == nil {
+ assert.NotNil(t, lTag)
+ assert.FailNow(t, "nil lTag: %s", lTagName)
+ }
+ assert.EqualValues(t, lTagName, lTag.Name)
+ assert.EqualValues(t, lTagCommitID, lTag.ID.String())
+ assert.EqualValues(t, lTagCommitID, lTag.Object.String())
+ assert.EqualValues(t, "commit", lTag.Type)
+
+ // ANNOTATED TAGS
+ aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ aTagName := "annotatedTag"
+ aTagMessage := "my annotated message \n - test two line"
+
+ // Create the annotated tag
+ err = bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the annotated tag: %s for ID: %s. Error: %v", aTagName, aTagCommitID, err)
+ return
+ }
+
+ // Now try to get the tag for the annotated Tag
+ aTagID, err := bareRepo1.GetTagID(aTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ aTag, err := bareRepo1.GetTag(aTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ if aTag == nil {
+ assert.NotNil(t, aTag)
+ assert.FailNow(t, "nil aTag: %s", aTagName)
+ }
+ assert.EqualValues(t, aTagName, aTag.Name)
+ assert.EqualValues(t, aTagID, aTag.ID.String())
+ assert.NotEqual(t, aTagID, aTag.Object.String())
+ assert.EqualValues(t, aTagCommitID, aTag.Object.String())
+ assert.EqualValues(t, "tag", aTag.Type)
+
+ // RELEASE TAGS
+
+ rTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ rTagName := "release/" + lTagName
+
+ err = bareRepo1.CreateTag(rTagName, rTagCommitID)
+ if err != nil {
+ require.NoError(t, err, "Unable to create the tag: %s for ID: %s. Error: %v", rTagName, rTagCommitID, err)
+ return
+ }
+
+ rTagID, err := bareRepo1.GetTagID(rTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.EqualValues(t, rTagCommitID, rTagID)
+
+ oTagID, err := bareRepo1.GetTagID(lTagName)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.EqualValues(t, lTagCommitID, oTagID)
+}
+
+func TestRepository_GetAnnotatedTag(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+
+ clonedPath, err := cloneRepo(t, bareRepo1Path)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ bareRepo1, err := openRepositoryWithDefaultContext(clonedPath)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ defer bareRepo1.Close()
+
+ lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1"
+ lTagName := "lightweightTag"
+ bareRepo1.CreateTag(lTagName, lTagCommitID)
+
+ aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"
+ aTagName := "annotatedTag"
+ aTagMessage := "my annotated message"
+ bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID)
+ aTagID, _ := bareRepo1.GetTagID(aTagName)
+
+ // Try an annotated tag
+ tag, err := bareRepo1.GetAnnotatedTag(aTagID)
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.NotNil(t, tag)
+ assert.EqualValues(t, aTagName, tag.Name)
+ assert.EqualValues(t, aTagID, tag.ID.String())
+ assert.EqualValues(t, "tag", tag.Type)
+
+ // Annotated tag's Commit ID should fail
+ tag2, err := bareRepo1.GetAnnotatedTag(aTagCommitID)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ assert.Nil(t, tag2)
+
+ // Annotated tag's name should fail
+ tag3, err := bareRepo1.GetAnnotatedTag(aTagName)
+ require.Error(t, err)
+ require.Errorf(t, err, "Length must be 40: %d", len(aTagName))
+ assert.Nil(t, tag3)
+
+ // Lightweight Tag should fail
+ tag4, err := bareRepo1.GetAnnotatedTag(lTagCommitID)
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ assert.Nil(t, tag4)
+}
+
+func TestRepository_parseTagRef(t *testing.T) {
+ tests := []struct {
+ name string
+
+ givenRef map[string]string
+
+ want *Tag
+ wantErr bool
+ expectedErr error
+ }{
+ {
+ name: "lightweight tag",
+
+ givenRef: map[string]string{
+ "objecttype": "commit",
+ "refname:lstrip=2": "v1.9.1",
+ // object will be empty for lightweight tags
+ "object": "",
+ "objectname": "ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ "contents:signature": "",
+ },
+
+ want: &Tag{
+ Name: "v1.9.1",
+ ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
+ Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
+ Type: "commit",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
+ Signature: nil,
+ },
+ },
+
+ {
+ name: "annotated tag",
+
+ givenRef: map[string]string{
+ "objecttype": "tag",
+ "refname:lstrip=2": "v0.0.1",
+ // object will refer to commit hash for annotated tag
+ "object": "3325fd8a973321fd59455492976c042dde3fd1ca",
+ "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ "contents:signature": "",
+ },
+
+ want: &Tag{
+ Name: "v0.0.1",
+ ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
+ Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
+ Type: "tag",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
+ Signature: nil,
+ },
+ },
+
+ {
+ name: "annotated tag with signature",
+
+ givenRef: map[string]string{
+ "objecttype": "tag",
+ "refname:lstrip=2": "v0.0.1",
+ "object": "3325fd8a973321fd59455492976c042dde3fd1ca",
+ "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9",
+ "creator": "Foo Bar <foo@bar.com> 1565789218 +0300",
+ "contents": `Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ "contents:signature": `-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ },
+
+ want: &Tag{
+ Name: "v0.0.1",
+ ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
+ Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
+ Type: "tag",
+ Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
+ Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md",
+ Signature: &ObjectSignature{
+ Signature: `-----BEGIN PGP SIGNATURE-----
+
+aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
+3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT
+T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU
+REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE
+slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G
+1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt
+f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx
+yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6
+kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg
+qbHDASXl
+=2yGi
+-----END PGP SIGNATURE-----
+
+`,
+ Payload: `object 3325fd8a973321fd59455492976c042dde3fd1ca
+type commit
+tag v0.0.1
+tagger Foo Bar <foo@bar.com> 1565789218 +0300
+
+Add changelog of v1.9.1 (#7859)
+
+* add changelog of v1.9.1
+* Update CHANGELOG.md
+`,
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ tc := test // don't close over loop variable
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseTagRef(tc.givenRef)
+
+ if tc.wantErr {
+ require.Error(t, err)
+ require.ErrorIs(t, err, tc.expectedErr)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tc.want, got)
+ }
+ })
+ }
+}
diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go
new file mode 100644
index 0000000..8fb19a5
--- /dev/null
+++ b/modules/git/repo_test.go
@@ -0,0 +1,57 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetLatestCommitTime(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ lct, err := GetLatestCommitTime(DefaultContext, bareRepo1Path)
+ require.NoError(t, err)
+ // Time is Sun Nov 13 16:40:14 2022 +0100
+ // which is the time of commit
+ // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
+ assert.EqualValues(t, 1668354014, lct.Unix())
+}
+
+func TestRepoIsEmpty(t *testing.T) {
+ emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty")
+ repo, err := openRepositoryWithDefaultContext(emptyRepo2Path)
+ require.NoError(t, err)
+ defer repo.Close()
+ isEmpty, err := repo.IsEmpty()
+ require.NoError(t, err)
+ assert.True(t, isEmpty)
+}
+
+func TestRepoGetDivergingCommits(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ do, err := GetDivergingCommits(context.Background(), bareRepo1Path, "master", "branch2")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 1,
+ Behind: 5,
+ }, do)
+
+ do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "master")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 0,
+ Behind: 0,
+ }, do)
+
+ do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "test")
+ require.NoError(t, err)
+ assert.Equal(t, DivergeObject{
+ Ahead: 0,
+ Behind: 2,
+ }, do)
+}
diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go
new file mode 100644
index 0000000..53d94d9
--- /dev/null
+++ b/modules/git/repo_tree.go
@@ -0,0 +1,156 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+// CommitTreeOpts represents the possible options to CommitTree
+type CommitTreeOpts struct {
+ Parents []string
+ Message string
+ KeyID string
+ NoGPGSign bool
+ AlwaysSign bool
+}
+
+// CommitTree creates a commit from a given tree id for the user with provided message
+func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opts CommitTreeOpts) (ObjectID, error) {
+ commitTimeStr := time.Now().Format(time.RFC3339)
+
+ // Because this may call hooks we should pass in the environment
+ env := append(os.Environ(),
+ "GIT_AUTHOR_NAME="+author.Name,
+ "GIT_AUTHOR_EMAIL="+author.Email,
+ "GIT_AUTHOR_DATE="+commitTimeStr,
+ "GIT_COMMITTER_NAME="+committer.Name,
+ "GIT_COMMITTER_EMAIL="+committer.Email,
+ "GIT_COMMITTER_DATE="+commitTimeStr,
+ )
+ cmd := NewCommand(repo.Ctx, "commit-tree").AddDynamicArguments(tree.ID.String())
+
+ for _, parent := range opts.Parents {
+ cmd.AddArguments("-p").AddDynamicArguments(parent)
+ }
+
+ messageBytes := new(bytes.Buffer)
+ _, _ = messageBytes.WriteString(opts.Message)
+ _, _ = messageBytes.WriteString("\n")
+
+ if opts.KeyID != "" || opts.AlwaysSign {
+ cmd.AddOptionFormat("-S%s", opts.KeyID)
+ }
+
+ if opts.NoGPGSign {
+ cmd.AddArguments("--no-gpg-sign")
+ }
+
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ err := cmd.Run(&RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdin: messageBytes,
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ return nil, ConcatenateError(err, stderr.String())
+ }
+ return NewIDFromString(strings.TrimSpace(stdout.String()))
+}
+
+func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
+ wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(id.String() + "\n"))
+
+ // ignore the SHA
+ _, typ, size, err := ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+
+ switch typ {
+ case "tag":
+ resolvedID := id
+ data, err := io.ReadAll(io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ tag, err := parseTagData(id.Type(), data)
+ if err != nil {
+ return nil, err
+ }
+ commit, err := tag.Commit(repo)
+ if err != nil {
+ return nil, err
+ }
+ commit.Tree.ResolvedID = resolvedID
+ return &commit.Tree, nil
+ case "commit":
+ commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := rd.Discard(1); err != nil {
+ return nil, err
+ }
+ commit.Tree.ResolvedID = commit.ID
+ return &commit.Tree, nil
+ case "tree":
+ tree := NewTree(repo, id)
+ tree.ResolvedID = id
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size)
+ if err != nil {
+ return nil, err
+ }
+ tree.entriesParsed = true
+ return tree, nil
+ default:
+ if err := DiscardFull(rd, size+1); err != nil {
+ return nil, err
+ }
+ return nil, ErrNotExist{
+ ID: id.String(),
+ }
+ }
+}
+
+// GetTree find the tree object in the repository.
+func (repo *Repository) GetTree(idStr string) (*Tree, error) {
+ objectFormat, err := repo.GetObjectFormat()
+ if err != nil {
+ return nil, err
+ }
+ if len(idStr) != objectFormat.FullLength() {
+ res, err := repo.GetRefCommitID(idStr)
+ if err != nil {
+ return nil, err
+ }
+ if len(res) > 0 {
+ idStr = res
+ }
+ }
+ id, err := NewIDFromString(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ return repo.getTree(id)
+}
diff --git a/modules/git/signature.go b/modules/git/signature.go
new file mode 100644
index 0000000..c368ce3
--- /dev/null
+++ b/modules/git/signature.go
@@ -0,0 +1,67 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Signature represents the Author, Committer or Tagger information.
+type Signature struct {
+ Name string // the committer name, it can be anything
+ Email string // the committer email, it can be anything
+ When time.Time // the timestamp of the signature
+}
+
+func (s *Signature) String() string {
+ return fmt.Sprintf("%s <%s>", s.Name, s.Email)
+}
+
+// Decode decodes a byte array representing a signature to signature
+func (s *Signature) Decode(b []byte) {
+ *s = *parseSignatureFromCommitLine(util.UnsafeBytesToString(b))
+}
+
+// Helper to get a signature from the commit line, which looks like:
+//
+// full name <user@example.com> 1378823654 +0200
+//
+// Haven't found the official reference for the standard format yet.
+// This function never fails, if the "line" can't be parsed, it returns a default Signature with "zero" time.
+func parseSignatureFromCommitLine(line string) *Signature {
+ sig := &Signature{}
+ s1, sx, ok1 := strings.Cut(line, " <")
+ s2, s3, ok2 := strings.Cut(sx, "> ")
+ if !ok1 || !ok2 {
+ sig.Name = line
+ return sig
+ }
+ sig.Name, sig.Email = s1, s2
+
+ if strings.Count(s3, " ") == 1 {
+ ts, tz, _ := strings.Cut(s3, " ")
+ seconds, _ := strconv.ParseInt(ts, 10, 64)
+ if tzTime, err := time.Parse("-0700", tz); err == nil {
+ sig.When = time.Unix(seconds, 0).In(tzTime.Location())
+ }
+ } else {
+ // the old gitea code tried to parse the date in a few different formats, but it's not clear why.
+ // according to public document, only the standard format "timestamp timezone" could be found, so drop other formats.
+ log.Error("suspicious commit line format: %q", line)
+ for _, fmt := range []string{ /*"Mon Jan _2 15:04:05 2006 -0700"*/ } {
+ if t, err := time.Parse(fmt, s3); err == nil {
+ sig.When = t
+ break
+ }
+ }
+ }
+ return sig
+}
diff --git a/modules/git/signature_test.go b/modules/git/signature_test.go
new file mode 100644
index 0000000..92681fe
--- /dev/null
+++ b/modules/git/signature_test.go
@@ -0,0 +1,47 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseSignatureFromCommitLine(t *testing.T) {
+ tests := []struct {
+ line string
+ want *Signature
+ }{
+ {
+ line: "a b <c@d.com> 12345 +0100",
+ want: &Signature{
+ Name: "a b",
+ Email: "c@d.com",
+ When: time.Unix(12345, 0).In(time.FixedZone("", 3600)),
+ },
+ },
+ {
+ line: "bad line",
+ want: &Signature{Name: "bad line"},
+ },
+ {
+ line: "bad < line",
+ want: &Signature{Name: "bad < line"},
+ },
+ {
+ line: "bad > line",
+ want: &Signature{Name: "bad > line"},
+ },
+ {
+ line: "bad-line <name@example.com>",
+ want: &Signature{Name: "bad-line <name@example.com>"},
+ },
+ }
+ for _, test := range tests {
+ got := parseSignatureFromCommitLine(test.line)
+ assert.EqualValues(t, test.want, got)
+ }
+}
diff --git a/modules/git/submodule.go b/modules/git/submodule.go
new file mode 100644
index 0000000..b99c815
--- /dev/null
+++ b/modules/git/submodule.go
@@ -0,0 +1,119 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "path"
+ "regexp"
+ "strings"
+)
+
+var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`)
+
+// SubModule submodule is a reference on git repository
+type SubModule struct {
+ Name string
+ URL string
+}
+
+// SubModuleFile represents a file with submodule type.
+type SubModuleFile struct {
+ *Commit
+
+ refURL string
+ refID string
+}
+
+// NewSubModuleFile create a new submodule file
+func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile {
+ return &SubModuleFile{
+ Commit: c,
+ refURL: refURL,
+ refID: refID,
+ }
+}
+
+func getRefURL(refURL, urlPrefix, repoFullName, sshDomain string) string {
+ if refURL == "" {
+ return ""
+ }
+
+ refURI := strings.TrimSuffix(refURL, ".git")
+
+ prefixURL, _ := url.Parse(urlPrefix)
+ urlPrefixHostname, _, err := net.SplitHostPort(prefixURL.Host)
+ if err != nil {
+ urlPrefixHostname = prefixURL.Host
+ }
+
+ urlPrefix = strings.TrimSuffix(urlPrefix, "/")
+
+ // FIXME: Need to consider branch - which will require changes in modules/git/commit.go:GetSubModules
+ // Relative url prefix check (according to git submodule documentation)
+ if strings.HasPrefix(refURI, "./") || strings.HasPrefix(refURI, "../") {
+ return urlPrefix + path.Clean(path.Join("/", repoFullName, refURI))
+ }
+
+ if !strings.Contains(refURI, "://") {
+ // scp style syntax which contains *no* port number after the : (and is not parsed by net/url)
+ // ex: git@try.gitea.io:go-gitea/gitea
+ match := scpSyntax.FindAllStringSubmatch(refURI, -1)
+ if len(match) > 0 {
+ m := match[0]
+ refHostname := m[2]
+ pth := m[3]
+
+ if !strings.HasPrefix(pth, "/") {
+ pth = "/" + pth
+ }
+
+ if urlPrefixHostname == refHostname || refHostname == sshDomain {
+ return urlPrefix + path.Clean(path.Join("/", pth))
+ }
+ return "http://" + refHostname + pth
+ }
+ }
+
+ ref, err := url.Parse(refURI)
+ if err != nil {
+ return ""
+ }
+
+ refHostname, _, err := net.SplitHostPort(ref.Host)
+ if err != nil {
+ refHostname = ref.Host
+ }
+
+ supportedSchemes := []string{"http", "https", "git", "ssh", "git+ssh"}
+
+ for _, scheme := range supportedSchemes {
+ if ref.Scheme == scheme {
+ if ref.Scheme == "http" || ref.Scheme == "https" {
+ if len(ref.User.Username()) > 0 {
+ return ref.Scheme + "://" + fmt.Sprintf("%v", ref.User) + "@" + ref.Host + ref.Path
+ }
+ return ref.Scheme + "://" + ref.Host + ref.Path
+ } else if urlPrefixHostname == refHostname || refHostname == sshDomain {
+ return urlPrefix + path.Clean(path.Join("/", ref.Path))
+ }
+ return "http://" + refHostname + ref.Path
+ }
+ }
+
+ return ""
+}
+
+// RefURL guesses and returns reference URL.
+func (sf *SubModuleFile) RefURL(urlPrefix, repoFullName, sshDomain string) string {
+ return getRefURL(sf.refURL, urlPrefix, repoFullName, sshDomain)
+}
+
+// RefID returns reference ID.
+func (sf *SubModuleFile) RefID() string {
+ return sf.refID
+}
diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go
new file mode 100644
index 0000000..e05f251
--- /dev/null
+++ b/modules/git/submodule_test.go
@@ -0,0 +1,42 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetRefURL(t *testing.T) {
+ kases := []struct {
+ refURL string
+ prefixURL string
+ parentPath string
+ SSHDomain string
+ expect string
+ }{
+ {"git://github.com/user1/repo1", "/", "user1/repo2", "", "http://github.com/user1/repo1"},
+ {"https://localhost/user1/repo1.git", "/", "user1/repo2", "", "https://localhost/user1/repo1"},
+ {"http://localhost/user1/repo1.git", "/", "owner/reponame", "", "http://localhost/user1/repo1"},
+ {"git@github.com:user1/repo1.git", "/", "owner/reponame", "", "http://github.com/user1/repo1"},
+ {"ssh://git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/zefie/lge_g6_kernel_scripts"},
+ {"git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/2222/zefie/lge_g6_kernel_scripts"},
+ {"git@try.gitea.io:go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"ssh://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"git://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"},
+ {"ssh://git@127.0.0.1:9999/go-gitea/gitea", "https://127.0.0.1:3000/", "go-gitea/sdk", "", "https://127.0.0.1:3000/go-gitea/gitea"},
+ {"https://gitea.com:3000/user1/repo1.git", "https://127.0.0.1:3000/", "user/repo2", "", "https://gitea.com:3000/user1/repo1"},
+ {"https://example.gitea.com/gitea/user1/repo1.git", "https://example.gitea.com/gitea/", "", "user/repo2", "https://example.gitea.com/gitea/user1/repo1"},
+ {"https://username:password@github.com/username/repository.git", "/", "username/repository2", "", "https://username:password@github.com/username/repository"},
+ {"somethingbad", "https://127.0.0.1:3000/go-gitea/gitea", "/", "", ""},
+ {"git@localhost:user/repo", "https://localhost/", "user2/repo1", "", "https://localhost/user/repo"},
+ {"../path/to/repo.git/", "https://localhost/", "user/repo2", "", "https://localhost/user/path/to/repo.git"},
+ {"ssh://git@ssh.gitea.io:2222/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "ssh.gitea.io", "https://try.gitea.io/go-gitea/gitea"},
+ }
+
+ for _, kase := range kases {
+ assert.EqualValues(t, kase.expect, getRefURL(kase.refURL, kase.prefixURL, kase.parentPath, kase.SSHDomain))
+ }
+}
diff --git a/modules/git/tag.go b/modules/git/tag.go
new file mode 100644
index 0000000..04f50e8
--- /dev/null
+++ b/modules/git/tag.go
@@ -0,0 +1,129 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "sort"
+ "strings"
+
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ beginpgp = "\n-----BEGIN PGP SIGNATURE-----\n"
+ endpgp = "\n-----END PGP SIGNATURE-----"
+ beginssh = "\n-----BEGIN SSH SIGNATURE-----\n"
+ endssh = "\n-----END SSH SIGNATURE-----"
+)
+
+// Tag represents a Git tag.
+type Tag struct {
+ Name string
+ ID ObjectID
+ Object ObjectID // The id of this commit object
+ Type string
+ Tagger *Signature
+ Message string
+ Signature *ObjectSignature
+ ArchiveDownloadCount *api.TagArchiveDownloadCount
+}
+
+// Commit return the commit of the tag reference
+func (tag *Tag) Commit(gitRepo *Repository) (*Commit, error) {
+ return gitRepo.getCommit(tag.Object)
+}
+
+// Parse commit information from the (uncompressed) raw
+// data from the commit object.
+// \n\n separate headers from message
+func parseTagData(objectFormat ObjectFormat, data []byte) (*Tag, error) {
+ tag := new(Tag)
+ tag.ID = objectFormat.EmptyObjectID()
+ tag.Object = objectFormat.EmptyObjectID()
+ tag.Tagger = &Signature{}
+ // we now have the contents of the commit object. Let's investigate...
+ nextline := 0
+l:
+ for {
+ eol := bytes.IndexByte(data[nextline:], '\n')
+ switch {
+ case eol > 0:
+ line := data[nextline : nextline+eol]
+ spacepos := bytes.IndexByte(line, ' ')
+ reftype := line[:spacepos]
+ switch string(reftype) {
+ case "object":
+ id, err := NewIDFromString(string(line[spacepos+1:]))
+ if err != nil {
+ return nil, err
+ }
+ tag.Object = id
+ case "type":
+ // A commit can have one or more parents
+ tag.Type = string(line[spacepos+1:])
+ case "tagger":
+ tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(line[spacepos+1:]))
+ }
+ nextline += eol + 1
+ case eol == 0:
+ tag.Message = string(data[nextline+1:])
+ break l
+ default:
+ break l
+ }
+ }
+
+ extractTagSignature := func(signatureBeginMark, signatureEndMark string) (bool, *ObjectSignature, string) {
+ idx := strings.LastIndex(tag.Message, signatureBeginMark)
+ if idx == -1 {
+ return false, nil, ""
+ }
+
+ endSigIdx := strings.Index(tag.Message[idx:], signatureEndMark)
+ if endSigIdx == -1 {
+ return false, nil, ""
+ }
+
+ return true, &ObjectSignature{
+ Signature: tag.Message[idx+1 : idx+endSigIdx+len(signatureEndMark)],
+ Payload: string(data[:bytes.LastIndex(data, []byte(signatureBeginMark))+1]),
+ }, tag.Message[:idx+1]
+ }
+
+ // Try to find an OpenPGP signature
+ found, sig, message := extractTagSignature(beginpgp, endpgp)
+ if !found {
+ // If not found, try an SSH one
+ found, sig, message = extractTagSignature(beginssh, endssh)
+ }
+ // If either is found, update the tag Signature and Message
+ if found {
+ tag.Signature = sig
+ tag.Message = message
+ }
+
+ return tag, nil
+}
+
+type tagSorter []*Tag
+
+func (ts tagSorter) Len() int {
+ return len([]*Tag(ts))
+}
+
+func (ts tagSorter) Less(i, j int) bool {
+ return []*Tag(ts)[i].Tagger.When.After([]*Tag(ts)[j].Tagger.When)
+}
+
+func (ts tagSorter) Swap(i, j int) {
+ []*Tag(ts)[i], []*Tag(ts)[j] = []*Tag(ts)[j], []*Tag(ts)[i]
+}
+
+// sortTagsByTime
+func sortTagsByTime(tags []*Tag) {
+ sorter := tagSorter(tags)
+ sort.Sort(sorter)
+}
diff --git a/modules/git/tag_test.go b/modules/git/tag_test.go
new file mode 100644
index 0000000..8279066
--- /dev/null
+++ b/modules/git/tag_test.go
@@ -0,0 +1,109 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_parseTagData(t *testing.T) {
+ testData := []struct {
+ data []byte
+ tag Tag
+ }{
+ {data: []byte(`object 3b114ab800c6432ad42387ccf6bc8d4388a2885a
+type commit
+tag 1.22.0
+tagger Lucas Michot <lucas@semalead.com> 1484491741 +0100
+
+`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0x3b, 0x11, 0x4a, 0xb8, 0x0, 0xc6, 0x43, 0x2a, 0xd4, 0x23, 0x87, 0xcc, 0xf6, 0xbc, 0x8d, 0x43, 0x88, 0xa2, 0x88, 0x5a},
+ Type: "commit",
+ Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0)},
+ Message: "",
+ Signature: nil,
+ }},
+ {data: []byte(`object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc
+type commit
+tag 1.22.1
+tagger Lucas Michot <lucas@semalead.com> 1484553735 +0100
+
+test message
+o
+
+ono`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0x7c, 0xdf, 0x42, 0xc0, 0xb1, 0xcc, 0x76, 0x3a, 0xb7, 0xe4, 0xc3, 0x3c, 0x47, 0xa2, 0x4e, 0x27, 0xc6, 0x6b, 0xfc, 0xcc},
+ Type: "commit",
+ Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0)},
+ Message: "test message\no\n\nono",
+ Signature: nil,
+ }},
+ {data: []byte(`object d8d1fdb5b20eaca882e34ee510eb55941a242b24
+type commit
+tag v0
+tagger Jane Doe <jane.doe@example.com> 1709146405 +0100
+
+v0
+-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh
+6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7
+5LVB3fV2GmmFDKGB+wCAo=
+-----END SSH SIGNATURE-----
+`), tag: Tag{
+ Name: "",
+ ID: Sha1ObjectFormat.EmptyObjectID(),
+ Object: &Sha1Hash{0xd8, 0xd1, 0xfd, 0xb5, 0xb2, 0x0e, 0xac, 0xa8, 0x82, 0xe3, 0x4e, 0xe5, 0x10, 0xeb, 0x55, 0x94, 0x1a, 0x24, 0x2b, 0x24},
+ Type: "commit",
+ Tagger: &Signature{Name: "Jane Doe", Email: "jane.doe@example.com", When: time.Unix(1709146405, 0)},
+ Message: "v0\n",
+ Signature: &ObjectSignature{
+ Signature: `-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh
+6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7
+5LVB3fV2GmmFDKGB+wCAo=
+-----END SSH SIGNATURE-----`,
+ Payload: `object d8d1fdb5b20eaca882e34ee510eb55941a242b24
+type commit
+tag v0
+tagger Jane Doe <jane.doe@example.com> 1709146405 +0100
+
+v0
+`,
+ },
+ }},
+ }
+
+ for _, test := range testData {
+ tag, err := parseTagData(Sha1ObjectFormat, test.data)
+ require.NoError(t, err)
+ assert.EqualValues(t, test.tag.ID, tag.ID)
+ assert.EqualValues(t, test.tag.Object, tag.Object)
+ assert.EqualValues(t, test.tag.Name, tag.Name)
+ assert.EqualValues(t, test.tag.Message, tag.Message)
+ assert.EqualValues(t, test.tag.Type, tag.Type)
+ if test.tag.Signature != nil && assert.NotNil(t, tag.Signature) {
+ assert.EqualValues(t, test.tag.Signature.Signature, tag.Signature.Signature)
+ assert.EqualValues(t, test.tag.Signature.Payload, tag.Signature.Payload)
+ } else {
+ assert.Nil(t, tag.Signature)
+ }
+ if test.tag.Tagger != nil && assert.NotNil(t, tag.Tagger) {
+ assert.EqualValues(t, test.tag.Tagger.Name, tag.Tagger.Name)
+ assert.EqualValues(t, test.tag.Tagger.Email, tag.Tagger.Email)
+ assert.EqualValues(t, test.tag.Tagger.When.Unix(), tag.Tagger.When.Unix())
+ } else {
+ assert.Nil(t, tag.Tagger)
+ }
+ }
+}
diff --git a/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG
new file mode 100644
index 0000000..ec4d890
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG
@@ -0,0 +1,3 @@
+Add some test files for GetLanguageStats
+
+Signed-off-by: Andrew Thornton <art27@cantab.net>
diff --git a/modules/git/tests/repos/language_stats_repo/HEAD b/modules/git/tests/repos/language_stats_repo/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/language_stats_repo/config b/modules/git/tests/repos/language_stats_repo/config
new file mode 100644
index 0000000..a4ef456
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/config
@@ -0,0 +1,5 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ logallrefupdates = true
diff --git a/modules/git/tests/repos/language_stats_repo/description b/modules/git/tests/repos/language_stats_repo/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/language_stats_repo/index b/modules/git/tests/repos/language_stats_repo/index
new file mode 100644
index 0000000..e6c0223
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/index
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/info/exclude b/modules/git/tests/repos/language_stats_repo/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/language_stats_repo/logs/HEAD b/modules/git/tests/repos/language_stats_repo/logs/HEAD
new file mode 100644
index 0000000..9cedbb6
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats
+8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push
diff --git a/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master
new file mode 100644
index 0000000..9cedbb6
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats
+8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push
diff --git a/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c
new file mode 100644
index 0000000..3c55bab
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb
new file mode 100644
index 0000000..947feec
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97 b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97
new file mode 100644
index 0000000..9ce337e
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085 b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085
new file mode 100644
index 0000000..ff3b642
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d
new file mode 100644
index 0000000..b71abc1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e
new file mode 100644
index 0000000..5c2485d
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc
new file mode 100644
index 0000000..873cb71
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd
new file mode 100644
index 0000000..f89ecb7
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3 b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3
new file mode 100644
index 0000000..0219c2d
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299 b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299
new file mode 100644
index 0000000..adc50f2
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb
new file mode 100644
index 0000000..9d4d4b1
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb
Binary files differ
diff --git a/modules/git/tests/repos/language_stats_repo/refs/heads/master b/modules/git/tests/repos/language_stats_repo/refs/heads/master
new file mode 100644
index 0000000..e89143e
--- /dev/null
+++ b/modules/git/tests/repos/language_stats_repo/refs/heads/master
@@ -0,0 +1 @@
+341fca5b5ea3de596dc483e54c2db28633cd2f97
diff --git a/modules/git/tests/repos/repo1_bare/HEAD b/modules/git/tests/repos/repo1_bare/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo1_bare/config b/modules/git/tests/repos/repo1_bare/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/modules/git/tests/repos/repo1_bare/description b/modules/git/tests/repos/repo1_bare/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo1_bare/index b/modules/git/tests/repos/repo1_bare/index
new file mode 100644
index 0000000..65d6751
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/index
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/info/exclude b/modules/git/tests/repos/repo1_bare/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo1_bare/logs/HEAD b/modules/git/tests/repos/repo1_bare/logs/HEAD
new file mode 100644
index 0000000..46da5fe
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/logs/HEAD
@@ -0,0 +1,2 @@
+37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push
+feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push
diff --git a/modules/git/tests/repos/repo1_bare/logs/refs/heads/master b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master
new file mode 100644
index 0000000..46da5fe
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master
@@ -0,0 +1,2 @@
+37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push
+feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push
diff --git a/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60
new file mode 100644
index 0000000..11de5ad
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0
new file mode 100644
index 0000000..3541cd1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310
new file mode 100644
index 0000000..8db3c79
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6
new file mode 100644
index 0000000..45e014e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb
new file mode 100644
index 0000000..fb50b65
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b
new file mode 100644
index 0000000..8c0b1b3
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02 b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02
new file mode 100644
index 0000000..05dc472
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3
new file mode 100644
index 0000000..e22e656
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3
@@ -0,0 +1 @@
+x…ÎÁ Â0 @QΙ" €â¸il që 8Ž¨ÔB‚Ôñ©X€óÿ‡'¯u»ÆSoª6*摨"Ž,É“æTsI\I+r,|2[júì–…“V*…u>KT?P4ÂèRt@Á¤O¼šö´n‹Úû[›½Þ,À\`oÏŽœ3òõ£þ]ÍTz…Kß»ùe;ƒ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32 b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32
new file mode 100644
index 0000000..7779599
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e
new file mode 100644
index 0000000..3e46ba4
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8
new file mode 100644
index 0000000..3a5c6c1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5
new file mode 100644
index 0000000..29f2d4f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64 b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64
new file mode 100644
index 0000000..c96b843
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123
new file mode 100644
index 0000000..3658e95
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123
@@ -0,0 +1 @@
+xŽKN1 YçÞ#ç3 !Øp.ØÎL‹™J›·§á,kñêû}5 PlªB÷5sKÔH|>ŸdíKÌKl kì% û¬S7ƒÜ›ä¢e¡Ó¢™c¥Î‰±çÆH‡§Señ®~ÙuLxŸëocì Óeµ—ý:D¾7µÓ˜—gð‰¢_Bñ=":þËüÝüSà^ETàø™·uûp?ƒ6M^ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702
new file mode 100644
index 0000000..c3d484a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a
new file mode 100644
index 0000000..ee2652b
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
new file mode 100644
index 0000000..adf6411
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f
new file mode 100644
index 0000000..b969059
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca
new file mode 100644
index 0000000..1629271
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658
new file mode 100644
index 0000000..234d41b
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658
@@ -0,0 +1,3 @@
+x…Ž;
+1@­sŠ¹€:Éf7ÁÂ#x€ÉL‚‚û!FØ㻈½Õ+Þ+žÌãøhàÐíZÍb—H¼²²ö„‘J<J™…B
+VK6 ×<5ˆJ®õ½)J1Iú‚)¤d#1ê Tœáw»Ï®+Ë3Ãí•+œÎ`{»å;„=FD#ß¡¶Ù¿©¹¨Bª<ÉÝ<´µ™3ã>= \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315
new file mode 100644
index 0000000..15b958a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e
new file mode 100644
index 0000000..eb0ad47
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375
new file mode 100644
index 0000000..7d217a7
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1
new file mode 100644
index 0000000..5b2cfb2
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1
@@ -0,0 +1 @@
+xŽ1nÃ0 E3ëÜ ”-™&ÉÒô’H&F+Õ¡·oÚ#døÃÞÃ/õñX: ÁïzS…i£±Zâb1“Ø”Saö”gÔ@ÄFÝ35];̈“'Ɇ%sD’„5ŽôÚìÉØR,èÒw¿Ö_mÙ೶­kƒCÑþ²ôÓv­"?«ö}m—#ø8"Í#|xDtåÿæŸófÀET ·zÓîËzÛÜ/—þN° \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8
new file mode 100644
index 0000000..113089d
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0
new file mode 100644
index 0000000..808fe6e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631
new file mode 100644
index 0000000..6a194fb
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe
new file mode 100644
index 0000000..8602ab5
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2
new file mode 100644
index 0000000..431a481
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b
new file mode 100644
index 0000000..e198e76
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653
new file mode 100644
index 0000000..6bb6a25
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56
new file mode 100644
index 0000000..ae6c93a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185
new file mode 100644
index 0000000..8a263d0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185
@@ -0,0 +1,2 @@
+x…ÎM
+1 @a×=E. ¤“¦Ó‚.<‚HÛ”œj…9¾â\¿oñò:Ï6ºCoª@è2ûDI+QA©š[V H9P,R %³IÓ¥Cä”\¡è]P•¶èKE+~°™ ƒ'ñLFÞ}ZÜv™·§Âý¥ ΰliddáˆÑäßPÿÖ¿Ô\KÔdÉ“=õ½›ܲ:c \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13 b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13
new file mode 100644
index 0000000..35d27dc
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351
new file mode 100644
index 0000000..02fe24f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3
new file mode 100644
index 0000000..aacc5ef
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24
new file mode 100644
index 0000000..6c2e007
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3
new file mode 100644
index 0000000..57c5d7c
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c
new file mode 100644
index 0000000..d4c2138
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c
@@ -0,0 +1,4 @@
+x¥ŽM
+Â0F]ç³ëB&&m"ž@\¹Of¦6ÐHG¥··ô
+~Ë·xïÃy³€Ñþ …Œ?[—Œ¶èBÓ&
+H<bÛyß™NGt­åÚ¨ø–~.ð"å1xÄIx`þÀå•å&=㚸,}¤ù{šX® ó¶ p¬·)ÜãÂjÔ}^ 1AZ¡ÚÀ´3¦,•ú½ÀI0 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612 b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612
new file mode 100644
index 0000000..93f1525
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d
new file mode 100644
index 0000000..1152b25
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da
new file mode 100644
index 0000000..d29ca5a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449
new file mode 100644
index 0000000..08245d0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930
new file mode 100644
index 0000000..6a412c7
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2 b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2
new file mode 100644
index 0000000..95edd9a
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare/pulls/1.patch b/modules/git/tests/repos/repo1_bare/pulls/1.patch
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/pulls/1.patch
diff --git a/modules/git/tests/repos/repo1_bare/pulls/2.patch b/modules/git/tests/repos/repo1_bare/pulls/2.patch
new file mode 100644
index 0000000..caab605
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/pulls/2.patch
@@ -0,0 +1,39 @@
+From 6e8e2a6f9efd71dbe6917816343ed8415ad696c3 Mon Sep 17 00:00:00 2001
+From: 99rgosse <renaud@mycompany.com>
+Date: Fri, 26 Mar 2021 12:44:22 +0000
+Subject: [PATCH] Update gitea_import_actions.py
+
+---
+ gitea_import_actions.py | 6 +++---
+ 1 file changed, 3 insertions(+), 3 deletions(-)
+
+diff --git a/gitea_import_actions.py b/gitea_import_actions.py
+index f0d72cd..7b31963 100644
+--- a/gitea_import_actions.py
++++ b/gitea_import_actions.py
+@@ -3,14 +3,14 @@
+ # git log --pretty=format:'%H,%at,%s' --date=default > /tmp/commit.log
+ # to get the commits logfile for a repository
+
+-import mysql.connector as mariadb
++import psycopg2
+
+ # set the following variables to fit your need...
+ USERID = 1
+ REPOID = 1
+ BRANCH = "master"
+
+-mydb = mariadb.connect(
++mydb = psycopg2.connect(
+ host="localhost",
+ user="user",
+ passwd="password",
+@@ -31,4 +31,4 @@ with open("/tmp/commit.log") as f:
+
+ mydb.commit()
+
+-print("actions inserted.")
+\ No newline at end of file
++print("actions inserted.")
+--
+GitLab
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch1 b/modules/git/tests/repos/repo1_bare/refs/heads/branch1
new file mode 100644
index 0000000..eb33bd0
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch1
@@ -0,0 +1 @@
+2839944139e0de9737a044f78b0e4b40d989a9e3
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch2 b/modules/git/tests/repos/repo1_bare/refs/heads/branch2
new file mode 100644
index 0000000..0475e61
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch2
@@ -0,0 +1 @@
+5c80b0245c1c6f8343fa418ec374b13b5d4ee658
diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/master b/modules/git/tests/repos/repo1_bare/refs/heads/master
new file mode 100644
index 0000000..9b0de22
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/heads/master
@@ -0,0 +1 @@
+ce064814f4a0d337b333e646ece456cd39fab612
diff --git a/modules/git/tests/repos/repo1_bare/refs/notes/commits b/modules/git/tests/repos/repo1_bare/refs/notes/commits
new file mode 100644
index 0000000..c88ca21
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/notes/commits
@@ -0,0 +1 @@
+ca6b5ddf303169a72d2a2971acde4f6eea194e5c
diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag
new file mode 100644
index 0000000..3998a68
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag
@@ -0,0 +1 @@
+36f97d9a96457e2bab511db30fe2db03893ebc64
diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/test b/modules/git/tests/repos/repo1_bare/refs/tags/test
new file mode 100644
index 0000000..ee31172
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare/refs/tags/test
@@ -0,0 +1 @@
+3ad28a9149a2864384548f3d17ed7f38014c9e8a
diff --git a/modules/git/tests/repos/repo1_bare_sha256/HEAD b/modules/git/tests/repos/repo1_bare_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo1_bare_sha256/config b/modules/git/tests/repos/repo1_bare_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo1_bare_sha256/description b/modules/git/tests/repos/repo1_bare_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/exclude b/modules/git/tests/repos/repo1_bare_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/refs b/modules/git/tests/repos/repo1_bare_sha256/info/refs
new file mode 100644
index 0000000..b4de954
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/info/refs
@@ -0,0 +1,7 @@
+42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1
+5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main
+29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/tags/signed-tag^{}
+171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test
+6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1 refs/tags/test^{}
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..2985d3e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs
new file mode 100644
index 0000000..c2d1bb8
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
+
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap
new file mode 100644
index 0000000..535ba16
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx
new file mode 100644
index 0000000..ab45b6f
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
new file mode 100644
index 0000000..c77bf20
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev
new file mode 100644
index 0000000..d24fd8e
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo1_bare_sha256/packed-refs b/modules/git/tests/repos/repo1_bare_sha256/packed-refs
new file mode 100644
index 0000000..36c92ce
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/packed-refs
@@ -0,0 +1,8 @@
+# pack-refs with: peeled fully-peeled sorted
+42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1
+5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main
+29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag
+^9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0
+171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test
+^6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1
diff --git a/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main
new file mode 100644
index 0000000..b09fd5c
--- /dev/null
+++ b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main
@@ -0,0 +1 @@
+9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0
diff --git a/modules/git/tests/repos/repo2_empty/HEAD b/modules/git/tests/repos/repo2_empty/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo2_empty/config b/modules/git/tests/repos/repo2_empty/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/modules/git/tests/repos/repo2_empty/description b/modules/git/tests/repos/repo2_empty/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo2_empty/info/exclude b/modules/git/tests/repos/repo2_empty/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep
diff --git a/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep
diff --git a/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG
new file mode 100644
index 0000000..0cfbf08
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG
@@ -0,0 +1 @@
+2
diff --git a/modules/git/tests/repos/repo3_notes/HEAD b/modules/git/tests/repos/repo3_notes/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo3_notes/config b/modules/git/tests/repos/repo3_notes/config
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/modules/git/tests/repos/repo3_notes/description b/modules/git/tests/repos/repo3_notes/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo3_notes/index b/modules/git/tests/repos/repo3_notes/index
new file mode 100644
index 0000000..783158b
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/index
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/logs/HEAD b/modules/git/tests/repos/repo3_notes/logs/HEAD
new file mode 100644
index 0000000..4bd0a61
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1
+ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2
diff --git a/modules/git/tests/repos/repo3_notes/logs/refs/heads/master b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master
new file mode 100644
index 0000000..4bd0a61
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1
+ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2
diff --git a/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6 b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6
new file mode 100644
index 0000000..96fb749
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef
new file mode 100644
index 0000000..71cff17
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4 b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4
new file mode 100644
index 0000000..8f13b31
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4
@@ -0,0 +1,3 @@
+xŽ;Â0 @™s
+ïH•ã&v*!ÄÄÈœ4Jô£(p~
+G`|oxziç©‘;´š3Ð –ÂÈÞ÷6  œ$`¦"NRäѺXla³iÍKƒ¨¨åÞ÷4rò$§\P0"yÌ£PQ'F_í±V¸NÏiƒ›¾µ*œÊ—ºåG—û¬Ó³Kë|ëY„eÀŽHˆf·ûfË ™ÜãEm \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02 b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02
new file mode 100644
index 0000000..3d522eb
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
new file mode 100644
index 0000000..b17dfe3
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544 b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544
new file mode 100644
index 0000000..b09d3a2
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37 b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37
new file mode 100644
index 0000000..0d317cb
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37
@@ -0,0 +1 @@
+x;Â0©}Ší‘"Ç¿u$„RQr‡•ÙK1F–Éù1nfŠ÷R-%w˜Ñzcá´{ä%7À¢h#Ñx¶ˆÉQ¤àéfXÑ»?jƒKÞò ®´S#8ÉצçÏÖ{¡¼M©–3Ì> †¨…£6Z«QÇmç¿Ôÿ8 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47 b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47
new file mode 100644
index 0000000..c21f2b2
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225 b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225
new file mode 100644
index 0000000..f5a8caa
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
new file mode 100644
index 0000000..4b1baef
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907 b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907
new file mode 100644
index 0000000..dc2af77
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e
new file mode 100644
index 0000000..6372ff1
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e
Binary files differ
diff --git a/modules/git/tests/repos/repo3_notes/refs/heads/master b/modules/git/tests/repos/repo3_notes/refs/heads/master
new file mode 100644
index 0000000..e96af8d
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/refs/heads/master
@@ -0,0 +1 @@
+3e668dbfac39cbc80a9ff9c61eb565d944453ba4 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo3_notes/refs/notes/commits b/modules/git/tests/repos/repo3_notes/refs/notes/commits
new file mode 100644
index 0000000..74e3d3a
--- /dev/null
+++ b/modules/git/tests/repos/repo3_notes/refs/notes/commits
@@ -0,0 +1 @@
+654c8b6b63c08bf37f638d3f521626b7fbbd4d37 \ No newline at end of file
diff --git a/modules/git/tests/repos/repo4_commitsbetween/HEAD b/modules/git/tests/repos/repo4_commitsbetween/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo4_commitsbetween/config b/modules/git/tests/repos/repo4_commitsbetween/config
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD
new file mode 100644
index 0000000..24cc684
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD
@@ -0,0 +1,4 @@
+0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2
+78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new
diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main
new file mode 100644
index 0000000..24cc684
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main
@@ -0,0 +1,4 @@
+0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2
+78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1
+fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea
new file mode 100644
index 0000000..5b26f8b
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
new file mode 100644
index 0000000..b17dfe3
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344 b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344
new file mode 100644
index 0000000..6d23de0
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344
@@ -0,0 +1,3 @@
+xÎM
+Â0@a×=Eö‚̤Iš€ˆà²àÂ$“Zl©ñþþÁ|¼Gµ”¹)îmÌŠuOä"€·€‚&`ã8GtÀIœ7Ý#n¼6%™09´)“8ë“F—(hl™Ò@ƒïâ«MuSãÕ\Æþ¦Ž1—y=×%?iªu™"Ý…O
+þDm½ÚƒèèwØøûź{p‹C_ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca
new file mode 100644
index 0000000..d5c554a
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5 b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5
new file mode 100644
index 0000000..26ed785
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098 b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098
new file mode 100644
index 0000000..8060b57
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
new file mode 100644
index 0000000..4b1baef
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684 b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684
new file mode 100644
index 0000000..0a70530
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78 b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78
new file mode 100644
index 0000000..2e6d945
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78
Binary files differ
diff --git a/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main
new file mode 100644
index 0000000..9e1b981
--- /dev/null
+++ b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main
@@ -0,0 +1 @@
+a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca
diff --git a/modules/git/tests/repos/repo5_pulls/HEAD b/modules/git/tests/repos/repo5_pulls/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo5_pulls/config b/modules/git/tests/repos/repo5_pulls/config
new file mode 100644
index 0000000..0a0ad6d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+[receive]
+ advertisePushOptions = true
diff --git a/modules/git/tests/repos/repo5_pulls/description b/modules/git/tests/repos/repo5_pulls/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo5_pulls/info/exclude b/modules/git/tests/repos/repo5_pulls/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df
new file mode 100644
index 0000000..90464be
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf
new file mode 100644
index 0000000..cf9d59f
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af
new file mode 100644
index 0000000..efc69b1
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af
@@ -0,0 +1 @@
+x%Ž½nÃ0 „;ë)¸0H…ú1 P]Úô(‘F2¸Tåýk·7|¸wu]–{OôÒ›„H¨²p®œ8³$A”1¦"\¢ªaÂRf÷fß4Û ‡Ù#ZL:JÊ\-„¢#fO2s°¢Nžý¶6èöÓ¯ÓçN»;ïv¼Å#úè 3p“«׺5ˆpÚy^‹µåyÔþL)xÚÛ¼s_•nð1]ÞÞ§aÑ_)@X \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1
new file mode 100644
index 0000000..74e848f
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab
new file mode 100644
index 0000000..d6e616d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2
new file mode 100644
index 0000000..271cffb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2
@@ -0,0 +1,2 @@
+x­MNÄ0 …Y÷Öl„œ'„€ ‰i%ú£4ÜŸÄ Ø<=ù}~²ó2MccÜM«" ¢ÈhÖ¬zŽ±÷)q(•CRIŠO¤¸tk¬27Ƚ1=²GrL&]ØYBFtÚ'&o„?^¸/–u‰”´ÕèÑѾ®‚*ÄL˜­ØÝ›Òů6,¶\ǵÅO©íöô
+ï²5øؤžî#xjûìå‡CžA9ƒVyB÷¨»üóc“ÿiëÞ¤^Rsà<Åmo>Ã8·Ž.kly¸¨îC©iè \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd
new file mode 100644
index 0000000..0e2dc87
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd
@@ -0,0 +1,2 @@
+x­ŽAJAE]÷)Š¬"‚VwWÏt E²Äz€NU5Ì$ôTö9ˆ¸ò&Þ$'1Ñ+¸y|þƒÏçíf³6=^XS…NpEÌ…"ÍRÌ1v>W–(Ò®•gD©ÞíJÓÁ@%W’PKZ
+Øc—2ŠŸùšD2)r¬®ìímÛ`ä¶ÞYy×fÓɼèhð:j›\Þü)˜Û©»=ãúŒø."ù>ùWÿ~6ýŸ5w<|>>Ü/Ÿž—ÇÃ| ¢mp?ˆXó \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f
new file mode 100644
index 0000000..33d2a21
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f
new file mode 100644
index 0000000..d64847c
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f
@@ -0,0 +1,3 @@
+x­ŽAJAE]÷)Š¬!V×ÌtMƒˆ"YâF=@uw5Ì$ô”ûD\yo’“hô
+nÞâ?ø¼¼ÝlÖÄxbMd ,ƒTŸ˜C7f%äÈuÄ”¼PŒÜ3Jr;i:ÔŽJ,µ`”€5øP)úa¬Ì”µ”1Æž
+9y³—mƒ9·õÎäU›.nàIgƒçYÛâìâOÁ¥ýl×G,¸:ì=÷q€s$D—›MÿçÍö÷w·«‡ÇÕaÿ_ŸSÑ6¹o9X‚ \ No newline at end of file
diff --git a/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640
new file mode 100644
index 0000000..9cd9d00
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/info/packs b/modules/git/tests/repos/repo5_pulls/objects/info/packs
new file mode 100644
index 0000000..8bbc848
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
+
diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx
new file mode 100644
index 0000000..b66df23
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
new file mode 100644
index 0000000..a5dfc5e
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls/packed-refs b/modules/git/tests/repos/repo5_pulls/packed-refs
new file mode 100644
index 0000000..d0012b5
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+c83380d7056593c51a699d12b9c00627bd5743e9 refs/heads/test-patch-1
+c83380d7056593c51a699d12b9c00627bd5743e9 refs/pull/1/head
+111cac04bd7d20301964e27a93698aabb5781b80 refs/pull/1/merge
+72866af952e98d02a73003501836074b286a78f6 refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master b/modules/git/tests/repos/repo5_pulls/refs/heads/master
new file mode 100644
index 0000000..9a8e3b2
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master
@@ -0,0 +1 @@
+d8e0bbb45f200e67d9a784ce55bd90821af45ebd
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone
new file mode 100644
index 0000000..9a8e3b2
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone
@@ -0,0 +1 @@
+d8e0bbb45f200e67d9a784ce55bd90821af45ebd
diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1
new file mode 100644
index 0000000..d8b26cb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1
@@ -0,0 +1 @@
+58a4bcc53ac13e7ff76127e0fb518b5262bf09af
diff --git a/modules/git/tests/repos/repo5_pulls/refs/pull/4/head b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head
new file mode 100644
index 0000000..d8b26cb
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head
@@ -0,0 +1 @@
+58a4bcc53ac13e7ff76127e0fb518b5262bf09af
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/HEAD b/modules/git/tests/repos/repo5_pulls_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/config b/modules/git/tests/repos/repo5_pulls_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/description b/modules/git/tests/repos/repo5_pulls_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/info/refs b/modules/git/tests/repos/repo5_pulls_sha256/info/refs
new file mode 100644
index 0000000..454e45d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/info/refs
@@ -0,0 +1,4 @@
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone
+7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1
+1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..8e5ef41
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs
new file mode 100644
index 0000000..6f51e7b
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
+
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap
new file mode 100644
index 0000000..38fca6e
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx
new file mode 100644
index 0000000..fd43d04
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
new file mode 100644
index 0000000..689318d
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev
new file mode 100644
index 0000000..c0bac95
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/packed-refs b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs
new file mode 100644
index 0000000..1525083
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone
+7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1
+1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99
diff --git a/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main
new file mode 100644
index 0000000..9b32e79
--- /dev/null
+++ b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main
@@ -0,0 +1 @@
+35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b
diff --git a/modules/git/tests/repos/repo6_blame/HEAD b/modules/git/tests/repos/repo6_blame/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/git/tests/repos/repo6_blame/config b/modules/git/tests/repos/repo6_blame/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c
new file mode 100644
index 0000000..6cde910
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1
new file mode 100644
index 0000000..b8db01d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376
new file mode 100644
index 0000000..6c0ae47
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9
new file mode 100644
index 0000000..5c2b564
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7
new file mode 100644
index 0000000..3c64718
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044
new file mode 100644
index 0000000..847b7bc
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93
new file mode 100644
index 0000000..206ef1e
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421
new file mode 100644
index 0000000..bb26889
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8
new file mode 100644
index 0000000..1653ed9
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame/refs/heads/master b/modules/git/tests/repos/repo6_blame/refs/heads/master
new file mode 100644
index 0000000..01c9922
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame/refs/heads/master
@@ -0,0 +1 @@
+544d8f7a3b15927cddf2299b4b562d6ebd71b6a7
diff --git a/modules/git/tests/repos/repo6_blame_sha256/HEAD b/modules/git/tests/repos/repo6_blame_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/config b/modules/git/tests/repos/repo6_blame_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo6_blame_sha256/description b/modules/git/tests/repos/repo6_blame_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/exclude b/modules/git/tests/repos/repo6_blame_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/refs b/modules/git/tests/repos/repo6_blame_sha256/info/refs
new file mode 100644
index 0000000..bee6d1d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/info/refs
@@ -0,0 +1 @@
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..f963aa0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs
new file mode 100644
index 0000000..73744cf
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
+
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap
new file mode 100644
index 0000000..c34487c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx
new file mode 100644
index 0000000..faaee22
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
new file mode 100644
index 0000000..626f081
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev
new file mode 100644
index 0000000..5617555
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_blame_sha256/packed-refs b/modules/git/tests/repos/repo6_blame_sha256/packed-refs
new file mode 100644
index 0000000..6442692
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/packed-refs
@@ -0,0 +1,2 @@
+# pack-refs with: peeled fully-peeled sorted
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main
diff --git a/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main
new file mode 100644
index 0000000..829662c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main
@@ -0,0 +1 @@
+e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3
diff --git a/modules/git/tests/repos/repo6_merge/HEAD b/modules/git/tests/repos/repo6_merge/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699 b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699
new file mode 100644
index 0000000..0778a1c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc
new file mode 100644
index 0000000..c71794f
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576 b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576
new file mode 100644
index 0000000..365f368
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411 b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411
new file mode 100644
index 0000000..83890b5
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411
@@ -0,0 +1,2 @@
+xÍM
+1 †a×=Eö‚$µÍLAÄ«ô'Á‡‘iæþR½€›g÷~_ÝÖu1 ˜N¶‹@mZ)g2…D‘j™Š*_“fŒÌs ¥4ïòaÏm“np>—Áˆç€Ì>!œÑ#ºú½1ù;p]ìxÿæuyIwÑN4à \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439 b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439
new file mode 100644
index 0000000..582d98c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439
@@ -0,0 +1,2 @@
+xÎM
+Â0†a×9Åì™üM2 âUšæ ¬•=¿Ô¸yv/¼ó¶®Ë Çî0:@±ò$±UѬ«.—[Ê>« ”l“‹IÌsêx ò©ú8'T¯°R¹Ä¤S,ª±$x. Öšé=n[§× óîuç´s!+9°ˆ÷BGvÌfþm ü˜Žuû€Úr‡ùÿÁ>® \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b
new file mode 100644
index 0000000..d7faff6
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597 b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597
new file mode 100644
index 0000000..8ac2814
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2 b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2
new file mode 100644
index 0000000..c0f6b13
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2
@@ -0,0 +1,5 @@
+xÎM
+1 @a×=Eö‚¤?iñ*mšÁÇ‘™xOàæÛ=x².Ëlà™O¶©R¢Z80ŠÄ\[í*Ú%µb
+ƒ&qï¶éË –IŠŽÈšÔç
+7êÌÔ‹F쨜¼wícuÓÝàzx?¸ÜÀçš0ç
+œ1 :ùm™þ¸6LóSÝÀí>& \ No newline at end of file
diff --git a/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a
new file mode 100644
index 0000000..edef2a7
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8 b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8
new file mode 100644
index 0000000..353d86c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831 b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831
new file mode 100644
index 0000000..5449d72
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2 b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2
new file mode 100644
index 0000000..030eb98
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77 b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77
new file mode 100644
index 0000000..867e4c0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2 b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2
new file mode 100644
index 0000000..52d300c
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92 b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92
new file mode 100644
index 0000000..112998d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/main b/modules/git/tests/repos/repo6_merge/refs/heads/main
new file mode 100644
index 0000000..adf9e86
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/main
@@ -0,0 +1 @@
+022f4ce6214973e018f02bf363bf8a2e3691f699
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file
new file mode 100644
index 0000000..035ad11
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file
@@ -0,0 +1 @@
+ae4b035e7c4afbc000576cee3f713ea0c2f1e1e2
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file
new file mode 100644
index 0000000..f0d5105
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file
@@ -0,0 +1 @@
+d1792641396ff7630d35fbb0b74b86b0c71bca77
diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file
new file mode 100644
index 0000000..ada3c8b
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file
@@ -0,0 +1 @@
+38ec3e0cdc88bde01014bda4a5dd9fc835f41439
diff --git a/modules/git/tests/repos/repo6_merge_sha256/HEAD b/modules/git/tests/repos/repo6_merge_sha256/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/modules/git/tests/repos/repo6_merge_sha256/config b/modules/git/tests/repos/repo6_merge_sha256/config
new file mode 100644
index 0000000..2388a50
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 1
+ filemode = true
+ bare = true
+[extensions]
+ objectformat = sha256
diff --git a/modules/git/tests/repos/repo6_merge_sha256/description b/modules/git/tests/repos/repo6_merge_sha256/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/exclude b/modules/git/tests/repos/repo6_merge_sha256/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/refs b/modules/git/tests/repos/repo6_merge_sha256/info/refs
new file mode 100644
index 0000000..7dae8a1
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/info/refs
@@ -0,0 +1,4 @@
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main
+b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file
+ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file
+da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph
new file mode 100644
index 0000000..9806847
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs
new file mode 100644
index 0000000..f3cf819
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs
@@ -0,0 +1,3 @@
+P pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
+P pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
+
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap
new file mode 100644
index 0000000..d1624a0
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx
new file mode 100644
index 0000000..09b897d
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
new file mode 100644
index 0000000..3b406be
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev
new file mode 100644
index 0000000..4a695fc
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx
new file mode 100644
index 0000000..3b58342
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes
new file mode 100644
index 0000000..a669a06
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
new file mode 100644
index 0000000..a28808e
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev
new file mode 100644
index 0000000..c09bb32
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev
Binary files differ
diff --git a/modules/git/tests/repos/repo6_merge_sha256/packed-refs b/modules/git/tests/repos/repo6_merge_sha256/packed-refs
new file mode 100644
index 0000000..b906893
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/packed-refs
@@ -0,0 +1,5 @@
+# pack-refs with: peeled fully-peeled sorted
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main
+b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file
+ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file
+da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file
diff --git a/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main
new file mode 100644
index 0000000..c8c0292
--- /dev/null
+++ b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main
@@ -0,0 +1 @@
+d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1
diff --git a/modules/git/tree.go b/modules/git/tree.go
new file mode 100644
index 0000000..5b06cbf
--- /dev/null
+++ b/modules/git/tree.go
@@ -0,0 +1,178 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "bytes"
+ "io"
+ "strings"
+)
+
+// Tree represents a flat directory listing.
+type Tree struct {
+ ID ObjectID
+ ResolvedID ObjectID
+ repo *Repository
+
+ // parent tree
+ ptree *Tree
+
+ entries Entries
+ entriesParsed bool
+
+ entriesRecursive Entries
+ entriesRecursiveParsed bool
+}
+
+// NewTree create a new tree according the repository and tree id
+func NewTree(repo *Repository, id ObjectID) *Tree {
+ return &Tree{
+ ID: id,
+ repo: repo,
+ }
+}
+
+// ListEntries returns all entries of current tree.
+func (t *Tree) ListEntries() (Entries, error) {
+ if t.entriesParsed {
+ return t.entries, nil
+ }
+
+ if t.repo != nil {
+ wr, rd, cancel, err := t.repo.CatFileBatch(t.repo.Ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer cancel()
+
+ _, _ = wr.Write([]byte(t.ID.String() + "\n"))
+ _, typ, sz, err := ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+ if typ == "commit" {
+ treeID, err := ReadTreeID(rd, sz)
+ if err != nil && err != io.EOF {
+ return nil, err
+ }
+ _, _ = wr.Write([]byte(treeID + "\n"))
+ _, typ, sz, err = ReadBatchLine(rd)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if typ == "tree" {
+ t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz)
+ if err != nil {
+ return nil, err
+ }
+ t.entriesParsed = true
+ return t.entries, nil
+ }
+
+ // Not a tree just use ls-tree instead
+ if err := DiscardFull(rd, sz+1); err != nil {
+ return nil, err
+ }
+ }
+
+ stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-l").AddDynamicArguments(t.ID.String()).RunStdBytes(&RunOpts{Dir: t.repo.Path})
+ if runErr != nil {
+ if strings.Contains(runErr.Error(), "fatal: Not a valid object name") || strings.Contains(runErr.Error(), "fatal: not a tree object") {
+ return nil, ErrNotExist{
+ ID: t.ID.String(),
+ }
+ }
+ return nil, runErr
+ }
+
+ var err error
+ t.entries, err = parseTreeEntries(stdout, t)
+ if err == nil {
+ t.entriesParsed = true
+ }
+
+ return t.entries, err
+}
+
+// listEntriesRecursive returns all entries of current tree recursively including all subtrees
+// extraArgs could be "-l" to get the size, which is slower
+func (t *Tree) listEntriesRecursive(extraArgs TrustedCmdArgs) (Entries, error) {
+ if t.entriesRecursiveParsed {
+ return t.entriesRecursive, nil
+ }
+
+ stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-t", "-r").
+ AddArguments(extraArgs...).
+ AddDynamicArguments(t.ID.String()).
+ RunStdBytes(&RunOpts{Dir: t.repo.Path})
+ if runErr != nil {
+ return nil, runErr
+ }
+
+ var err error
+ t.entriesRecursive, err = parseTreeEntries(stdout, t)
+ if err == nil {
+ t.entriesRecursiveParsed = true
+ }
+
+ return t.entriesRecursive, err
+}
+
+// ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size
+func (t *Tree) ListEntriesRecursiveFast() (Entries, error) {
+ return t.listEntriesRecursive(nil)
+}
+
+// ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees, with size
+func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
+ return t.listEntriesRecursive(TrustedCmdArgs{"--long"})
+}
+
+// SubTree get a sub tree by the sub dir path
+func (t *Tree) SubTree(rpath string) (*Tree, error) {
+ if len(rpath) == 0 {
+ return t, nil
+ }
+
+ paths := strings.Split(rpath, "/")
+ var (
+ err error
+ g = t
+ p = t
+ te *TreeEntry
+ )
+ for _, name := range paths {
+ te, err = p.GetTreeEntryByPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ g, err = t.repo.getTree(te.ID)
+ if err != nil {
+ return nil, err
+ }
+ g.ptree = p
+ p = g
+ }
+ return g, nil
+}
+
+// LsTree checks if the given filenames are in the tree
+func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error) {
+ cmd := NewCommand(repo.Ctx, "ls-tree", "-z", "--name-only").
+ AddDashesAndList(append([]string{ref}, filenames...)...)
+
+ res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
+ if err != nil {
+ return nil, err
+ }
+ filelist := make([]string, 0, len(filenames))
+ for _, line := range bytes.Split(res, []byte{'\000'}) {
+ filelist = append(filelist, string(line))
+ }
+
+ return filelist, err
+}
diff --git a/modules/git/tree_blob.go b/modules/git/tree_blob.go
new file mode 100644
index 0000000..df339f6
--- /dev/null
+++ b/modules/git/tree_blob.go
@@ -0,0 +1,81 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path"
+ "strings"
+)
+
+// GetTreeEntryByPath get the tree entries according the sub dir
+func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
+ if len(relpath) == 0 {
+ return &TreeEntry{
+ ptree: t,
+ ID: t.ID,
+ name: "",
+ fullName: "",
+ entryMode: EntryModeTree,
+ }, nil
+ }
+
+ // FIXME: This should probably use git cat-file --batch to be a bit more efficient
+ relpath = path.Clean(relpath)
+ parts := strings.Split(relpath, "/")
+ var err error
+ tree := t
+ for i, name := range parts {
+ if i == len(parts)-1 {
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+ for _, v := range entries {
+ if v.Name() == name {
+ return v, nil
+ }
+ }
+ } else {
+ tree, err = tree.SubTree(name)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ return nil, ErrNotExist{"", relpath}
+}
+
+// GetBlobByPath get the blob object according the path
+func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) {
+ entry, err := t.GetTreeEntryByPath(relpath)
+ if err != nil {
+ return nil, err
+ }
+
+ if !entry.IsDir() && !entry.IsSubModule() {
+ return entry.Blob(), nil
+ }
+
+ return nil, ErrNotExist{"", relpath}
+}
+
+// GetBlobByFoldedPath returns the blob object at relpath, regardless of the
+// case of relpath. If there are multiple files with the same case-insensitive
+// name, the first one found will be returned.
+func (t *Tree) GetBlobByFoldedPath(relpath string) (*Blob, error) {
+ entries, err := t.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, entry := range entries {
+ if strings.EqualFold(entry.Name(), relpath) {
+ return t.GetBlobByPath(entry.Name())
+ }
+ }
+
+ return nil, ErrNotExist{"", relpath}
+}
diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go
new file mode 100644
index 0000000..0d9cfd2
--- /dev/null
+++ b/modules/git/tree_entry.go
@@ -0,0 +1,277 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "io"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+)
+
+// TreeEntry the leaf in the git tree
+type TreeEntry struct {
+ ID ObjectID
+
+ ptree *Tree
+
+ entryMode EntryMode
+ name string
+
+ size int64
+ sized bool
+ fullName string
+}
+
+// Name returns the name of the entry
+func (te *TreeEntry) Name() string {
+ if te.fullName != "" {
+ return te.fullName
+ }
+ return te.name
+}
+
+// Mode returns the mode of the entry
+func (te *TreeEntry) Mode() EntryMode {
+ return te.entryMode
+}
+
+// Size returns the size of the entry
+func (te *TreeEntry) Size() int64 {
+ if te.IsDir() {
+ return 0
+ } else if te.sized {
+ return te.size
+ }
+
+ wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(te.ptree.repo.Ctx)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+ defer cancel()
+ _, err = wr.Write([]byte(te.ID.String() + "\n"))
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+ _, _, te.size, err = ReadBatchLine(rd)
+ if err != nil {
+ log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
+ return 0
+ }
+
+ te.sized = true
+ return te.size
+}
+
+// IsSubModule if the entry is a sub module
+func (te *TreeEntry) IsSubModule() bool {
+ return te.entryMode == EntryModeCommit
+}
+
+// IsDir if the entry is a sub dir
+func (te *TreeEntry) IsDir() bool {
+ return te.entryMode == EntryModeTree
+}
+
+// IsLink if the entry is a symlink
+func (te *TreeEntry) IsLink() bool {
+ return te.entryMode == EntryModeSymlink
+}
+
+// IsRegular if the entry is a regular file
+func (te *TreeEntry) IsRegular() bool {
+ return te.entryMode == EntryModeBlob
+}
+
+// IsExecutable if the entry is an executable file (not necessarily binary)
+func (te *TreeEntry) IsExecutable() bool {
+ return te.entryMode == EntryModeExec
+}
+
+// Blob returns the blob object the entry
+func (te *TreeEntry) Blob() *Blob {
+ return &Blob{
+ ID: te.ID,
+ name: te.Name(),
+ size: te.size,
+ gotSize: te.sized,
+ repo: te.ptree.repo,
+ }
+}
+
+// Type returns the type of the entry (commit, tree, blob)
+func (te *TreeEntry) Type() string {
+ switch te.Mode() {
+ case EntryModeCommit:
+ return "commit"
+ case EntryModeTree:
+ return "tree"
+ default:
+ return "blob"
+ }
+}
+
+// FollowLink returns the entry pointed to by a symlink
+func (te *TreeEntry) FollowLink() (*TreeEntry, string, error) {
+ if !te.IsLink() {
+ return nil, "", ErrBadLink{te.Name(), "not a symlink"}
+ }
+
+ // read the link
+ r, err := te.Blob().DataAsync()
+ if err != nil {
+ return nil, "", err
+ }
+ closed := false
+ defer func() {
+ if !closed {
+ _ = r.Close()
+ }
+ }()
+ buf := make([]byte, te.Size())
+ _, err = io.ReadFull(r, buf)
+ if err != nil {
+ return nil, "", err
+ }
+ _ = r.Close()
+ closed = true
+
+ lnk := string(buf)
+ t := te.ptree
+
+ // traverse up directories
+ for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] {
+ t = t.ptree
+ }
+
+ if t == nil {
+ return nil, "", ErrBadLink{te.Name(), "points outside of repo"}
+ }
+
+ target, err := t.GetTreeEntryByPath(lnk)
+ if err != nil {
+ if IsErrNotExist(err) {
+ return nil, "", ErrBadLink{te.Name(), "broken link"}
+ }
+ return nil, "", err
+ }
+ return target, lnk, nil
+}
+
+// FollowLinks returns the entry ultimately pointed to by a symlink
+func (te *TreeEntry) FollowLinks() (*TreeEntry, string, error) {
+ if !te.IsLink() {
+ return nil, "", ErrBadLink{te.Name(), "not a symlink"}
+ }
+ entry := te
+ entryLink := ""
+ for i := 0; i < 999; i++ {
+ if entry.IsLink() {
+ next, link, err := entry.FollowLink()
+ entryLink = link
+ if err != nil {
+ return nil, "", err
+ }
+ if next.ID == entry.ID {
+ return nil, "", ErrBadLink{
+ entry.Name(),
+ "recursive link",
+ }
+ }
+ entry = next
+ } else {
+ break
+ }
+ }
+ if entry.IsLink() {
+ return nil, "", ErrBadLink{
+ te.Name(),
+ "too many levels of symbolic links",
+ }
+ }
+ return entry, entryLink, nil
+}
+
+// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree
+func (te *TreeEntry) Tree() *Tree {
+ t, err := te.ptree.repo.getTree(te.ID)
+ if err != nil {
+ return nil
+ }
+ t.ptree = te.ptree
+ return t
+}
+
+// GetSubJumpablePathName return the full path of subdirectory jumpable ( contains only one directory )
+func (te *TreeEntry) GetSubJumpablePathName() string {
+ if te.IsSubModule() || !te.IsDir() {
+ return ""
+ }
+ tree, err := te.ptree.SubTree(te.Name())
+ if err != nil {
+ return te.Name()
+ }
+ entries, _ := tree.ListEntries()
+ if len(entries) == 1 && entries[0].IsDir() {
+ name := entries[0].GetSubJumpablePathName()
+ if name != "" {
+ return te.Name() + "/" + name
+ }
+ }
+ return te.Name()
+}
+
+// Entries a list of entry
+type Entries []*TreeEntry
+
+type customSortableEntries struct {
+ Comparer func(s1, s2 string) bool
+ Entries
+}
+
+var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{
+ func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
+ return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule()
+ },
+ func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
+ return cmp(t1.Name(), t2.Name())
+ },
+}
+
+func (ctes customSortableEntries) Len() int { return len(ctes.Entries) }
+
+func (ctes customSortableEntries) Swap(i, j int) {
+ ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i]
+}
+
+func (ctes customSortableEntries) Less(i, j int) bool {
+ t1, t2 := ctes.Entries[i], ctes.Entries[j]
+ var k int
+ for k = 0; k < len(sorter)-1; k++ {
+ s := sorter[k]
+ switch {
+ case s(t1, t2, ctes.Comparer):
+ return true
+ case s(t2, t1, ctes.Comparer):
+ return false
+ }
+ }
+ return sorter[k](t1, t2, ctes.Comparer)
+}
+
+// Sort sort the list of entry
+func (tes Entries) Sort() {
+ sort.Sort(customSortableEntries{func(s1, s2 string) bool {
+ return s1 < s2
+ }, tes})
+}
+
+// CustomSort customizable string comparing sort entry list
+func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) {
+ sort.Sort(customSortableEntries{cmp, tes})
+}
diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go
new file mode 100644
index 0000000..a399118
--- /dev/null
+++ b/modules/git/tree_entry_mode.go
@@ -0,0 +1,35 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import "strconv"
+
+// EntryMode the type of the object in the git tree
+type EntryMode int
+
+// There are only a few file modes in Git. They look like unix file modes, but they can only be
+// one of these.
+const (
+ // EntryModeBlob
+ EntryModeBlob EntryMode = 0o100644
+ // EntryModeExec
+ EntryModeExec EntryMode = 0o100755
+ // EntryModeSymlink
+ EntryModeSymlink EntryMode = 0o120000
+ // EntryModeCommit
+ EntryModeCommit EntryMode = 0o160000
+ // EntryModeTree
+ EntryModeTree EntryMode = 0o040000
+)
+
+// String converts an EntryMode to a string
+func (e EntryMode) String() string {
+ return strconv.FormatInt(int64(e), 8)
+}
+
+// ToEntryMode converts a string to an EntryMode
+func ToEntryMode(value string) EntryMode {
+ v, _ := strconv.ParseInt(value, 8, 32)
+ return EntryMode(v)
+}
diff --git a/modules/git/tree_test.go b/modules/git/tree_test.go
new file mode 100644
index 0000000..6e5d7f4
--- /dev/null
+++ b/modules/git/tree_test.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSubTree_Issue29101(t *testing.T) {
+ repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
+ require.NoError(t, err)
+ defer repo.Close()
+
+ commit, err := repo.GetCommit("ce064814f4a0d337b333e646ece456cd39fab612")
+ require.NoError(t, err)
+
+ // old code could produce a different error if called multiple times
+ for i := 0; i < 10; i++ {
+ _, err = commit.SubTree("file1.txt")
+ require.Error(t, err)
+ assert.True(t, IsErrNotExist(err))
+ }
+}
diff --git a/modules/git/url/url.go b/modules/git/url/url.go
new file mode 100644
index 0000000..6376851
--- /dev/null
+++ b/modules/git/url/url.go
@@ -0,0 +1,89 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package url
+
+import (
+ "fmt"
+ stdurl "net/url"
+ "strings"
+)
+
+// ErrWrongURLFormat represents an error with wrong url format
+type ErrWrongURLFormat struct {
+ URL string
+}
+
+func (err ErrWrongURLFormat) Error() string {
+ return fmt.Sprintf("git URL %s format is wrong", err.URL)
+}
+
+// GitURL represents a git URL
+type GitURL struct {
+ *stdurl.URL
+ extraMark int // 0 no extra 1 scp 2 file path with no prefix
+}
+
+// String returns the URL's string
+func (u *GitURL) String() string {
+ switch u.extraMark {
+ case 0:
+ return u.URL.String()
+ case 1:
+ return fmt.Sprintf("%s@%s:%s", u.User.Username(), u.Host, u.Path)
+ case 2:
+ return u.Path
+ default:
+ return ""
+ }
+}
+
+// Parse parse all kinds of git URL
+func Parse(remote string) (*GitURL, error) {
+ if strings.Contains(remote, "://") {
+ u, err := stdurl.Parse(remote)
+ if err != nil {
+ return nil, err
+ }
+ return &GitURL{URL: u}, nil
+ } else if strings.Contains(remote, "@") && strings.Contains(remote, ":") {
+ url := stdurl.URL{
+ Scheme: "ssh",
+ }
+ squareBrackets := false
+ lastIndex := -1
+ FOR:
+ for i := 0; i < len(remote); i++ {
+ switch remote[i] {
+ case '@':
+ url.User = stdurl.User(remote[:i])
+ lastIndex = i + 1
+ case ':':
+ if !squareBrackets {
+ url.Host = strings.ReplaceAll(remote[lastIndex:i], "%25", "%")
+ if len(remote) <= i+1 {
+ return nil, ErrWrongURLFormat{URL: remote}
+ }
+ url.Path = remote[i+1:]
+ break FOR
+ }
+ case '[':
+ squareBrackets = true
+ case ']':
+ squareBrackets = false
+ }
+ }
+ return &GitURL{
+ URL: &url,
+ extraMark: 1,
+ }, nil
+ }
+
+ return &GitURL{
+ URL: &stdurl.URL{
+ Scheme: "file",
+ Path: remote,
+ },
+ extraMark: 2,
+ }, nil
+}
diff --git a/modules/git/url/url_test.go b/modules/git/url/url_test.go
new file mode 100644
index 0000000..e1e52c0
--- /dev/null
+++ b/modules/git/url/url_test.go
@@ -0,0 +1,167 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package url
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseGitURLs(t *testing.T) {
+ kases := []struct {
+ kase string
+ expected *GitURL
+ }{
+ {
+ kase: "git@127.0.0.1:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "127.0.0.1",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[fe80:14fc:cec5:c174:d88%10]",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@[::1]:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[::1]",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "git@github.com:go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "github.com",
+ Path: "go-gitea/gitea.git",
+ },
+ extraMark: 1,
+ },
+ },
+ {
+ kase: "ssh://git@github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "ssh://git@[::1]/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "ssh",
+ User: url.User("git"),
+ Host: "[::1]",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "/repositories/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "file",
+ Path: "/repositories/go-gitea/gitea.git",
+ },
+ extraMark: 2,
+ },
+ },
+ {
+ kase: "file:///repositories/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "file",
+ Path: "/repositories/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://git:git@github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "github.com",
+ User: url.UserPassword("git", "git"),
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ {
+ kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "[fe80:14fc:cec5:c174:d88%10]:20",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+
+ {
+ kase: "git://github.com/go-gitea/gitea.git",
+ expected: &GitURL{
+ URL: &url.URL{
+ Scheme: "git",
+ Host: "github.com",
+ Path: "/go-gitea/gitea.git",
+ },
+ extraMark: 0,
+ },
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.kase, func(t *testing.T) {
+ u, err := Parse(kase.kase)
+ require.NoError(t, err)
+ assert.EqualValues(t, kase.expected.extraMark, u.extraMark)
+ assert.EqualValues(t, *kase.expected, *u)
+ })
+ }
+}
diff --git a/modules/git/utils.go b/modules/git/utils.go
new file mode 100644
index 0000000..53211c6
--- /dev/null
+++ b/modules/git/utils.go
@@ -0,0 +1,138 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// ObjectCache provides thread-safe cache operations.
+type ObjectCache struct {
+ lock sync.RWMutex
+ cache map[string]any
+}
+
+func newObjectCache() *ObjectCache {
+ return &ObjectCache{
+ cache: make(map[string]any, 10),
+ }
+}
+
+// Set add obj to cache
+func (oc *ObjectCache) Set(id string, obj any) {
+ oc.lock.Lock()
+ defer oc.lock.Unlock()
+
+ oc.cache[id] = obj
+}
+
+// Get get cached obj by id
+func (oc *ObjectCache) Get(id string) (any, bool) {
+ oc.lock.RLock()
+ defer oc.lock.RUnlock()
+
+ obj, has := oc.cache[id]
+ return obj, has
+}
+
+// isDir returns true if given path is a directory,
+// or returns false when it's a file or does not exist.
+func isDir(dir string) bool {
+ f, e := os.Stat(dir)
+ if e != nil {
+ return false
+ }
+ return f.IsDir()
+}
+
+// isFile returns true if given path is a file,
+// or returns false when it's a directory or does not exist.
+func isFile(filePath string) bool {
+ f, e := os.Stat(filePath)
+ if e != nil {
+ return false
+ }
+ return !f.IsDir()
+}
+
+// isExist checks whether a file or directory exists.
+// It returns false when the file or directory does not exist.
+func isExist(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil || os.IsExist(err)
+}
+
+// ConcatenateError concatenats an error with stderr string
+func ConcatenateError(err error, stderr string) error {
+ if len(stderr) == 0 {
+ return err
+ }
+ return fmt.Errorf("%w - %s", err, stderr)
+}
+
+// ParseBool returns the boolean value represented by the string as per git's git_config_bool
+// true will be returned for the result if the string is empty, but valid will be false.
+// "true", "yes", "on" are all true, true
+// "false", "no", "off" are all false, true
+// 0 is false, true
+// Any other integer is true, true
+// Anything else will return false, false
+func ParseBool(value string) (result, valid bool) {
+ // Empty strings are true but invalid
+ if len(value) == 0 {
+ return true, false
+ }
+ // These are the git expected true and false values
+ if strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") || strings.EqualFold(value, "on") {
+ return true, true
+ }
+ if strings.EqualFold(value, "false") || strings.EqualFold(value, "no") || strings.EqualFold(value, "off") {
+ return false, true
+ }
+ // Try a number
+ intValue, err := strconv.ParseInt(value, 10, 32)
+ if err != nil {
+ return false, false
+ }
+ return intValue != 0, true
+}
+
+// LimitedReaderCloser is a limited reader closer
+type LimitedReaderCloser struct {
+ R io.Reader
+ C io.Closer
+ N int64
+}
+
+// Read implements io.Reader
+func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) {
+ if l.N <= 0 {
+ _ = l.C.Close()
+ return 0, io.EOF
+ }
+ if int64(len(p)) > l.N {
+ p = p[0:l.N]
+ }
+ n, err = l.R.Read(p)
+ l.N -= int64(n)
+ return n, err
+}
+
+// Close implements io.Closer
+func (l *LimitedReaderCloser) Close() error {
+ return l.C.Close()
+}
+
+func HashFilePathForWebUI(s string) string {
+ h := sha1.New()
+ _, _ = h.Write([]byte(s))
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/modules/git/utils_test.go b/modules/git/utils_test.go
new file mode 100644
index 0000000..a8c3fe3
--- /dev/null
+++ b/modules/git/utils_test.go
@@ -0,0 +1,26 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// This file contains utility functions that are used across multiple tests,
+// but not in production code.
+
+func skipIfSHA256NotSupported(t *testing.T) {
+ if CheckGitVersionAtLeast("2.42") != nil {
+ t.Skip("skipping because installed Git version doesn't support SHA256")
+ }
+}
+
+func TestHashFilePathForWebUI(t *testing.T) {
+ assert.Equal(t,
+ "8843d7f92416211de9ebb963ff4ce28125932878",
+ HashFilePathForWebUI("foobar"),
+ )
+}
diff --git a/modules/gitgraph/graph.go b/modules/gitgraph/graph.go
new file mode 100644
index 0000000..331ad6b
--- /dev/null
+++ b/modules/gitgraph/graph.go
@@ -0,0 +1,116 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "os"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// GetCommitGraph return a list of commit (GraphItems) from all branches
+func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bool, branches, files []string) (*Graph, error) {
+ format := "DATA:%D|%H|%ad|%h|%s"
+
+ if page == 0 {
+ page = 1
+ }
+
+ graphCmd := git.NewCommand(r.Ctx, "log", "--graph", "--date-order", "--decorate=full")
+
+ if hidePRRefs {
+ graphCmd.AddArguments("--exclude=" + git.PullPrefix + "*")
+ }
+
+ if len(branches) == 0 {
+ graphCmd.AddArguments("--all")
+ }
+
+ graphCmd.AddArguments("-C", "-M", "--date=iso").
+ AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
+ AddOptionFormat("--pretty=format:%s", format)
+
+ if len(branches) > 0 {
+ graphCmd.AddDynamicArguments(branches...)
+ }
+ if len(files) > 0 {
+ graphCmd.AddDashesAndList(files...)
+ }
+ graph := NewGraph()
+
+ stderr := new(strings.Builder)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ commitsToSkip := setting.UI.GraphMaxCommitNum * (page - 1)
+
+ scanner := bufio.NewScanner(stdoutReader)
+
+ if err := graphCmd.Run(&git.RunOpts{
+ Dir: r.Path,
+ Stdout: stdoutWriter,
+ Stderr: stderr,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ defer stdoutReader.Close()
+ parser := &Parser{}
+ parser.firstInUse = -1
+ parser.maxAllowedColors = maxAllowedColors
+ if maxAllowedColors > 0 {
+ parser.availableColors = make([]int, maxAllowedColors)
+ for i := range parser.availableColors {
+ parser.availableColors[i] = i + 1
+ }
+ } else {
+ parser.availableColors = []int{1, 2}
+ }
+ for commitsToSkip > 0 && scanner.Scan() {
+ line := scanner.Bytes()
+ dataIdx := bytes.Index(line, []byte("DATA:"))
+ if dataIdx < 0 {
+ dataIdx = len(line)
+ }
+ starIdx := bytes.IndexByte(line, '*')
+ if starIdx >= 0 && starIdx < dataIdx {
+ commitsToSkip--
+ }
+ parser.ParseGlyphs(line[:dataIdx])
+ }
+
+ row := 0
+
+ // Skip initial non-commit lines
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if bytes.IndexByte(line, '*') >= 0 {
+ if err := parser.AddLineToGraph(graph, row, line); err != nil {
+ cancel()
+ return err
+ }
+ break
+ }
+ parser.ParseGlyphs(line)
+ }
+
+ for scanner.Scan() {
+ row++
+ line := scanner.Bytes()
+ if err := parser.AddLineToGraph(graph, row, line); err != nil {
+ cancel()
+ return err
+ }
+ }
+ return scanner.Err()
+ },
+ }); err != nil {
+ return graph, err
+ }
+ return graph, nil
+}
diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go
new file mode 100644
index 0000000..82f460e
--- /dev/null
+++ b/modules/gitgraph/graph_models.go
@@ -0,0 +1,256 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// NewGraph creates a basic graph
+func NewGraph() *Graph {
+ graph := &Graph{}
+ graph.relationCommit = &Commit{
+ Row: -1,
+ Column: -1,
+ }
+ graph.Flows = map[int64]*Flow{}
+ return graph
+}
+
+// Graph represents a collection of flows
+type Graph struct {
+ Flows map[int64]*Flow
+ Commits []*Commit
+ MinRow int
+ MinColumn int
+ MaxRow int
+ MaxColumn int
+ relationCommit *Commit
+}
+
+// Width returns the width of the graph
+func (graph *Graph) Width() int {
+ return graph.MaxColumn - graph.MinColumn + 1
+}
+
+// Height returns the height of the graph
+func (graph *Graph) Height() int {
+ return graph.MaxRow - graph.MinRow + 1
+}
+
+// AddGlyph adds glyph to flows
+func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) {
+ flow, ok := graph.Flows[flowID]
+ if !ok {
+ flow = NewFlow(flowID, color, row, column)
+ graph.Flows[flowID] = flow
+ }
+ flow.AddGlyph(row, column, glyph)
+
+ if row < graph.MinRow {
+ graph.MinRow = row
+ }
+ if row > graph.MaxRow {
+ graph.MaxRow = row
+ }
+ if column < graph.MinColumn {
+ graph.MinColumn = column
+ }
+ if column > graph.MaxColumn {
+ graph.MaxColumn = column
+ }
+}
+
+// AddCommit adds a commit at row, column on flowID with the provided data
+func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error {
+ commit, err := NewCommit(row, column, data)
+ if err != nil {
+ return err
+ }
+ commit.Flow = flowID
+ graph.Commits = append(graph.Commits, commit)
+
+ graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit)
+ return nil
+}
+
+// LoadAndProcessCommits will load the git.Commits for each commit in the graph,
+// the associate the commit with the user author, and check the commit verification
+// before finally retrieving the latest status
+func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error {
+ var err error
+ var ok bool
+
+ emails := map[string]*user_model.User{}
+ keyMap := map[string]bool{}
+
+ for _, c := range graph.Commits {
+ if len(c.Rev) == 0 {
+ continue
+ }
+ c.Commit, err = gitRepo.GetCommit(c.Rev)
+ if err != nil {
+ return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err)
+ }
+
+ if c.Commit.Author != nil {
+ email := c.Commit.Author.Email
+ if c.User, ok = emails[email]; !ok {
+ c.User, _ = user_model.GetUserByEmail(ctx, email)
+ emails[email] = c.User
+ }
+ }
+
+ c.Verification = asymkey_model.ParseCommitWithSignature(ctx, c.Commit)
+
+ _ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
+ return repo_model.IsOwnerMemberCollaborator(ctx, repository, user.ID)
+ }, &keyMap)
+
+ statuses, _, err := git_model.GetLatestCommitStatus(ctx, repository.ID, c.Commit.ID.String(), db.ListOptions{})
+ if err != nil {
+ log.Error("GetLatestCommitStatus: %v", err)
+ } else {
+ c.Status = git_model.CalcCommitStatus(statuses)
+ }
+ }
+ return nil
+}
+
+// NewFlow creates a new flow
+func NewFlow(flowID int64, color, row, column int) *Flow {
+ return &Flow{
+ ID: flowID,
+ ColorNumber: color,
+ MinRow: row,
+ MinColumn: column,
+ MaxRow: row,
+ MaxColumn: column,
+ }
+}
+
+// Flow represents a series of glyphs
+type Flow struct {
+ ID int64
+ ColorNumber int
+ Glyphs []Glyph
+ Commits []*Commit
+ MinRow int
+ MinColumn int
+ MaxRow int
+ MaxColumn int
+}
+
+// Color16 wraps the color numbers around mod 16
+func (flow *Flow) Color16() int {
+ return flow.ColorNumber % 16
+}
+
+// AddGlyph adds glyph at row and column
+func (flow *Flow) AddGlyph(row, column int, glyph byte) {
+ if row < flow.MinRow {
+ flow.MinRow = row
+ }
+ if row > flow.MaxRow {
+ flow.MaxRow = row
+ }
+ if column < flow.MinColumn {
+ flow.MinColumn = column
+ }
+ if column > flow.MaxColumn {
+ flow.MaxColumn = column
+ }
+
+ flow.Glyphs = append(flow.Glyphs, Glyph{
+ row,
+ column,
+ glyph,
+ })
+}
+
+// Glyph represents a coordinate and glyph
+type Glyph struct {
+ Row int
+ Column int
+ Glyph byte
+}
+
+// RelationCommit represents an empty relation commit
+var RelationCommit = &Commit{
+ Row: -1,
+}
+
+// NewCommit creates a new commit from a provided line
+func NewCommit(row, column int, line []byte) (*Commit, error) {
+ data := bytes.SplitN(line, []byte("|"), 5)
+ if len(data) < 5 {
+ return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
+ }
+ return &Commit{
+ Row: row,
+ Column: column,
+ // 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1)
+ Refs: newRefsFromRefNames(data[0]),
+ // 1 matches git log --pretty=format:%H => commit hash
+ Rev: string(data[1]),
+ // 2 matches git log --pretty=format:%ad => author date (format respects --date= option)
+ Date: string(data[2]),
+ // 3 matches git log --pretty=format:%h => abbreviated commit hash
+ ShortRev: string(data[3]),
+ // 4 matches git log --pretty=format:%s => subject
+ Subject: string(data[4]),
+ }, nil
+}
+
+func newRefsFromRefNames(refNames []byte) []git.Reference {
+ refBytes := bytes.Split(refNames, []byte{',', ' '})
+ refs := make([]git.Reference, 0, len(refBytes))
+ for _, refNameBytes := range refBytes {
+ if len(refNameBytes) == 0 {
+ continue
+ }
+ refName := string(refNameBytes)
+ if strings.HasPrefix(refName, "tag: ") {
+ refName = strings.TrimPrefix(refName, "tag: ")
+ } else {
+ refName = strings.TrimPrefix(refName, "HEAD -> ")
+ }
+ refs = append(refs, git.Reference{
+ Name: refName,
+ })
+ }
+ return refs
+}
+
+// Commit represents a commit at coordinate X, Y with the data
+type Commit struct {
+ Commit *git.Commit
+ User *user_model.User
+ Verification *asymkey_model.ObjectVerification
+ Status *git_model.CommitStatus
+ Flow int64
+ Row int
+ Column int
+ Refs []git.Reference
+ Rev string
+ Date string
+ ShortRev string
+ Subject string
+}
+
+// OnlyRelation returns whether this a relation only commit
+func (c *Commit) OnlyRelation() bool {
+ return c.Row == -1
+}
diff --git a/modules/gitgraph/graph_test.go b/modules/gitgraph/graph_test.go
new file mode 100644
index 0000000..18d427a
--- /dev/null
+++ b/modules/gitgraph/graph_test.go
@@ -0,0 +1,714 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+func BenchmarkGetCommitGraph(b *testing.B) {
+ currentRepo, err := git.OpenRepository(git.DefaultContext, ".")
+ if err != nil || currentRepo == nil {
+ b.Error("Could not open repository")
+ }
+ defer currentRepo.Close()
+
+ for i := 0; i < b.N; i++ {
+ graph, err := GetCommitGraph(currentRepo, 1, 0, false, nil, nil)
+ if err != nil {
+ b.Error("Could get commit graph")
+ }
+
+ if len(graph.Commits) < 100 {
+ b.Error("Should get 100 log lines.")
+ }
+ }
+}
+
+func BenchmarkParseCommitString(b *testing.B) {
+ testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
+
+ parser := &Parser{}
+ parser.Reset()
+ for i := 0; i < b.N; i++ {
+ parser.Reset()
+ graph := NewGraph()
+ if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil {
+ b.Error("could not parse teststring")
+ }
+ if graph.Flows[1].Commits[0].Rev != "4e61bacab44e9b4730e44a6615d04098dd3a8eaf" {
+ b.Error("Did not get expected data")
+ }
+ }
+}
+
+func BenchmarkParseGlyphs(b *testing.B) {
+ parser := &Parser{}
+ parser.Reset()
+ tgBytes := []byte(testglyphs)
+ var tg []byte
+ for i := 0; i < b.N; i++ {
+ parser.Reset()
+ tg = tgBytes
+ idx := bytes.Index(tg, []byte("\n"))
+ for idx > 0 {
+ parser.ParseGlyphs(tg[:idx])
+ tg = tg[idx+1:]
+ idx = bytes.Index(tg, []byte("\n"))
+ }
+ }
+}
+
+func TestReleaseUnusedColors(t *testing.T) {
+ testcases := []struct {
+ availableColors []int
+ oldColors []int
+ firstInUse int // these values have to be either be correct or suggest less is
+ firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it
+ }{
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{1, 1, 1, 1, 1},
+ firstAvailable: -1,
+ firstInUse: 1,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{1, 2, 3, 4},
+ firstAvailable: 6,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+ oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0},
+ firstAvailable: 6,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+ oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7},
+ firstAvailable: -1,
+ firstInUse: 0,
+ },
+ {
+ availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+ oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7},
+ firstAvailable: -1,
+ firstInUse: 0,
+ },
+ }
+ for _, testcase := range testcases {
+ parser := &Parser{}
+ parser.Reset()
+ parser.availableColors = append([]int{}, testcase.availableColors...)
+ parser.oldColors = append(parser.oldColors, testcase.oldColors...)
+ parser.firstAvailable = testcase.firstAvailable
+ parser.firstInUse = testcase.firstInUse
+ parser.releaseUnusedColors()
+
+ if parser.firstAvailable == -1 {
+ // All in use
+ for _, color := range parser.availableColors {
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ } else if parser.firstInUse != -1 {
+ // Some in use
+ for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) {
+ color := parser.availableColors[i]
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) {
+ color := parser.availableColors[i]
+ found := false
+ for _, oldColor := range parser.oldColors {
+ if oldColor == color {
+ found = true
+ break
+ }
+ }
+ if found {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ } else {
+ // None in use
+ for _, color := range parser.oldColors {
+ if color != 0 {
+ t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+ testcase.availableColors,
+ testcase.oldColors,
+ testcase.firstAvailable,
+ testcase.firstInUse,
+ parser.availableColors,
+ parser.oldColors,
+ parser.firstAvailable,
+ parser.firstInUse,
+ color)
+ }
+ }
+ }
+ }
+}
+
+func TestParseGlyphs(t *testing.T) {
+ parser := &Parser{}
+ parser.Reset()
+ tgBytes := []byte(testglyphs)
+ tg := tgBytes
+ idx := bytes.Index(tg, []byte("\n"))
+ row := 0
+ for idx > 0 {
+ parser.ParseGlyphs(tg[:idx])
+ tg = tg[idx+1:]
+ idx = bytes.Index(tg, []byte("\n"))
+ if parser.flows[0] != 1 {
+ t.Errorf("First column flow should be 1 but was %d", parser.flows[0])
+ }
+ colorToFlow := map[int]int64{}
+ flowToColor := map[int64]int{}
+
+ for i, flow := range parser.flows {
+ if flow == 0 {
+ continue
+ }
+ color := parser.colors[i]
+
+ if fColor, in := flowToColor[flow]; in && fColor != color {
+ t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor)
+ }
+ flowToColor[flow] = color
+ if cFlow, in := colorToFlow[color]; in && cFlow != flow {
+ t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow)
+ }
+ colorToFlow[color] = flow
+ }
+ row++
+ }
+ if len(parser.availableColors) != 9 {
+ t.Errorf("Expected 9 colors but have %d", len(parser.availableColors))
+ }
+}
+
+func TestCommitStringParsing(t *testing.T) {
+ dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
+ tests := []struct {
+ shouldPass bool
+ testName string
+ commitMessage string
+ }{
+ {true, "normal", "not a fancy message"},
+ {true, "extra pipe", "An extra pipe: |"},
+ {true, "extra 'Data:'", "DATA: might be trouble"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.testName, func(t *testing.T) {
+ testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
+ idx := strings.Index(testString, "DATA:")
+ commit, err := NewCommit(0, 0, []byte(testString[idx+5:]))
+ if err != nil && test.shouldPass {
+ t.Errorf("Could not parse %s", testString)
+ return
+ }
+
+ if test.commitMessage != commit.Subject {
+ t.Errorf("%s does not match %s", test.commitMessage, commit.Subject)
+ }
+ })
+ }
+}
+
+var testglyphs = `*
+*
+*
+*
+*
+*
+*
+*
+|\
+* |
+* |
+* |
+* |
+* |
+| *
+* |
+| *
+| |\
+* | |
+| | *
+| | |\
+* | | \
+|\ \ \ \
+| * | | |
+| |\| | |
+* | | | |
+|/ / / /
+| | | *
+| * | |
+| * | |
+| * | |
+* | | |
+* | | |
+* | | |
+* | | |
+* | | |
+|\ \ \ \
+| | * | |
+| | |\| |
+| | | * |
+| | | | *
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | |/ / /
+| |/| | |
+| | | | *
+| * | | |
+|/| | | |
+| * | | |
+|/| | | |
+| | |/ /
+| |/| |
+| * | |
+| * | |
+| |\ \ \
+| | * | |
+| |/| | |
+| | | |/
+| | |/|
+| * | |
+| * | |
+| * | |
+| | * |
+| | |\ \
+| | | * |
+| | |/| |
+| | | * |
+| | | |\ \
+| | | | * |
+| | | |/| |
+| | * | | |
+| | * | | |
+| | |\ \ \ \
+| | | * | | |
+| | |/| | | |
+| | | | | * |
+| | | | |/ /
+* | | | / /
+|/ / / / /
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| * | | | |
+| * | | | |
+| |\ \ \ \ \
+| | | * \ \ \
+| | | |\ \ \ \
+| | | | * | | |
+| | | |/| | | |
+| | | | | |/ /
+| | | | |/| |
+* | | | | | |
+* | | | | | |
+* | | | | | |
+| | | | * | |
+* | | | | | |
+| | * | | | |
+| |/| | | | |
+* | | | | | |
+| |/ / / / /
+|/| | | | |
+| | | | * |
+| | | |/ /
+| | |/| |
+| * | | |
+| | | | *
+| | * | |
+| | |\ \ \
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | | * |
+| | * | |
+| | |\ \ \
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | | * |
+* | | | |
+|\ \ \ \ \
+| * \ \ \ \
+| |\ \ \ \ \
+| | | |/ / /
+| | |/| | |
+| | | | * |
+| | | | * |
+* | | | | |
+* | | | | |
+|/ / / / /
+| | | * |
+* | | | |
+* | | | |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | * | | |
+| | |\ \ \ \
+| | | * | | |
+| | |/| | | |
+| |/| | |/ /
+| | | |/| |
+| | | | | *
+| |_|_|_|/
+|/| | | |
+| | * | |
+| |/ / /
+* | | |
+* | | |
+| | * |
+* | | |
+* | | |
+| * | |
+| | * |
+| * | |
+* | | |
+|\ \ \ \
+| * | | |
+|/| | | |
+| |/ / /
+| * | |
+| |\ \ \
+| | * | |
+| |/| | |
+| | |/ /
+| | * |
+| | |\ \
+| | | * |
+| | |/| |
+* | | | |
+* | | | |
+|\ \ \ \ \
+| * | | | |
+|/| | | | |
+| | * | | |
+| | * | | |
+| | * | | |
+| |/ / / /
+| * | | |
+| |\ \ \ \
+| | * | | |
+| |/| | | |
+* | | | | |
+* | | | | |
+* | | | | |
+* | | | | |
+* | | | | |
+| | | | * |
+* | | | | |
+|\ \ \ \ \ \
+| * | | | | |
+|/| | | | | |
+| | | | | * |
+| | | | |/ /
+* | | | | |
+|\ \ \ \ \ \
+* | | | | | |
+* | | | | | |
+| | | | * | |
+* | | | | | |
+* | | | | | |
+|\ \ \ \ \ \ \
+| | |_|_|/ / /
+| |/| | | | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | | * | |
+| | | |/ / /
+| | | * | |
+| | | * | |
+| | | * | |
+| | |/| | |
+| | | * | |
+| | |/| | |
+| | | |/ /
+| | * | |
+| |/| | |
+| | | * |
+| | |/ /
+| | * |
+| * | |
+| |\ \ \
+| * | | |
+| | * | |
+| |/| | |
+| | |/ /
+| | * |
+| | |\ \
+| | * | |
+* | | | |
+|\| | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| * | | |
+| |\| | |
+| * | | |
+| | * | |
+| | * | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+* | | | |
+|\| | | |
+| | * | |
+| * | | |
+| |\| | |
+| | * | |
+| | * | |
+| | * | |
+| | | * |
+* | | | |
+|\| | | |
+| | * | |
+| | |/ /
+| * | |
+| * | |
+| |\| |
+* | | |
+|\| | |
+| | * |
+| | * |
+| | * |
+| * | |
+| | * |
+| * | |
+| | * |
+| | * |
+| | * |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| |\| |
+| | * |
+| | |\ \
+* | | | |
+|\| | | |
+| * | | |
+| |\| | |
+| | * | |
+| | | * |
+| | |/ /
+* | | |
+* | | |
+|\| | |
+| * | |
+| |\| |
+| | * |
+| | * |
+| | * |
+| | | *
+* | | |
+|\| | |
+| * | |
+| * | |
+| | | *
+| | | |\
+* | | | |
+| |_|_|/
+|/| | |
+| * | |
+| |\| |
+| | * |
+| | * |
+| | * |
+| | * |
+| | * |
+| * | |
+* | | |
+|\| | |
+| * | |
+|/| | |
+| |/ /
+| * |
+| |\ \
+| * | |
+| * | |
+* | | |
+|\| | |
+| | * |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| * | |
+| | * |
+| | |\ \
+| | |/ /
+| |/| |
+| * | |
+* | | |
+|\| | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| |\ \ \
+| * | | |
+| * | | |
+| | | * |
+| * | | |
+| * | | |
+| | |/ /
+| |/| |
+| | * |
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+| |\ \ \
+* | | | |
+|\| | | |
+| * | | |
+| * | | |
+* | | | |
+* | | | |
+|\| | | |
+| | | | *
+| | | | |\
+| |_|_|_|/
+|/| | | |
+| * | | |
+* | | | |
+* | | | |
+|\| | | |
+| * | | |
+| |\ \ \ \
+| | | |/ /
+| | |/| |
+| * | | |
+| * | | |
+| * | | |
+| * | | |
+| | * | |
+| | | * |
+| | |/ /
+| |/| |
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+| * | |
+| * | |
+* | | |
+|\| | |
+| * | |
+| * | |
+* | | |
+| * | |
+| * | |
+| * | |
+* | | |
+* | | |
+* | | |
+|\| | |
+| * | |
+* | | |
+* | | |
+* | | |
+* | | |
+| | | *
+* | | |
+|\| | |
+| * | |
+| * | |
+| * | |
+`
diff --git a/modules/gitgraph/parser.go b/modules/gitgraph/parser.go
new file mode 100644
index 0000000..f6bf9b0
--- /dev/null
+++ b/modules/gitgraph/parser.go
@@ -0,0 +1,336 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitgraph
+
+import (
+ "bytes"
+ "fmt"
+)
+
+// Parser represents a git graph parser. It is stateful containing the previous
+// glyphs, detected flows and color assignments.
+type Parser struct {
+ glyphs []byte
+ oldGlyphs []byte
+ flows []int64
+ oldFlows []int64
+ maxFlow int64
+ colors []int
+ oldColors []int
+ availableColors []int
+ nextAvailable int
+ firstInUse int
+ firstAvailable int
+ maxAllowedColors int
+}
+
+// Reset resets the internal parser state.
+func (parser *Parser) Reset() {
+ parser.glyphs = parser.glyphs[0:0]
+ parser.oldGlyphs = parser.oldGlyphs[0:0]
+ parser.flows = parser.flows[0:0]
+ parser.oldFlows = parser.oldFlows[0:0]
+ parser.maxFlow = 0
+ parser.colors = parser.colors[0:0]
+ parser.oldColors = parser.oldColors[0:0]
+ parser.availableColors = parser.availableColors[0:0]
+ parser.availableColors = append(parser.availableColors, 1, 2)
+ parser.nextAvailable = 0
+ parser.firstInUse = -1
+ parser.firstAvailable = 0
+ parser.maxAllowedColors = 0
+}
+
+// AddLineToGraph adds the line as a row to the graph
+func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
+ idx := bytes.Index(line, []byte("DATA:"))
+ if idx < 0 {
+ parser.ParseGlyphs(line)
+ } else {
+ parser.ParseGlyphs(line[:idx])
+ }
+
+ var err error
+ commitDone := false
+
+ for column, glyph := range parser.glyphs {
+ if glyph == ' ' {
+ continue
+ }
+
+ flowID := parser.flows[column]
+
+ graph.AddGlyph(row, column, flowID, parser.colors[column], glyph)
+
+ if glyph == '*' {
+ if commitDone {
+ if err != nil {
+ err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err)
+ } else {
+ err = fmt.Errorf("double commit on line %d: %s", row, string(line))
+ }
+ }
+ commitDone = true
+ if idx < 0 {
+ if err != nil {
+ err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err)
+ } else {
+ err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line))
+ }
+ continue
+ }
+ err2 := graph.AddCommit(row, column, flowID, line[idx+5:])
+ if err != nil && err2 != nil {
+ err = fmt.Errorf("%v %w", err2, err)
+ continue
+ } else if err2 != nil {
+ err = err2
+ continue
+ }
+ }
+ }
+ if !commitDone {
+ graph.Commits = append(graph.Commits, RelationCommit)
+ }
+ return err
+}
+
+func (parser *Parser) releaseUnusedColors() {
+ if parser.firstInUse > -1 {
+ // Here we step through the old colors, searching for them in the
+ // "in-use" section of availableColors (that is, the colors between
+ // firstInUse and firstAvailable)
+ // Ensure that the benchmarks are not worsened with proposed changes
+ stepstaken := 0
+ position := parser.firstInUse
+ for _, color := range parser.oldColors {
+ if color == 0 {
+ continue
+ }
+ found := false
+ i := position
+ for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ {
+ colorToCheck := parser.availableColors[i]
+ if colorToCheck == color {
+ found = true
+ break
+ }
+ i = (i + 1) % len(parser.availableColors)
+ }
+ if !found {
+ // Duplicate color
+ continue
+ }
+ // Swap them around
+ parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position]
+ stepstaken++
+ position = (parser.firstInUse + stepstaken) % len(parser.availableColors)
+ if position == parser.firstAvailable || stepstaken == len(parser.availableColors) {
+ break
+ }
+ }
+ if stepstaken == len(parser.availableColors) {
+ parser.firstAvailable = -1
+ } else {
+ parser.firstAvailable = position
+ if parser.nextAvailable == -1 {
+ parser.nextAvailable = parser.firstAvailable
+ }
+ }
+ }
+}
+
+// ParseGlyphs parses the provided glyphs and sets the internal state
+func (parser *Parser) ParseGlyphs(glyphs []byte) {
+ // Clean state for parsing this row
+ parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs
+ parser.glyphs = parser.glyphs[0:0]
+ parser.flows, parser.oldFlows = parser.oldFlows, parser.flows
+ parser.flows = parser.flows[0:0]
+ parser.colors, parser.oldColors = parser.oldColors, parser.colors
+
+ // Ensure we have enough flows and colors
+ parser.colors = parser.colors[0:0]
+ for range glyphs {
+ parser.flows = append(parser.flows, 0)
+ parser.colors = append(parser.colors, 0)
+ }
+
+ // Copy the provided glyphs in to state.glyphs for safekeeping
+ parser.glyphs = append(parser.glyphs, glyphs...)
+
+ // release unused colors
+ parser.releaseUnusedColors()
+
+ for i := len(glyphs) - 1; i >= 0; i-- {
+ glyph := glyphs[i]
+ switch glyph {
+ case '|':
+ fallthrough
+ case '*':
+ parser.setUpFlow(i)
+ case '/':
+ parser.setOutFlow(i)
+ case '\\':
+ parser.setInFlow(i)
+ case '_':
+ parser.setRightFlow(i)
+ case '.':
+ fallthrough
+ case '-':
+ parser.setLeftFlow(i)
+ case ' ':
+ // no-op
+ default:
+ parser.newFlow(i)
+ }
+ }
+}
+
+func (parser *Parser) takePreviousFlow(i, j int) {
+ if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 {
+ parser.flows[i] = parser.oldFlows[j]
+ parser.oldFlows[j] = 0
+ parser.colors[i] = parser.oldColors[j]
+ parser.oldColors[j] = 0
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+func (parser *Parser) takeCurrentFlow(i, j int) {
+ if j < len(parser.flows) && parser.flows[j] > 0 {
+ parser.flows[i] = parser.flows[j]
+ parser.colors[i] = parser.colors[j]
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+func (parser *Parser) newFlow(i int) {
+ parser.maxFlow++
+ parser.flows[i] = parser.maxFlow
+
+ // Now give this flow a color
+ if parser.nextAvailable == -1 {
+ next := len(parser.availableColors)
+ if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors {
+ parser.nextAvailable = next
+ parser.firstAvailable = next
+ parser.availableColors = append(parser.availableColors, next+1)
+ }
+ }
+ parser.colors[i] = parser.availableColors[parser.nextAvailable]
+ if parser.firstInUse == -1 {
+ parser.firstInUse = parser.nextAvailable
+ }
+ parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable]
+
+ parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors)
+ parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors)
+
+ if parser.nextAvailable == parser.firstInUse {
+ parser.nextAvailable = parser.firstAvailable
+ }
+ if parser.nextAvailable == parser.firstInUse {
+ parser.nextAvailable = -1
+ parser.firstAvailable = -1
+ }
+}
+
+// setUpFlow handles '|' or '*'
+func (parser *Parser) setUpFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: '\? ' ' |' ' /'
+ // Current Row: ' | ' ' |' ' | '
+ if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' {
+ parser.takePreviousFlow(i, i-1)
+ } else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') {
+ parser.takePreviousFlow(i, i)
+ } else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' {
+ parser.takePreviousFlow(i, i+1)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setOutFlow handles '/'
+func (parser *Parser) setOutFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\'
+ // Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/'
+ if i+2 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') &&
+ (parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') &&
+ i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') {
+ parser.takePreviousFlow(i, i+2)
+ } else if i+1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' ||
+ parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') {
+ parser.takePreviousFlow(i, i+1)
+ if parser.oldGlyphs[i+1] == '/' {
+ parser.glyphs[i] = '|'
+ }
+ } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' {
+ parser.takePreviousFlow(i, i)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setInFlow handles '\'
+func (parser *Parser) setInFlow(i int) {
+ // In preference order:
+ //
+ // Previous Row: '| ' '-. ' '| ' '\ ' '/' '---'
+ // Current Row: '|\' ' \' ' \' ' \' '\' ' \ '
+ if i > 0 && i-1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') &&
+ (parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') {
+ parser.newFlow(i)
+ } else if i > 0 && i-1 < len(parser.oldGlyphs) &&
+ (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' ||
+ parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') {
+ parser.takePreviousFlow(i, i-1)
+ if parser.oldGlyphs[i-1] == '\\' {
+ parser.glyphs[i] = '|'
+ }
+ } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' {
+ parser.takePreviousFlow(i, i)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setRightFlow handles '_'
+func (parser *Parser) setRightFlow(i int) {
+ // In preference order:
+ //
+ // Current Row: '__' '_/' '_|_' '_|/'
+ if i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') {
+ parser.takeCurrentFlow(i, i+1)
+ } else if i+2 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') &&
+ (parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') {
+ parser.takeCurrentFlow(i, i+2)
+ } else {
+ parser.newFlow(i)
+ }
+}
+
+// setLeftFlow handles '----.'
+func (parser *Parser) setLeftFlow(i int) {
+ if parser.glyphs[i] == '.' {
+ parser.newFlow(i)
+ } else if i+1 < len(parser.glyphs) &&
+ (parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') {
+ parser.takeCurrentFlow(i, i+1)
+ } else {
+ parser.newFlow(i)
+ }
+}
diff --git a/modules/gitrepo/branch.go b/modules/gitrepo/branch.go
new file mode 100644
index 0000000..e13a4c8
--- /dev/null
+++ b/modules/gitrepo/branch.go
@@ -0,0 +1,49 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// GetBranchesByPath returns a branch by its path
+// if limit = 0 it will not limit
+func GetBranchesByPath(ctx context.Context, repo Repository, skip, limit int) ([]*git.Branch, int, error) {
+ gitRepo, err := OpenRepository(ctx, repo)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer gitRepo.Close()
+
+ return gitRepo.GetBranches(skip, limit)
+}
+
+func GetBranchCommitID(ctx context.Context, repo Repository, branch string) (string, error) {
+ gitRepo, err := OpenRepository(ctx, repo)
+ if err != nil {
+ return "", err
+ }
+ defer gitRepo.Close()
+
+ return gitRepo.GetBranchCommitID(branch)
+}
+
+// SetDefaultBranch sets default branch of repository.
+func SetDefaultBranch(ctx context.Context, repo Repository, name string) error {
+ _, _, err := git.NewCommand(ctx, "symbolic-ref", "HEAD").
+ AddDynamicArguments(git.BranchPrefix + name).
+ RunStdString(&git.RunOpts{Dir: repoPath(repo)})
+ return err
+}
+
+// GetDefaultBranch gets default branch of repository.
+func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) {
+ return git.GetDefaultBranch(ctx, repoPath(repo))
+}
+
+func GetWikiDefaultBranch(ctx context.Context, repo Repository) (string, error) {
+ return git.GetDefaultBranch(ctx, wikiPath(repo))
+}
diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go
new file mode 100644
index 0000000..d89f8f9
--- /dev/null
+++ b/modules/gitrepo/gitrepo.go
@@ -0,0 +1,103 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type Repository interface {
+ GetName() string
+ GetOwnerName() string
+}
+
+func repoPath(repo Repository) string {
+ return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".git")
+}
+
+func wikiPath(repo Repository) string {
+ return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".wiki.git")
+}
+
+// OpenRepository opens the repository at the given relative path with the provided context.
+func OpenRepository(ctx context.Context, repo Repository) (*git.Repository, error) {
+ return git.OpenRepository(ctx, repoPath(repo))
+}
+
+func OpenWikiRepository(ctx context.Context, repo Repository) (*git.Repository, error) {
+ return git.OpenRepository(ctx, wikiPath(repo))
+}
+
+// contextKey is a value for use with context.WithValue.
+type contextKey struct {
+ name string
+}
+
+// RepositoryContextKey is a context key. It is used with context.Value() to get the current Repository for the context
+var RepositoryContextKey = &contextKey{"repository"}
+
+// RepositoryFromContext attempts to get the repository from the context
+func repositoryFromContext(ctx context.Context, repo Repository) *git.Repository {
+ value := ctx.Value(RepositoryContextKey)
+ if value == nil {
+ return nil
+ }
+
+ if gitRepo, ok := value.(*git.Repository); ok && gitRepo != nil {
+ if gitRepo.Path == repoPath(repo) {
+ return gitRepo
+ }
+ }
+
+ return nil
+}
+
+type nopCloser func()
+
+func (nopCloser) Close() error { return nil }
+
+// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
+func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
+ gitRepo := repositoryFromContext(ctx, repo)
+ if gitRepo != nil {
+ return gitRepo, nopCloser(nil), nil
+ }
+
+ gitRepo, err := OpenRepository(ctx, repo)
+ return gitRepo, gitRepo, err
+}
+
+// repositoryFromContextPath attempts to get the repository from the context
+func repositoryFromContextPath(ctx context.Context, path string) *git.Repository {
+ value := ctx.Value(RepositoryContextKey)
+ if value == nil {
+ return nil
+ }
+
+ if repo, ok := value.(*git.Repository); ok && repo != nil {
+ if repo.Path == path {
+ return repo
+ }
+ }
+
+ return nil
+}
+
+// RepositoryFromContextOrOpenPath attempts to get the repository from the context or just opens it
+// Deprecated: Use RepositoryFromContextOrOpen instead
+func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) {
+ gitRepo := repositoryFromContextPath(ctx, path)
+ if gitRepo != nil {
+ return gitRepo, nopCloser(nil), nil
+ }
+
+ gitRepo, err := git.OpenRepository(ctx, path)
+ return gitRepo, gitRepo, err
+}
diff --git a/modules/gitrepo/walk.go b/modules/gitrepo/walk.go
new file mode 100644
index 0000000..8c672ea
--- /dev/null
+++ b/modules/gitrepo/walk.go
@@ -0,0 +1,15 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitrepo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+// WalkReferences walks all the references from the repository
+func WalkReferences(ctx context.Context, repo Repository, walkfn func(sha1, refname string) error) (int, error) {
+ return git.WalkShowRef(ctx, repoPath(repo), nil, 0, 0, walkfn)
+}