Keep missing repos and let admins drop them from settings

Startup deleted the row of any repository whose git directory was
missing. The delete cascaded to issues, patches, releases, and CI runs.
One wrong DATA_DIR or an unmounted volume wiped all of that.

Startup now keeps the row and logs the missing repo. The admin settings
page lists missing repositories with their issue and patch counts. Each
one has a Drop button, and a Drop all button clears the list. Drop
re-checks the directory before deleting, so a restored repo survives.
Opening a missing repo returns 404. Listings are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit56378908e31242103ad49b360575d4838f66a572
Parent5903655
10 files changed, 310 insertions(+), 27 deletions(-)
▾Minternal/db/repos.go
@@ -192,11 +192,33 @@ func (d *DB) AllRepoRefs(ctx context.Context) ([]RepoRef, error) {
return out, rows.Err()
}
func (d *DB) DeleteRepos(ctx context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
// RepoCounts is a repo row with its issue and patch counts. A delete also
// cascades to releases and CI runs, which are not counted here.
type RepoCounts struct {
ID int64
Name string
Issues int
Patches int
}
// AllRepoCounts lists every repo with its issue and patch counts.
func (d *DB) AllRepoCounts(ctx context.Context) ([]RepoCounts, error) {
rows, err := d.QueryContext(ctx, `
SELECT r.id, r.name,
(SELECT COUNT(*) FROM issues WHERE repo_id = r.id),
(SELECT COUNT(*) FROM patches WHERE repo_id = r.id)
FROM repositories r ORDER BY r.name`)
if err != nil {
return nil, err
}
_, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE id IN (`+placeholders(len(ids))+`)`,
int64Args(ids)...)
return err
defer rows.Close()
var out []RepoCounts
for rows.Next() {
var r RepoCounts
if err := rows.Scan(&r.ID, &r.Name, &r.Issues, &r.Patches); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
▾Minternal/web/ci_test.go
@@ -45,6 +45,9 @@ 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 {
t.Fatal(err)
}
user := &db.SessionUser{ID: admin.ID, Username: admin.Username, IsAdmin: true}
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
▾Ainternal/web/e2e/missingrepos_test.go
@@ -0,0 +1,103 @@
package e2e
import (
"net/url"
"os"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// A repo whose git directory is gone keeps its database row. The admin
// settings page lists it and lets the admin drop it.
func TestMissingRepos(t *testing.T) {
e := newEnv(t)
admin := e.admin()
for _, name := range []string{"gone-a", "gone-b", "stays"} {
e.createRepo(admin, name)
admin.post("/"+name+"/issues", url.Values{"title": {"An issue"}, "body": {"x"}}).mustStatus(302)
}
for _, name := range []string{"gone-a", "gone-b"} {
if err := os.RemoveAll(e.repoPath(name)); err != nil {
t.Fatal(err)
}
}
// dropID returns the hidden id of the drop form for a listed repo.
dropID := func(name string) string {
var id string
admin.get("/settings").Find(".queue-item").Each(func(_ int, s *goquery.Selection) {
if s.Find("strong").Text() == name {
id, _ = s.Find(`input[name=id]`).Attr("value")
}
})
return id
}
t.Run("settings lists missing repos with counts", func(t *testing.T) {
r := admin.get("/settings")
names := r.Texts(".queue-item strong")
if !contains(names, "gone-a") || !contains(names, "gone-b") || contains(names, "stays") {
t.Errorf("missing list = %v", names)
}
if !r.Contains("1 issue, 0 patches") {
t.Error("issue count not shown")
}
})
t.Run("opening a missing repo errors", func(t *testing.T) {
if c := admin.get("/gone-a").Code; c < 400 {
t.Errorf("status = %d", c)
}
})
t.Run("drop refuses an unknown or present repo", func(t *testing.T) {
r := admin.post("/admin/repos/drop", url.Values{"id": {"999999"}})
if !strings.Contains(r.Location(), "error=") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("drop one removes only that repo", func(t *testing.T) {
id := dropID("gone-a")
if id == "" {
t.Fatal("no drop form for gone-a")
}
admin.post("/admin/repos/drop", url.Values{"id": {id}}).mustRedirect("/settings?success=repo_dropped")
names := admin.get("/settings").Texts(".queue-item strong")
if contains(names, "gone-a") || !contains(names, "gone-b") {
t.Errorf("after drop: %v", names)
}
admin.get("/gone-a").mustStatus(404)
admin.get("/stays/issues/1").mustStatus(200)
})
t.Run("drop all clears the section", func(t *testing.T) {
admin.post("/admin/repos/drop-all", nil).mustRedirect("/settings?success=repos_dropped")
r := admin.get("/settings")
if r.Count(".queue-item input[name=id]") != 0 || !r.Contains("Every repository has its git directory") {
t.Error("section not empty after drop all")
}
admin.get("/gone-b").mustStatus(404)
admin.get("/stays/issues/1").mustStatus(200)
})
t.Run("restart keeps a missing repo's data", func(t *testing.T) {
e.createRepo(admin, "gone-c")
admin.post("/gone-c/issues", url.Values{"title": {"Kept"}, "body": {"x"}}).mustStatus(302)
if err := os.RemoveAll(e.repoPath("gone-c")); err != nil {
t.Fatal(err)
}
if err := e.Srv.SyncRepos(t.Context()); err != nil {
t.Fatal(err)
}
if !contains(admin.get("/settings").Texts(".queue-item strong"), "gone-c") {
t.Error("gone-c dropped by startup sync")
}
})
t.Run("non-admin cannot drop", func(t *testing.T) {
e.register("bob", "password123").post("/admin/repos/drop-all", nil).mustStatus(403)
})
}
▾Minternal/web/issues.go
@@ -2,6 +2,7 @@ package web
import (
"net/http"
"os"
"slices"
"strconv"
"strings"
@@ -33,9 +34,22 @@ func (s *Server) visibleRepo(w http.ResponseWriter, r *http.Request) (*db.Repo,
http.Error(w, "Not found", http.StatusNotFound)
return nil, false
}
if !s.repoOnDisk(w, repo.Name) {
return nil, false
}
return repo, true
}
// repoOnDisk writes a 404 and returns false when the git directory is gone.
// The row stays until an admin drops it from the settings page.
func (s *Server) repoOnDisk(w http.ResponseWriter, name string) bool {
if _, err := os.Stat(s.Git.RepoPath(name)); os.IsNotExist(err) {
http.Error(w, "Repository directory is missing on disk", http.StatusNotFound)
return false
}
return true
}
// leadingInt parses the digits at the start of s, like JavaScript's parseInt.
// It returns false when s does not start with a number.
func leadingInt(s string) (int64, bool) {
▾Minternal/web/issues_test.go
@@ -13,6 +13,7 @@ import (
"hearthforge/internal/config"
"hearthforge/internal/db"
"hearthforge/internal/gitcmd"
"hearthforge/internal/markdown"
)
@@ -20,7 +21,8 @@ import (
func issueTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
t.Helper()
ctx := context.Background()
d, err := db.Open(filepath.Join(t.TempDir(), "hearthforge.db"))
dir := t.TempDir()
d, err := db.Open(filepath.Join(dir, "hearthforge.db"))
if err != nil {
t.Fatal(err)
}
@@ -36,15 +38,17 @@ func issueTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
t.Fatal(err)
}
s := &Server{
Cfg: &config.Config{
OwnerDisplayName: "Admin",
MaxTitleBytes: 500,
MaxTextBodyBytes: 100000,
RateLimitDisabled: true,
},
DB: d,
MD: markdown.New(),
cfg := &config.Config{
DataDir: dir,
OwnerDisplayName: "Admin",
MaxTitleBytes: 500,
MaxTextBodyBytes: 100000,
RateLimitDisabled: true,
}
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 {
t.Fatal(err)
}
r := chi.NewRouter()
s.issueRoutes(r)
@@ -138,6 +142,9 @@ 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 {
t.Fatal(err)
}
if res := do(t, h, nil, "GET", "/secret/issues", nil); res.Code != http.StatusNotFound {
t.Fatalf("anonymous status = %d, want 404", res.Code)
}
▾Minternal/web/repos.go
@@ -100,6 +100,9 @@ func (s *Server) adminRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bo
http.Error(w, "Not found", http.StatusNotFound)
return nil, false
}
if !s.repoOnDisk(w, repo.Name) {
return nil, false
}
return repo, true
}
▾Minternal/web/settings.go
@@ -1,8 +1,10 @@
package web
import (
"context"
"io"
"net/http"
"os"
"strconv"
"strings"
@@ -62,6 +64,8 @@ func (s *Server) settingsRoutes(r chi.Router) {
r.Post("/admin/users/deny", s.adminDenyUser)
r.Post("/admin/users/approve-all", s.adminApproveAll)
r.Post("/admin/users/deny-all", s.adminDenyAll)
r.Post("/admin/repos/drop", s.adminDropRepo)
r.Post("/admin/repos/drop-all", s.adminDropAllRepos)
})
}
@@ -99,13 +103,21 @@ func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
}
}
var missing []db.RepoCounts
if u.IsAdmin {
if missing, err = s.missingRepos(r.Context()); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
}
theme := "auto"
if c, err := r.Cookie("theme"); err == nil && c.Value != "" {
theme = c.Value
}
q := r.URL.Query()
views.Render(w, http.StatusOK, views.Settings(s.Cfg, u, row.PasswordHash != nil,
passkeys, sshKeys, theme, q.Get("success"), q.Get("error"), pending))
passkeys, sshKeys, theme, q.Get("success"), q.Get("error"), pending, missing))
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
@@ -447,3 +459,59 @@ func formID(r *http.Request, name string) (int64, error) {
}
return strconv.ParseInt(r.FormValue(name), 10, 64)
}
// missingRepos lists repo rows whose git directory is gone. The check is
// live, so a restored directory drops off the list on the next page load.
func (s *Server) missingRepos(ctx context.Context) ([]db.RepoCounts, error) {
all, err := s.DB.AllRepoCounts(ctx)
if err != nil {
return nil, err
}
var missing []db.RepoCounts
for _, r := range all {
if _, err := os.Stat(s.Git.RepoPath(r.Name)); os.IsNotExist(err) {
missing = append(missing, r)
}
}
return missing, nil
}
func (s *Server) adminDropRepo(w http.ResponseWriter, r *http.Request) {
id, err := formID(r, "id")
if err != nil {
settingsError(w, r, "Repository not found")
return
}
missing, err := s.missingRepos(r.Context())
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Re-check on disk so a repo restored after the page rendered survives.
for _, m := range missing {
if m.ID == id {
if err := s.DB.DeleteRepo(r.Context(), id); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
settingsSuccess(w, r, "repo_dropped")
return
}
}
settingsError(w, r, "Repository is not missing")
}
func (s *Server) adminDropAllRepos(w http.ResponseWriter, r *http.Request) {
missing, err := s.missingRepos(r.Context())
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
for _, m := range missing {
if err := s.DB.DeleteRepo(r.Context(), m.ID); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
}
settingsSuccess(w, r, "repos_dropped")
}
▾Minternal/web/startup.go
@@ -9,7 +9,8 @@ import (
)
// SyncRepos reconciles the repositories table with the repos on disk.
// Disk wins: new bare repos get a row, rows without a directory are removed.
// New bare repos get a row. Rows without a directory are kept, so an admin
// can restore the directory or drop the data from the settings page.
func (s *Server) SyncRepos(ctx context.Context) error {
names, err := s.Git.SyncStartup(ctx)
if err != nil {
@@ -24,17 +25,12 @@ func (s *Server) SyncRepos(ctx context.Context) error {
return err
}
inDB := make(map[string]bool, len(rows))
var stale []int64
for _, r := range rows {
inDB[r.Name] = true
if !onDisk[r.Name] {
stale = append(stale, r.ID)
log.Printf("repo %q missing on disk, removing row", r.Name)
}
}
if len(stale) > 0 {
if err := s.DB.DeleteRepos(ctx, stale); err != nil {
return err
// Keep the row. Deleting it would cascade to issues and patches.
// An admin drops it from the settings page.
log.Printf("repo %q missing on disk, keeping its data", r.Name)
}
}
for _, n := range names {
▾Minternal/web/views/settings.go
@@ -25,12 +25,14 @@ var successMessages = map[string]string{
"all_denied": "All pending accounts denied.",
"ssh_key_added": "SSH key added.",
"ssh_key_deleted": "SSH key removed.",
"repo_dropped": "Repository data dropped.",
"repos_dropped": "All missing repositories dropped.",
}
// Settings renders the user settings page, including the admin sections.
func Settings(cfg *config.Config, user *db.SessionUser, hasPassword bool,
passkeys []db.Passkey, sshKeys []db.SSHKey, theme, success, errMsg string,
pendingUsers []db.PendingUser,
pendingUsers []db.PendingUser, missingRepos []db.RepoCounts,
) g.Node {
successMsg := successMessages[success]
maxPassword := strconv.Itoa(cfg.MaxPasswordBytes)
@@ -176,6 +178,7 @@ func Settings(cfg *config.Config, user *db.SessionUser, hasPassword bool,
),
g.If(user.IsAdmin, registrationQueue(pendingUsers)),
g.If(user.IsAdmin, missingRepoList(missingRepos)),
g.If(user.IsAdmin, userManagement()),
),
Script(Type("module"), Src("/assets/passkey-settings.js")),
@@ -255,6 +258,65 @@ func registrationQueue(pendingUsers []db.PendingUser) g.Node {
)
}
// missingRepoList shows repos whose git directory is gone. Dropping one
// deletes its issues, patches, releases, and CI runs.
func missingRepoList(repos []db.RepoCounts) g.Node {
return Div(Class("form-card"),
H2(Class("section-title"), g.Text("Missing repositories")),
g.If(len(repos) == 0,
P(Style("font-size: var(--text-sm); color: var(--color-text-muted); margin: 0"),
g.Text("Every repository has its git directory on disk."))),
g.If(len(repos) > 0, Div(
P(Class("text-muted"), g.Text("These repositories have no git directory on disk. "+
"Put the directory back to restore them, or drop their data.")),
Div(Class("queue-bulk-actions"),
Details(Class("confirm-details"),
Summary(Class("btn btn-sm btn-danger"), g.Text("Drop all")),
Div(Class("confirm-popup"),
g.Text("Delete the issues, patches, releases, and CI runs of every missing repository?"),
Form(Method("POST"), Action("/admin/repos/drop-all"), Class("inline-form"),
Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, drop all")),
),
),
),
),
Ul(Class("queue-list queue-list-popups"),
g.Map(repos, func(r db.RepoCounts) g.Node {
id := strconv.FormatInt(r.ID, 10)
return Li(Class("queue-item"),
Div(Class("queue-item-meta"),
Div(Class("queue-item-header"),
Strong(g.Text(r.Name)),
Span(Class("queue-item-date"), g.Text(countLabel(r.Issues, "issue", "issues")+", "+countLabel(r.Patches, "patch", "patches"))),
),
),
Div(Class("queue-item-actions"),
Details(Class("confirm-details"),
Summary(Class("btn btn-sm btn-danger"), g.Text("Drop")),
Div(Class("confirm-popup"),
g.Textf("Delete all data of %s?", r.Name),
Form(Method("POST"), Action("/admin/repos/drop"), Class("inline-form"),
Input(Type("hidden"), Name("id"), Value(id)),
Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, drop")),
),
),
),
),
)
}),
),
)),
)
}
// countLabel renders "1 issue" or "3 issues".
func countLabel(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return strconv.Itoa(n) + " " + many
}
func userManagement() g.Node {
return Div(Class("form-card"),
H2(Class("section-title"), g.Text("User Management")),
▾Mweb/static/assets/css/components.css
@@ -1303,6 +1303,11 @@
max-height: 32rem;
overflow-y: auto;
}
/* Confirm popups are absolute; a scrolling list would clip them. */
.queue-list-popups {
max-height: none;
overflow: visible;
}
.queue-item {
display: flex;
align-items: flex-start;