Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-pr-url-temp-repos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jaydenfyi/diffx": patch
---

Resolve GitHub PR URLs in an isolated temporary Git repository so URL diffs work outside an existing Git checkout and support sandbox temp roots via `DIFFX_TMPDIR`.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@
"patch",
"pull-request"
],
"homepage": "https://github.com/jaydenfyi/diffx#readme",
"bugs": {
"url": "https://github.com/jaydenfyi/diffx/issues"
},
"license": "MIT",
"author": "Jayden",
"repository": {
"type": "git",
"url": "git+https://github.com/jaydenfyi/diffx.git"
},
"homepage": "https://github.com/jaydenfyi/diffx#readme",
"bugs": {
"url": "https://github.com/jaydenfyi/diffx/issues"
},
"bin": {
"diffx": "./dist/bin.mjs"
},
Expand Down
2 changes: 2 additions & 0 deletions src/cli/command-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PatchStyle, FilterOptions } from "../types";
import type { GitClient } from "../git/git-client";

export interface ResolvedRefs {
left: string;
right: string;
cleanup?: () => Promise<void>;
patchStyle?: PatchStyle;
gitClient?: GitClient;
}

export type FileFilterOptions = FilterOptions;
Expand Down
3 changes: 3 additions & 0 deletions src/cli/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ async function generateDiffOutput(
): Promise<string> {
const { left, right, patchStyle } = refs;
if (right) {
if (refs.gitClient) {
return generateOutput(mode, left, right, diffOptions, patchStyle, refs.gitClient);
}
return generateOutput(mode, left, right, diffOptions, patchStyle);
}

Expand Down
4 changes: 3 additions & 1 deletion src/cli/git-pass-through.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export async function runGitPassThrough({
noPager: boolean | undefined;
}): Promise<void> {
let cleanup: (() => Promise<void>) | undefined;
let diffGitClient = gitClient;

let left = "";
let right = "";
Expand Down Expand Up @@ -46,6 +47,7 @@ export async function runGitPassThrough({
left = resolved.left;
right = resolved.right;
cleanup = resolved.cleanup;
diffGitClient = resolved.gitClient ?? gitClient;
}
} else if (useGitCompat) {
left = "";
Expand All @@ -63,7 +65,7 @@ export async function runGitPassThrough({
const fullGitArgs = [...partitioned.gitArgs, ...gitDiffArgs];

try {
const result = await gitClient.runGitDiffRaw(fullGitArgs);
const result = await diffGitClient.runGitDiffRaw(fullGitArgs);
if (result.exitCode !== 0) {
const message = result.stderr.trim().length > 0 ? result.stderr : "git diff failed";
throw new DiffxError(message, ExitCode.GIT_ERROR);
Expand Down
15 changes: 13 additions & 2 deletions src/git/git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
*/

import git from "simple-git";
import type { StatusResult } from "simple-git";
import type { SimpleGit, StatusResult } from "simple-git";
import type { GitDiffOptions, GitRemote } from "./types";

/**
* Git client wrapper for diffx operations
*/
export class GitClient {
private readonly git = git();
private readonly git: SimpleGit;

constructor(baseDir?: string) {
this.git = baseDir ? git({ baseDir }) : git();
}

private buildColorFlag(color: "always" | "never" | "auto" | undefined): string[] {
return color ? [`--color=${color}`] : [];
Expand Down Expand Up @@ -300,6 +304,13 @@ export class GitClient {
await this.git.raw(args);
}

/**
* Initialize the client base directory as a bare repository.
*/
async initBare(): Promise<void> {
await this.git.raw(["init", "--bare"]);
}

/**
* Fetch a specific PR reference (without depth limit to get merge history)
*/
Expand Down
91 changes: 91 additions & 0 deletions src/git/temp-git-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mockedFn } from "vitest-mock-extended";
import { createTemporaryGitClient } from "./temp-git-repo";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { GitClient } from "./git-client";

vi.mock("node:fs/promises", () => ({
mkdtemp: vi.fn(),
mkdir: vi.fn(),
rm: vi.fn(),
}));

vi.mock("node:os", () => ({
tmpdir: vi.fn(),
}));

const gitClientMocks = vi.hoisted(() => ({
initBare: vi.fn(),
}));

vi.mock("./git-client", () => ({
GitClient: vi.fn(function GitClient() {
return gitClientMocks;
}),
}));

describe("createTemporaryGitClient", () => {
const originalDiffxTmpdir = process.env.DIFFX_TMPDIR;
const mockMkdtemp = mockedFn(mkdtemp);
const mockMkdir = mockedFn(mkdir);
const mockRm = mockedFn(rm);
const mockTmpdir = mockedFn(tmpdir);
const mockGitClient = mockedFn(GitClient);
const mockInitBare = mockedFn(gitClientMocks.initBare);

beforeEach(() => {
vi.clearAllMocks();
delete process.env.DIFFX_TMPDIR;
mockTmpdir.mockReturnValue("/tmp");
mockMkdir.mockResolvedValue(undefined);
mockMkdtemp.mockResolvedValue("/tmp/diffx-abc");
mockRm.mockResolvedValue(undefined);
mockInitBare.mockResolvedValue(undefined);
});

afterEach(() => {
if (originalDiffxTmpdir === undefined) {
delete process.env.DIFFX_TMPDIR;
} else {
process.env.DIFFX_TMPDIR = originalDiffxTmpdir;
}
});

it("creates a bare repository under the OS temp directory by default", async () => {
const result = await createTemporaryGitClient();

expect(mockMkdir).toHaveBeenCalledWith("/tmp", { recursive: true });
expect(mockMkdtemp).toHaveBeenCalledWith("/tmp/diffx-");
expect(mockGitClient).toHaveBeenCalledWith("/tmp/diffx-abc");
expect(mockInitBare).toHaveBeenCalled();
expect(result.gitClient).toBe(gitClientMocks);
});

it("uses DIFFX_TMPDIR as the temp root when provided", async () => {
process.env.DIFFX_TMPDIR = "/workspace/.diffx-tmp";
mockMkdtemp.mockResolvedValue("/workspace/.diffx-tmp/diffx-abc");

await createTemporaryGitClient();

expect(mockMkdir).toHaveBeenCalledWith("/workspace/.diffx-tmp", { recursive: true });
expect(mockMkdtemp).toHaveBeenCalledWith("/workspace/.diffx-tmp/diffx-");
expect(mockGitClient).toHaveBeenCalledWith("/workspace/.diffx-tmp/diffx-abc");
});

it("removes the temporary repo during cleanup", async () => {
const result = await createTemporaryGitClient();

await result.cleanup();

expect(mockRm).toHaveBeenCalledWith("/tmp/diffx-abc", { recursive: true, force: true });
});

it("removes the temporary repo when bare init fails", async () => {
mockInitBare.mockRejectedValue(new Error("init failed"));

await expect(createTemporaryGitClient()).rejects.toThrow("init failed");

expect(mockRm).toHaveBeenCalledWith("/tmp/diffx-abc", { recursive: true, force: true });
});
});
37 changes: 37 additions & 0 deletions src/git/temp-git-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { GitClient } from "./git-client";

const TMP_PREFIX = "diffx-";

export type TemporaryGitClient = {
gitClient: GitClient;
cleanup: () => Promise<void>;
};

function getTempRoot(): string {
return process.env.DIFFX_TMPDIR || tmpdir();
}

export async function createTemporaryGitClient(): Promise<TemporaryGitClient> {
const tempRoot = getTempRoot();
await mkdir(tempRoot, { recursive: true });

const repoPath = await mkdtemp(join(tempRoot, TMP_PREFIX));
const gitClient = new GitClient(repoPath);

try {
await gitClient.initBare();
} catch (error) {
await rm(repoPath, { recursive: true, force: true });
throw error;
}

return {
gitClient,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
}
58 changes: 43 additions & 15 deletions src/output/output-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { OutputMode, PatchStyle } from "../types";
import type { GitClient } from "../git/git-client";
import { gitClient } from "../git/git-client";
import { generatePatch } from "./patch-generator";
import { GitDiffOptions } from "../git/types";
Expand All @@ -13,24 +14,50 @@ type OutputGeneratorFn = (
right: string,
options: GitDiffOptions | undefined,
patchStyle?: PatchStyle,
client?: GitClient,
) => Promise<string>;

const outputGeneratorsByMode = {
diff: (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diff(left, right, options),
diff: (left: string, right: string, options: GitDiffOptions | undefined, _patchStyle, client) =>
(client ?? gitClient).diff(left, right, options),
patch: generatePatch as OutputGeneratorFn,
stat: (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffStat(left, right, options),
numstat: (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffNumStat(left, right, options),
shortstat: (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffShortStat(left, right, options),
"name-only": (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffNameOnly(left, right, options),
"name-status": (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffNameStatus(left, right, options),
summary: (left: string, right: string, options: GitDiffOptions | undefined) =>
gitClient.diffSummary(left, right, options),
stat: (left: string, right: string, options: GitDiffOptions | undefined, _patchStyle, client) =>
(client ?? gitClient).diffStat(left, right, options),
numstat: (
left: string,
right: string,
options: GitDiffOptions | undefined,
_patchStyle,
client,
) => (client ?? gitClient).diffNumStat(left, right, options),
shortstat: (
left: string,
right: string,
options: GitDiffOptions | undefined,
_patchStyle,
client,
) => (client ?? gitClient).diffShortStat(left, right, options),
"name-only": (
left: string,
right: string,
options: GitDiffOptions | undefined,
_patchStyle,
client,
) => (client ?? gitClient).diffNameOnly(left, right, options),
"name-status": (
left: string,
right: string,
options: GitDiffOptions | undefined,
_patchStyle,
client,
) => (client ?? gitClient).diffNameStatus(left, right, options),
summary: (
left: string,
right: string,
options: GitDiffOptions | undefined,
_patchStyle,
client,
) => (client ?? gitClient).diffSummary(left, right, options),
} as const satisfies Record<OutputMode, OutputGeneratorFn>;

type OutputGeneratorAgainstWorktreeFn = (
Expand Down Expand Up @@ -66,9 +93,10 @@ export async function generateOutput(
right: string,
options: GitDiffOptions | undefined,
patchStyle: PatchStyle | undefined,
client?: GitClient,
): Promise<string> {
const generator = outputGeneratorsByMode[mode];
return generator(left, right, options, patchStyle);
return generator(left, right, options, patchStyle, client);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/output/patch-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { GitDiffOptions } from "../git/types";
import type { GitClient } from "../git/git-client";
import { gitClient } from "../git/git-client";
import type { PatchStyle } from "../types";

Expand All @@ -15,10 +16,11 @@ export async function generatePatch(
right: string,
options: GitDiffOptions | undefined,
patchStyle: PatchStyle = "diff",
client: GitClient = gitClient,
): Promise<string> {
if (patchStyle === "diff") {
return gitClient.diff(left, right, options);
return client.diff(left, right, options);
}

return gitClient.formatPatch(left, right, options);
return client.formatPatch(left, right, options);
}
Loading
Loading