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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,12 @@ linear-release update --stage="in review" --name="Release 1.2.0"

### Environment Variables

| Variable | Required | Description |
| ------------------- | -------- | ------------------------------- |
| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear |
| Variable | Required | Description |
| --------------------- | -------- | ---------------------------------------------------------------- |
| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear |
| `LINEAR_VCS_PROVIDER` | No | Override VCS provider detection: `github`, `gitlab`, `bitbucket` |

The provider is detected from the remote hostname, or on GitLab CI from the job environment. Set `LINEAR_VCS_PROVIDER` when neither identifies the host (e.g. self-hosted GitHub Enterprise on a custom domain). When the provider can't be determined, `sync` exits with code `2` before syncing anything.

### CLI Options

Expand Down
266 changes: 4 additions & 262 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ import {
getCommitContext,
getCommitContextsBetweenShas,
getCommitParents,
getRepoInfo,
getRemoteUrl,
isAncestor,
normalizePathspec,
parseRepoUrl,
resolveFirstSyncBoundary,
} from "./git";

Expand Down Expand Up @@ -128,266 +127,9 @@ describe("extractBranchName", () => {
});
});

describe("getRepoInfo", () => {
it("should return the repo info", () => {
const result = getRepoInfo();
expect(result).toBeDefined();
expect(result?.owner).toBe("linear");
expect(result?.name).toBe("linear-release");
expect(result?.provider).toBe("github");
expect(result?.url).toBe("https://github.com/linear/linear-release");
});
});

describe("parseRepoUrl", () => {
describe("HTTPS URLs", () => {
it("should parse github.com HTTPS URL", () => {
const result = parseRepoUrl("https://github.com/linear/linear-app.git");
expect(result).toEqual({
owner: "linear",
name: "linear-app",
provider: "github",
url: "https://github.com/linear/linear-app",
});
});

it("should parse github.com HTTPS URL without .git suffix", () => {
const result = parseRepoUrl("https://github.com/linear/linear-app");
expect(result).toEqual({
owner: "linear",
name: "linear-app",
provider: "github",
url: "https://github.com/linear/linear-app",
});
});

it("should parse gitlab.com HTTPS URL", () => {
const result = parseRepoUrl("https://gitlab.com/myorg/myrepo.git");
expect(result).toEqual({
owner: "myorg",
name: "myrepo",
provider: "gitlab",
url: "https://gitlab.com/myorg/myrepo",
});
});

it("should parse GitHub Enterprise HTTPS URL", () => {
const result = parseRepoUrl("https://github.mycompany.com/engineering/platform.git");
expect(result).toEqual({
owner: "engineering",
name: "platform",
provider: "github",
url: "https://github.mycompany.com/engineering/platform",
});
});

it("should parse self-hosted GitLab HTTPS URL", () => {
const result = parseRepoUrl("https://gitlab.internal.io/team/service.git");
expect(result).toEqual({
owner: "team",
name: "service",
provider: "gitlab",
url: "https://gitlab.internal.io/team/service",
});
});

it("should parse gitlab.com HTTPS URL with nested groups", () => {
const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git");
expect(result).toEqual({
owner: "my-org",
name: "my-group/my-repo",
provider: "gitlab",
url: "https://gitlab.com/my-org/my-group/my-repo",
});
});

it("should parse gitlab.com HTTPS URL with deeply nested groups", () => {
const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git");
expect(result).toEqual({
owner: "org",
name: "group/subgroup/repo",
provider: "gitlab",
url: "https://gitlab.com/org/group/subgroup/repo",
});
});

it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => {
const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service");
expect(result).toEqual({
owner: "team",
name: "platform/service",
provider: "gitlab",
url: "https://gitlab.internal.io/team/platform/service",
});
});

it("should parse bitbucket.org HTTPS URL", () => {
const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git");
expect(result).toEqual({
owner: "myorg",
name: "myrepo",
provider: "bitbucket",
url: "https://bitbucket.org/myorg/myrepo",
});
});

it("should parse self-hosted Bitbucket HTTPS URL", () => {
const result = parseRepoUrl("https://bitbucket.mycompany.com/team/service.git");
expect(result).toEqual({
owner: "team",
name: "service",
provider: "bitbucket",
url: "https://bitbucket.mycompany.com/team/service",
});
});

it("should parse HTTPS URL with credentials", () => {
const result = parseRepoUrl("https://token@github.com/linear/linear-app.git");
expect(result).toEqual({
owner: "linear",
name: "linear-app",
provider: "github",
url: "https://github.com/linear/linear-app",
});
});
});

describe("SSH URLs", () => {
it("should parse github.com SSH URL", () => {
const result = parseRepoUrl("git@github.com:linear/linear-app.git");
expect(result).toEqual({
owner: "linear",
name: "linear-app",
provider: "github",
url: "https://github.com/linear/linear-app",
});
});

it("should parse github.com SSH URL without .git suffix", () => {
const result = parseRepoUrl("git@github.com:linear/linear-app");
expect(result).toEqual({
owner: "linear",
name: "linear-app",
provider: "github",
url: "https://github.com/linear/linear-app",
});
});

it("should parse gitlab.com SSH URL", () => {
const result = parseRepoUrl("git@gitlab.com:myorg/myrepo.git");
expect(result).toEqual({
owner: "myorg",
name: "myrepo",
provider: "gitlab",
url: "https://gitlab.com/myorg/myrepo",
});
});

it("should parse GitHub Enterprise SSH URL", () => {
const result = parseRepoUrl("git@github.mycompany.com:engineering/platform.git");
expect(result).toEqual({
owner: "engineering",
name: "platform",
provider: "github",
url: "https://github.mycompany.com/engineering/platform",
});
});

it("should parse self-hosted GitLab SSH URL", () => {
const result = parseRepoUrl("git@gitlab.internal.io:team/service.git");
expect(result).toEqual({
owner: "team",
name: "service",
provider: "gitlab",
url: "https://gitlab.internal.io/team/service",
});
});

it("should parse gitlab.com SSH URL with nested groups", () => {
const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git");
expect(result).toEqual({
owner: "my-org",
name: "my-group/my-repo",
provider: "gitlab",
url: "https://gitlab.com/my-org/my-group/my-repo",
});
});

it("should parse gitlab.com SSH URL with deeply nested groups", () => {
const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git");
expect(result).toEqual({
owner: "org",
name: "group/subgroup/repo",
provider: "gitlab",
url: "https://gitlab.com/org/group/subgroup/repo",
});
});

it("should parse bitbucket.org SSH URL", () => {
const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git");
expect(result).toEqual({
owner: "myorg",
name: "myrepo",
provider: "bitbucket",
url: "https://bitbucket.org/myorg/myrepo",
});
});

it("should parse self-hosted Bitbucket SSH URL", () => {
const result = parseRepoUrl("git@bitbucket.mycompany.com:team/service.git");
expect(result).toEqual({
owner: "team",
name: "service",
provider: "bitbucket",
url: "https://bitbucket.mycompany.com/team/service",
});
});
});

describe("GitHub Enterprise Cloud (*.ghe.com)", () => {
it("should detect github provider for a *.ghe.com host", () => {
const result = parseRepoUrl("https://acme.ghe.com/engineering/platform.git");
expect(result).toEqual({
owner: "engineering",
name: "platform",
provider: "github",
url: "https://acme.ghe.com/engineering/platform",
});
});

it("should detect github provider for multi-part subdomains under .ghe.com", () => {
const result = parseRepoUrl("https://tenant-name.ghe.com/owner/repo.git");
expect(result?.provider).toBe("github");
});

it("should not match the bare ghe.com host (no subdomain)", () => {
const result = parseRepoUrl("https://ghe.com/owner/repo.git");
expect(result?.provider).toBeNull();
});

it("should not match hosts that merely contain .ghe.com as a substring", () => {
// Suffix match guards against attacker-controlled hosts that happen
// to include "ghe.com" somewhere in the middle of the hostname.
const result = parseRepoUrl("https://evil-ghe.com.attacker.com/owner/repo.git");
expect(result?.provider).toBeNull();
});
});

describe("unknown providers", () => {
it("should return null provider for unknown hosts", () => {
const result = parseRepoUrl("https://example.com/myorg/myrepo.git");
expect(result).toEqual({
owner: "myorg",
name: "myrepo",
provider: null,
url: "https://example.com/myorg/myrepo",
});
});

it("should return null for unrecognized URL formats", () => {
expect(parseRepoUrl("not-a-url")).toBeNull();
expect(parseRepoUrl("")).toBeNull();
});
describe("getRemoteUrl", () => {
it("should return the origin remote URL", () => {
expect(getRemoteUrl()).toMatch(/github\.com[:/]linear\/linear-release/);
});
});

Expand Down
61 changes: 3 additions & 58 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process";
import type { CommitContext, GitInfo, RepoInfo } from "./types";
import type { CommitContext, GitInfo } from "./types";
import { error as logError, verbose, warn } from "./log";

/** Strips leading "./" or "/" so paths are clean for git pathspec. */
Expand Down Expand Up @@ -516,68 +516,13 @@ export function getCommitContextsBetweenShas(
return commits;
}

function hostToProvider(host: string): string | null {
if (host === "gitlab.com" || host.includes("gitlab")) {
return "gitlab";
}
if (host === "github.com" || host.endsWith(".ghe.com") || host.includes("github")) {
return "github";
}
if (host === "bitbucket.org" || host.includes("bitbucket")) {
return "bitbucket";
}
return null;
}

/**
* Parses a git remote URL (HTTPS or SSH) into repo information.
*
* @param remoteUrl The raw git remote URL string.
* @returns Parsed repo info, or null if the URL could not be parsed.
*/
export function parseRepoUrl(remoteUrl: string): RepoInfo | null {
// GitLab nested groups: split on the first slash so subgroup paths fold
// into the name segment (e.g. owner=group, name=subgroup/repo).
const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
if (httpsMatch) {
const host = httpsMatch[1];
const owner = httpsMatch[2] || null;
const name = httpsMatch[3]?.replace(/\.git$/, "") || null;
return {
owner,
name,
provider: hostToProvider(host),
url: owner && name ? `https://${host}/${owner}/${name}` : null,
};
}

// Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups
// follow the same first-slash split as the HTTPS case above).
const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/);
if (sshMatch) {
const host = sshMatch[1];
const owner = sshMatch[2] || null;
const name = sshMatch[3]?.replace(/\.git$/, "") || null;
return {
owner,
name,
provider: hostToProvider(host),
url: owner && name ? `https://${host}/${owner}/${name}` : null,
};
}

return null;
}

export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd()): RepoInfo | null {
export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null {
try {
const url = execSync(`git remote get-url ${remote}`, {
return execSync(`git remote get-url ${remote}`, {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
}).trim();

return parseRepoUrl(url);
} catch (error) {
logError(`Failed to read repo info: ${error instanceof Error ? error.message : String(error)}`);
return null;
Expand Down
Loading
Loading