ref: create git repos with .git extension

Ayman Bagabas created

Bare repos should have a '.git' extension.
This also uses Soft Serve own git middleware.

Change summary

config/git.go          |  24 +++++---
git/errors.go          |   4 
git/repo.go            |   2 
git/types.go           |   9 +++
server/git/ssh.go      | 117 ++++++++++++++++++++++---------------------
server/git/ssh_test.go |  18 +-----
server/server_test.go  |  48 ++++++++---------
7 files changed, 113 insertions(+), 109 deletions(-)

Detailed changes

config/git.go 🔗

@@ -53,6 +53,11 @@ func (rs *RepoSource) open(path string) (*Repo, error) {
 	return r, nil
 }
 
+// IsBare returns true if the repository is a bare repository.
+func (r *Repo) IsBare() bool {
+	return r.repository.IsBare
+}
+
 // IsPrivate returns true if the repository is private.
 func (r *Repo) IsPrivate() bool {
 	return r.private
@@ -65,13 +70,13 @@ func (r *Repo) Path() string {
 
 // Repo returns the repository directory name.
 func (r *Repo) Repo() string {
-	return filepath.Base(r.path)
+	return strings.TrimSuffix(filepath.Base(r.path), ".git")
 }
 
 // Name returns the name of the repository.
 func (r *Repo) Name() string {
 	if r.name == "" {
-		return strings.TrimSuffix(r.Repo(), ".git")
+		return r.Repo()
 	}
 	return r.name
 }
@@ -226,14 +231,16 @@ func (rs *RepoSource) LoadRepo(name string) error {
 	rp := filepath.Join(rs.Path, name)
 	if _, err := os.Stat(rp); os.IsNotExist(err) {
 		rp += ".git"
-	} else {
-		log.Printf("warning: %q should be renamed to %q", rp, rp+".git")
 	}
 	r, err := rs.open(rp)
 	if err != nil {
-		log.Printf("error opening repository %q: %s", rp, err)
 		return err
 	}
+	if !r.IsBare() {
+		log.Printf("warning: %q is not a bare repository", rp)
+	} else if r.IsBare() && !strings.HasSuffix(rp, ".git") {
+		log.Printf("warning: %q should be renamed to %q", rp, rp+".git")
+	}
 	rs.repos[name] = r
 	return nil
 }
@@ -249,13 +256,10 @@ func (rs *RepoSource) LoadRepos() error {
 			log.Printf("warning: %q is not a directory", filepath.Join(rs.Path, de.Name()))
 			continue
 		}
-		err = rs.LoadRepo(de.Name())
-		if err == git.ErrNotAGitRepository {
+		if err := rs.LoadRepo(de.Name()); err != nil {
+			log.Printf("error opening repository %q: %s", de.Name(), err)
 			continue
 		}
-		if err != nil {
-			return err
-		}
 	}
 	return nil
 }

git/errors.go 🔗

@@ -11,8 +11,8 @@ var (
 	ErrFileNotFound = errors.New("file not found")
 	// ErrDirectoryNotFound is returned when a directory is not found.
 	ErrDirectoryNotFound = errors.New("directory not found")
-	// ErrReferenceNotFound is returned when a reference is not found.
-	ErrReferenceNotFound = errors.New("reference not found")
+	// ErrReferenceNotExist is returned when a reference does not exist.
+	ErrReferenceNotExist = git.ErrReferenceNotExist
 	// ErrRevisionNotExist is returned when a revision is not found.
 	ErrRevisionNotExist = git.ErrRevisionNotExist
 	// ErrNotAGitRepository is returned when the given path is not a Git repository.

git/repo.go 🔗

@@ -74,7 +74,7 @@ func (r *Repository) Name() string {
 
 // HEAD returns the HEAD reference for a repository.
 func (r *Repository) HEAD() (*Reference, error) {
-	rn, err := r.SymbolicRef()
+	rn, err := r.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"})
 	if err != nil {
 		return nil, err
 	}

git/types.go 🔗

@@ -0,0 +1,9 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// CommandOptions contain options for running a git command.
+type CommandOptions = git.CommandOptions
+
+// CloneOptions contain options for cloning a repository.
+type CloneOptions = git.CloneOptions

server/git/ssh.go 🔗

@@ -9,10 +9,9 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
-	"github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing"
 )
 
 // ErrNotAuthed represents unauthorized access.
@@ -75,55 +74,63 @@ type Hooks interface {
 func Middleware(repoDir string, gh Hooks) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
-			cmd := s.Command()
-			if len(cmd) == 2 {
-				gc := cmd[0]
-				// repo should be in the form of "repo.git"
-				repo := strings.TrimPrefix(cmd[1], "/")
-				repo = filepath.Clean(repo)
-				if strings.Contains(repo, "/") {
-					Fatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
-				}
-				// git bare repositories should end in ".git"
-				// https://git-scm.com/docs/gitrepository-layout
-				if !strings.HasSuffix(repo, ".git") {
-					repo += ".git"
-				}
-				pk := s.PublicKey()
-				access := gh.AuthRepo(repo, pk)
-				switch gc {
-				case "git-receive-pack":
-					switch access {
-					case ReadWriteAccess, AdminAccess:
-						err := gitPack(s, gc, repoDir, repo)
-						if err != nil {
-							Fatal(s, ErrSystemMalfunction)
-						} else {
-							gh.Push(repo, pk)
-						}
-					default:
-						Fatal(s, ErrNotAuthed)
+			func() {
+				cmd := s.Command()
+				if len(cmd) == 2 {
+					gc := cmd[0]
+					// repo should be in the form of "repo.git"
+					repo := strings.TrimPrefix(cmd[1], "/")
+					repo = filepath.Clean(repo)
+					if strings.Contains(repo, "/") {
+						log.Printf("invalid repo: %s", repo)
+						Fatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
+						return
+					}
+					pk := s.PublicKey()
+					access := gh.AuthRepo(strings.TrimSuffix(repo, ".git"), pk)
+					// git bare repositories should end in ".git"
+					// https://git-scm.com/docs/gitrepository-layout
+					if !strings.HasSuffix(repo, ".git") {
+						repo += ".git"
 					}
-					return
-				case "git-upload-archive", "git-upload-pack":
-					switch access {
-					case ReadOnlyAccess, ReadWriteAccess, AdminAccess:
-						err := gitPack(s, gc, repoDir, repo)
-						switch err {
-						case ErrInvalidRepo:
-							Fatal(s, ErrInvalidRepo)
-						case nil:
-							gh.Fetch(repo, pk)
+					switch gc {
+					case "git-receive-pack":
+						switch access {
+						case ReadWriteAccess, AdminAccess:
+							err := gitPack(s, gc, repoDir, repo)
+							if err != nil {
+								Fatal(s, ErrSystemMalfunction)
+							} else {
+								gh.Push(repo, pk)
+							}
 						default:
-							log.Printf("unknown git error: %s", err)
-							Fatal(s, ErrSystemMalfunction)
+							Fatal(s, ErrNotAuthed)
 						}
-					default:
-						Fatal(s, ErrNotAuthed)
+						return
+					case "git-upload-archive", "git-upload-pack":
+						switch access {
+						case ReadOnlyAccess, ReadWriteAccess, AdminAccess:
+							// try to upload <repo>.git first, then <repo>
+							err := gitPack(s, gc, repoDir, repo)
+							if err != nil {
+								err = gitPack(s, gc, repoDir, strings.TrimSuffix(repo, ".git"))
+							}
+							switch err {
+							case ErrInvalidRepo:
+								Fatal(s, ErrInvalidRepo)
+							case nil:
+								gh.Fetch(repo, pk)
+							default:
+								log.Printf("unknown git error: %s", err)
+								Fatal(s, ErrSystemMalfunction)
+							}
+						default:
+							Fatal(s, ErrNotAuthed)
+						}
+						return
 					}
-					return
 				}
-			}
+			}()
 			sh(s)
 		}
 	}
@@ -199,7 +206,7 @@ func ensureRepo(dir string, repo string) error {
 		return err
 	}
 	if !exists {
-		_, err := git.PlainInit(rp, true)
+		_, err := git.Init(rp, true)
 		if err != nil {
 			return err
 		}
@@ -219,7 +226,7 @@ func runGit(s ssh.Session, dir string, args ...string) error {
 }
 
 func ensureDefaultBranch(s ssh.Session, repoPath string) error {
-	r, err := git.PlainOpen(repoPath)
+	r, err := git.Open(repoPath)
 	if err != nil {
 		return err
 	}
@@ -227,20 +234,18 @@ func ensureDefaultBranch(s ssh.Session, repoPath string) error {
 	if err != nil {
 		return err
 	}
-	defer brs.Close()
-	fb, err := brs.Next()
-	if err != nil {
-		return err
+	if len(brs) == 0 {
+		return fmt.Errorf("no branches found")
 	}
 	// Rename the default branch to the first branch available
-	_, err = r.Head()
-	if err == plumbing.ErrReferenceNotFound {
-		err = runGit(s, repoPath, "branch", "-M", fb.Name().Short())
+	_, err = r.HEAD()
+	if err == git.ErrReferenceNotExist {
+		err = runGit(s, repoPath, "branch", "-M", brs[0])
 		if err != nil {
 			return err
 		}
 	}
-	if err != nil && err != plumbing.ErrReferenceNotFound {
+	if err != nil && err != git.ErrReferenceNotExist {
 		return err
 	}
 	return nil

server/git/ssh_test.go 🔗

@@ -128,9 +128,7 @@ func runGitHelper(t *testing.T, pk, cwd string, args ...string) error {
 	cmd.Dir = cwd
 	cmd.Env = []string{fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i %s -F /dev/null`, pk)}
 	out, err := cmd.CombinedOutput()
-	if err != nil {
-		t.Log("git out:", string(out))
-	}
+	t.Log("git out:", string(out))
 	return err
 }
 
@@ -154,15 +152,7 @@ func requireHasAction(t *testing.T, actions []action, key ssh.PublicKey, repo st
 	t.Helper()
 
 	for _, action := range actions {
-		r1 := repo
-		if !strings.HasSuffix(r1, ".git") {
-			r1 += ".git"
-		}
-		r2 := action.repo
-		if !strings.HasSuffix(r2, ".git") {
-			r2 += ".git"
-		}
-		if r1 == r2 && ssh.KeysEqual(key, action.key) {
+		if repo == strings.TrimSuffix(action.repo, ".git") && ssh.KeysEqual(key, action.key) {
 			return
 		}
 	}
@@ -203,9 +193,7 @@ type testHooks struct {
 
 func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) AccessLevel {
 	for _, dets := range h.access {
-		r1 := strings.TrimSuffix(dets.repo, ".git")
-		r2 := strings.TrimSuffix(repo, ".git")
-		if r1 == r2 && ssh.KeysEqual(key, dets.key) {
+		if dets.repo == repo && ssh.KeysEqual(key, dets.key) {
 			return dets.level
 		}
 	}

server/server_test.go 🔗

@@ -2,11 +2,11 @@ package server
 
 import (
 	"fmt"
-	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/charmbracelet/keygen"
+	ggit "github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/gliderlabs/ssh"
 	"github.com/go-git/go-git/v5"
@@ -18,32 +18,21 @@ import (
 )
 
 var (
-	testdata = "testdata"
-	cfg      = &config.Config{
+	cfg = &config.Config{
 		BindAddr: "",
 		Host:     "localhost",
 		Port:     22222,
-		RepoPath: fmt.Sprintf("%s/repos", testdata),
-		KeyPath:  fmt.Sprintf("%s/key", testdata),
 	}
-	pkPath = ""
 )
 
-func TestServer(t *testing.T) {
-	t.Cleanup(func() {
-		os.RemoveAll(testdata)
-	})
+func TestPushRepo(t *testing.T) {
 	is := is.New(t)
-	_, pkPath = createKeyPair(t)
+	_, pkPath := createKeyPair(t)
 	s := setupServer(t)
+	defer s.Close()
 	err := s.Reload()
 	is.NoErr(err)
-	t.Run("TestPushRepo", testPushRepo)
-	t.Run("TestCloneRepo", testCloneRepo)
-}
 
-func testPushRepo(t *testing.T) {
-	is := is.New(t)
 	rp := t.TempDir()
 	r, err := git.PlainInit(rp, false)
 	is.NoErr(err)
@@ -79,22 +68,31 @@ func testPushRepo(t *testing.T) {
 	is.NoErr(err)
 }
 
-func testCloneRepo(t *testing.T) {
+func TestCloneRepo(t *testing.T) {
 	is := is.New(t)
-	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
+	_, pkPath := createKeyPair(t)
+	s := setupServer(t)
+	defer s.Close()
+	err := s.Reload()
 	is.NoErr(err)
-	auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
-		HostKeyCallback: cssh.InsecureIgnoreHostKey(),
-	}
+
 	dst := t.TempDir()
-	_, err = git.PlainClone(dst, false, &git.CloneOptions{
-		URL:  fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port),
-		Auth: auth,
+	url := fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port)
+	err = ggit.Clone(url, dst, ggit.CloneOptions{
+		CommandOptions: ggit.CommandOptions{
+			Envs: []string{
+				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i %s -F /dev/null`, pkPath),
+			},
+		},
 	})
 	is.NoErr(err)
 }
 
 func setupServer(t *testing.T) *Server {
+	t.Helper()
+	tmpdir := t.TempDir()
+	cfg.RepoPath = filepath.Join(tmpdir, "repos")
+	cfg.KeyPath = filepath.Join(tmpdir, "key")
 	s := NewServer(cfg)
 	go func() {
 		s.Start()
@@ -106,8 +104,8 @@ func setupServer(t *testing.T) *Server {
 }
 
 func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
-	is := is.New(t)
 	t.Helper()
+	is := is.New(t)
 	keyDir := t.TempDir()
 	kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
 	is.NoErr(err)