26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
286 lines
11 KiB
Go
286 lines
11 KiB
Go
package gate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/db"
|
|
"github.com/kunchenguid/no-mistakes/internal/git"
|
|
"github.com/kunchenguid/no-mistakes/internal/paths"
|
|
"github.com/kunchenguid/no-mistakes/internal/scm"
|
|
"github.com/kunchenguid/no-mistakes/internal/scm/github"
|
|
)
|
|
|
|
// RemoteName is the name of the git remote that points to the local gate.
|
|
const RemoteName = "no-mistakes"
|
|
|
|
// repoID generates a deterministic 12-char hex ID from an absolute path.
|
|
func repoID(absPath string) string {
|
|
h := sha256.Sum256([]byte(absPath))
|
|
return fmt.Sprintf("%x", h[:6])
|
|
}
|
|
|
|
// Init sets up a no-mistakes gate for the git repo at workDir.
|
|
// It creates a bare repo, installs the post-receive hook, best-effort
|
|
// isolates the bare repo's hooks path from shared local config writes when
|
|
// Git supports config --worktree, adds the no-mistakes remote, and records
|
|
// the repo in the database.
|
|
//
|
|
// Init is idempotent: re-running it on an already-initialized repo repairs and
|
|
// refreshes the gate (for example installing a newer hook, picking up hook-path
|
|
// isolation, or restoring a missing remote) instead of failing. This includes
|
|
// a working directory that was renamed or moved since the gate was created:
|
|
// the gate identified by the leftover no-mistakes remote is reattached at the
|
|
// new path, preserving its run history. The returned bool reports whether a
|
|
// new gate was created (true) or an existing one was refreshed (false).
|
|
func Init(ctx context.Context, d *db.DB, p *paths.Paths, workDir string) (*db.Repo, bool, error) {
|
|
return InitWithFork(ctx, d, p, workDir, "")
|
|
}
|
|
|
|
// InitWithFork is Init plus an optional GitHub fork push URL. The origin remote
|
|
// remains the parent repository used for PRs. When forkURL is empty, an
|
|
// existing fork setting is preserved across idempotent refreshes.
|
|
func InitWithFork(ctx context.Context, d *db.DB, p *paths.Paths, workDir, forkURL string) (*db.Repo, bool, error) {
|
|
forkURL = strings.TrimSpace(forkURL)
|
|
|
|
// Normalize worktrees back to the main repo root so one repo record works
|
|
// from either the main checkout or any attached worktree.
|
|
gitRoot, err := git.FindMainRepoRoot(workDir)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("find git root: %w", err)
|
|
}
|
|
absRoot := gitRoot
|
|
|
|
// Look up any existing gate so we know whether this is a fresh init or a
|
|
// refresh, and so we never tear down a working gate on a repair failure.
|
|
existing, err := d.GetRepoByPath(absRoot)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("check existing: %w", err)
|
|
}
|
|
if existing == nil {
|
|
// No record at this path, but the repo may have been moved or renamed
|
|
// after init; if so, reattach its existing gate instead of failing on
|
|
// the leftover remote.
|
|
existing, err = reattachRelocatedRepo(ctx, d, p, absRoot)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
// Read origin URL. Keep the historical rewritten value for non-fork repos,
|
|
// but preserve the literal parent URL when fork routing is configured.
|
|
getOriginURL := git.GetRemoteURL
|
|
if forkURL != "" || (existing != nil && strings.TrimSpace(existing.ForkURL) != "") {
|
|
getOriginURL = git.GetConfiguredRemoteURL
|
|
}
|
|
upstreamURL, err := getOriginURL(ctx, absRoot, "origin")
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("get origin url: %w", err)
|
|
}
|
|
if forkURL != "" {
|
|
if err := validateForkRouting(upstreamURL, forkURL); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
id := repoID(absRoot)
|
|
if existing != nil {
|
|
id = existing.ID
|
|
}
|
|
bareDir := p.RepoDir(id)
|
|
|
|
// Provision (or repair) the on-disk gate. This is idempotent.
|
|
if err := provisionGate(ctx, bareDir, absRoot, upstreamURL, p.ReposDir(), existing != nil); err != nil {
|
|
// Only tear down a gate we created in this call; never destroy an
|
|
// already-initialized gate when a repair pass fails.
|
|
if existing == nil {
|
|
if remoteURL, remoteErr := git.GetRemoteURL(ctx, absRoot, RemoteName); remoteErr == nil && remoteURL == bareDir {
|
|
git.RemoveRemote(ctx, absRoot, RemoteName)
|
|
}
|
|
os.RemoveAll(bareDir)
|
|
}
|
|
return nil, false, err
|
|
}
|
|
|
|
// Detect default branch from upstream remote.
|
|
branch := git.DefaultBranch(ctx, absRoot, "origin")
|
|
|
|
if existing != nil {
|
|
var repo *db.Repo
|
|
if forkURL != "" {
|
|
repo, err = d.UpdateRepoMetadataWithFork(existing.ID, upstreamURL, forkURL, branch)
|
|
} else {
|
|
repo, err = d.UpdateRepoMetadata(existing.ID, upstreamURL, branch)
|
|
}
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("update repo metadata: %w", err)
|
|
}
|
|
slog.Info("gate refreshed", "repo_id", repo.ID, "path", absRoot)
|
|
return repo, false, nil
|
|
}
|
|
|
|
// Insert repo record with deterministic ID.
|
|
repo, err := d.InsertRepoWithIDAndFork(id, absRoot, upstreamURL, forkURL, branch)
|
|
if err != nil {
|
|
// Rollback: remove remote and bare repo.
|
|
git.RemoveRemote(ctx, absRoot, RemoteName)
|
|
os.RemoveAll(bareDir)
|
|
return nil, false, fmt.Errorf("insert repo: %w", err)
|
|
}
|
|
|
|
slog.Info("gate initialized", "repo_id", id, "path", absRoot, "upstream", upstreamURL)
|
|
return repo, true, nil
|
|
}
|
|
|
|
func validateForkRouting(upstreamURL, forkURL string) error {
|
|
parentProvider := scm.DetectProvider(upstreamURL)
|
|
forkProvider := scm.DetectProvider(forkURL)
|
|
if parentProvider == scm.ProviderGitHub && forkProvider == scm.ProviderGitHub {
|
|
if github.RepoSlug(upstreamURL) == "" || github.RepoSlug(forkURL) == "" {
|
|
return fmt.Errorf("fork URL routing requires GitHub parent and fork remotes with owner/repo paths")
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("fork URL routing is currently supported only for GitHub parent and fork remotes (parent provider: %s, fork provider: %s)", parentProvider, forkProvider)
|
|
}
|
|
|
|
// provisionGate creates or repairs the on-disk gate for a repo: the bare repo,
|
|
// its push/hook configuration, hook-path isolation, and the git remotes wiring
|
|
// the working repo to the gate and the gate to its upstream. Every step is
|
|
// idempotent so this doubles as the repair path for re-running init.
|
|
func provisionGate(ctx context.Context, bareDir, absRoot, upstreamURL, reposDir string, refresh bool) error {
|
|
// Create the bare repo. git init --bare is a no-op on an existing one.
|
|
if err := git.InitBare(ctx, bareDir); err != nil {
|
|
return fmt.Errorf("create bare repo: %w", err)
|
|
}
|
|
if _, err := git.Run(ctx, bareDir, "config", "receive.advertisePushOptions", "true"); err != nil {
|
|
return fmt.Errorf("enable push options: %w", err)
|
|
}
|
|
|
|
if _, err := git.RefreshManagedPostReceiveHook(bareDir); err != nil {
|
|
return fmt.Errorf("install hook: %w", err)
|
|
}
|
|
|
|
// Pin core.hookspath in the bare's per-worktree config so subprocess
|
|
// writes to shared local config (e.g. husky during pnpm install) can't
|
|
// disable the gate hook. See git.IsolateHooksPath for details.
|
|
if err := git.IsolateHooksPath(ctx, bareDir); err != nil {
|
|
return fmt.Errorf("isolate hooks path: %w", err)
|
|
}
|
|
|
|
// Record upstream as origin on the gate repo so gh can resolve repository
|
|
// context from detached worktrees created from the gate.
|
|
if err := git.EnsureRemote(ctx, bareDir, "origin", upstreamURL); err != nil {
|
|
return fmt.Errorf("add gate origin remote: %w", err)
|
|
}
|
|
|
|
if err := ensureWorkingRemote(ctx, absRoot, bareDir, reposDir, refresh); err != nil {
|
|
return fmt.Errorf("add remote: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ensureWorkingRemote(ctx context.Context, absRoot, bareDir, reposDir string, refresh bool) error {
|
|
if refresh {
|
|
return git.EnsureRemote(ctx, absRoot, RemoteName, bareDir)
|
|
}
|
|
existingURL, err := git.GetRemoteURL(ctx, absRoot, RemoteName)
|
|
if err != nil {
|
|
return git.AddRemote(ctx, absRoot, RemoteName, bareDir)
|
|
}
|
|
if existingURL == bareDir {
|
|
return nil
|
|
}
|
|
// A leftover remote pointing into our own repos dir is stale gate wiring
|
|
// (e.g. the working directory was copied, or its gate was half-ejected);
|
|
// repoint it. Anything else is a user-managed remote we must not touch.
|
|
if filepath.Dir(existingURL) == reposDir {
|
|
return git.EnsureRemote(ctx, absRoot, RemoteName, bareDir)
|
|
}
|
|
return fmt.Errorf("remote %q already exists with url %q", RemoteName, existingURL)
|
|
}
|
|
|
|
// reattachRelocatedRepo detects a working directory that was renamed or moved
|
|
// after init: it carries a no-mistakes remote pointing at a gate in our repos
|
|
// dir, but its repo record references the old path. When the old path no
|
|
// longer exists, the record is migrated to the new path so the existing gate
|
|
// and its run history are reattached. It returns nil when the repo should be
|
|
// treated as a fresh init instead: no gate remote, an orphan gate with no
|
|
// record, or a copy whose original still exists on disk.
|
|
func reattachRelocatedRepo(ctx context.Context, d *db.DB, p *paths.Paths, absRoot string) (*db.Repo, error) {
|
|
remoteURL, err := git.GetRemoteURL(ctx, absRoot, RemoteName)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
id := strings.TrimSuffix(filepath.Base(remoteURL), ".git")
|
|
if p.RepoDir(id) != remoteURL {
|
|
// Not one of our gate paths; fresh init decides what to do with it.
|
|
return nil, nil
|
|
}
|
|
repo, err := d.GetRepo(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("look up relocated repo: %w", err)
|
|
}
|
|
if repo == nil {
|
|
return nil, nil
|
|
}
|
|
if _, err := os.Stat(repo.WorkingPath); err == nil {
|
|
// The recorded checkout still exists, so absRoot is a copy of it, not
|
|
// a move; the copy gets its own gate.
|
|
return nil, nil
|
|
}
|
|
migrated, err := d.UpdateRepoWorkingPath(id, absRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("migrate repo working path: %w", err)
|
|
}
|
|
slog.Info("gate reattached after working dir move", "repo_id", id, "old_path", repo.WorkingPath, "new_path", absRoot)
|
|
return migrated, nil
|
|
}
|
|
|
|
// Eject removes the no-mistakes gate from the repo at workDir.
|
|
// It removes the remote, deletes the bare repo and worktrees,
|
|
// and deletes the repo record from the database.
|
|
func Eject(ctx context.Context, d *db.DB, p *paths.Paths, workDir string) (*db.Repo, error) {
|
|
// Normalize worktrees back to the main repo root so eject works no matter
|
|
// which checkout the user runs it from.
|
|
gitRoot, err := git.FindMainRepoRoot(workDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find git root: %w", err)
|
|
}
|
|
absRoot := gitRoot
|
|
|
|
// Look up repo in DB.
|
|
repo, err := d.GetRepoByPath(absRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get repo: %w", err)
|
|
}
|
|
if repo == nil {
|
|
return nil, fmt.Errorf("not initialized for %s", absRoot)
|
|
}
|
|
|
|
// Remove remote from working repo (non-fatal).
|
|
_ = git.RemoveRemote(ctx, absRoot, RemoteName)
|
|
|
|
// Delete bare repo.
|
|
bareDir := p.RepoDir(repo.ID)
|
|
os.RemoveAll(bareDir)
|
|
|
|
// Delete worktrees for this repo.
|
|
repoWtDir := filepath.Join(p.WorktreesDir(), repo.ID)
|
|
os.RemoveAll(repoWtDir)
|
|
|
|
// Delete repo record (cascades to runs + steps).
|
|
if err := d.DeleteRepo(repo.ID); err != nil {
|
|
return nil, fmt.Errorf("delete repo record: %w", err)
|
|
}
|
|
|
|
slog.Info("gate ejected", "repo_id", repo.ID, "path", absRoot)
|
|
return repo, nil
|
|
}
|