Skip to content
This repository was archived by the owner on May 15, 2025. It is now read-only.

feat(git-clone): add support for tree github or gitlab clone url #210

Merged
merged 23 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6b1fdbe
feat(git-clone): add support for tree git clone url
michaelbrewer Apr 1, 2024
96367b8
bun fmt
michaelbrewer Apr 1, 2024
002a8b5
tests(git-clone): add real tests
michaelbrewer Apr 1, 2024
b525c78
feat: used -b for clone
michaelbrewer Apr 1, 2024
60d0c5e
feat(git-clone): add support for web_url
michaelbrewer Apr 2, 2024
6510434
Update git-clone/main.tf
michaelbrewer Apr 5, 2024
118b93e
Merge branch 'coder:main' into feat/git-clone-tree
michaelbrewer Apr 5, 2024
741d656
test(git-clone): add missing fragment test
mikefiserv Apr 5, 2024
d76e4d9
test(git-clone): add missing fragment test
mikefiserv Apr 5, 2024
52c8009
feat(git-clone): add support git_providers setting
mikefiserv Apr 6, 2024
cb67c37
doc(git-clone): add basic docs
mikefiserv Apr 6, 2024
53cf0b5
feat(git-clone): easier ux
mikefiserv Apr 6, 2024
402838c
chore(git-clone): code cleanup and use one
michaelbrewer Apr 6, 2024
3ebf901
doc(git-clone): update docs
michaelbrewer Apr 6, 2024
12efc56
feat(git-clone): add outputs for folder_name and git_provider
michaelbrewer Apr 7, 2024
b46c628
doc(git-clone): more complete example
michaelbrewer Apr 7, 2024
4b51797
Merge branch 'coder:main' into feat/git-clone-tree
michaelbrewer Apr 11, 2024
101ffb0
Merge branch 'coder:main' into feat/git-clone-tree
michaelbrewer Apr 14, 2024
042fe6e
Merge branch 'coder:main' into feat/git-clone-tree
michaelbrewer Apr 15, 2024
d58c69a
Update README.md
michaelbrewer Apr 16, 2024
0ff5872
Update README.md
michaelbrewer Apr 16, 2024
228666d
Update main.tf
michaelbrewer Apr 16, 2024
b8073e3
feat: add branch_name
michaelbrewer Apr 16, 2024
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
103 changes: 103 additions & 0 deletions git-clone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,106 @@ data "coder_git_auth" "github" {
id = "github"
}
```

## GitHub clone with branch name

To GitHub clone with a specific branch like `feat/example`

```tf
# Prompt the user for the git repo URL
data "coder_parameter" "git_repo" {
name = "git_repo"
display_name = "Git repository"
default = "https://github.com/coder/coder/tree/feat/example"
}

# Clone the repository for branch `feat/example`
module "git_clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}

# Create a code-server instance for the cloned repository
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
order = 1
folder = "/home/${local.username}/${module.git_clone.folder_name}"
}

# Create a Coder app for the website
resource "coder_app" "website" {
agent_id = coder_agent.example.id
order = 2
slug = "website"
external = true
display_name = module.git_clone.folder_name
url = module.git_clone.web_url
icon = module.git_clone.git_provider != "" ? "/icon/${module.git_clone.git_provider}.svg" : "/icon/git.svg"
count = module.git_clone.web_url != "" ? 1 : 0
}
```

Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `github.example.com`

```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
"https://github.example.com/" = {
provider = "github"
}
}
}
```

## GitLab clone with branch name

To GitLab clone with a specific branch like `feat/example`

```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
```

Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`

```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
"https://gitlab.example.com/" = {
provider = "gitlab"
}
}
}
```

## Git clone with branch_name set

Alternatively, you can set the `branch_name` attribute to clone a specific branch.

For example, to clone the `feat/example` branch:

```tf
module "git-clone" {
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
}
```
192 changes: 192 additions & 0 deletions git-clone/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,196 @@ describe("git-clone", async () => {
"Cloning fake-url to ~/fake-url...",
]);
});

it("repo_dir should match repo name for https", async () => {
const url = "https://github.com/coder/coder.git";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
expect(state.outputs.folder_name.value).toEqual("coder");
expect(state.outputs.clone_url.value).toEqual(url);
expect(state.outputs.web_url.value).toEqual(url);
expect(state.outputs.branch_name.value).toEqual("");
});

it("repo_dir should match repo name for https without .git", async () => {
const url = "https://github.com/coder/coder";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
expect(state.outputs.clone_url.value).toEqual(url);
expect(state.outputs.web_url.value).toEqual(url);
expect(state.outputs.branch_name.value).toEqual("");
});

it("repo_dir should match repo name for ssh", async () => {
const url = "git@github.com:coder/coder.git";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
expect(state.outputs.git_provider.value).toEqual("");
expect(state.outputs.clone_url.value).toEqual(url);
const https_url = "https://github.com/coder/coder.git";
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("");
});

it("branch_name should not include query string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch?ref_type=heads",
});
expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
expect(state.outputs.folder_name.value).toEqual("repo-tests.log");
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
expect(state.outputs.clone_url.value).toEqual(https_url);
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("feat/branch");
});

it("branch_name should not include fragments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch#name",
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
expect(state.outputs.clone_url.value).toEqual(https_url);
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("feat/branch");
});

it("gitlab url with branch should match", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
expect(state.outputs.git_provider.value).toEqual("gitlab");
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
expect(state.outputs.clone_url.value).toEqual(https_url);
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("feat/branch");
});

it("github url with branch should match", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
expect(state.outputs.git_provider.value).toEqual("github");
const https_url = "https://github.com/michaelbrewer/repo-tests.log";
expect(state.outputs.clone_url.value).toEqual(https_url);
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("feat/branch");
});

it("self-host git url with branch should match", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url: "https://git.example.com/example/project/-/tree/feat/example",
git_providers: `
{
"https://git.example.com/" = {
provider = "gitlab"
}
}`,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/project");
expect(state.outputs.git_provider.value).toEqual("gitlab");
const https_url = "https://git.example.com/example/project";
expect(state.outputs.clone_url.value).toEqual(https_url);
expect(state.outputs.web_url.value).toEqual(https_url);
expect(state.outputs.branch_name.value).toEqual("feat/example");
});

it("handle unsupported git provider configuration", async () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "foo",
git_providers: `
{
"https://git.example.com/" = {
provider = "bitbucket"
}
}`,
});
};
expect(t).toThrow('Allowed values for provider are "github" or "gitlab".');
});

it("handle unknown git provider url", async () => {
const url = "https://git.unknown.com/coder/coder";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
base_dir: "/tmp",
url,
});
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
expect(state.outputs.clone_url.value).toEqual(url);
expect(state.outputs.web_url.value).toEqual(url);
expect(state.outputs.branch_name.value).toEqual("");
});

it("runs with github clone with switch to feat/branch", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
});

it("runs with gitlab clone with switch to feat/branch", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
});

it("runs with github clone with branch_name set to feat/branch", async () => {
const url = "https://github.com/michaelbrewer/repo-tests.log";
const branch_name = "feat/branch";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url,
branch_name,
});
expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
expect(state.outputs.clone_url.value).toEqual(url);
expect(state.outputs.web_url.value).toEqual(url);
expect(state.outputs.branch_name.value).toEqual(branch_name);

const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
});
});
74 changes: 71 additions & 3 deletions git-clone/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,88 @@ variable "agent_id" {
type = string
}

variable "git_providers" {
type = map(object({
provider = string
}))
description = "A mapping of URLs to their git provider."
default = {
"https://github.com/" = {
provider = "github"
},
"https://gitlab.com/" = {
provider = "gitlab"
},
}
validation {
error_message = "Allowed values for provider are \"github\" or \"gitlab\"."
condition = alltrue([for provider in var.git_providers : contains(["github", "gitlab"], provider.provider)])
}
}

variable "branch_name" {
description = "The branch name to clone. If not provided, the default branch will be cloned."
type = string
default = ""
}

locals {
clone_path = var.base_dir != "" ? join("/", [var.base_dir, replace(basename(var.url), ".git", "")]) : join("/", ["~", replace(basename(var.url), ".git", "")])
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")

# Find the git provider based on the URL and determine the tree path
provider_key = try(one([for key in keys(var.git_providers) : key if startswith(local.url, key)]), null)
provider = try(lookup(var.git_providers, local.provider_key).provider, "")
tree_path = local.provider == "gitlab" ? "/-/tree/" : local.provider == "github" ? "/tree/" : ""

# Remove tree and branch name from the URL
clone_url = var.branch_name == "" && local.tree_path != "" ? replace(local.url, "/${local.tree_path}.*/", "") : local.url
# Extract the branch name from the URL
branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
# Extract the folder name from the URL
folder_name = replace(basename(local.clone_url), ".git", "")
# Construct the path to clone the repository
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
# Construct the web URL
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
}

output "repo_dir" {
value = local.clone_path
description = "Full path of cloned repo directory"
}

output "git_provider" {
value = local.provider
description = "The git provider of the repository"
}

output "folder_name" {
value = local.folder_name
description = "The name of the folder that will be created"
}

output "clone_url" {
value = local.clone_url
description = "The exact Git repository URL that will be cloned"
}

output "web_url" {
value = local.web_url
description = "Git https repository URL (may be invalid for unsupported providers)"
}

output "branch_name" {
value = local.branch_name
description = "Git branch name (may be empty)"
}

resource "coder_script" "git_clone" {
agent_id = var.agent_id
script = templatefile("${path.module}/run.sh", {
CLONE_PATH = local.clone_path
REPO_URL : var.url,
CLONE_PATH = local.clone_path,
REPO_URL : local.clone_url,
BRANCH_NAME : local.branch_name,
})
display_name = "Git Clone"
icon = "/icon/git.svg"
Expand Down
Loading