branch: main
git-e2e.spec.mjs
17348 bytesRaw
import { Buffer } from "node:buffer";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
  addRemote,
  appendLineAndCommit,
  cleanupTempDirs,
  cloneFixture,
  git,
  gitStdout,
  makeTempDir,
  pushAsOwner,
} from "./helpers/git.mjs";
import { actorHeaders, createTestServer, uniqueId } from "./helpers/mf.mjs";

let server;
const tempDirs = [];
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

function uniqueToken(prefix) {
  return `${prefix}${uniqueId("token")}`.replace(/[^a-zA-Z0-9]/g, "");
}

function pktLine(text) {
  const payload = textEncoder.encode(text);
  return Buffer.concat([
    Buffer.from((payload.length + 4).toString(16).padStart(4, "0")),
    Buffer.from(payload),
  ]);
}

function buildUploadPackRequest(want, capabilities) {
  return Buffer.concat([
    pktLine(`want ${want} ${capabilities.join(" ")}\n`),
    Buffer.from("0000"),
    pktLine("done\n"),
  ]);
}

function buildReceivePackRequest(oldHash, newHash, refName, capabilities) {
  return Buffer.concat([
    pktLine(`${oldHash} ${newHash} ${refName}\0${capabilities.join(" ")}\n`),
    Buffer.from("0000"),
  ]);
}

function readPktLine(bytes, offset) {
  const length = Number.parseInt(textDecoder.decode(bytes.slice(offset, offset + 4)), 16);
  if (length === 0) {
    return { payload: null, nextOffset: offset + 4 };
  }

  return {
    payload: bytes.slice(offset + 4, offset + length),
    nextOffset: offset + length,
  };
}

async function cloneRemote(remoteUrl) {
  const workDir = await makeTempDir("ripgit-clone");
  const repoDir = join(workDir, "repo");
  tempDirs.push(workDir);
  await git(undefined, ["clone", remoteUrl, repoDir]);
  return { workDir, repoDir };
}

beforeAll(async () => {
  server = await createTestServer();
});

afterAll(async () => {
  await cleanupTempDirs(tempDirs);
  await server.mf.dispose();
});

describe("git CLI e2e", () => {
  test("pushes a real-world fixture, clones it, and force-pushes rewritten history", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const remoteUrl = new URL(`/${owner}/${repo}`, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);

    const initialHead = await gitStdout(source.repoDir, ["rev-parse", "HEAD"]);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    const firstClone = await cloneRemote(remoteUrl);
    expect(await gitStdout(firstClone.repoDir, ["rev-parse", "HEAD"])).toBe(initialHead);

    const fastForwardToken = uniqueToken("fastforward");
    await appendLineAndCommit(
      source.repoDir,
      "README.md",
      `ripgit e2e token ${fastForwardToken}`,
      "e2e fast-forward change",
    );
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    let response = await server.dispatch(`/${owner}/${repo}/search?q=${fastForwardToken}&scope=code`);
    expect(response.status).toBe(200);
    let payload = await response.json();
    expect(payload.total_matches).toBeGreaterThan(0);

    const rewrittenSource = await cloneFixture();
    tempDirs.push(rewrittenSource.workDir);
    await addRemote(rewrittenSource.repoDir, "ripgit", remoteUrl);

    const forcePushToken = uniqueToken("forcepush");
    const forceHead = await appendLineAndCommit(
      rewrittenSource.repoDir,
      "README.md",
      `ripgit e2e token ${forcePushToken}`,
      "e2e rewritten change",
    );
    await pushAsOwner(
      rewrittenSource.repoDir,
      owner,
      "push",
      "--force",
      "ripgit",
      "HEAD:refs/heads/main",
    );

    const secondClone = await cloneRemote(remoteUrl);
    expect(await gitStdout(secondClone.repoDir, ["rev-parse", "HEAD"])).toBe(forceHead);

    const readme = await readFile(join(secondClone.repoDir, "README.md"), "utf8");
    expect(readme).toContain(forcePushToken);
    expect(readme).not.toContain(fastForwardToken);

    response = await server.dispatch(`/${owner}/${repo}/search?q=${forcePushToken}&scope=code`);
    expect(response.status).toBe(200);
    payload = await response.json();
    expect(payload.total_matches).toBeGreaterThan(0);

    response = await server.dispatch(`/${owner}/${repo}/search?q=${fastForwardToken}&scope=code`);
    expect(response.status).toBe(200);
    payload = await response.json();
    expect(payload.total_matches).toBe(0);

    await git(firstClone.repoDir, ["fetch", "origin"]);
    expect(await gitStdout(firstClone.repoDir, ["rev-parse", "origin/main"])).toBe(forceHead);
  });

  test("fetches new branch and tag refs into an existing clone", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const featureBranch = uniqueId("feature");
    const tagName = `${uniqueId("tag")}-release`;
    const branchToken = uniqueToken("branchref");
    const remoteUrl = new URL(`/${owner}/${repo}`, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    const existingClone = await cloneRemote(remoteUrl);

    await git(source.repoDir, ["checkout", "-b", featureBranch]);
    const featureHead = await appendLineAndCommit(
      source.repoDir,
      "README.md",
      `ripgit e2e token ${branchToken}`,
      "e2e branch ref change",
    );
    await git(source.repoDir, ["tag", tagName]);
    const tagHead = await gitStdout(source.repoDir, ["rev-parse", tagName]);

    await pushAsOwner(
      source.repoDir,
      owner,
      "push",
      "ripgit",
      `HEAD:refs/heads/${featureBranch}`,
      `refs/tags/${tagName}:refs/tags/${tagName}`,
    );

    let response = await server.dispatch(`/${owner}/${repo}/refs`);
    expect(response.status).toBe(200);
    let refs = await response.json();
    expect(refs.heads.main).toBeTypeOf("string");
    expect(refs.heads[featureBranch]).toBe(featureHead);
    expect(refs.tags[tagName]).toBe(tagHead);

    await git(existingClone.repoDir, ["fetch", "origin", "--tags"]);
    expect(await gitStdout(existingClone.repoDir, ["rev-parse", `refs/remotes/origin/${featureBranch}`])).toBe(featureHead);
    expect(await gitStdout(existingClone.repoDir, ["rev-parse", `refs/tags/${tagName}`])).toBe(tagHead);
  });

  test("includes upload-pack sideband progress and honors no-progress", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const remotePath = `/${owner}/${repo}`;
    const remoteUrl = new URL(remotePath, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    const head = await gitStdout(source.repoDir, ["rev-parse", "HEAD"]);

    let response = await server.dispatch(`${remotePath}/git-upload-pack`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-git-upload-pack-request",
      },
      body: buildUploadPackRequest(head, ["multi_ack_detailed", "side-band-64k", "ofs-delta"]),
    });
    expect(response.status).toBe(200);
    let bytes = new Uint8Array(await response.arrayBuffer());

    let first = readPktLine(bytes, 0);
    expect(textDecoder.decode(first.payload)).toBe("NAK\n");

    let second = readPktLine(bytes, first.nextOffset);
    expect(second.payload[0]).toBe(2);
    expect(textDecoder.decode(second.payload.slice(1))).toContain("Enumerating objects:");

    let sawPackData = false;
    let offset = second.nextOffset;
    while (offset < bytes.length) {
      const pkt = readPktLine(bytes, offset);
      offset = pkt.nextOffset;
      if (!pkt.payload) {
        break;
      }
      if (pkt.payload[0] === 1) {
        sawPackData = true;
        expect(textDecoder.decode(pkt.payload.slice(1, 5))).toBe("PACK");
        break;
      }
    }
    expect(sawPackData).toBe(true);

    response = await server.dispatch(`${remotePath}/git-upload-pack`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-git-upload-pack-request",
      },
      body: buildUploadPackRequest(head, [
        "multi_ack_detailed",
        "side-band-64k",
        "no-progress",
        "ofs-delta",
      ]),
    });
    expect(response.status).toBe(200);
    bytes = new Uint8Array(await response.arrayBuffer());

    first = readPktLine(bytes, 0);
    expect(textDecoder.decode(first.payload)).toBe("NAK\n");

    second = readPktLine(bytes, first.nextOffset);
    expect(second.payload[0]).toBe(1);
    expect(textDecoder.decode(second.payload.slice(1, 5))).toBe("PACK");
  });

  test("returns channel 3 fatal sideband messages for protocol errors", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const remotePath = `/${owner}/${repo}`;
    const remoteUrl = new URL(remotePath, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    const head = await gitStdout(source.repoDir, ["rev-parse", "HEAD"]);

    let response = await server.dispatch(`${remotePath}/git-upload-pack`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-git-upload-pack-request",
      },
      body: buildUploadPackRequest(head, [
        "multi_ack_detailed",
        "side-band",
        "side-band-64k",
        "ofs-delta",
      ]),
    });
    expect(response.status).toBe(200);
    let bytes = new Uint8Array(await response.arrayBuffer());
    let first = readPktLine(bytes, 0);
    expect(first.payload[0]).toBe(3);
    expect(textDecoder.decode(first.payload.slice(1))).toContain(
      "fatal: upload-pack capabilities: client requested both side-band and side-band-64k",
    );

    response = await server.dispatch(`${remotePath}/git-receive-pack`, {
      method: "POST",
      headers: {
        ...actorHeaders(owner),
        "Content-Type": "application/x-git-receive-pack-request",
      },
      body: buildReceivePackRequest(
        "0000000000000000000000000000000000000000",
        head,
        "refs/heads/main",
        ["report-status", "side-band", "side-band-64k"],
      ),
    });
    expect(response.status).toBe(200);
    bytes = new Uint8Array(await response.arrayBuffer());
    first = readPktLine(bytes, 0);
    expect(first.payload[0]).toBe(3);
    expect(textDecoder.decode(first.payload.slice(1))).toContain(
      "fatal: receive-pack capabilities: client requested both side-band and side-band-64k",
    );
  });

  test("rejects oversize pushes as git protocol status, not handler failure", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const remotePath = `/${owner}/${repo}`;
    const requestBody = Buffer.concat([
      buildReceivePackRequest(
        "0000000000000000000000000000000000000000",
        "1111111111111111111111111111111111111111",
        "refs/heads/main",
        ["report-status", "side-band-64k", "quiet"],
      ),
      Buffer.allocUnsafe(50_000_001),
    ]);

    const response = await server.dispatch(`${remotePath}/git-receive-pack`, {
      method: "POST",
      headers: {
        ...actorHeaders(owner),
        "Content-Type": "application/x-git-receive-pack-request",
      },
      body: requestBody,
    });

    expect(response.status).toBe(200);
    expect(response.headers.get("content-type")).toBe("application/x-git-receive-pack-result");

    const bytes = new Uint8Array(await response.arrayBuffer());
    const first = readPktLine(bytes, 0);
    expect(first.payload[0]).toBe(1);

    const status = textDecoder.decode(first.payload.slice(1));
    expect(status).toContain("unpack pack too large");
    expect(status).toContain("ng refs/heads/main pack too large");

    const flush = readPktLine(bytes, first.nextOffset);
    expect(flush.payload).toBeNull();
  });

  test("includes receive-pack progress and honors quiet", async () => {
    const owner = uniqueId("owner");
    const loudRepo = uniqueId("repo");
    const quietRepo = uniqueId("repo");
    const loudUrl = new URL(`/${owner}/${loudRepo}`, server.url).toString();
    const quietUrl = new URL(`/${owner}/${quietRepo}`, server.url).toString();

    const loudSource = await cloneFixture();
    tempDirs.push(loudSource.workDir);
    await addRemote(loudSource.repoDir, "ripgit", loudUrl);
    const loudPush = await pushAsOwner(
      loudSource.repoDir,
      owner,
      "push",
      "--progress",
      "ripgit",
      "HEAD:refs/heads/main",
    );
    expect(loudPush.stderr).toContain("remote: Processing 1 ref update(s).");
    expect(loudPush.stderr).toContain("remote: Received pack:");
    expect(loudPush.stderr).toContain("remote: Updated refs: 1 succeeded, 0 failed.");

    const quietSource = await cloneFixture();
    tempDirs.push(quietSource.workDir);
    await addRemote(quietSource.repoDir, "ripgit", quietUrl);
    const quietPush = await pushAsOwner(
      quietSource.repoDir,
      owner,
      "push",
      "--quiet",
      "ripgit",
      "HEAD:refs/heads/main",
    );
    expect(quietPush.stderr).not.toContain("remote: Processing");
    expect(quietPush.stderr).not.toContain("remote: Received pack:");
    expect(quietPush.stderr).toBe("");
  });

  test("deletes branch and tag refs and prunes them from an existing clone", async () => {
    const owner = uniqueId("owner");
    const repo = uniqueId("repo");
    const featureBranch = uniqueId("feature");
    const tagName = `${uniqueId("tag")}-delete`;
    const remoteUrl = new URL(`/${owner}/${repo}`, server.url).toString();

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit", remoteUrl);
    await pushAsOwner(source.repoDir, owner, "push", "ripgit", "HEAD:refs/heads/main");

    await git(source.repoDir, ["checkout", "-b", featureBranch]);
    await appendLineAndCommit(
      source.repoDir,
      "README.md",
      `ripgit e2e token ${uniqueToken("deleteref")}`,
      "e2e delete ref change",
    );
    await git(source.repoDir, ["tag", tagName]);
    await pushAsOwner(
      source.repoDir,
      owner,
      "push",
      "ripgit",
      `HEAD:refs/heads/${featureBranch}`,
      `refs/tags/${tagName}:refs/tags/${tagName}`,
    );

    const existingClone = await cloneRemote(remoteUrl);
    await git(existingClone.repoDir, ["fetch", "origin", "--tags"]);
    expect(
      await gitStdout(existingClone.repoDir, ["rev-parse", `refs/remotes/origin/${featureBranch}`]),
    ).toHaveLength(40);
    expect(await gitStdout(existingClone.repoDir, ["rev-parse", `refs/tags/${tagName}`])).toHaveLength(40);

    await pushAsOwner(
      source.repoDir,
      owner,
      "push",
      "ripgit",
      `:refs/heads/${featureBranch}`,
      `:refs/tags/${tagName}`,
    );

    let response = await server.dispatch(`/${owner}/${repo}/refs`);
    expect(response.status).toBe(200);
    let refs = await response.json();
    expect(refs.heads[featureBranch]).toBeUndefined();
    expect(refs.tags[tagName]).toBeUndefined();

    await git(existingClone.repoDir, ["fetch", "origin", "--prune", "--prune-tags"]);
    await expect(
      gitStdout(existingClone.repoDir, ["rev-parse", `refs/remotes/origin/${featureBranch}`]),
    ).rejects.toThrow();
    await expect(gitStdout(existingClone.repoDir, ["rev-parse", `refs/tags/${tagName}`])).rejects.toThrow();
  });

  test("lists pushed repositories on the owner profile", async () => {
    const owner = uniqueId("owner");
    const repoOne = uniqueId("repo");
    const repoTwo = uniqueId("repo");
    const remoteOne = new URL(`/${owner}/${repoOne}`, server.url).toString();
    const remoteTwo = new URL(`/${owner}/${repoTwo}`, server.url).toString();

    let response = await server.dispatch(`/${owner}/?format=md`, {
      headers: actorHeaders(owner),
    });
    expect(response.status).toBe(200);
    let markdown = await response.text();
    expect(markdown).toContain("Repositories: `0`");
    expect(markdown).toContain("No repositories yet. Repositories are created on first push.");

    const source = await cloneFixture();
    tempDirs.push(source.workDir);
    await addRemote(source.repoDir, "ripgit-one", remoteOne);
    await addRemote(source.repoDir, "ripgit-two", remoteTwo);

    await pushAsOwner(source.repoDir, owner, "push", "ripgit-one", "HEAD:refs/heads/main");
    await pushAsOwner(source.repoDir, owner, "push", "ripgit-two", "HEAD:refs/heads/main");

    response = await server.dispatch(`/${owner}/?format=md`, {
      headers: actorHeaders(owner),
    });
    expect(response.status).toBe(200);
    markdown = await response.text();
    expect(markdown).toContain("Repositories: `2`");
    expect(markdown).toContain(`- \`${repoOne}\` - \`/${owner}/${repoOne}\``);
    expect(markdown).toContain(`- \`${repoTwo}\` - \`/${owner}/${repoTwo}\``);
    expect(markdown).toContain("## Push a New Repository");
  });
});