package gitcmd import ( "context" "fmt" "log" "net/url" "os" "os/exec" "path/filepath" "strings" "time" ) // ListDiskRepoNames returns the names of all bare repos under ReposDir. // A read error is returned, so a caller cannot mistake it for "no repos". func (g *Git) ListDiskRepoNames() ([]string, error) { entries, err := os.ReadDir(g.cfg.ReposDir()) if err != nil { return nil, err } var names []string for _, e := range entries { if e.IsDir() && strings.HasSuffix(e.Name(), ".git") { names = append(names, strings.TrimSuffix(e.Name(), ".git")) } } return names, nil } // SyncStartup runs the disk-side startup work: signing setup, stale lock // cleanup, and conversion of non-bare repos. It returns the valid repo names // found on disk. The caller reconciles those against the repositories table, // because this package does not touch the database. func (g *Git) SyncStartup(ctx context.Context) ([]string, error) { if err := g.EnsureSigningSetup(); err != nil { return nil, err } g.ClearStaleConfigLocks() g.ConvertNonBareRepos() names, err := g.ListDiskRepoNames() if err != nil { return nil, err } var valid []string for _, n := range names { // A name that fails validation can never be served, so skip it. if !ValidRepoName(n) { continue } if err := g.EnsureBare(ctx, n); err != nil { log.Printf("[git] ensureBare failed for %s: %v", n, err) } valid = append(valid, n) } return valid, nil } // ClearStaleConfigLocks removes config.lock files left behind by a crash. func (g *Git) ClearStaleConfigLocks() { entries, err := os.ReadDir(g.cfg.ReposDir()) if err != nil { return } for _, e := range entries { if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") { continue } lock := filepath.Join(g.cfg.ReposDir(), e.Name(), "config.lock") st, err := os.Stat(lock) if err != nil || time.Since(st.ModTime()) < staleLockAge { continue } if os.Remove(lock) == nil { log.Printf("Removed stale config lock: %s", e.Name()) } } } // ConvertNonBareRepos turns any repo with a .git subdirectory into a bare one. func (g *Git) ConvertNonBareRepos() { entries, err := os.ReadDir(g.cfg.ReposDir()) if err != nil { return } for _, e := range entries { if !e.IsDir() { continue } if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), bareTmpSuffix) { g.recoverBareTmp(e.Name()) continue } dir := filepath.Join(g.cfg.ReposDir(), e.Name()) // Lstat, because the removal of the work tree must never follow a // symlinked .git into the real git data. st, err := os.Lstat(filepath.Join(dir, ".git")) if err != nil || !st.IsDir() { if err == nil && st.Mode()&os.ModeSymlink != 0 { log.Printf("Skipping bare conversion of %s: .git is a symlink", e.Name()) } continue } if err := g.convertNonBareRepo(e.Name(), dir); err != nil { log.Printf("Failed to convert non-bare repo %s: %v", e.Name(), err) } } } // convertNonBareRepo moves entry/.git into place as entry.git and drops the // work tree. When the entry is already named *.git the move needs a temporary // name, because source and target would be the same path. func (g *Git) convertNonBareRepo(entryName, entryPath string) error { dotGit := filepath.Join(entryPath, ".git") baseName := entryName if !strings.HasSuffix(entryName, ".git") { baseName += ".git" } target := filepath.Join(g.cfg.ReposDir(), baseName) if strings.HasSuffix(entryName, ".git") { tmp := filepath.Join(g.cfg.ReposDir(), "."+entryName+bareTmpSuffix) if err := os.Rename(dotGit, tmp); err != nil { return err } if err := os.RemoveAll(entryPath); err != nil { return err } if err := os.Rename(tmp, target); err != nil { return err } } else { if err := os.Rename(dotGit, target); err != nil { return err } if err := os.RemoveAll(entryPath); err != nil { return err } } if err := os.RemoveAll(filepath.Join(target, "worktrees")); err != nil { return err } log.Printf("Converted non-bare repo to bare: %s", baseName) return nil } const bareTmpSuffix = ".bare_tmp" // recoverBareTmp finishes a *.git conversion that stopped after the git data // was moved aside to the hidden temporary name. func (g *Git) recoverBareTmp(tmpName string) { tmp := filepath.Join(g.cfg.ReposDir(), tmpName) target := filepath.Join(g.cfg.ReposDir(), strings.TrimSuffix(tmpName[1:], bareTmpSuffix)) if _, err := os.Lstat(target); err == nil { log.Printf("WARNING: %s holds the git data of %s, but %s still exists. Resolve by hand.", tmp, filepath.Base(target), target) return } if err := os.Rename(tmp, target); err != nil { log.Printf("WARNING: could not restore %s to %s: %v", tmp, target, err) return } log.Printf("Recovered interrupted bare conversion: %s", filepath.Base(target)) } // EnsureSigningSetup generates the ssh host key if missing and writes the // allowed_signers file used to verify commit signatures. func (g *Git) EnsureSigningSetup() error { if err := os.MkdirAll(g.cfg.DataDir, 0o700); err != nil { return err } if err := os.MkdirAll(g.cfg.ReposDir(), 0o700); err != nil { return err } hostname := "" if u, err := url.Parse(g.cfg.BaseURL); err == nil { hostname = u.Hostname() } keyPath := g.cfg.SSHHostKeyPath pubPath := keyPath + ".pub" if _, err := os.Stat(keyPath); err != nil { cmd := exec.CommandContext(context.Background(), "ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath, "-C", hostname) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("ssh-keygen: %w: %s", err, out) } log.Printf("Generated SSH host key at %s", keyPath) } pub, err := os.ReadFile(pubPath) if err != nil { log.Printf("Could not read SSH public key at %s", pubPath) return nil } pubKey := strings.TrimSpace(string(pub)) // The comment field holds the hostname the key was made for. A mismatch // means signatures will show an unexpected identity. if fields := strings.Fields(pubKey); len(fields) > 2 && fields[2] != hostname { log.Printf("Warning: SSH host key comment %q does not match hostname %q", fields[2], hostname) } content := "* namespaces=\"git\" " + pubKey + "\n" if g.cfg.ExtraAllowedSigners != "" { extra, err := os.ReadFile(g.cfg.ExtraAllowedSigners) if err != nil { log.Printf("Could not read EXTRA_ALLOWED_SIGNERS_PATH: %s", g.cfg.ExtraAllowedSigners) } else { content += strings.TrimRight(string(extra), "\n") + "\n" } } return os.WriteFile(g.cfg.AllowedSignersPath(), []byte(content), 0o600) }