Ctrl K
ComunidadGuías…

mcp/git.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from "vitest";
import { classifyPushError, describeEmptyResult, normalizeGitInvocation } from "./git.ts";

// re-export the normalizeUrl function for testing
// note: in a real scenario, we'd export this from git.ts or move to a shared utils file
function normalizeUrl(url: string): string {
  return url.replace(/\.git$/, "").toLowerCase();
}

describe("normalizeUrl", () => {
  it("removes .git suffix", () => {
    expect(normalizeUrl("https://github.com/owner/repo.git")).toBe("https://github.com/owner/repo");
  });

  it("lowercases URL", () => {
    expect(normalizeUrl("https://github.com/Owner/Repo")).toBe("https://github.com/owner/repo");
  });

  it("handles URL without .git suffix", () => {
    expect(normalizeUrl("https://github.com/owner/repo")).toBe("https://github.com/owner/repo");
  });

  it("handles combined case and .git suffix", () => {
    expect(normalizeUrl("https://github.com/OWNER/REPO.git")).toBe("https://github.com/owner/repo");
  });
});

describe("push URL validation", () => {
  // these tests document the expected behavior
  // actual integration testing happens via the agent test suite

  it("should block push when actual URL differs from pushUrl", () => {
    // pushUrl is set at checkout configuration (setupGit / checkout_repo) or checkout_pr (fork repo)
    const pushUrl = "https://github.com/fork-owner/repo.git";
    const actualUrl = "https://github.com/base-owner/repo.git"; // different repo

    const pushUrlNormalized = normalizeUrl(pushUrl);
    const actualUrlNormalized = normalizeUrl(actualUrl);

    expect(pushUrlNormalized).not.toBe(actualUrlNormalized);
    // in real code, this mismatch would throw an error
  });

  it("should allow push when actual URL matches pushUrl", () => {
    const pushUrl = "https://github.com/fork-owner/repo.git";
    const actualUrl = "https://github.com/fork-owner/repo"; // same repo, no .git

    const pushUrlNormalized = normalizeUrl(pushUrl);
    const actualUrlNormalized = normalizeUrl(actualUrl);

    expect(pushUrlNormalized).toBe(actualUrlNormalized);
    // in real code, this would allow the push
  });

  it("should handle case differences in URLs", () => {
    const pushUrl = "https://github.com/Owner/Repo.git";
    const actualUrl = "https://github.com/owner/repo";

    const pushUrlNormalized = normalizeUrl(pushUrl);
    const actualUrlNormalized = normalizeUrl(actualUrl);

    expect(pushUrlNormalized).toBe(actualUrlNormalized);
  });
});

describe("classifyPushError", () => {
  describe("concurrent-push", () => {
    it("matches client-side non-fast-forward (`fetch first`)", () => {
      const msg =
        "git push failed (exit 1): To https://github.com/o/r.git\n" +
        " ! [rejected]        feature -> feature (fetch first)\n" +
        "error: failed to push some refs to 'https://github.com/o/r.git'\n" +
        "hint: Updates were rejected because the remote contains work";
      expect(classifyPushError(msg)).toBe("concurrent-push");
    });

    it("matches client-side `non-fast-forward` wording", () => {
      const msg = "! [rejected] main -> main (non-fast-forward)";
      expect(classifyPushError(msg)).toBe("concurrent-push");
    });

    it("matches server-side `cannot lock ref` (the case from #571)", () => {
      const msg =
        "remote: error: cannot lock ref 'refs/heads/feature': is at " +
        "abc123 but expected def456\n" +
        " ! [remote rejected] feature -> feature (cannot lock ref ...)";
      expect(classifyPushError(msg)).toBe("concurrent-push");
    });
  });

  describe("transient", () => {
    it("matches RPC failed with HTTP 502", () => {
      expect(
        classifyPushError(
          "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 502"
        )
      ).toBe("transient");
    });

    it("matches early EOF mid-pack", () => {
      expect(
        classifyPushError("fatal: the remote end hung up unexpectedly\nfatal: early EOF")
      ).toBe("transient");
    });

    it("matches RPC failed", () => {
      expect(
        classifyPushError("fatal: RPC failed; curl 56 OpenSSL SSL_read: Connection reset by peer")
      ).toBe("transient");
    });

    it("matches HTTP/2 stream not closed cleanly", () => {
      expect(
        classifyPushError("fatal: HTTP/2 stream 7 was not closed cleanly: PROTOCOL_ERROR (err 1)")
      ).toBe("transient");
    });

    it("matches DNS resolution failure", () => {
      expect(classifyPushError("fatal: Could not resolve host: github.com")).toBe("transient");
    });

    it("matches unexpected disconnect during sideband read", () => {
      expect(classifyPushError("fatal: unexpected disconnect while reading sideband packet")).toBe(
        "transient"
      );
    });

    it("classifies HTTP 429 (rate-limit / abuse detection) as transient", () => {
      // 429 is the documented exception to the otherwise-permanent 4xx class —
      // GitHub's abuse detection occasionally surfaces it on git push.
      expect(
        classifyPushError(
          "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 429"
        )
      ).toBe("transient");
      expect(classifyPushError("remote: HTTP 429: too many requests")).toBe("transient");
    });
  });

  describe("unknown", () => {
    it("does NOT classify auth/403 as transient", () => {
      // permission denied is permanent within a run — retrying just wastes
      // time. must NOT match the HTTP-5xx regex.
      expect(
        classifyPushError(
          "remote: Permission to o/r.git denied to bot.\n" +
            "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403"
        )
      ).toBe("unknown");
    });

    it("does NOT classify protected-branch rejection as concurrent-push", () => {
      expect(
        classifyPushError(
          " ! [remote rejected] main -> main (push declined due to repository rule violations)"
        )
      ).toBe("unknown");
    });

    it("does NOT classify 404 as transient", () => {
      expect(
        classifyPushError(
          "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 404"
        )
      ).toBe("unknown");
    });

    it("returns unknown for an empty message", () => {
      expect(classifyPushError("")).toBe("unknown");
    });
  });

  describe("ordering", () => {
    it("prefers concurrent-push over transient when both signals appear", () => {
      // a server-side cannot-lock-ref response that also includes an HTTP
      // 5xx in the libcurl envelope should still route to the recovery
      // path, not a blind retry.
      const msg =
        "remote: error: cannot lock ref 'refs/heads/feature': is at A but expected B\n" +
        "fatal: unable to access ...: The requested URL returned error: 500";
      expect(classifyPushError(msg)).toBe("concurrent-push");
    });
  });
});

describe("normalizeGitInvocation (#1199)", () => {
  it("drops args[0] when it duplicates the subcommand", () => {
    expect(normalizeGitInvocation({ command: "log", args: ["log", "--oneline"] })).toEqual({
      command: "log",
      args: ["--oneline"],
    });
  });

  it("promotes the subcommand out of command: 'git'", () => {
    expect(normalizeGitInvocation({ command: "git", args: ["cat-file", "-t", "HEAD"] })).toEqual({
      command: "cat-file",
      args: ["-t", "HEAD"],
    });
  });

  it("refuses to promote a global option into the subcommand slot", () => {
    const input = { command: "git", args: ["-c", "alias.x=!evil", "x"] };
    expect(normalizeGitInvocation(input)).toEqual(input);
  });

  it("leaves a real pathspec alone", () => {
    expect(normalizeGitInvocation({ command: "log", args: ["--", "log"] })).toEqual({
      command: "log",
      args: ["--", "log"],
    });
  });
});

describe("describeEmptyResult (#1152)", () => {
  it("names the answer for subcommands whose exit 1 is a result", () => {
    expect(describeEmptyResult("grep", ["-rn", "sym"])).toBe("no matches");
    expect(describeEmptyResult("merge-base", ["--fork-point", "origin/main", "HEAD"])).toBe(
      "no fork point"
    );
    expect(describeEmptyResult("diff", ["--quiet"])).toBe("differences found");
  });

  it("returns null for subcommands where exit 1 is a real failure", () => {
    expect(describeEmptyResult("merge-base", ["origin/main", "HEAD"])).toBeNull();
    expect(describeEmptyResult("status", [])).toBeNull();
    expect(describeEmptyResult("diff", ["origin/main...HEAD"])).toBeNull();
  });
});