Skip to content

Commit 8766c67

Browse files
feat(git-clone): add support for tree github or gitlab clone url (coder#210)
1 parent 43304e5 commit 8766c67

File tree

4 files changed

+374
-5
lines changed

4 files changed

+374
-5
lines changed

git-clone/README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,106 @@ data "coder_git_auth" "github" {
5050
id = "github"
5151
}
5252
```
53+
54+
## GitHub clone with branch name
55+
56+
To GitHub clone with a specific branch like `feat/example`
57+
58+
```tf
59+
# Prompt the user for the git repo URL
60+
data "coder_parameter" "git_repo" {
61+
name = "git_repo"
62+
display_name = "Git repository"
63+
default = "https://github.com/coder/coder/tree/feat/example"
64+
}
65+
66+
# Clone the repository for branch `feat/example`
67+
module "git_clone" {
68+
source = "registry.coder.com/modules/git-clone/coder"
69+
version = "1.0.11"
70+
agent_id = coder_agent.example.id
71+
url = data.coder_parameter.git_repo.value
72+
}
73+
74+
# Create a code-server instance for the cloned repository
75+
module "code-server" {
76+
source = "registry.coder.com/modules/code-server/coder"
77+
version = "1.0.11"
78+
agent_id = coder_agent.example.id
79+
order = 1
80+
folder = "/home/${local.username}/${module.git_clone.folder_name}"
81+
}
82+
83+
# Create a Coder app for the website
84+
resource "coder_app" "website" {
85+
agent_id = coder_agent.example.id
86+
order = 2
87+
slug = "website"
88+
external = true
89+
display_name = module.git_clone.folder_name
90+
url = module.git_clone.web_url
91+
icon = module.git_clone.git_provider != "" ? "/icon/${module.git_clone.git_provider}.svg" : "/icon/git.svg"
92+
count = module.git_clone.web_url != "" ? 1 : 0
93+
}
94+
```
95+
96+
Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `github.example.com`
97+
98+
```tf
99+
module "git-clone" {
100+
source = "registry.coder.com/modules/git-clone/coder"
101+
version = "1.0.11"
102+
agent_id = coder_agent.example.id
103+
url = "https://github.example.com/coder/coder/tree/feat/example"
104+
git_providers = {
105+
"https://github.example.com/" = {
106+
provider = "github"
107+
}
108+
}
109+
}
110+
```
111+
112+
## GitLab clone with branch name
113+
114+
To GitLab clone with a specific branch like `feat/example`
115+
116+
```tf
117+
module "git-clone" {
118+
source = "registry.coder.com/modules/git-clone/coder"
119+
version = "1.0.11"
120+
agent_id = coder_agent.example.id
121+
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
122+
}
123+
```
124+
125+
Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
126+
127+
```tf
128+
module "git-clone" {
129+
source = "registry.coder.com/modules/git-clone/coder"
130+
version = "1.0.11"
131+
agent_id = coder_agent.example.id
132+
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
133+
git_providers = {
134+
"https://gitlab.example.com/" = {
135+
provider = "gitlab"
136+
}
137+
}
138+
}
139+
```
140+
141+
## Git clone with branch_name set
142+
143+
Alternatively, you can set the `branch_name` attribute to clone a specific branch.
144+
145+
For example, to clone the `feat/example` branch:
146+
147+
```tf
148+
module "git-clone" {
149+
source = "registry.coder.com/modules/git-clone/coder"
150+
version = "1.0.11"
151+
agent_id = coder_agent.example.id
152+
url = "https://github.com/coder/coder"
153+
branch_name = "feat/example"
154+
}
155+
```

git-clone/main.test.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,196 @@ describe("git-clone", async () => {
3636
"Cloning fake-url to ~/fake-url...",
3737
]);
3838
});
39+
40+
it("repo_dir should match repo name for https", async () => {
41+
const url = "https://github.com/coder/coder.git";
42+
const state = await runTerraformApply(import.meta.dir, {
43+
agent_id: "foo",
44+
base_dir: "/tmp",
45+
url,
46+
});
47+
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
48+
expect(state.outputs.folder_name.value).toEqual("coder");
49+
expect(state.outputs.clone_url.value).toEqual(url);
50+
expect(state.outputs.web_url.value).toEqual(url);
51+
expect(state.outputs.branch_name.value).toEqual("");
52+
});
53+
54+
it("repo_dir should match repo name for https without .git", async () => {
55+
const url = "https://github.com/coder/coder";
56+
const state = await runTerraformApply(import.meta.dir, {
57+
agent_id: "foo",
58+
base_dir: "/tmp",
59+
url,
60+
});
61+
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
62+
expect(state.outputs.clone_url.value).toEqual(url);
63+
expect(state.outputs.web_url.value).toEqual(url);
64+
expect(state.outputs.branch_name.value).toEqual("");
65+
});
66+
67+
it("repo_dir should match repo name for ssh", async () => {
68+
const url = "git@github.com:coder/coder.git";
69+
const state = await runTerraformApply(import.meta.dir, {
70+
agent_id: "foo",
71+
base_dir: "/tmp",
72+
url,
73+
});
74+
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
75+
expect(state.outputs.git_provider.value).toEqual("");
76+
expect(state.outputs.clone_url.value).toEqual(url);
77+
const https_url = "https://github.com/coder/coder.git";
78+
expect(state.outputs.web_url.value).toEqual(https_url);
79+
expect(state.outputs.branch_name.value).toEqual("");
80+
});
81+
82+
it("branch_name should not include query string", async () => {
83+
const state = await runTerraformApply(import.meta.dir, {
84+
agent_id: "foo",
85+
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch?ref_type=heads",
86+
});
87+
expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
88+
expect(state.outputs.folder_name.value).toEqual("repo-tests.log");
89+
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
90+
expect(state.outputs.clone_url.value).toEqual(https_url);
91+
expect(state.outputs.web_url.value).toEqual(https_url);
92+
expect(state.outputs.branch_name.value).toEqual("feat/branch");
93+
});
94+
95+
it("branch_name should not include fragments", async () => {
96+
const state = await runTerraformApply(import.meta.dir, {
97+
agent_id: "foo",
98+
base_dir: "/tmp",
99+
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch#name",
100+
});
101+
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
102+
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
103+
expect(state.outputs.clone_url.value).toEqual(https_url);
104+
expect(state.outputs.web_url.value).toEqual(https_url);
105+
expect(state.outputs.branch_name.value).toEqual("feat/branch");
106+
});
107+
108+
it("gitlab url with branch should match", async () => {
109+
const state = await runTerraformApply(import.meta.dir, {
110+
agent_id: "foo",
111+
base_dir: "/tmp",
112+
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
113+
});
114+
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
115+
expect(state.outputs.git_provider.value).toEqual("gitlab");
116+
const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
117+
expect(state.outputs.clone_url.value).toEqual(https_url);
118+
expect(state.outputs.web_url.value).toEqual(https_url);
119+
expect(state.outputs.branch_name.value).toEqual("feat/branch");
120+
});
121+
122+
it("github url with branch should match", async () => {
123+
const state = await runTerraformApply(import.meta.dir, {
124+
agent_id: "foo",
125+
base_dir: "/tmp",
126+
url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
127+
});
128+
expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
129+
expect(state.outputs.git_provider.value).toEqual("github");
130+
const https_url = "https://github.com/michaelbrewer/repo-tests.log";
131+
expect(state.outputs.clone_url.value).toEqual(https_url);
132+
expect(state.outputs.web_url.value).toEqual(https_url);
133+
expect(state.outputs.branch_name.value).toEqual("feat/branch");
134+
});
135+
136+
it("self-host git url with branch should match", async () => {
137+
const state = await runTerraformApply(import.meta.dir, {
138+
agent_id: "foo",
139+
base_dir: "/tmp",
140+
url: "https://git.example.com/example/project/-/tree/feat/example",
141+
git_providers: `
142+
{
143+
"https://git.example.com/" = {
144+
provider = "gitlab"
145+
}
146+
}`,
147+
});
148+
expect(state.outputs.repo_dir.value).toEqual("/tmp/project");
149+
expect(state.outputs.git_provider.value).toEqual("gitlab");
150+
const https_url = "https://git.example.com/example/project";
151+
expect(state.outputs.clone_url.value).toEqual(https_url);
152+
expect(state.outputs.web_url.value).toEqual(https_url);
153+
expect(state.outputs.branch_name.value).toEqual("feat/example");
154+
});
155+
156+
it("handle unsupported git provider configuration", async () => {
157+
const t = async () => {
158+
await runTerraformApply(import.meta.dir, {
159+
agent_id: "foo",
160+
url: "foo",
161+
git_providers: `
162+
{
163+
"https://git.example.com/" = {
164+
provider = "bitbucket"
165+
}
166+
}`,
167+
});
168+
};
169+
expect(t).toThrow('Allowed values for provider are "github" or "gitlab".');
170+
});
171+
172+
it("handle unknown git provider url", async () => {
173+
const url = "https://git.unknown.com/coder/coder";
174+
const state = await runTerraformApply(import.meta.dir, {
175+
agent_id: "foo",
176+
base_dir: "/tmp",
177+
url,
178+
});
179+
expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
180+
expect(state.outputs.clone_url.value).toEqual(url);
181+
expect(state.outputs.web_url.value).toEqual(url);
182+
expect(state.outputs.branch_name.value).toEqual("");
183+
});
184+
185+
it("runs with github clone with switch to feat/branch", async () => {
186+
const state = await runTerraformApply(import.meta.dir, {
187+
agent_id: "foo",
188+
url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
189+
});
190+
const output = await executeScriptInContainer(state, "alpine/git");
191+
expect(output.exitCode).toBe(0);
192+
expect(output.stdout).toEqual([
193+
"Creating directory ~/repo-tests.log...",
194+
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
195+
]);
196+
});
197+
198+
it("runs with gitlab clone with switch to feat/branch", async () => {
199+
const state = await runTerraformApply(import.meta.dir, {
200+
agent_id: "foo",
201+
url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
202+
});
203+
const output = await executeScriptInContainer(state, "alpine/git");
204+
expect(output.exitCode).toBe(0);
205+
expect(output.stdout).toEqual([
206+
"Creating directory ~/repo-tests.log...",
207+
"Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
208+
]);
209+
});
210+
211+
it("runs with github clone with branch_name set to feat/branch", async () => {
212+
const url = "https://github.com/michaelbrewer/repo-tests.log";
213+
const branch_name = "feat/branch";
214+
const state = await runTerraformApply(import.meta.dir, {
215+
agent_id: "foo",
216+
url,
217+
branch_name,
218+
});
219+
expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
220+
expect(state.outputs.clone_url.value).toEqual(url);
221+
expect(state.outputs.web_url.value).toEqual(url);
222+
expect(state.outputs.branch_name.value).toEqual(branch_name);
223+
224+
const output = await executeScriptInContainer(state, "alpine/git");
225+
expect(output.exitCode).toBe(0);
226+
expect(output.stdout).toEqual([
227+
"Creating directory ~/repo-tests.log...",
228+
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
229+
]);
230+
});
39231
});

git-clone/main.tf

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,88 @@ variable "agent_id" {
2525
type = string
2626
}
2727

28+
variable "git_providers" {
29+
type = map(object({
30+
provider = string
31+
}))
32+
description = "A mapping of URLs to their git provider."
33+
default = {
34+
"https://github.com/" = {
35+
provider = "github"
36+
},
37+
"https://gitlab.com/" = {
38+
provider = "gitlab"
39+
},
40+
}
41+
validation {
42+
error_message = "Allowed values for provider are \"github\" or \"gitlab\"."
43+
condition = alltrue([for provider in var.git_providers : contains(["github", "gitlab"], provider.provider)])
44+
}
45+
}
46+
47+
variable "branch_name" {
48+
description = "The branch name to clone. If not provided, the default branch will be cloned."
49+
type = string
50+
default = ""
51+
}
52+
2853
locals {
29-
clone_path = var.base_dir != "" ? join("/", [var.base_dir, replace(basename(var.url), ".git", "")]) : join("/", ["~", replace(basename(var.url), ".git", "")])
54+
# Remove query parameters and fragments from the URL
55+
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
56+
57+
# Find the git provider based on the URL and determine the tree path
58+
provider_key = try(one([for key in keys(var.git_providers) : key if startswith(local.url, key)]), null)
59+
provider = try(lookup(var.git_providers, local.provider_key).provider, "")
60+
tree_path = local.provider == "gitlab" ? "/-/tree/" : local.provider == "github" ? "/tree/" : ""
61+
62+
# Remove tree and branch name from the URL
63+
clone_url = var.branch_name == "" && local.tree_path != "" ? replace(local.url, "/${local.tree_path}.*/", "") : local.url
64+
# Extract the branch name from the URL
65+
branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
66+
# Extract the folder name from the URL
67+
folder_name = replace(basename(local.clone_url), ".git", "")
68+
# Construct the path to clone the repository
69+
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
70+
# Construct the web URL
71+
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
3072
}
3173

3274
output "repo_dir" {
3375
value = local.clone_path
3476
description = "Full path of cloned repo directory"
3577
}
3678

79+
output "git_provider" {
80+
value = local.provider
81+
description = "The git provider of the repository"
82+
}
83+
84+
output "folder_name" {
85+
value = local.folder_name
86+
description = "The name of the folder that will be created"
87+
}
88+
89+
output "clone_url" {
90+
value = local.clone_url
91+
description = "The exact Git repository URL that will be cloned"
92+
}
93+
94+
output "web_url" {
95+
value = local.web_url
96+
description = "Git https repository URL (may be invalid for unsupported providers)"
97+
}
98+
99+
output "branch_name" {
100+
value = local.branch_name
101+
description = "Git branch name (may be empty)"
102+
}
103+
37104
resource "coder_script" "git_clone" {
38105
agent_id = var.agent_id
39106
script = templatefile("${path.module}/run.sh", {
40-
CLONE_PATH = local.clone_path
41-
REPO_URL : var.url,
107+
CLONE_PATH = local.clone_path,
108+
REPO_URL : local.clone_url,
109+
BRANCH_NAME : local.branch_name,
42110
})
43111
display_name = "Git Clone"
44112
icon = "/icon/git.svg"

0 commit comments

Comments
 (0)