Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: update git repo #347

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion envbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro

fallbackErr = git.ShallowCloneRepo(ctx, logStage, cloneOpts)
if fallbackErr == nil {
endStage("📦 Cloned repository!")
endStage("📦 Cloned repository")
buildTimeWorkspaceFolder = cloneOpts.Path
} else {
opts.Logger(log.LevelError, "Failed to clone repository for remote repo mode: %s", fallbackErr.Error())
Expand Down
124 changes: 115 additions & 9 deletions git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
"os"
"strings"

"github.com/coder/envbuilder/log"
"github.com/coder/envbuilder/options"

giturls "github.com/chainguard-dev/git-urls"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
Expand All @@ -31,6 +33,7 @@ import (
type CloneRepoOptions struct {
Path string
Storage billy.Filesystem
Logger log.Func

RepoURL string
RepoAuth transport.AuthMethod
Expand All @@ -46,13 +49,15 @@ type CloneRepoOptions struct {
// If a repository is already initialized at the given path, it will not
// be cloned again.
//
// The string returned is the hash of the repository HEAD.
// The bool returned states whether the repository was cloned or not.
func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOptions) (bool, error) {
parsed, err := giturls.Parse(opts.RepoURL)
if err != nil {
return false, fmt.Errorf("parse url %q: %w", opts.RepoURL, err)
}
logf("Parsed Git URL as %q", parsed.Redacted())
opts.RepoURL = parsed.String()
if parsed.Hostname() == "dev.azure.com" {
// Azure DevOps requires capabilities multi_ack / multi_ack_detailed,
// which are not fully implemented and by default are included in
Expand Down Expand Up @@ -96,19 +101,19 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
return false, fmt.Errorf("chroot .git: %w", err)
}
gitStorage := filesystem.NewStorage(gitDir, cache.NewObjectLRU(cache.DefaultMaxSize*10))
fsStorage := filesystem.NewStorage(fs, cache.NewObjectLRU(cache.DefaultMaxSize*10))
repo, err := git.Open(fsStorage, gitDir)
if errors.Is(err, git.ErrRepositoryNotExists) {
err = nil
repo, err := git.Open(gitStorage, fs)
if err == nil {
// Repo exists, so fast-forward it.
return fastForwardRepo(ctx, logf, opts, repo, reference)
}
if err != nil {

// Something went wrong opening the repo.
if !errors.Is(err, git.ErrRepositoryNotExists) {
return false, fmt.Errorf("open %q: %w", opts.RepoURL, err)
}
if repo != nil {
return false, nil
}

_, err = git.CloneContext(ctx, gitStorage, fs, &git.CloneOptions{
// Repo does not exist, so clone it.
repo, err = git.CloneContext(ctx, gitStorage, fs, &git.CloneOptions{
URL: parsed.String(),
Auth: opts.RepoAuth,
Progress: opts.Progress,
Expand All @@ -125,13 +130,114 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
if err != nil {
return false, fmt.Errorf("clone %q: %w", opts.RepoURL, err)
}
if head, err := repoHead(repo); err != nil {
logf("failed to get repo HEAD: %s", err)
} else {
logf("cloned repo at %s", head)
}
return true, nil
}

func fastForwardRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOptions, repo *git.Repository, referenceName string) (bool, error) {
if referenceName == "" {
referenceName = "refs/heads/main"
}
ref := plumbing.ReferenceName(referenceName)
headBefore, err := repoHead(repo)
if err != nil {
return false, err
}
logf("existing repo HEAD: %s", headBefore)

remote, err := repo.Remote("origin")
if err != nil {
return false, fmt.Errorf("remote: %w", err)
}

if len(remote.Config().URLs) == 0 {
logf("remote %q has no URLs configured!", remote.String())
return false, nil
}

// If the remote URL differs, stop! We don't want to accidentally
// fetch from a different remote.
for _, u := range remote.Config().URLs {
if u != opts.RepoURL {
logf("remote %q has URL %q, expected %q", remote.String(), u, opts.RepoURL)
return false, nil
}
}

var refSpecs []gitconfig.RefSpec
if referenceName != "" {
// git checkout -b <branch> --track <remote>/<branch>
refSpecs = []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("%s:%s", referenceName, referenceName)),
}
}

if err := remote.FetchContext(ctx, &git.FetchOptions{
Auth: opts.RepoAuth,
CABundle: opts.CABundle,
Depth: opts.Depth,
Force: false,
InsecureSkipTLS: opts.Insecure,
Progress: opts.Progress,
ProxyOptions: opts.ProxyOptions,
RefSpecs: refSpecs,
RemoteName: "origin",
}); err != nil {
if err != git.NoErrAlreadyUpToDate {
return false, fmt.Errorf("fetch changes from remote: %w", err)
}
logf("repository is already up-to-date")
}

// Now that we've fetched changes from the remote,
// attempt to check out the requested reference.
wt, err := repo.Worktree()
if err != nil {
return false, fmt.Errorf("worktree: %w", err)
}
st, err := wt.Status()
if err != nil {
return false, fmt.Errorf("status: %w", err)
}
// If the working tree is dirty, assume that the user wishes to
// test local changes. Skip the checkout.
if !st.IsClean() {
logf("working tree is dirty, skipping checkout")
return false, nil
}
if err := wt.Checkout(&git.CheckoutOptions{
Branch: ref,
// Note: we already checked that the working tree is clean, but I'd rather
// err on the side of caution.
Keep: true,
}); err != nil {
return false, fmt.Errorf("checkout branch %s: %w", ref.String(), err)
}
headAfter, err := repoHead(repo)
if err != nil {
return false, fmt.Errorf("check repo HEAD: %w", err)
}
logf("checked out %s", headAfter)
return headBefore != headAfter, nil
}

func repoHead(repo *git.Repository) (string, error) {
head, err := repo.Head()
if err != nil {
return "", err
}
return head.Hash().String(), nil
}

// ShallowCloneRepo will clone the repository at the given URL into the given path
// with a depth of 1. If the destination folder exists and is not empty, the
// clone will not be performed.
//
// The string returned is the hash of the repository HEAD.
// The bool returned states whether the repository was cloned or not.
func ShallowCloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOptions) error {
opts.Depth = 1
Expand Down
79 changes: 67 additions & 12 deletions git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/go-git/go-billy/v5/osfs"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
)
Expand All @@ -39,6 +40,7 @@ func TestCloneRepo(t *testing.T) {
mungeURL func(*string)
expectError string
expectClone bool
prepFS func(billy.Filesystem)
}{
{
name: "no auth",
Expand Down Expand Up @@ -84,26 +86,36 @@ func TestCloneRepo(t *testing.T) {
// We do not overwrite a repo if one is already present.
t.Run("AlreadyCloned", func(t *testing.T) {
srvFS := memfs.New()
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))
clientFS := memfs.New()
// A repo already exists!
_ = gittest.NewRepo(t, clientFS)
_ = gittest.NewRepo(t, clientFS).Commit(gittest.Commit(t, "WRITEME.md", "I'm already here!", "Wow!"))
cloned, err := git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
Path: "/",
Path: "/workspace",
RepoURL: srv.URL,
Storage: clientFS,
RepoAuth: &githttp.BasicAuth{
Username: tc.username,
Password: tc.password,
},
})
require.NoError(t, err)
require.False(t, cloned)
if tc.expectError != "" {
assert.ErrorContains(t, err, tc.expectError)
}
assert.False(t, cloned)

// Ensure we do not overwrite the existing repo.
readme := mustRead(t, clientFS, "/workspace/WRITEME.md")
assert.Equal(t, "I'm already here!", readme)
})

// Basic Auth
t.Run("BasicAuth", func(t *testing.T) {
t.Parallel()
srvFS := memfs.New()
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))
clientFS := memfs.New()
Expand Down Expand Up @@ -136,7 +148,7 @@ func TestCloneRepo(t *testing.T) {
t.Run("InURL", func(t *testing.T) {
t.Parallel()
srvFS := memfs.New()
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))

Expand Down Expand Up @@ -174,7 +186,7 @@ func TestShallowCloneRepo(t *testing.T) {
t.Run("NotEmpty", func(t *testing.T) {
t.Parallel()
srvFS := memfs.New()
_ = gittest.NewRepo(t, srvFS,
_ = gittest.NewRepo(t, srvFS).Commit(
gittest.Commit(t, "README.md", "Hello, world!", "Many wow!"),
gittest.Commit(t, "foo", "bar!", "Such commit!"),
gittest.Commit(t, "baz", "qux", "V nice!"),
Expand Down Expand Up @@ -208,7 +220,7 @@ func TestShallowCloneRepo(t *testing.T) {

t.Parallel()
srvFS := memfs.New()
_ = gittest.NewRepo(t, srvFS,
_ = gittest.NewRepo(t, srvFS).Commit(
gittest.Commit(t, "README.md", "Hello, world!", "Many wow!"),
gittest.Commit(t, "foo", "bar!", "Such commit!"),
gittest.Commit(t, "baz", "qux", "V nice!"),
Expand All @@ -235,6 +247,49 @@ func TestShallowCloneRepo(t *testing.T) {
})
}

func TestFetchAfterClone(t *testing.T) {
t.Parallel()

ctx := context.Background()
// ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
// defer cancel()

// setup a git repo
srvDir := t.TempDir()
srvFS := osfs.New(srvDir)
repo := gittest.NewRepo(t, srvFS)
repo.Commit(gittest.Commit(t, "README.md", "Hello, worldd!", "initial commit"))
srv := httptest.NewServer(gittest.NewServer(srvFS))

// clone to a tempdir
clientDir := t.TempDir()
clientFS := osfs.New(clientDir)
cloned, err := git.CloneRepo(ctx, t.Logf, git.CloneRepoOptions{
Path: "/repo",
RepoURL: srv.URL,
Storage: clientFS,
})
require.NoError(t, err)
require.True(t, cloned)

// add some commits on the server
repo.Commit(gittest.Commit(t, "README.md", "Hello, world!", "fix typo"))

// run CloneRepo again
clonedAgain, err := git.CloneRepo(ctx, t.Logf, git.CloneRepoOptions{
Path: "/repo",
RepoURL: srv.URL,
Storage: clientFS,
})
require.NoError(t, err)
require.True(t, clonedAgain)
contentAfter, err := clientFS.Open("/repo/README.md")
require.NoError(t, err)
content, err := io.ReadAll(contentAfter)
require.NoError(t, err)
require.Equal(t, "Hello, world!", string(content), "expected client repo to be updated after fetch")
}

func TestCloneRepoSSH(t *testing.T) {
t.Parallel()

Expand All @@ -245,7 +300,7 @@ func TestCloneRepoSSH(t *testing.T) {
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
key := randKeygen(t)
tr := gittest.NewServerSSH(t, srvFS, key.PublicKey())
gitURL := tr.String()
Expand Down Expand Up @@ -276,7 +331,7 @@ func TestCloneRepoSSH(t *testing.T) {
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
key := randKeygen(t)
tr := gittest.NewServerSSH(t, srvFS, key.PublicKey())
gitURL := tr.String()
Expand Down Expand Up @@ -307,7 +362,7 @@ func TestCloneRepoSSH(t *testing.T) {
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
key := randKeygen(t)
tr := gittest.NewServerSSH(t, srvFS, key.PublicKey())
gitURL := tr.String()
Expand Down
6 changes: 3 additions & 3 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ func TestGitSSHAuth(t *testing.T) {
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())

_, err = runEnvbuilder(t, runOpts{env: []string{
Expand All @@ -456,7 +456,7 @@ func TestGitSSHAuth(t *testing.T) {
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
_ = gittest.NewRepo(t, srvFS).Commit(gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())

_, err = runEnvbuilder(t, runOpts{env: []string{
Expand Down Expand Up @@ -2085,7 +2085,7 @@ RUN echo $FOO > /root/foo.txt
RUN date --utc > /root/date.txt
`, testImageAlpine)

newServer := func(dockerfile string) *httptest.Server {
newServer := func(dockerfile string) *gittest.GitServer {
return gittest.CreateGitServer(t, gittest.Options{
Files: map[string]string{"Dockerfile": dockerfile},
})
Expand Down
Loading
Loading