Skip to content

Commit 2e3d334

Browse files
authored
Add support for git metadata on deployment resource + data source (#387)
* Support meta field on deployment resource Closes #292 * Add support for git metadata on deployment resource + data source
1 parent e7dd870 commit 2e3d334

File tree

6 files changed

+561
-29
lines changed

6 files changed

+561
-29
lines changed

client/deployment.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ type gitSource struct {
3030
Ref string `json:"ref"`
3131
}
3232

33+
// GitMetadata mirrors the fields used by the Vercel CLI when sending gitMetadata
34+
// to the deployments API. All fields are optional and omitted when empty.
35+
type GitMetadata struct {
36+
CommitAuthorName string `json:"commitAuthorName,omitempty"`
37+
CommitAuthorEmail string `json:"commitAuthorEmail,omitempty"`
38+
CommitMessage string `json:"commitMessage,omitempty"`
39+
CommitRef string `json:"commitRef,omitempty"`
40+
CommitSha string `json:"commitSha,omitempty"`
41+
Dirty bool `json:"dirty,omitempty"`
42+
RemoteUrl string `json:"remoteUrl,omitempty"`
43+
}
44+
3345
// CreateDeploymentRequest defines the request the Vercel API expects in order to create a deployment.
3446
type CreateDeploymentRequest struct {
3547
Files []DeploymentFile `json:"files,omitempty"`
@@ -47,6 +59,7 @@ type CreateDeploymentRequest struct {
4759
GitSource *gitSource `json:"gitSource,omitempty"`
4860
CustomEnvironmentSlugOrID string `json:"customEnvironmentSlugOrId,omitempty"`
4961
Meta map[string]string `json:"meta,omitempty"`
62+
GitMetadata *GitMetadata `json:"gitMetadata,omitempty"`
5063
Ref string `json:"-"`
5164
}
5265

vercel/data_source_deployment_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package vercel_test
22

33
import (
44
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
58
"testing"
69

710
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
@@ -124,3 +127,80 @@ data "vercel_deployment" "test" {
124127
}
125128
`, name)
126129
}
130+
131+
// runGitDS executes a git command in the given directory for the data source tests.
132+
func runGitDS(t *testing.T, dir string, args ...string) {
133+
t.Helper()
134+
cmd := exec.Command("git", args...)
135+
cmd.Dir = dir
136+
cmd.Env = os.Environ()
137+
out, err := cmd.CombinedOutput()
138+
if err != nil {
139+
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
140+
}
141+
}
142+
143+
func TestAcc_DeploymentDataSource_GitMetadata(t *testing.T) {
144+
name := acctest.RandString(8)
145+
146+
// Prepare a real git repo in the examples/one directory
147+
repoDir := filepath.Join("..", "vercel", "examples", "one")
148+
_ = os.RemoveAll(filepath.Join(repoDir, ".git"))
149+
runGitDS(t, repoDir, "init")
150+
runGitDS(t, repoDir, "checkout", "-b", "main")
151+
runGitDS(t, repoDir, "config", "user.email", "test@example.com")
152+
runGitDS(t, repoDir, "config", "user.name", "Test User")
153+
runGitDS(t, repoDir, "add", ".")
154+
runGitDS(t, repoDir, "commit", "-m", "e2e: git metadata ds test")
155+
runGitDS(t, repoDir, "remote", "add", "origin", fmt.Sprintf("https://github.com/%s", testGithubRepo(t)))
156+
t.Cleanup(func() {
157+
_ = os.RemoveAll(filepath.Join(repoDir, ".git"))
158+
})
159+
160+
cfgHCL := fmt.Sprintf(`
161+
resource "vercel_project" "test" {
162+
name = "test-acc-deployment-ds-gitmeta-%[1]s"
163+
git_repository = {
164+
type = "github"
165+
repo = "%[2]s"
166+
}
167+
}
168+
169+
data "vercel_project_directory" "test" {
170+
path = "%[3]s"
171+
}
172+
173+
resource "vercel_deployment" "test" {
174+
project_id = vercel_project.test.id
175+
files = data.vercel_project_directory.test.files
176+
path_prefix = data.vercel_project_directory.test.path
177+
}
178+
179+
data "vercel_deployment" "by_id" {
180+
id = vercel_deployment.test.id
181+
}
182+
183+
data "vercel_deployment" "by_url" {
184+
id = vercel_deployment.test.url
185+
}
186+
`, name, testGithubRepo(t), filepath.Clean(repoDir))
187+
188+
resource.Test(t, resource.TestCase{
189+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
190+
Steps: []resource.TestStep{
191+
{
192+
Config: cfg(cfgHCL),
193+
Check: resource.ComposeAggregateTestCheckFunc(
194+
// by_id assertions
195+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_id", "id"),
196+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_id", "meta.githubCommitSha"),
197+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_id", "meta.githubCommitMessage"),
198+
// by_url assertions
199+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_url", "id"),
200+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_url", "meta.githubCommitSha"),
201+
resource.TestCheckResourceAttrSet("data.vercel_deployment.by_url", "meta.githubCommitMessage"),
202+
),
203+
},
204+
},
205+
})
206+
}

vercel/git_metadata.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package vercel
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"sort"
11+
"strings"
12+
"time"
13+
14+
"github.com/hashicorp/terraform-plugin-log/tflog"
15+
"github.com/vercel/terraform-provider-vercel/v3/client"
16+
)
17+
18+
// findRepoRoot traverses upward from startDir until it finds either
19+
// - .vercel/repo.json (treat as repo root), OR
20+
// - .git/config (or a .git file for worktrees/submodules)
21+
// Returns the directory path or empty string if not found.
22+
func findRepoRoot(startDir string) string {
23+
curr := startDir
24+
for {
25+
if curr == "/" || curr == "." || len(curr) == 0 {
26+
return ""
27+
}
28+
29+
// .vercel/repo.json
30+
if fileExists(filepath.Join(curr, ".vercel", "repo.json")) || fileExists(filepath.Join(curr, ".now", "repo.json")) {
31+
return curr
32+
}
33+
// .git/config directory or .git file
34+
if fileExists(filepath.Join(curr, ".git", "config")) || fileExists(filepath.Join(curr, ".git")) {
35+
return curr
36+
}
37+
38+
parent := filepath.Dir(curr)
39+
if parent == curr {
40+
return ""
41+
}
42+
curr = parent
43+
}
44+
}
45+
46+
func fileExists(path string) bool {
47+
info, err := os.Lstat(path)
48+
if err != nil {
49+
return false
50+
}
51+
return info.Mode()&os.ModeSymlink != 0 || info.IsDir() || info.Mode().IsRegular()
52+
}
53+
54+
// runGit executes a git command in cwd with the given timeout.
55+
func runGit(ctx context.Context, cwd string, timeout time.Duration, args ...string) (string, error) {
56+
cctx, cancel := context.WithTimeout(ctx, timeout)
57+
defer cancel()
58+
cmd := exec.CommandContext(cctx, "git", args...)
59+
cmd.Dir = cwd
60+
var stdout, stderr bytes.Buffer
61+
cmd.Stdout = &stdout
62+
cmd.Stderr = &stderr
63+
err := cmd.Run()
64+
if cctx.Err() == context.DeadlineExceeded {
65+
return "", fmt.Errorf("git %v timed out", args)
66+
}
67+
if err != nil {
68+
if stderr.Len() > 0 {
69+
return "", fmt.Errorf("git %v failed: %s", args, strings.TrimSpace(stderr.String()))
70+
}
71+
return "", err
72+
}
73+
return strings.TrimSpace(stdout.String()), nil
74+
}
75+
76+
// collectGitMetadata collects Git metadata from repoRoot, preferring a remote url that contains
77+
// linkRepo (e.g. "org/repo") when provided.
78+
func collectGitMetadata(ctx context.Context, repoRoot string, linkRepo string) (*client.GitMetadata, error) {
79+
if repoRoot == "" {
80+
return nil, nil
81+
}
82+
// Determine remote URL(s)
83+
remoteURL := ""
84+
remotesOut, err := runGit(ctx, repoRoot, 2*time.Second, "remote", "-v")
85+
if err == nil && remotesOut != "" {
86+
lines := strings.Split(remotesOut, "\n")
87+
remoteMap := map[string]string{}
88+
for _, line := range lines {
89+
parts := strings.Fields(line)
90+
if len(parts) >= 2 {
91+
name := parts[0]
92+
url := parts[1]
93+
// Only set if not set yet to prefer first occurrence
94+
if _, ok := remoteMap[name]; !ok {
95+
remoteMap[name] = url
96+
}
97+
}
98+
}
99+
if linkRepo != "" {
100+
for _, url := range remoteMap {
101+
if strings.Contains(url, linkRepo) {
102+
remoteURL = url
103+
break
104+
}
105+
}
106+
}
107+
if remoteURL == "" {
108+
if u, ok := remoteMap["origin"]; ok {
109+
remoteURL = u
110+
} else {
111+
// pick any deterministic remote (alphabetical)
112+
keys := make([]string, 0, len(remoteMap))
113+
for k := range remoteMap {
114+
keys = append(keys, k)
115+
}
116+
sort.Strings(keys)
117+
if len(keys) > 0 {
118+
remoteURL = remoteMap[keys[0]]
119+
}
120+
}
121+
}
122+
}
123+
if remoteURL == "" {
124+
// Fallback to git config get origin
125+
if u, err := runGit(ctx, repoRoot, 2*time.Second, "config", "--get", "remote.origin.url"); err == nil {
126+
remoteURL = u
127+
}
128+
}
129+
130+
// Get last commit info
131+
an, err1 := runGit(ctx, repoRoot, 2*time.Second, "log", "-1", "--pretty=%an")
132+
ae, err2 := runGit(ctx, repoRoot, 2*time.Second, "log", "-1", "--pretty=%ae")
133+
sub, err3 := runGit(ctx, repoRoot, 2*time.Second, "log", "-1", "--pretty=%s")
134+
branch, err4 := runGit(ctx, repoRoot, 2*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
135+
sha, err5 := runGit(ctx, repoRoot, 2*time.Second, "rev-parse", "HEAD")
136+
137+
// Dirty state
138+
dirtyOut, err6 := runGit(ctx, repoRoot, 2*time.Second, "--no-optional-locks", "status", "-s")
139+
if err6 != nil {
140+
// fallback without flag
141+
dirtyOut, _ = runGit(ctx, repoRoot, 2*time.Second, "status", "-s")
142+
}
143+
144+
// If commit info or dirty check failed entirely, skip metadata (mirror CLI best-effort)
145+
if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil {
146+
return nil, nil
147+
}
148+
149+
gm := &client.GitMetadata{
150+
CommitAuthorName: an,
151+
CommitAuthorEmail: ae,
152+
CommitMessage: sub,
153+
CommitRef: branch,
154+
CommitSha: sha,
155+
Dirty: strings.TrimSpace(dirtyOut) != "",
156+
}
157+
if remoteURL != "" {
158+
gm.RemoteUrl = remoteURL
159+
}
160+
return gm, nil
161+
}
162+
163+
// startingDirsFromFiles returns candidate starting directories ordered by shallowness
164+
// (fewest path separators). It resolves absolute path and symlinks.
165+
func startingDirsFromFiles(files []client.DeploymentFile) []string {
166+
type cand struct {
167+
dir string
168+
depth int
169+
}
170+
seen := map[string]struct{}{}
171+
cands := []cand{}
172+
for _, f := range files {
173+
if f.File == "" { // skip
174+
continue
175+
}
176+
abs, err := filepath.Abs(f.File)
177+
if err != nil {
178+
continue
179+
}
180+
abs, _ = filepath.EvalSymlinks(abs)
181+
dir := filepath.Dir(abs)
182+
if _, ok := seen[dir]; ok {
183+
continue
184+
}
185+
seen[dir] = struct{}{}
186+
depth := strings.Count(filepath.ToSlash(dir), "/")
187+
cands = append(cands, cand{dir: dir, depth: depth})
188+
}
189+
sort.Slice(cands, func(i, j int) bool { return cands[i].depth < cands[j].depth })
190+
out := make([]string, 0, len(cands))
191+
for _, c := range cands {
192+
out = append(out, c.dir)
193+
}
194+
return out
195+
}
196+
197+
// detectRepoRootFromFiles picks a small number of shallowest candidates and tries to
198+
// discover a repo root by traversing upwards from each.
199+
func detectRepoRootFromFiles(files []client.DeploymentFile) string {
200+
cands := startingDirsFromFiles(files)
201+
limit := min(len(cands), 5)
202+
for i := range limit {
203+
root := findRepoRoot(cands[i])
204+
if root != "" {
205+
return root
206+
}
207+
}
208+
return ""
209+
}
210+
211+
// getLinkRepo returns the "org/repo" string for a linked project when available.
212+
func getLinkRepo(pr client.ProjectResponse) string {
213+
if pr.Link == nil {
214+
return ""
215+
}
216+
switch pr.Link.Type {
217+
case "github":
218+
return fmt.Sprintf("%s/%s", pr.Link.Org, pr.Link.Repo)
219+
case "gitlab":
220+
if pr.Link.ProjectNamespace != "" && pr.Link.ProjectURL != "" {
221+
// For GitLab the CLI matches by org/repo; construct a best-effort "namespace/repo"
222+
// repo name inferred from URL is handled elsewhere in client.Repository(), but here
223+
// a simple contains match on namespace is still useful, so return namespace.
224+
return pr.Link.ProjectNamespace
225+
}
226+
case "bitbucket":
227+
if pr.Link.Owner != "" && pr.Link.Slug != "" {
228+
return fmt.Sprintf("%s/%s", pr.Link.Owner, pr.Link.Slug)
229+
}
230+
}
231+
return ""
232+
}
233+
234+
// prepareGitMetadata best-effort detects repo root and collects git metadata.
235+
func prepareGitMetadata(ctx context.Context, files []client.DeploymentFile, _ string, project client.ProjectResponse) *client.GitMetadata {
236+
var startRoot string
237+
if len(files) > 0 {
238+
startRoot = detectRepoRootFromFiles(files)
239+
}
240+
if startRoot == "" {
241+
// Ref-only deployments or failed detection: try from current working dir
242+
cwd, _ := os.Getwd()
243+
startRoot = findRepoRoot(cwd)
244+
}
245+
if startRoot == "" {
246+
return nil
247+
}
248+
linkRepo := getLinkRepo(project)
249+
gm, err := collectGitMetadata(ctx, startRoot, linkRepo)
250+
if err != nil {
251+
// Log and proceed without metadata
252+
tflog.Debug(ctx, fmt.Sprintf("skipping git metadata: %v", err))
253+
return nil
254+
}
255+
if gm != nil {
256+
short := gm.CommitSha
257+
if len(short) > 7 {
258+
short = short[:7]
259+
}
260+
tflog.Debug(ctx, fmt.Sprintf("attached git metadata (sha=%s, ref=%s, dirty=%t)", short, gm.CommitRef, gm.Dirty))
261+
}
262+
return gm
263+
}

0 commit comments

Comments
 (0)