From 4b91acc3b1505146d244313bbad7032fb19733e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:16:22 +0000 Subject: [PATCH] fix(push): stop the pack walk at remote haves and shallow boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_push_remote` packed everything reachable from HEAD before talking to the receiver at all. On a shallow history that walk runs off the end: the tip's parents were never fetched, so it aborted with "Missing commit object" — the failure reported for a Cloudflare Artifacts repo imported with `depth: 1`, where the *remote* is shallow and any clone of it is too. Real git pushes to such a remote because the boundary commit is already on the server and never needs to be sent. Discover the receiver's refs first and use them as the exclusion set, as the native `push_http` path already does. That both fixes the shallow case and stops re-sending history the remote advertises. Teach the collectors about shallow boundaries as well, so a walk stops at a commit whose parents were never fetched instead of failing, the way git's grafted walk does. The boundary is only known if it was recorded, so the JS clone and fetch paths now write `.git/shallow` from the server's shallow/unshallow lines, and fetch reports its current boundary back. `sync_remote_have_ids` moves to `bit_lib` as `advertised_have_ids` so both push paths share one implementation. Verified with the reproduction from the issue: before, three of its four cases failed; after, all four push and the shallow remote's branch advances. Added as `tools/shallow-push.test.mjs`, which serves a bare repo and a depth-1 copy of it over `git http-backend`. Fixes #182 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VDVUJHu38YtVAr6sKZtRDB --- Taskfile.pkl | 2 +- Test.pkl | 2 +- modules/bit_lib/src/js_api_exports.mbt | 38 ++++- modules/bit_lib/src/native/sync_http.mbt | 15 +- modules/bit_lib/src/pack_collect.mbt | 62 ++++++++- modules/bit_lib/src/pack_collect_test.mbt | 102 ++++++++++++++ modules/bit_lib/src/pkg.generated.mbti | 8 +- tools/shallow-push.test.mjs | 162 ++++++++++++++++++++++ 8 files changed, 364 insertions(+), 27 deletions(-) create mode 100644 tools/shallow-push.test.mjs diff --git a/Taskfile.pkl b/Taskfile.pkl index 6950aad4..afc51c77 100644 --- a/Taskfile.pkl +++ b/Taskfile.pkl @@ -313,7 +313,7 @@ local testUpdate: Task = new { local testJsBuild: Task = new { name = "test-js-build" description = "Node-side smoke tests for the published JS lib + npm wrapper" - cmd = "node --test tools/js-build.test.mjs tools/npm-lib.test.mjs tools/npm-cli.test.mjs tools/demo-relay.test.mjs tools/demo-editor-link.test.mjs tools/playground-commands.test.mjs tools/playground-view.test.mjs tools/flaker-affected-rules.test.mjs tools/select-affected-tests.test.mjs" + cmd = "node --test tools/js-build.test.mjs tools/npm-lib.test.mjs tools/shallow-push.test.mjs tools/npm-cli.test.mjs tools/demo-relay.test.mjs tools/demo-editor-link.test.mjs tools/playground-commands.test.mjs tools/playground-view.test.mjs tools/flaker-affected-rules.test.mjs tools/select-affected-tests.test.mjs" deps { syncNpmLibRaw syncNpmBitMjs diff --git a/Test.pkl b/Test.pkl index 3a99235a..4e91263d 100644 --- a/Test.pkl +++ b/Test.pkl @@ -66,6 +66,6 @@ tests { } new { name = "js-build" - cmd = "node --test tools/js-build.test.mjs tools/npm-lib.test.mjs tools/npm-cli.test.mjs tools/demo-relay.test.mjs tools/demo-editor-link.test.mjs tools/playground-commands.test.mjs tools/playground-view.test.mjs" + cmd = "node --test tools/js-build.test.mjs tools/npm-lib.test.mjs tools/shallow-push.test.mjs tools/npm-cli.test.mjs tools/demo-relay.test.mjs tools/demo-editor-link.test.mjs tools/playground-commands.test.mjs tools/playground-view.test.mjs" } } diff --git a/modules/bit_lib/src/js_api_exports.mbt b/modules/bit_lib/src/js_api_exports.mbt index fa136286..c0309a0a 100644 --- a/modules/bit_lib/src/js_api_exports.mbt +++ b/modules/bit_lib/src/js_api_exports.mbt @@ -2579,7 +2579,7 @@ pub async fn js_clone_remote( } Some((refname, commit_id)) => { if wants.length() > 0 { - let pack = @bitnative.fetch_pack_with_http( + let result = @bitnative.fetch_pack_with_http_result( remote_url, wants, prefer_v2, @@ -2599,6 +2599,7 @@ pub async fn js_clone_remote( js_transport_post(transport_id, url, body, headers) }, ) + let pack = result.pack let objects = @pack.parse_packfile(pack) let git_dir = join_path(root, ".git") @bit.FileSystem::mkdir_p(fs, join_path(git_dir, "objects/pack")) @@ -2607,6 +2608,16 @@ pub async fn js_clone_remote( @bit.materialize_clone_to_fs( store, commit_id, refname, remote_url, fs, root, fs, ) + // Record the history boundary so later walks (push in particular) + // know the parents of these commits were never fetched. The server + // reports it for a `depth` request and for an already-shallow repo. + @repo.apply_shallow_updates( + fs, + fs, + js_resolve_git_dir(fs, root), + result.shallow, + result.unshallow, + ) } else { init_repo( fs, @@ -2680,7 +2691,7 @@ pub async fn js_fetch_remote( match js_select_fetch_target(refs, symrefs, refspec) { None => { status: "empty", commit_id: "", refname: "", remote_ref: "" } Some((refname, commit_id)) => { - let pack = @bitnative.fetch_pack_with_http( + let result = @bitnative.fetch_pack_with_http_result( remote_url, [commit_id], prefer_v2, @@ -2699,10 +2710,19 @@ pub async fn js_fetch_remote( ) -> (@bit.HttpResponse, Bytes) raise @bit.GitError { js_transport_post(transport_id, url, body, headers) }, + shallow=@repo.read_shallow_boundaries(fs, git_dir), ) + let pack = result.pack let objects = @pack.parse_packfile(pack) @bit.FileSystem::mkdir_p(fs, join_path(git_dir, "objects/pack")) @pack.write_packfile_with_index(fs, git_dir, pack, objects) + @repo.apply_shallow_updates( + fs, + fs, + git_dir, + result.shallow, + result.unshallow, + ) let remote_ref = js_remote_tracking_refname(refname) let remote_ref_path = join_path(git_dir, remote_ref) @bit.FileSystem::mkdir_p(fs, rebase_parent_dir(remote_ref_path)) @@ -2769,9 +2789,11 @@ pub async fn js_push_remote( js_normalize_push_refname(refname) } let db = ObjectDb::load(fs, git_dir) - let objects = collect_reachable_objects(db, fs, head_id) - let pack = @pack.create_packfile(objects) let remote = @protocol.Remote::new(remote_url) + // Discover before collecting: the refs the receiver advertises bound the + // walk. Packing HEAD's full history first both sends objects the remote + // already has and fails outright when this clone (or the remote itself) + // is shallow, because the advertised tip's parents are not local. let (refs, _caps) = @protocol.discover_refs_with_http(remote, async fn( url : String, headers : Map[String, String], @@ -2786,6 +2808,14 @@ pub async fn js_push_remote( ) } } + let objects = collect_reachable_objects_excluding_commits( + db, + fs, + [head_id], + advertised_have_ids(refs), + shallow=@repo.read_shallow_boundaries(fs, git_dir), + ) + let pack = @pack.create_packfile(objects) let req = @protocol.PushRequest::new(old_id, head_id, target_ref, pack) @protocol.push_with_http( remote, diff --git a/modules/bit_lib/src/native/sync_http.mbt b/modules/bit_lib/src/native/sync_http.mbt index fdd5b30b..62cc1204 100644 --- a/modules/bit_lib/src/native/sync_http.mbt +++ b/modules/bit_lib/src/native/sync_http.mbt @@ -160,6 +160,7 @@ pub async fn push_http( rfs, [head_id], sync_remote_have_ids(refs), + shallow=@repo.read_shallow_boundaries(rfs, actual_git_dir), ) let pack = @pack.create_packfile(objects) sync_raise_lfs_upload_errors( @@ -204,6 +205,7 @@ pub async fn push_http_from_commit( rfs, [commit_id], sync_remote_have_ids(refs), + shallow=@repo.read_shallow_boundaries(rfs, actual_git_dir), ) let pack = @pack.create_packfile(objects) sync_raise_lfs_upload_errors( @@ -274,18 +276,7 @@ fn sync_find_ref( fn sync_remote_have_ids( refs : Array[(@bit.ObjectId, String)], ) -> Array[@bit.ObjectId] { - let zero = @bit.ObjectId::zero() - let seen : Map[String, Bool] = Map([]) - let haves : Array[@bit.ObjectId] = [] - for item in refs { - let (id, _) = item - let hex = id.to_hex() - if id != zero && !seen.contains(hex) { - seen[hex] = true - haves.push(id) - } - } - haves + @bitlib.advertised_have_ids(refs) } ///| diff --git a/modules/bit_lib/src/pack_collect.mbt b/modules/bit_lib/src/pack_collect.mbt index 71ccbd2d..67a4e669 100644 --- a/modules/bit_lib/src/pack_collect.mbt +++ b/modules/bit_lib/src/pack_collect.mbt @@ -54,6 +54,7 @@ fn collect_object_ids_only( fs : &@bit.RepoFileSystem, commit_id : @bit.ObjectId, seen : Map[String, Bool], + shallow? : Map[String, Bool] = Map([]), ) -> Unit raise @bit.GitError { let hex = commit_id.to_hex() if seen.contains(hex) { @@ -67,8 +68,12 @@ fn collect_object_ids_only( if o.obj_type == @bit.ObjectType::Commit { let info = @bit.parse_commit(o.data) collect_tree_ids_only(db, fs, info.tree, seen) + if shallow.contains(hex) { + // Shallow boundary: the parents are absent by construction. + return + } for p in info.parents { - collect_object_ids_only(db, fs, p, seen) + collect_object_ids_only(db, fs, p, seen, shallow~) } } } @@ -202,10 +207,12 @@ pub fn collect_reachable_objects( db : ObjectDb, fs : &@bit.RepoFileSystem, commit_id : @bit.ObjectId, + shallow? : Array[@bit.ObjectId] = [], ) -> Array[@bit.PackObject] raise @bit.GitError { let seen : Map[String, Bool] = Map([]) let out : Array[@bit.PackObject] = [] - collect_commit_objects(db, fs, commit_id, seen, out) + let boundaries = shallow_boundary_set(shallow) + collect_commit_objects(db, fs, commit_id, seen, out, shallow=boundaries) out } @@ -215,11 +222,13 @@ pub fn collect_reachable_objects_from_commits( db : ObjectDb, fs : &@bit.RepoFileSystem, commits : Array[@bit.ObjectId], + shallow? : Array[@bit.ObjectId] = [], ) -> Array[@bit.PackObject] raise @bit.GitError { let seen : Map[String, Bool] = Map([]) let out : Array[@bit.PackObject] = [] + let boundaries = shallow_boundary_set(shallow) for commit_id in commits { - collect_commit_objects(db, fs, commit_id, seen, out) + collect_commit_objects(db, fs, commit_id, seen, out, shallow=boundaries) } out } @@ -234,17 +243,52 @@ pub fn collect_reachable_objects_excluding_commits( fs : &@bit.RepoFileSystem, commits : Array[@bit.ObjectId], excluded_commits : Array[@bit.ObjectId], + shallow? : Array[@bit.ObjectId] = [], ) -> Array[@bit.PackObject] raise @bit.GitError { // Mark the complete object closure already owned by the receiver. Merely // stopping the commit walk at a have commit is insufficient: the wanted // commit's new tree can still reference unchanged blobs from that commit. let seen : Map[String, Bool] = Map([]) + let boundaries = shallow_boundary_set(shallow) for id in excluded_commits { - collect_object_ids_only(db, fs, id, seen) + collect_object_ids_only(db, fs, id, seen, shallow=boundaries) } let out : Array[@bit.PackObject] = [] for commit_id in commits { - collect_commit_objects(db, fs, commit_id, seen, out) + collect_commit_objects(db, fs, commit_id, seen, out, shallow=boundaries) + } + out +} + +///| +/// The distinct non-zero commit ids a peer advertises, usable as the exclusion +/// set of a push walk: everything reachable from them is already on the other +/// side, so it must not be packed again. +pub fn advertised_have_ids( + refs : Array[(@bit.ObjectId, String)], +) -> Array[@bit.ObjectId] { + let zero = @bit.ObjectId::zero() + let seen : Map[String, Bool] = Map([]) + let haves : Array[@bit.ObjectId] = [] + for item in refs { + let (id, _) = item + let hex = id.to_hex() + if id != zero && !seen.contains(hex) { + seen[hex] = true + haves.push(id) + } + } + haves +} + +///| +/// Index shallow boundary commits by hex id for O(1) lookups during a walk. +fn shallow_boundary_set( + shallow : Array[@bit.ObjectId], +) -> Map[String, Bool] { + let out : Map[String, Bool] = Map([]) + for id in shallow { + out[id.to_hex()] = true } out } @@ -256,6 +300,7 @@ fn collect_commit_objects( commit_id : @bit.ObjectId, seen : Map[String, Bool], out : Array[@bit.PackObject], + shallow? : Map[String, Bool] = Map([]), ) -> Unit raise @bit.GitError { let hex = commit_id.to_hex() if seen.contains(hex) { @@ -272,8 +317,13 @@ fn collect_commit_objects( out.push(o) let info = @bit.parse_commit(o.data) collect_tree_objects(db, fs, info.tree, seen, out) + if shallow.contains(hex) { + // Shallow boundary: this commit's parents were never fetched, so the + // history stops here just as it does for git's grafted walk. + return + } for p in info.parents { - collect_commit_objects(db, fs, p, seen, out) + collect_commit_objects(db, fs, p, seen, out, shallow~) } } } diff --git a/modules/bit_lib/src/pack_collect_test.mbt b/modules/bit_lib/src/pack_collect_test.mbt index ba80873a..94b9fa9a 100644 --- a/modules/bit_lib/src/pack_collect_test.mbt +++ b/modules/bit_lib/src/pack_collect_test.mbt @@ -397,3 +397,105 @@ test "collect_reachable_objects_excluding_commits: stops at have commits" { assert_eq(nested_shared_blob_count, 0) assert_eq(new_blob_count, 1) } + +///| +/// Drop a loose object so the walk sees the truncated history a shallow clone +/// actually has on disk. +fn drop_loose_object( + fs : @bit.TestFs, + git_dir : String, + id : @bit.ObjectId, +) -> Unit raise @bit.GitError { + let hex = id.to_hex() + let prefix = String::unsafe_substring(hex, start=0, end=2) + let suffix = String::unsafe_substring(hex, start=2, end=hex.length()) + fs.remove_file(git_dir + "/objects/" + prefix + "/" + suffix) +} + +///| +test "collect_reachable_objects_excluding_commits: shallow tip whose parent is absent" { + let fs = @bit.TestFs::new() + init_repo(fs, "/repo") catch { + _ => () + } + fs.write_file("/repo/base.txt", b"base") + add_paths(fs, fs, "/repo", ["."]) + let base = commit(fs, fs, "/repo", "base", "test ", 1000L) + fs.write_file("/repo/boundary.txt", b"boundary") + add_paths(fs, fs, "/repo", ["."]) + let boundary = commit( + fs, fs, "/repo", "boundary", "test ", 2000L, + ) + fs.write_file("/repo/new.txt", b"new") + add_paths(fs, fs, "/repo", ["."]) + let tip = commit(fs, fs, "/repo", "tip", "test ", 3000L) + // A depth-1 clone (or a clone of an already shallow remote) holds `boundary` + // but never received its parent. + drop_loose_object(fs, "/repo/.git", base) + let db = ObjectDb::load(fs, "/repo/.git") + + // The receiver advertises `boundary`, so the walk must stop there instead of + // trying to load the absent parent. + let objects = collect_reachable_objects_excluding_commits(db, fs, [tip], [ + boundary, + ]) + let ids : Map[String, Bool] = Map([]) + for obj in objects { + ids[obj.id.to_hex()] = true + } + assert_true(ids.contains(tip.to_hex())) + assert_true(!ids.contains(boundary.to_hex())) +} + +///| +test "collect_reachable_objects: stops at a shallow boundary commit" { + let fs = @bit.TestFs::new() + init_repo(fs, "/repo") catch { + _ => () + } + fs.write_file("/repo/base.txt", b"base") + add_paths(fs, fs, "/repo", ["."]) + let base = commit(fs, fs, "/repo", "base", "test ", 1000L) + fs.write_file("/repo/boundary.txt", b"boundary") + add_paths(fs, fs, "/repo", ["."]) + let boundary = commit( + fs, fs, "/repo", "boundary", "test ", 2000L, + ) + drop_loose_object(fs, "/repo/.git", base) + let db = ObjectDb::load(fs, "/repo/.git") + + // Without the boundary the walk reaches the absent parent and fails. + let mut failed = false + (collect_reachable_objects(db, fs, boundary) |> ignore) catch { + _ => failed = true + } + assert_true(failed) + + // Declaring the boundary grafts the history closed, as git's shallow file does. + let objects = collect_reachable_objects(db, fs, boundary, shallow=[boundary]) + let ids : Map[String, Bool] = Map([]) + for obj in objects { + ids[obj.id.to_hex()] = true + } + assert_true(ids.contains(boundary.to_hex())) + assert_true(!ids.contains(base.to_hex())) +} + +///| +test "advertised_have_ids: drops zero and duplicate ids" { + let first = @bit.ObjectId::from_hex( + "1111111111111111111111111111111111111111", + ) + let second = @bit.ObjectId::from_hex( + "2222222222222222222222222222222222222222", + ) + let haves = advertised_have_ids([ + (first, "HEAD"), + (first, "refs/heads/main"), + (@bit.ObjectId::zero(), "refs/heads/empty"), + (second, "refs/heads/topic"), + ]) + assert_eq(haves.length(), 2) + assert_eq(haves[0].to_hex(), first.to_hex()) + assert_eq(haves[1].to_hex(), second.to_hex()) +} diff --git a/modules/bit_lib/src/pkg.generated.mbti b/modules/bit_lib/src/pkg.generated.mbti index b0942102..bdc9c58f 100644 --- a/modules/bit_lib/src/pkg.generated.mbti +++ b/modules/bit_lib/src/pkg.generated.mbti @@ -13,6 +13,8 @@ pub fn add_paths(&@bit_types.FileSystem, &@bit_types.RepoFileSystem, String, Arr pub async fn add_paths_async(&@bit_types.FileSystem, &@bit_types.RepoFileSystem, String, Array[String], run_filter_cmd? : FilterCmd, warnings? : Array[String]) -> Unit raise @bit_object.GitError +pub fn advertised_have_ids(Array[(@bit_object.ObjectId, String)]) -> Array[@bit_object.ObjectId] + pub fn append_reflog(&@bit_types.FileSystem, &@bit_types.RepoFileSystem, String, String, @bit_object.ObjectId, @bit_object.ObjectId, String, String, Int64, String, String) -> Unit raise @bit_object.GitError pub fn apply_config_overrides(Map[String, RemoteConfig], Map[String, BranchConfig], Array[(String, String)]) -> Unit @@ -61,11 +63,11 @@ pub fn collect_loose_ref_ids(&@bit_types.RepoFileSystem, String, String, Map[Str pub fn collect_packed_ref_ids(&@bit_types.RepoFileSystem, String, Map[String, @bit_object.ObjectId], String?) -> Unit -pub fn collect_reachable_objects(ObjectDb, &@bit_types.RepoFileSystem, @bit_object.ObjectId) -> Array[@bit_object.PackObject] raise @bit_object.GitError +pub fn collect_reachable_objects(ObjectDb, &@bit_types.RepoFileSystem, @bit_object.ObjectId, shallow? : Array[@bit_object.ObjectId]) -> Array[@bit_object.PackObject] raise @bit_object.GitError -pub fn collect_reachable_objects_excluding_commits(ObjectDb, &@bit_types.RepoFileSystem, Array[@bit_object.ObjectId], Array[@bit_object.ObjectId]) -> Array[@bit_object.PackObject] raise @bit_object.GitError +pub fn collect_reachable_objects_excluding_commits(ObjectDb, &@bit_types.RepoFileSystem, Array[@bit_object.ObjectId], Array[@bit_object.ObjectId], shallow? : Array[@bit_object.ObjectId]) -> Array[@bit_object.PackObject] raise @bit_object.GitError -pub fn collect_reachable_objects_from_commits(ObjectDb, &@bit_types.RepoFileSystem, Array[@bit_object.ObjectId]) -> Array[@bit_object.PackObject] raise @bit_object.GitError +pub fn collect_reachable_objects_from_commits(ObjectDb, &@bit_types.RepoFileSystem, Array[@bit_object.ObjectId], shallow? : Array[@bit_object.ObjectId]) -> Array[@bit_object.PackObject] raise @bit_object.GitError pub fn collect_tree_blobs(ObjectDb, &@bit_types.RepoFileSystem, @bit_object.ObjectId) -> Array[@bit_object.ObjectId] raise @bit_object.GitError diff --git a/tools/shallow-push.test.mjs b/tools/shallow-push.test.mjs new file mode 100644 index 00000000..81cf7c06 --- /dev/null +++ b/tools/shallow-push.test.mjs @@ -0,0 +1,162 @@ +// Regression guard for pushing when the local clone or the remote is shallow. +// +// A repo imported with `depth: 1` (Cloudflare Artifacts does this) advertises a +// tip whose parents it does not have. Packing HEAD's whole history for a push +// used to walk past that boundary into objects nobody has, failing with +// "Missing commit object" even though real git pushes to the same remote fine. +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFileSync, spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as bit from "@mizchi/bit"; + +const AUTHOR = "bit "; + +function hasGit() { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }); + execFileSync("git", ["http-backend", "--help"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function git(cwd, ...args) { + return execFileSync( + "git", + [ + "-c", "user.email=dev@example.com", + "-c", "user.name=dev", + "-c", "commit.gpgsign=false", + ...args, + ], + { cwd, encoding: "utf8" }, + ).trim(); +} + +// Serve GIT_PROJECT_ROOT over smart HTTP by piping requests through git's own CGI. +function startHttpBackend(projectRoot) { + const server = createServer((req, res) => { + const url = new URL(req.url, "http://placeholder"); + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks); + const cgi = spawn("git", ["http-backend"], { + env: { + ...process.env, + GIT_PROJECT_ROOT: projectRoot, + GIT_HTTP_EXPORT_ALL: "1", + REQUEST_METHOD: req.method, + PATH_INFO: url.pathname, + QUERY_STRING: url.search.slice(1), + CONTENT_TYPE: req.headers["content-type"] ?? "", + CONTENT_LENGTH: String(body.length), + REMOTE_USER: "test", + REMOTE_ADDR: "127.0.0.1", + }, + }); + const out = []; + cgi.stdout.on("data", (chunk) => out.push(chunk)); + cgi.on("close", () => { + const all = Buffer.concat(out); + const separator = all.indexOf("\r\n\r\n"); + const headers = Object.fromEntries( + all.subarray(0, separator).toString() + .split("\r\n") + .filter((line) => line.includes(":")) + .map((line) => line.split(/:\s*/)), + ); + res.writeHead(Number(headers.Status?.split(" ")[0] ?? 200), headers); + res.end(all.subarray(separator + 4)); + }); + cgi.stdin.end(body); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +const fetchTransport = { + async get(url, headers) { + const response = await fetch(url, { headers }); + return { status: response.status, body: new Uint8Array(await response.arrayBuffer()) }; + }, + async post(url, body, headers) { + const response = await fetch(url, { method: "POST", headers, body }); + return { status: response.status, body: new Uint8Array(await response.arrayBuffer()) }; + }, +}; + +async function commitAndPush(remoteUrl, cloneOptions, fileName) { + const backend = bit.createMemoryBackend(); + try { + await bit.clone(backend, "/repo", remoteUrl, fetchTransport, cloneOptions); + bit.writeString(backend, `/repo/${fileName}`, "hello\n"); + bit.add(backend, "/repo", ["."]); + const commitId = bit.commit( + backend, "/repo", `from bit (${fileName})`, AUTHOR, 1700002000, + ); + await bit.push(backend, "/repo", remoteUrl, fetchTransport, { + refname: "refs/heads/main", + force: false, + }); + return commitId; + } finally { + bit.destroyBackend(backend); + } +} + +test("push succeeds when the clone or the remote is shallow", { skip: !hasGit() && "git with http-backend is unavailable" }, async () => { + const dir = mkdtempSync(join(tmpdir(), "bit-shallow-push-")); + let server; + try { + // A three-commit origin, plus a depth-1 bare copy of it: the shallow remote + // holds only c3, so c3's parents exist nowhere the client can reach. + const origin = join(dir, "origin.git"); + const work = join(dir, "work"); + git(dir, "init", "-q", "--bare", origin); + git(origin, "config", "http.receivepack", "true"); + git(dir, "clone", "-q", origin, work); + for (const n of [1, 2, 3]) { + writeFileSync(join(work, `f${n}.txt`), `${n}\n`); + git(work, "add", "."); + git(work, "commit", "-q", "-m", `c${n}`); + } + git(work, "push", "-q", "origin", "HEAD:refs/heads/main"); + git(origin, "symbolic-ref", "HEAD", "refs/heads/main"); + + const shallow = join(dir, "shallow.git"); + git(dir, "clone", "-q", "--bare", "--depth", "1", `file://${origin}`, shallow); + git(shallow, "config", "http.receivepack", "true"); + git(shallow, "symbolic-ref", "HEAD", "refs/heads/main"); + assert.equal(git(shallow, "rev-list", "--count", "main"), "1"); + + server = await startHttpBackend(dir); + const base = `http://127.0.0.1:${server.address().port}`; + + for (const [label, repo, depth] of [ + ["full remote, full clone", origin, undefined], + ["full remote, depth 1", origin, 1], + ["shallow remote, full clone", shallow, undefined], + ["shallow remote, depth 1", shallow, 1], + ]) { + const remoteUrl = `${base}/${repo === origin ? "origin" : "shallow"}.git`; + const fileName = `${label.replace(/[^a-z0-9]+/gi, "-")}.txt`; + const commitId = await commitAndPush(remoteUrl, depth ? { depth } : {}, fileName); + assert.equal(commitId.length, 40, label); + // The receiver accepted the pack and moved its branch to our commit. + assert.equal(git(repo, "rev-parse", "main"), commitId, label); + assert.equal(git(repo, "cat-file", "-e", `${commitId}^{tree}`), "", label); + } + } finally { + if (server) server.close(); + rmSync(dir, { recursive: true, force: true }); + } +});