Support SHA-256 repositories and protocol v2
Hash format: - The create form has a SHA-1 / SHA-256 field. SHA-1 stays the default. - An empty repo can switch its format under Settings. git cannot convert a repo with refs, so the switch is refused once the repo has any ref. - The empty-repo page names the format the first push must use. - Git ignores the host's init.defaultObjectFormat for Hearthforge, because the server runs git without user or system config. The form field is the way to choose the format. Protocol v2: - HTTP and SSH now pass the client's GIT_PROTOCOL to git. Before, every fetch fell back to v0. v0 does not send the name of an unborn HEAD, so an empty clone checked out the client's default branch, often master. - The value is checked before it reaches git's environment. - The empty-repo snippet pushes the repo's default branch, not a fixed "main". Also: - The Pipelines header wraps on narrow screens. The branch picker pushed the Run pipeline button off the screen. - The gitops e2e test accepts 64-character commit hashes.
Minternal/gitcmd/gitcmd.go
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -34,14 +35,16 @@ const (
// Sentinel errors. Handlers map these to 404 / 400 / 409.
var (
ErrInvalidName = errors.New("invalid repository name")
ErrInvalidRef = errors.New("invalid ref")
ErrNotFound = errors.New("not found")
ErrExists = errors.New("already exists")
ErrBadRef = errors.New("ref does not resolve")
ErrConflict = errors.New("patch does not apply")
ErrRefChanged = errors.New("ref changed concurrently")
ErrTooLarge = errors.New("output too large")
ErrInvalidName = errors.New("invalid repository name")
ErrInvalidRef = errors.New("invalid ref")
ErrNotFound = errors.New("not found")
ErrExists = errors.New("already exists")
ErrBadRef = errors.New("ref does not resolve")
ErrConflict = errors.New("patch does not apply")
ErrRefChanged = errors.New("ref changed concurrently")
ErrTooLarge = errors.New("output too large")
ErrNotEmpty = errors.New("repository is not empty")
ErrInvalidFormat = errors.New("unknown object format")
)
var validRepoName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
@@ -117,6 +120,19 @@ func Env() []string {
)
}
var validProtocol = regexp.MustCompile(`^[a-z0-9=:._-]{1,64}$`)
// EnvWithProtocol is Env plus the client's GIT_PROTOCOL value. Without it,
// git falls back to protocol v0, and an empty clone then misses the default
// branch name.
func EnvWithProtocol(protocol string) []string {
env := Env()
if validProtocol.MatchString(protocol) {
env = append(env, "GIT_PROTOCOL="+protocol)
}
return env
}
// RepoPath is the bare repo directory for a validated name.
func (g *Git) RepoPath(name string) string {
return filepath.Join(g.cfg.ReposDir(), name+".git")
@@ -322,7 +338,11 @@ func field(parts []string, i int) string {
// --- read operations ---
func (g *Git) Init(ctx context.Context, name, branch string) error {
// ObjectFormats are the hash algorithms a repo can use.
var ObjectFormats = []string{"sha1", "sha256"}
// Init creates a bare repo. An empty format leaves the choice to git.
func (g *Git) Init(ctx context.Context, name, branch, format string) error {
p, err := g.repoDir(name)
if err != nil {
return err
@@ -333,13 +353,77 @@ func (g *Git) Init(ctx context.Context, name, branch string) error {
if !ValidRef(branch) {
return fmt.Errorf("%q: %w", branch, ErrInvalidRef)
}
if format != "" && !slices.Contains(ObjectFormats, format) {
return fmt.Errorf("%q: %w", format, ErrInvalidFormat)
}
m := g.lock(name)
m.Lock()
defer m.Unlock()
_, err = g.run(ctx, runOpts{}, "init", "--bare", "--initial-branch="+branch, p)
return g.initAt(ctx, p, branch, format)
}
func (g *Git) initAt(ctx context.Context, p, branch, format string) error {
args := []string{"init", "--bare", "--initial-branch=" + branch}
if format != "" {
args = append(args, "--object-format="+format)
}
_, err := g.run(ctx, runOpts{}, append(args, p)...)
return err
}
// ObjectFormat returns the repo's hash algorithm, "sha1" or "sha256".
func (g *Git) ObjectFormat(ctx context.Context, name string) (string, error) {
p, err := g.repoDir(name)
if err != nil {
return "", err
}
return g.line(ctx, "-C", p, "rev-parse", "--show-object-format")
}
// SetObjectFormat re-creates an empty repo with another hash algorithm.
// git cannot convert a repo in place.
func (g *Git) SetObjectFormat(ctx context.Context, name, format string) error {
p, err := g.repoDir(name)
if err != nil {
return err
}
if !slices.Contains(ObjectFormats, format) {
return fmt.Errorf("%q: %w", format, ErrInvalidFormat)
}
m := g.lock(name)
m.Lock()
defer m.Unlock()
ref, err := g.line(ctx, "-C", p, "for-each-ref", "--count=1", "--format=%(refname)")
if err != nil {
return err
}
if ref != "" {
return ErrNotEmpty
}
branch, err := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD")
if err != nil {
return err
}
// The suffixes do not end in .git, so the startup scan skips leftovers.
tmp, old := p+".reinit", p+".old"
_ = os.RemoveAll(tmp)
_ = os.RemoveAll(old)
if err := g.initAt(ctx, tmp, branch, format); err != nil {
return err
}
// ponytail: pushes do not take g.lock, so a push that lands after the ref
// check goes to the old dir and is lost. Admin-only on an empty repo.
if err := os.Rename(p, old); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := os.Rename(tmp, p); err != nil {
_ = os.Rename(old, p)
return err
}
return os.RemoveAll(old)
}
// EnsureBare sets core.bare on a repo discovered on disk.
func (g *Git) EnsureBare(ctx context.Context, name string) error {
p, err := g.repoDir(name)
Minternal/gitcmd/gitcmd_test.go
@@ -400,3 +400,65 @@ func TestDiffLimitAndBlobSizes(t *testing.T) {
t.Fatalf("sizes = %v", sizes)
}
}
func TestObjectFormat(t *testing.T) {
g, repo := newTestGit(t)
ctx := context.Background()
if err := g.Init(ctx, "empty", "trunk", "sha256"); err != nil {
t.Fatal(err)
}
if f, _ := g.ObjectFormat(ctx, "empty"); f != "sha256" {
t.Fatalf("format after init = %q", f)
}
if err := g.Init(ctx, "bad", "main", "md5"); !errors.Is(err, ErrInvalidFormat) {
t.Fatalf("init with md5: %v", err)
}
if err := g.SetObjectFormat(ctx, "empty", "sha1"); err != nil {
t.Fatal(err)
}
if f, _ := g.ObjectFormat(ctx, "empty"); f != "sha1" {
t.Fatalf("format after switch = %q", f)
}
if head, _ := g.line(ctx, "-C", g.RepoPath("empty"), "symbolic-ref", "--short", "HEAD"); head != "trunk" {
t.Fatalf("HEAD after switch = %q", head)
}
for _, leftover := range []string{".reinit", ".old"} {
if _, err := os.Stat(g.RepoPath("empty") + leftover); !os.IsNotExist(err) {
t.Errorf("%s left behind: %v", leftover, err)
}
}
before, _ := g.ObjectFormat(ctx, repo)
other := "sha256"
if before == other {
other = "sha1"
}
if err := g.SetObjectFormat(ctx, repo, other); !errors.Is(err, ErrNotEmpty) {
t.Fatalf("switch on repo with commits: %v", err)
}
if f, _ := g.ObjectFormat(ctx, repo); f != before {
t.Fatalf("non-empty repo format changed from %q to %q", before, f)
}
}
func TestEnvWithProtocol(t *testing.T) {
for _, tc := range []struct {
in string
want bool
}{
{"version=2", true},
{"version=2:object-format=sha256", true},
{"", false},
{"version=2\nGIT_DIR=/etc", false},
{"version=2 x", false},
{strings.Repeat("a", 65), false},
} {
env := EnvWithProtocol(tc.in)
got := env[len(env)-1] == "GIT_PROTOCOL="+tc.in
if got != tc.want {
t.Errorf("EnvWithProtocol(%q) forwarded = %v, want %v", tc.in, got, tc.want)
}
}
}
Minternal/sshd/sshd.go
@@ -167,7 +167,13 @@ func (s *Server) session(sess ssh.Session) {
// also returns the first bytes of stdin.
func (s *Server) runGit(sess ssh.Session, command, repoPath string) (int, []byte) {
cmd := exec.CommandContext(sess.Context(), command, repoPath)
cmd.Env = gitcmd.Env()
protocol := ""
for _, kv := range sess.Environ() {
if v, ok := strings.CutPrefix(kv, "GIT_PROTOCOL="); ok {
protocol = v
}
}
cmd.Env = gitcmd.EnvWithProtocol(protocol)
var preamble *ci.CapWriter
stdin := io.Reader(sess)
Minternal/web/ci_test.go
@@ -45,7 +45,7 @@ func ciTestServer(t *testing.T) (http.Handler, *db.DB, int64) {
BaseURL: "http://localhost:3000", CIMaxConcurrent: 1, MaxConcurrentArchives: 1,
}
s := &Server{Cfg: cfg, DB: database, Git: gitcmd.New(cfg)}
if err := s.Git.Init(ctx, "ci-repo", "main"); err != nil {
if err := s.Git.Init(ctx, "ci-repo", "main", ""); err != nil {
t.Fatal(err)
}
user := &db.SessionUser{ID: admin.ID, Username: admin.Username, IsAdmin: true}
Minternal/web/e2e/gitops_test.go
@@ -11,7 +11,7 @@ import (
)
// gitopsCommitRedirect matches the redirect target of a write operation.
var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}$`)
var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}([0-9a-f]{24})?$`)
// gitopsRefNames lists the branch or tag names on a ref list page.
func gitopsRefNames(r *response) []string { return r.Texts(".ref-item .ref-name") }
Minternal/web/e2e/repos_test.go
@@ -339,3 +339,92 @@ func TestRepos(t *testing.T) {
os.RemoveAll(dir)
})
}
func TestRepoObjectFormat(t *testing.T) {
e := newEnv(t)
admin := e.admin()
format := func(name string) string {
return gitRun(t, e.repoPath(name), "rev-parse", "--show-object-format")
}
t.Run("new repo uses the chosen format", func(t *testing.T) {
e.createRepo(admin, "hash-repo", "object_format", "sha256")
if f := format("hash-repo"); f != "sha256" {
t.Fatalf("format = %q", f)
}
if !strings.Contains(admin.get("/hash-repo").Text(".empty-state"), "SHA-256") {
t.Error("empty state does not name the format")
}
})
t.Run("unknown format is rejected", func(t *testing.T) {
r := admin.post("/new", url.Values{"name": {"md5-repo"}, "object_format": {"md5"}}).mustStatus(200)
if !r.Contains("Invalid hash format") {
t.Error("error message missing")
}
})
t.Run("empty repo can switch format", func(t *testing.T) {
admin.post("/hash-repo/settings/object-format", url.Values{"object_format": {"sha1"}}).
mustRedirect("/hash-repo/settings?success=")
if f := format("hash-repo"); f != "sha1" {
t.Fatalf("format = %q", f)
}
admin.post("/hash-repo/settings/object-format", url.Values{"object_format": {"sha256"}}).
mustRedirect("/hash-repo/settings?success=")
})
t.Run("sha256 clone and push over HTTP", func(t *testing.T) {
work := t.TempDir()
gitRun(t, work, "clone", "-q", e.authURL("hash-repo"), ".")
gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "first")
gitRun(t, work, "push", "-q", "origin", "HEAD:main")
sha := gitRun(t, work, "rev-parse", "HEAD")
if len(sha) != 64 {
t.Fatalf("local sha = %q", sha)
}
admin.get("/hash-repo/commit/" + sha).mustStatus(200)
})
t.Run("repo with commits cannot switch format", func(t *testing.T) {
admin.post("/hash-repo/settings/object-format", url.Values{"object_format": {"sha1"}}).
mustRedirect("/hash-repo/settings?error=")
if f := format("hash-repo"); f != "sha256" {
t.Fatalf("format = %q", f)
}
if admin.get("/hash-repo/settings").Has(`form[action$="/settings/object-format"]`) {
t.Error("format form shown on a repo with commits")
}
})
}
func TestEmptyCloneBranch(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "trunk-repo", "default_branch", "trunk")
t.Run("setup snippet pushes the default branch", func(t *testing.T) {
if !strings.Contains(admin.get("/trunk-repo").Text(".code-setup"), "git push origin trunk") {
t.Error("snippet does not push trunk")
}
})
// Only protocol v2 tells the client the name of an unborn HEAD.
t.Run("empty clone over HTTP checks out the default branch", func(t *testing.T) {
work := t.TempDir()
gitRun(t, work, "-c", "protocol.version=2", "clone", "-q", e.authURL("trunk-repo"), ".")
if head := gitRun(t, work, "symbolic-ref", "HEAD"); head != "refs/heads/trunk" {
t.Fatalf("HEAD = %q", head)
}
gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "first")
gitRun(t, work, "push", "-q", "origin", "trunk")
})
t.Run("protocol v0 clone still works", func(t *testing.T) {
work := t.TempDir()
gitRun(t, work, "-c", "protocol.version=0", "clone", "-q", "-b", "trunk", e.authURL("trunk-repo"), ".")
if n := gitRun(t, work, "rev-list", "--count", "HEAD"); n != "1" {
t.Fatalf("commit count = %q", n)
}
})
}
Minternal/web/git.go
@@ -109,7 +109,7 @@ func (s *Server) gitInfoRefs(w http.ResponseWriter, r *http.Request) {
verb := strings.TrimPrefix(service, "git-")
cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", "--advertise-refs", path)
cmd.Env = gitcmd.Env()
cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol"))
// The advertisement is small, so buffer it. That lets a git failure
// surface as a 500 instead of a truncated body.
out, err := cmd.Output()
@@ -172,7 +172,7 @@ func (s *Server) gitReceivePack(w http.ResponseWriter, r *http.Request) {
// client. It reports whether git exited successfully.
func (s *Server) runGitRPC(w http.ResponseWriter, r *http.Request, verb, repoName, path string, body io.Reader) bool {
cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", path)
cmd.Env = gitcmd.Env()
cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol"))
cmd.Stdin = body
cmd.Stdout = w
var stderr strings.Builder
Minternal/web/git_test.go
@@ -70,7 +70,7 @@ func gitTestServer(t *testing.T) (*Server, *httptest.Server) {
t.Fatal(err)
}
g := gitcmd.New(cfg)
if err := g.Init(ctx, "smoke", "main"); err != nil {
if err := g.Init(ctx, "smoke", "main", ""); err != nil {
t.Fatal(err)
}
// Seed one commit by pushing from a scratch working tree.
Minternal/web/issues_test.go
@@ -48,7 +48,7 @@ func issueTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
}
s := &Server{Cfg: cfg, DB: d, MD: markdown.New(), Git: gitcmd.New(cfg)}
// Repo routes 404 when the git directory is missing.
if err := s.Git.Init(ctx, "demo", "main"); err != nil {
if err := s.Git.Init(ctx, "demo", "main", ""); err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
@@ -143,7 +143,7 @@ func TestIssuePrivateRepoHiddenFromAnonymous(t *testing.T) {
if repo == nil {
t.Fatal("repo was not created")
}
if err := s.Git.Init(context.Background(), "secret", "main"); err != nil {
if err := s.Git.Init(context.Background(), "secret", "main", ""); err != nil {
t.Fatal(err)
}
if res := do(t, h, nil, "GET", "/secret/issues", nil); res.Code != http.StatusNotFound {
Minternal/web/patches_test.go
@@ -77,7 +77,7 @@ func patchTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
Git: gitcmd.New(cfg),
Patches: gitcmd.NewPatchCache(),
}
if err := s.Git.Init(ctx, "demo", "main"); err != nil {
if err := s.Git.Init(ctx, "demo", "main", ""); err != nil {
t.Fatal(err)
}
who := gitcmd.Ident{Name: "Admin", Email: "admin@example.com"}
Minternal/web/releases_test.go
@@ -64,7 +64,7 @@ func releaseTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
if err := s.Git.EnsureSigningSetup(); err != nil {
t.Fatal(err)
}
if err := s.Git.Init(ctx, "demo", "main"); err != nil {
if err := s.Git.Init(ctx, "demo", "main", ""); err != nil {
t.Fatal(err)
}
who := gitcmd.Ident{Name: "Admin", Email: "admin@localhost"}
Minternal/web/repos.go
@@ -79,6 +79,7 @@ func (s *Server) repoRoutes(r chi.Router) {
r.Post("/{repo}/settings", s.saveRepoSettings)
r.Post("/{repo}/settings/delete", s.deleteRepo)
r.Post("/{repo}/settings/rename", s.renameRepo)
r.Post("/{repo}/settings/object-format", s.setObjectFormat)
r.Post("/{repo}/settings/labels", s.createLabel)
r.Post("/{repo}/settings/labels/delete", s.deleteLabel)
@@ -234,6 +235,14 @@ func (s *Server) createRepo(w http.ResponseWriter, r *http.Request) {
if branch == "" {
branch = "main"
}
format := r.FormValue("object_format")
if format == "" {
format = gitcmd.ObjectFormats[0]
}
if !slices.Contains(gitcmd.ObjectFormats, format) {
views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "Invalid hash format"))
return
}
existing, err := s.DB.RepoByName(r.Context(), name)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
@@ -254,7 +263,7 @@ func (s *Server) createRepo(w http.ResponseWriter, r *http.Request) {
return
}
// Roll the record back when git init fails so the two stay in sync.
if err := s.Git.Init(r.Context(), name, branch); err != nil {
if err := s.Git.Init(r.Context(), name, branch, format); err != nil {
_ = s.DB.DeleteRepoByName(r.Context(), name)
http.Error(w, "Failed to create repository", http.StatusInternalServerError)
return
@@ -308,9 +317,12 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
entries []gitcmd.TreeEntry
branches, tags []string
readme views.Readme
objectFormat string
)
hasContent := s.Git.HasCommits(r.Context(), repo.Name)
if hasContent {
if !hasContent {
objectFormat, _ = s.Git.ObjectFormat(r.Context(), repo.Name)
} else {
entries, _ = s.Git.LsTree(r.Context(), repo.Name, repo.DefaultBranch, "")
branches, _ = s.Git.Branches(r.Context(), repo.Name)
tags, _ = s.Git.Tags(r.Context(), repo.Name)
@@ -318,7 +330,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
readme = s.readme(r, repo.Name, repo.DefaultBranch, "", resolved, entries)
}
views.Render(w, http.StatusOK, views.RepoHome(s.Cfg, User(r), repo, entries,
readme, hasContent, branches, tags))
readme, hasContent, branches, tags, objectFormat))
}
// branchSwitch turns the ref selector's GET form into a redirect.
@@ -362,9 +374,10 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
objectFormat, _ := s.Git.ObjectFormat(r.Context(), repo.Name)
q := r.URL.Query()
views.Render(w, http.StatusOK, views.RepoSettings(s.Cfg, User(r), repo, branches, labels,
secrets, q.Get("success"), q.Get("error")))
views.Render(w, http.StatusOK, views.RepoSettings(s.Cfg, User(r), repo, branches, objectFormat,
labels, secrets, q.Get("success"), q.Get("error")))
}
// trimmedOrNil returns nil for an empty field so the column stays NULL.
@@ -412,6 +425,26 @@ func (s *Server) saveRepoSettings(w http.ResponseWriter, r *http.Request) {
redirectTo(w, r, settings+"?success=Settings+saved.")
}
func (s *Server) setObjectFormat(w http.ResponseWriter, r *http.Request) {
repo, ok := s.adminRepo(w, r)
if !ok {
return
}
settings := "/" + repo.Name + "/settings"
err := s.Git.SetObjectFormat(r.Context(), repo.Name, r.FormValue("object_format"))
switch {
case errors.Is(err, gitcmd.ErrNotEmpty):
s.backTo(w, r, settings, "error", "Only an empty repository can change its hash format.")
case errors.Is(err, gitcmd.ErrInvalidFormat):
s.backTo(w, r, settings, "error", "Invalid hash format.")
case err != nil:
s.backTo(w, r, settings, "error", "Failed to change the hash format.")
default:
s.Git.InvalidateRefCache(repo.Name)
s.backTo(w, r, settings, "success", "Hash format changed.")
}
}
func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) {
repo, ok := s.adminRepo(w, r)
if !ok {
Minternal/web/views/repos_files.go
@@ -177,7 +177,7 @@ func cloneURLs(cfg *config.Config, name string) (string, string) {
// RepoHome renders the repository landing page.
func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries []gitcmd.TreeEntry,
readme Readme, hasContent bool, branches, tags []string,
readme Readme, hasContent bool, branches, tags []string, objectFormat string,
) g.Node {
httpURL, sshURL := cloneURLs(cfg, repo.Name)
sshLine := ""
@@ -185,7 +185,7 @@ func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries [
sshLine = "\n# or: git clone " + sshURL
}
setup := "git clone " + httpURL + sshLine + "\ncd " + repo.Name +
"\necho \"# " + repo.Name + "\" > README.md\ngit add .\ngit commit -m \"Initial commit\"\ngit push origin main"
"\necho \"# " + repo.Name + "\" > README.md\ngit add .\ngit commit -m \"Initial commit\"\ngit push origin " + repo.DefaultBranch
return Layout(Page{Title: repo.Name, User: user, Cfg: cfg},
Div(Class("container"),
@@ -195,7 +195,8 @@ func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries [
RepoNav(cfg, repo, "code", user),
g.If(!hasContent, Div(Class("empty-state"),
H2(g.Text("This repository is empty.")),
P(g.Text("Push your first commit to get started:")),
P(g.Text("This repository uses "+ObjectFormatLabel(objectFormat)+
". Push your first commit from a "+ObjectFormatLabel(objectFormat)+" repository:")),
Pre(Class("code-setup"), Code(g.Text(setup))),
)),
g.If(hasContent, g.Group{
Minternal/web/views/repos_shell.go
@@ -6,6 +6,7 @@ import (
"hearthforge/internal/config"
"hearthforge/internal/db"
"hearthforge/internal/gitcmd"
"hearthforge/internal/util"
g "maragu.dev/gomponents"
@@ -103,6 +104,11 @@ func NewRepo(cfg *config.Config, user *db.SessionUser, errMsg string) g.Node {
Input(ID("default_branch"), Name("default_branch"), Type("text"),
Value("main"), Placeholder("main")),
),
Div(Class("form-group"),
Label(For("object_format"), g.Text("Hash format")),
objectFormatSelect(""),
P(Class("form-hint"), g.Text(objectFormatHint)),
),
Div(Class("form-group"),
Label(Class("checkbox-label"),
Input(Type("checkbox"), Name("is_private"), Value("1")),
@@ -215,7 +221,7 @@ func escapeRegexp(s string) string {
// RepoSettings renders the repository settings page.
func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []string,
labels []db.Label, secrets []db.CiSecret, success, errMsg string,
objectFormat string, labels []db.Label, secrets []db.CiSecret, success, errMsg string,
) g.Node {
return Layout(Page{Title: "Settings — " + repo.Name, User: user, Cfg: cfg},
Div(Class("container"),
@@ -275,6 +281,7 @@ func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branc
),
Button(Type("submit"), Class("btn btn-primary"), g.Text("Save settings")),
),
repoObjectFormatCard(repo, objectFormat, len(branches) == 0),
repoLabelsCard(repo, labels),
repoSecretsCard(repo, secrets),
repoDangerZone(repo),
@@ -282,6 +289,43 @@ func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branc
)
}
const objectFormatHint = "Your local repository must use the same format to push. SHA-256 needs git 2.29 or newer."
// ObjectFormatLabel is the display name of a git hash algorithm.
func ObjectFormatLabel(format string) string {
switch format {
case "sha1":
return "SHA-1"
case "sha256":
return "SHA-256"
}
return format
}
func objectFormatSelect(current string) g.Node {
return Select(ID("object_format"), Name("object_format"),
g.Map(gitcmd.ObjectFormats, func(f string) g.Node {
return Option(Value(f), g.If(f == current, Selected()), g.Text(ObjectFormatLabel(f)))
}),
)
}
func repoObjectFormatCard(repo *db.Repo, objectFormat string, empty bool) g.Node {
return Div(Class("form-card"),
H2(Class("section-title"), g.Text("Hash format")),
g.If(!empty, P(g.Text("This repository uses "+ObjectFormatLabel(objectFormat)+
". The format can only change while the repository is empty."))),
g.If(empty, Form(Method("POST"), Action("/"+repo.Name+"/settings/object-format"),
Div(Class("form-group"),
Label(For("object_format"), g.Text("Format")),
objectFormatSelect(objectFormat),
P(Class("form-hint"), g.Text(objectFormatHint)),
),
Button(Type("submit"), Class("btn btn-secondary"), g.Text("Change hash format")),
)),
)
}
func repoLabelsCard(repo *db.Repo, labels []db.Label) g.Node {
return Div(Class("form-card"),
H2(Class("section-title"), g.Text("Labels")),