test: smoke tests with testscript (#305)

Carlos Alexandro Becker and Ayman Bagabas created

* test: playing with testscript

* fix: multiple servers

* fix: -update flag

* test: user management tests

* test: fix config

* fix: ensure perms after clone

* fix: race condition

* fix: shutdown

* test: set-username

* test: repo collab

* test: repo mirror and other ops

* test: import repo

* test: repo create

* test: disable hooks on testscript

* test: random port: prevent port reuse

* test: wait for server

* fix: git with no user info

* test: no idea whats going on on windows

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: create keys on runtime

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore: organizing repo

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: remove if for windows

* Revert "fix: remove if for windows"

This reverts commit 5776fde194d675d25336967e5f89c8fd9a5e7b4f.

* chore: trying something out

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* refactor: use crypto/ssh instead of ssh binary

* fix: neg only the actual cmd

* fix: unix2dos on windows tests

* fix: unix2dos

* fix: skip hooks on tests

* fix: trainling whitespace

* chore: editorconfig and gitattributes

* test: maybe its not really needed?

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* Revert "test: maybe its not really needed?"

This reverts commit a704c5fbf17dad7bd34646950074b125a093ae31.

* fix: improve \r\n handling

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: crlf

* chore: trigger

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: disable unix2docs

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: validate repo

* chore: debug

* fix(test): convert dos crlf to lf (#311)

* fix(test): convert dos crlf to lf

* use temp files

* chore: log

* fix: ssh config

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: config

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix(ci): use build workflow from main

* fix: editorconfig

* fix: editorconfig

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* feat(test): add SanitizeRepo tests

* fix(test): sanitizerepo test

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>

Change summary

.editorconfig                             |  13 +
.gitattributes                            |   2 
.github/workflows/build.yml               |   2 
go.mod                                    |   4 
go.sum                                    |   8 
server/backend/sqlite/user.go             |   3 
server/cmd/repo.go                        |  14 +
server/hooks/hooks.go                     |  18 +
server/jobs.go                            |   1 
server/server.go                          |   7 
server/test/test.go                       |  22 ++
server/utils/utils.go                     |   6 
server/utils/utils_test.go                |  56 ++++++
testscript/script_test.go                 | 229 +++++++++++++++++++++++++
testscript/testdata/mirror.txtar          |  96 ++++++++++
testscript/testdata/repo-collab.txtar     |  18 +
testscript/testdata/repo-create.txtar     |  78 ++++++++
testscript/testdata/repo-import.txt       |  30 +++
testscript/testdata/set-username.txtar    |  24 ++
testscript/testdata/settings.txtar        |  31 +++
testscript/testdata/user_management.txtar | 105 +++++++++++
21 files changed, 744 insertions(+), 23 deletions(-)

Detailed changes

.editorconfig 🔗

@@ -0,0 +1,13 @@
+root = true
+
+[*]
+charset=utf-8
+end_of_line=lf
+insert_final_newline=true
+trim_trailing_whitespace=true
+indent_size=2
+indent_style=space
+
+[*.go]
+indent_size=4
+indent_style=tab

.gitattributes 🔗

@@ -0,0 +1,2 @@
+# To prevent CRLF breakages on Windows for fragile files, like testdata.
+* -text

.github/workflows/build.yml 🔗

@@ -25,4 +25,4 @@ jobs:
         run: go test -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt ./... -timeout 5m
       - uses: codecov/codecov-action@v3
         with:
-          file: ./coverage.txt
+          file: ./coverage.txt

go.mod 🔗

@@ -31,6 +31,7 @@ require (
 	github.com/muesli/roff v0.1.0
 	github.com/prometheus/client_golang v1.15.1
 	github.com/robfig/cron/v3 v3.0.1
+	github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97
 	github.com/spf13/cobra v1.7.0
 	go.uber.org/automaxprocs v1.5.2
 	goji.io v2.0.2+incompatible
@@ -73,12 +74,11 @@ require (
 	github.com/prometheus/procfs v0.9.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
-	github.com/rogpeppe/go-internal v1.10.0 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
-	golang.org/x/mod v0.8.0 // indirect
+	golang.org/x/mod v0.9.0 // indirect
 	golang.org/x/net v0.10.0 // indirect
 	golang.org/x/sys v0.8.0 // indirect
 	golang.org/x/term v0.8.0 // indirect

go.sum 🔗

@@ -153,8 +153,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY=
+github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
@@ -185,8 +185,8 @@ goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMI
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
 golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
-golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
+golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=

server/backend/sqlite/user.go 🔗

@@ -42,7 +42,8 @@ func (u *User) PublicKeys() []ssh.PublicKey {
 		if err := tx.Select(&keyStrings, `SELECT public_key
 			FROM public_key
 			INNER JOIN user ON user.id = public_key.user_id
-			WHERE user.username = ?;`, u.username); err != nil {
+			WHERE user.username = ?
+			ORDER BY public_key.id asc;`, u.username); err != nil {
 			return err
 		}
 

server/cmd/repo.go 🔗

@@ -1,6 +1,11 @@
 package cmd
 
-import "github.com/spf13/cobra"
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
 
 func repoCommand() *cobra.Command {
 	cmd := &cobra.Command{
@@ -53,9 +58,12 @@ func repoCommand() *cobra.Command {
 
 				branches, _ := r.Branches()
 				tags, _ := r.Tags()
-				cmd.Println("Project Name:", rr.ProjectName())
+
+				// project name and description are optional, handle trailing
+				// whitespace to avoid breaking tests.
+				cmd.Println(strings.TrimSpace(fmt.Sprint("Project Name: ", rr.ProjectName())))
 				cmd.Println("Repository:", rr.Name())
-				cmd.Println("Description:", rr.Description())
+				cmd.Println(strings.TrimSpace(fmt.Sprint("Description: ", rr.Description())))
 				cmd.Println("Private:", rr.IsPrivate())
 				cmd.Println("Hidden:", rr.IsHidden())
 				cmd.Println("Mirror:", rr.IsMirror())

server/hooks/hooks.go 🔗

@@ -3,6 +3,7 @@ package hooks
 import (
 	"bytes"
 	"context"
+	"flag"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -28,8 +29,13 @@ const (
 // - post-update
 //
 // This function should be called by the backend when a repository is created.
-// TODO: support context
-func GenerateHooks(ctx context.Context, cfg *config.Config, repo string) error {
+// TODO: support context.
+func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {
+	// TODO: support git hook tests.
+	if flag.Lookup("test.v") != nil {
+		log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test")
+		return nil
+	}
 	repo = utils.SanitizeRepo(repo) + ".git"
 	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
 	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
@@ -136,10 +142,9 @@ done
 `
 )
 
-var (
-	// hooksTmpl is the soft-serve hook that will be run by the git hooks
-	// inside the hooks directory.
-	hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
+// hooksTmpl is the soft-serve hook that will be run by the git hooks
+// inside the hooks directory.
+var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
 # AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
 if [ -z "$SOFT_SERVE_REPO_NAME" ]; then
 	echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks."
@@ -149,4 +154,3 @@ fi
 {{ $env }} \{{ end }}
 {{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
 `))
-)

server/jobs.go 🔗

@@ -51,7 +51,6 @@ func (s *Server) mirrorJob() func() {
 					if _, err := cmd.RunInDir(r.Path); err != nil {
 						logger.Error("error running git remote update", "repo", name, "err", err)
 					}
-
 				})
 			}
 		}

server/server.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 
 	"github.com/charmbracelet/log"
@@ -158,6 +159,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
 		s.Cron.Stop()
 		return nil
 	})
+	if closer, ok := s.Backend.(io.Closer); ok {
+		defer closer.Close() // nolint: errcheck
+	}
 	return errg.Wait()
 }
 
@@ -172,5 +176,8 @@ func (s *Server) Close() error {
 		s.Cron.Stop()
 		return nil
 	})
+	if closer, ok := s.Backend.(io.Closer); ok {
+		defer closer.Close() // nolint: errcheck
+	}
 	return errg.Wait()
 }

server/test/test.go 🔗

@@ -1,11 +1,29 @@
 package test
 
-import "net"
+import (
+	"net"
+	"sync"
+)
+
+var (
+	used = map[int]struct{}{}
+	lock sync.Mutex
+)
 
 // RandomPort returns a random port number.
 // This is mainly used for testing.
 func RandomPort() int {
 	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
 	_ = addr.Close()
-	return addr.Addr().(*net.TCPAddr).Port
+	port := addr.Addr().(*net.TCPAddr).Port
+	lock.Lock()
+
+	if _, ok := used[port]; ok {
+		lock.Unlock()
+		return RandomPort()
+	}
+
+	used[port] = struct{}{}
+	lock.Unlock()
+	return port
 }

server/utils/utils.go 🔗

@@ -2,7 +2,7 @@ package utils
 
 import (
 	"fmt"
-	"path/filepath"
+	"path"
 	"strings"
 	"unicode"
 )
@@ -10,7 +10,9 @@ import (
 // SanitizeRepo returns a sanitized version of the given repository name.
 func SanitizeRepo(repo string) string {
 	repo = strings.TrimPrefix(repo, "/")
-	repo = filepath.Clean(repo)
+	// We're using path instead of filepath here because this is not OS dependent
+	// looking at you Windows
+	repo = path.Clean(repo)
 	repo = strings.TrimSuffix(repo, ".git")
 	return repo
 }

server/utils/utils_test.go 🔗

@@ -0,0 +1,56 @@
+package utils
+
+import "testing"
+
+func TestValidateRepo(t *testing.T) {
+	t.Run("valid", func(t *testing.T) {
+		for _, repo := range []string{
+			"lower",
+			"Upper",
+			"with-dash",
+			"with/slash",
+			"withnumb3r5",
+			"with.dot",
+			"with_underline",
+		} {
+			t.Run(repo, func(t *testing.T) {
+				if err := ValidateRepo(repo); err != nil {
+					t.Errorf("expected no error, got %v", err)
+				}
+			})
+		}
+	})
+	t.Run("invalid", func(t *testing.T) {
+		for _, repo := range []string{
+			"with$",
+			"with@",
+			"with!",
+		} {
+			t.Run(repo, func(t *testing.T) {
+				if err := ValidateRepo(repo); err == nil {
+					t.Error("expected an error, got nil")
+				}
+			})
+		}
+	})
+}
+
+func TestSanitizeRepo(t *testing.T) {
+	cases := []struct {
+		in, out string
+	}{
+		{"lower", "lower"},
+		{"Upper", "Upper"},
+		{"with/slash", "with/slash"},
+		{"with.dot", "with.dot"},
+		{"/with_forward_slash", "with_forward_slash"},
+		{"withgitsuffix.git", "withgitsuffix"},
+	}
+	for _, c := range cases {
+		t.Run(c.in, func(t *testing.T) {
+			if got := SanitizeRepo(c.in); got != c.out {
+				t.Errorf("expected %q, got %q", c.out, got)
+			}
+		})
+	}
+}

testscript/script_test.go 🔗

@@ -0,0 +1,229 @@
+package testscript
+
+import (
+	"bytes"
+	"context"
+	"flag"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/soft-serve/server"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/test"
+	"github.com/rogpeppe/go-internal/testscript"
+	"golang.org/x/crypto/ssh"
+)
+
+var update = flag.Bool("update", false, "update script files")
+
+func TestScript(t *testing.T) {
+	flag.Parse()
+	var lock sync.Mutex
+
+	mkkey := func(name string) (string, *keygen.SSHKeyPair) {
+		path := filepath.Join(t.TempDir(), name)
+		pair, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())
+		if err != nil {
+			t.Fatal(err)
+		}
+		return path, pair
+	}
+
+	key, admin1 := mkkey("admin1")
+	_, admin2 := mkkey("admin2")
+	_, user1 := mkkey("user1")
+
+	testscript.Run(t, testscript.Params{
+		Dir:           "./testdata/",
+		UpdateScripts: *update,
+		Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
+			"soft":     cmdSoft(admin1.Signer()),
+			"git":      cmdGit(key),
+			"mkreadme": cmdMkReadme,
+			"dos2unix": cmdDos2Unix,
+		},
+		Setup: func(e *testscript.Env) error {
+			sshPort := test.RandomPort()
+			e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort))
+			e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
+			e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
+			e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
+			e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts"))
+			e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config"))
+			data := t.TempDir()
+			cfg := config.Config{
+				Name:             "Test Soft Serve",
+				DataPath:         data,
+				InitialAdminKeys: []string{admin1.AuthorizedKey()},
+				SSH: config.SSHConfig{
+					ListenAddr:    fmt.Sprintf("localhost:%d", sshPort),
+					PublicURL:     fmt.Sprintf("ssh://localhost:%d", sshPort),
+					KeyPath:       filepath.Join(data, "ssh", "soft_serve_host_ed25519"),
+					ClientKeyPath: filepath.Join(data, "ssh", "soft_serve_client_ed25519"),
+				},
+				Git: config.GitConfig{
+					ListenAddr:     fmt.Sprintf("localhost:%d", test.RandomPort()),
+					IdleTimeout:    3,
+					MaxConnections: 32,
+				},
+				HTTP: config.HTTPConfig{
+					ListenAddr: fmt.Sprintf("localhost:%d", test.RandomPort()),
+					PublicURL:  fmt.Sprintf("http://localhost:%d", test.RandomPort()),
+				},
+				Stats: config.StatsConfig{
+					ListenAddr: fmt.Sprintf("localhost:%d", test.RandomPort()),
+				},
+				Log: config.LogConfig{
+					Format:     "text",
+					TimeFormat: time.DateTime,
+				},
+			}
+			ctx := config.WithContext(context.Background(), &cfg)
+
+			// prevent race condition in lipgloss...
+			// this will probably be autofixed when we start using the colors
+			// from the ssh session instead of the server.
+			// XXX: take another look at this soon
+			lock.Lock()
+			srv, err := server.NewServer(ctx)
+			if err != nil {
+				return err
+			}
+			lock.Unlock()
+
+			go func() {
+				if err := srv.Start(); err != nil {
+					e.T().Fatal(err)
+				}
+			}()
+
+			e.Defer(func() {
+				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+				defer cancel()
+				if err := srv.Shutdown(ctx); err != nil {
+					e.T().Fatal(err)
+				}
+			})
+
+			// wait until the server is up
+			for {
+				conn, _ := net.DialTimeout(
+					"tcp",
+					net.JoinHostPort("localhost", fmt.Sprintf("%d", sshPort)),
+					time.Second,
+				)
+				if conn != nil {
+					conn.Close()
+					break
+				}
+			}
+
+			return nil
+		},
+	})
+}
+
+func cmdSoft(key ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) {
+	return func(ts *testscript.TestScript, neg bool, args []string) {
+		cli, err := ssh.Dial(
+			"tcp",
+			net.JoinHostPort("localhost", ts.Getenv("SSH_PORT")),
+			&ssh.ClientConfig{
+				User:            "admin",
+				Auth:            []ssh.AuthMethod{ssh.PublicKeys(key)},
+				HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+			},
+		)
+		ts.Check(err)
+		defer cli.Close()
+
+		sess, err := cli.NewSession()
+		ts.Check(err)
+		defer sess.Close()
+
+		sess.Stdout = ts.Stdout()
+		sess.Stderr = ts.Stderr()
+
+		check(ts, sess.Run(strings.Join(args, " ")), neg)
+	}
+}
+
+// P.S. Windows sucks!
+func cmdDos2Unix(ts *testscript.TestScript, neg bool, args []string) {
+	if neg {
+		ts.Fatalf("unsupported: ! dos2unix")
+	}
+	if len(args) < 1 {
+		ts.Fatalf("usage: dos2unix paths...")
+	}
+	for _, arg := range args {
+		filename := ts.MkAbs(arg)
+		data, err := os.ReadFile(filename)
+		if err != nil {
+			ts.Fatalf("%s: %v", filename, err)
+		}
+
+		// Replace all '\r\n' with '\n'.
+		data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
+
+		if err := os.WriteFile(filename, data, 0o644); err != nil {
+			ts.Fatalf("%s: %v", filename, err)
+		}
+	}
+}
+
+var sshConfig = `
+Host *
+  UserKnownHostsFile %q
+  StrictHostKeyChecking no
+  IdentityAgent none
+  IdentitiesOnly yes
+  ServerAliveInterval 60
+`
+
+func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string) {
+	return func(ts *testscript.TestScript, neg bool, args []string) {
+		ts.Check(os.WriteFile(
+			ts.Getenv("SSH_KNOWN_CONFIG_FILE"),
+			[]byte(fmt.Sprintf(sshConfig, ts.Getenv("SSH_KNOWN_HOSTS_FILE"))),
+			0o600,
+		))
+		sshArgs := []string{
+			"-F", filepath.ToSlash(ts.Getenv("SSH_KNOWN_CONFIG_FILE")),
+			"-i", filepath.ToSlash(key),
+		}
+		ts.Setenv(
+			"GIT_SSH_COMMAND",
+			strings.Join(append([]string{"ssh"}, sshArgs...), " "),
+		)
+		args = append([]string{
+			"-c", "user.email=john@example.com",
+			"-c", "user.name=John Doe",
+		}, args...)
+		check(ts, ts.Exec("git", args...), neg)
+	}
+}
+
+func cmdMkReadme(ts *testscript.TestScript, neg bool, args []string) {
+	if len(args) != 1 {
+		ts.Fatalf("usage: mkreadme path")
+	}
+	content := []byte("# example\ntest project")
+	check(ts, os.WriteFile(ts.MkAbs(args[0]), content, 0o644), neg)
+}
+
+func check(ts *testscript.TestScript, err error, neg bool) {
+	if neg && err == nil {
+		ts.Fatalf("expected error, got nil")
+	}
+	if !neg {
+		ts.Check(err)
+	}
+}

testscript/testdata/mirror.txtar 🔗

@@ -0,0 +1,96 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info1.txt info2.txt tree.txt
+
+# import a repo
+soft repo import --mirror charmbracelet/catwalk https://github.com/charmbracelet/catwalk.git
+
+# check repo info
+soft repo info charmbracelet/catwalk
+cmp stdout info1.txt
+
+# check repo list
+soft repo list
+stdout charmbracelet/catwalk
+
+# is-mirror?
+soft repo is-mirror charmbracelet/catwalk
+stdout true
+
+# set project name
+soft repo project-name charmbracelet/catwalk catwalk
+soft repo list
+stdout catwalk
+
+
+# check description
+soft repo description charmbracelet/catwalk
+! stdout .
+
+# set description
+soft repo description charmbracelet/catwalk "testing repo"
+soft repo description charmbracelet/catwalk
+stdout 'testing repo'
+
+# rename
+soft repo rename charmbracelet/catwalk charmbracelet/test
+soft repo list
+stdout charmbracelet/test # TODO: shouldn't this still show the project-name?
+
+# check its not private
+soft repo private charmbracelet/test
+stdout false
+
+# make it private
+soft repo private charmbracelet/test  true
+soft repo private charmbracelet/test
+stdout true
+
+# check its not hidden
+soft repo hidden charmbracelet/test
+stdout false
+
+# make it hidden
+soft repo hidden charmbracelet/test  true
+soft repo hidden charmbracelet/test
+stdout true
+
+# print tree
+soft repo tree charmbracelet/test
+cmp stdout tree.txt
+
+# check repo info again
+soft repo info charmbracelet/test
+cmp stdout info2.txt
+
+# get a file
+soft repo blob charmbracelet/test LICENSE
+stdout '.*Creative Commons.*'
+
+
+-- info1.txt --
+Project Name:
+Repository: charmbracelet/catwalk
+Description:
+Private: false
+Hidden: false
+Mirror: true
+Default Branch: main
+Branches:
+  - main
+-- info2.txt --
+Project Name: catwalk
+Repository: charmbracelet/test
+Description: testing repo
+Private: true
+Hidden: true
+Mirror: true
+Default Branch: main
+Branches:
+  - main
+-- tree.txt --
+drwxrwxrwx	-	 30k
+drwxrwxrwx	-	 50k
+-rw-r--r--	19 kB	 LICENSE
+-rw-r--r--	1.1 kB	 README.md

testscript/testdata/repo-collab.txtar 🔗

@@ -0,0 +1,18 @@
+# vi: set ft=conf
+# setup
+soft repo import test https://github.com/charmbracelet/catwalk.git
+soft user create foo --key "$USER1_AUTHORIZED_KEY"
+
+# list collabs
+soft repo collab list test
+! stdout .
+
+# add collab
+soft repo collab add test foo
+soft repo collab list test
+stdout 'foo'
+
+# remove collab
+soft repo collab remove test foo
+soft repo collab list test
+! stdout .

testscript/testdata/repo-create.txtar 🔗

@@ -0,0 +1,78 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix tree.txt readme.md branch_list.1.txt
+
+# create a repo
+soft repo create repo1 -d 'description' -H -p -n 'repo11'
+soft repo hidden repo1
+stdout true
+soft repo private repo1
+stdout true
+soft repo description repo1
+stdout 'description'
+
+# clone repo
+git clone ssh://localhost:$SSH_PORT/repo1 repo1
+
+# create some files, commits, tags...
+mkreadme ./repo1/README.md
+git -C repo1 add -A
+git -C repo1 commit -m 'first'
+git -C repo1 tag v0.1.0
+git -C repo1 push origin HEAD
+git -C repo1 push origin HEAD --tags
+
+# list tags
+soft repo tag list repo1
+stdout 'v0.1.0'
+
+# delete tag
+soft repo tag delete repo1 v0.1.0
+soft repo tag list repo1
+! stdout .
+
+# print tree
+soft repo tree repo1
+cmp stdout tree.txt
+
+# cat blob
+soft repo blob repo1 README.md
+cmp stdout readme.md
+
+# cat blob that doesn't exist
+! soft repo blob repo1 README.txt
+! stdout .
+stderr '.*revision does not exist.*'
+
+# check main branch
+soft repo branch default repo1
+stdout master
+
+# create a new branch
+git -C repo1 checkout -b branch1
+git -C repo1 push origin branch1
+soft repo branch list repo1
+cmp stdout branch_list.1.txt
+
+# change default branch
+soft repo branch default repo1 branch1
+soft repo branch default repo1
+stdout branch1
+
+# cannot delete main branch
+! soft repo branch delete repo1 branch1
+
+# delete other branch
+soft repo branch delete repo1 master
+soft repo branch list repo1
+stdout branch1
+
+-- tree.txt --
+-rw-r--r--	22 B	 README.md
+-- readme.md --
+# example
+test project
+-- branch_list.1.txt --
+branch1
+master

testscript/testdata/repo-import.txt 🔗

@@ -0,0 +1,30 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix repo3.txt
+
+# import private
+soft repo import --private repo1 https://github.com/charmbracelet/catwalk.git
+soft repo private repo1
+stdout 'true'
+
+# import hidden
+soft repo import --hidden repo2 https://github.com/charmbracelet/catwalk.git
+soft repo hidden repo2
+stdout 'true'
+
+# import with name and description
+soft repo import --name 'repo33' --description 'descriptive' repo3 https://github.com/charmbracelet/catwalk.git
+soft repo info repo3
+cmp stdout repo3.txt
+
+-- repo3.txt --
+Project Name: repo33
+Repository: repo3
+Description: descriptive
+Private: false
+Hidden: false
+Mirror: false
+Default Branch: main
+Branches:
+  - main

testscript/testdata/set-username.txtar 🔗

@@ -0,0 +1,24 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info1.txt info2.txt
+
+# get original username
+soft info
+cmpenv stdout info1.txt
+
+# set another username
+soft set-username test
+soft info
+cmpenv stdout info2.txt
+
+-- info1.txt --
+Username: admin
+Admin: true
+Public keys:
+  $ADMIN1_AUTHORIZED_KEY
+-- info2.txt --
+Username: test
+Admin: true
+Public keys:
+  $ADMIN1_AUTHORIZED_KEY

testscript/testdata/settings.txtar 🔗

@@ -0,0 +1,31 @@
+# vi: set ft=conf
+# check default allow-keyless
+soft settings allow-keyless true
+soft settings allow-keyless
+stdout 'true.*'
+
+# change allow-keyless and check
+soft settings allow-keyless false
+soft settings allow-keyless
+stdout 'false.*'
+
+# check default anon-access
+soft settings anon-access
+stdout 'read-only.*'
+
+# chaneg anon-access to all available options, and check them
+soft settings anon-access no-access
+soft settings anon-access
+stdout 'no-access.*'
+
+soft settings anon-access read-only
+soft settings anon-access
+stdout 'read-only.*'
+
+soft settings anon-access read-write
+soft settings anon-access
+stdout 'read-write.*'
+
+soft settings anon-access admin-access
+soft settings anon-access
+stdout 'admin-access.*'

testscript/testdata/user_management.txtar 🔗

@@ -0,0 +1,105 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt
+
+# add key to admin
+soft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY"
+soft user info admin
+soft info
+cmpenv stdout info.txt
+
+
+# list admin pubkeys
+soft pubkey list
+cmpenv stdout admin_key_list1.txt
+
+# remove key
+soft pubkey remove $ADMIN2_AUTHORIZED_KEY
+soft pubkey list
+cmpenv stdout admin_key_list2.txt
+
+# add key back key
+soft pubkey add $ADMIN2_AUTHORIZED_KEY
+soft pubkey list
+cmpenv stdout admin_key_list1.txt
+
+# list users
+soft user list
+cmpenv stdout list1.txt
+
+# create a new user
+soft user create foo --key "$USER1_AUTHORIZED_KEY"
+soft user list
+cmpenv stdout list2.txt
+
+# get new user info
+soft user info foo
+cmpenv stdout foo_info1.txt
+
+# make user admin
+soft user set-admin foo true
+soft user info foo
+cmpenv stdout foo_info2.txt
+
+# remove admin
+soft user set-admin foo false
+soft user info foo
+cmpenv stdout foo_info3.txt
+
+# remove key from user
+soft user remove-pubkey foo "$USER1_AUTHORIZED_KEY"
+soft user info foo
+cmpenv stdout foo_info4.txt
+
+# rename user
+soft user set-username foo foo2
+soft user info foo2
+cmpenv stdout foo_info5.txt
+
+# remove user
+soft user delete foo2
+! stdout .
+soft user list
+cmpenv stdout list1.txt
+
+
+-- info.txt --
+Username: admin
+Admin: true
+Public keys:
+  $ADMIN1_AUTHORIZED_KEY
+  $ADMIN2_AUTHORIZED_KEY
+-- list1.txt --
+admin
+-- list2.txt --
+admin
+foo
+-- foo_info1.txt --
+Username: foo
+Admin: false
+Public keys:
+  $USER1_AUTHORIZED_KEY
+-- foo_info2.txt --
+Username: foo
+Admin: true
+Public keys:
+  $USER1_AUTHORIZED_KEY
+-- foo_info3.txt --
+Username: foo
+Admin: false
+Public keys:
+  $USER1_AUTHORIZED_KEY
+-- foo_info4.txt --
+Username: foo
+Admin: false
+Public keys:
+-- foo_info5.txt --
+Username: foo2
+Admin: false
+Public keys:
+-- admin_key_list1.txt --
+$ADMIN1_AUTHORIZED_KEY
+$ADMIN2_AUTHORIZED_KEY
+-- admin_key_list2.txt --
+$ADMIN1_AUTHORIZED_KEY