Files
cloud/apps/git/github_import.go
hanzo-dev f873d1a180 apps: the 131 subsystems move from clients/ to apps/
They were never clients. A "client" is something that CALLS a service; these
are the subsystems the cloud binary mounts and serves. The name said the
opposite of what the code does, and it sat one directory above a composition
root already named `apps` — so the tree read as though `apps/` and `clients/`
were different kinds of thing when one is simply the wiring for the other.

`apps` is a package, so its subsystems nest under it directly:

    apps/apps.go       package apps — Wire() returns the 118 MountSpecs
    apps/git/          package git
    apps/projects/     package projects

`apps.Wire()` composing `apps/git` and `apps/projects` needs no second noun.

WHAT DID NOT MOVE. `clients/*.go` (package clients — aihttp, rpc, s3vfs) is
genuinely a client package: HTTP/RPC/VFS handles the subsystems dial OUT with.
It keeps the name, because for those six files the name was always right. Only
the 131 misfiled subdirectories moved, and the import rewrite is scoped to
`hanzoai/cloud/clients/<x>` so the surviving package is untouched.

This is a MOVE, not a rewrite, and deliberately so. mk/plugin.mk already
derives every path from its own location precisely so that "an extracted
apps/<app> + cmd/<app> + mk/ keeps these paths intact" — the build contract was
written for this migration before the directory was renamed to match it. 31
hanzoai/* modules are already extracted and wired as external imports, 16 of
them with thin in-repo adapters that import their own module (verified: zero
duplicates, no forked implementations). This rename puts the remaining 115 in
the directory the extraction contract already names.

Mechanical: git mv per subdir, then `hanzoai/cloud/clients/` ->
`hanzoai/cloud/apps/` across 656 Go files, plus 23 docs/Makefiles/manifests
rewritten only where the path names a real moved app. Builds clean; projects,
git and catalog tests pass.

(cmd/admission and cmd/affiliates fail to LINK here, before and after: they
need native/flags/target/release/libhanzo_flags.a, a Rust artifact never built
in this checkout. Pre-existing and unrelated.)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 11:45:30 -07:00

428 lines
17 KiB
Go

package git
import (
"bytes"
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/hanzoai/cloud"
)
// github_import.go implements the cloud.GitImporter seam (git_import.go in the
// root): the git object-plane half of the GitHub-App bidirectional sync. The
// integrations plane owns the App (token minting, repo list, webhook signature);
// this file owns the repo objects — create + mirror-in (import), the fast-forward-
// ONLY inbound advance, and the per-repo status roll-up. Registered in Mount via
// cloud.RegisterGitImporter so integrations reaches it with no integrations⇄git
// cycle.
//
// CANONICAL-SOURCE INVARIANT (the whole point): native is the source of truth.
// The inbound fetch uses a NON-forcing refspec (no leading '+'), so git itself
// REFUSES a non-fast-forward update — a diverged native ref is NEVER overwritten.
// A rejection is a Conflict (recorded + surfaced), not a force. This is enforced
// by git, not by our own ancestor check, so it cannot be reasoned around.
// githubImporter is the registered cloud.GitImporter. It carries no state — every
// method resolves the mounted service through the package `mounted` var (set in
// Mount before RegisterGitImporter), exactly like the lifecycle reactors, and
// fails closed when the subsystem is unmounted.
type githubImporter struct{}
// ImportRepo creates the native repo if absent, fast-forward mirror-ins every
// branch from the upstream using the per-call installation token, and registers the
// outbound mirror target so a later native push force-safe-mirrors back. Import is
// FAST-FORWARD-ONLY per branch — like the webhook, it NEVER force-overwrites native
// (a re-import of a repo whose native branch diverged records that branch as a
// conflict and preserves native, rather than destroying native-only commits). For
// the first import (empty native) every branch is a create, so the whole repo lands.
// Idempotent.
func (githubImporter) ImportRepo(ctx context.Context, req cloud.GitImportReq) error {
s := mounted.Load()
if s == nil {
return fmt.Errorf("git: not mounted")
}
name := normalizeName(req.Repo)
if !nameRE.MatchString(name) {
return fmt.Errorf("git: invalid repo name")
}
project := req.Project
if project != "" && !projectRE.MatchString(project) {
return fmt.Errorf("git: invalid project")
}
// Harden the upstream URL the same way the org-supplied /mirror endpoint does:
// https, no userinfo, SSRF-guarded host. github.com is public so it passes.
src, err := mirrorSource(req.CloneURL)
if err != nil {
return err
}
store, err := storeFor(s, req.Org)
if err != nil {
return err
}
if _, err := ensureRepo(s, ctx, store, req.Org, project, name); err != nil {
return err
}
cred := gitCred{}
if req.Token != "" {
cred = gitCred{User: "x-access-token", Token: req.Token}
}
outcomes, err := s.State.storage.importFetch(ctx, req.Org, project, name, src, cred)
if err != nil {
return err
}
now := time.Now().Unix()
for branch, oc := range outcomes {
if oc.Conflict {
_ = store.RecordConflict(ctx, req.Org, project, name, branch, oc.Detail, now)
} else {
_ = store.ClearConflict(ctx, req.Org, project, name, branch)
}
}
if req.MirrorURL != "" {
if err := ensureMirrorTarget(ctx, store, req.Org, project, name, req.MirrorURL); err != nil {
s.Log.Warn("git import: register outbound mirror", "org", req.Org, "repo", name, "err", err)
}
}
recordUsage(s, context.WithoutCancel(ctx), req.Org, project, name)
// Index-on-import: emit the SAME push.landed the push/inbound paths emit so the
// code index (index_on_push) covers this repo NOW, not only after its next push
// (the one-way /v1/code-over-all-of-/v1/git guarantee). Origin = source host, so
// the outbound mirror suppresses the echo back to where we just imported from.
// Best-effort + detached (EmitLifecycle fans out on a cancel-immune ctx).
emitImportPush(s, context.WithoutCancel(ctx), req.Org, project, name, src)
return nil
}
// InboundSync fast-forward-only advances one branch from an upstream push. Only an
// ALREADY-IMPORTED repo is synced (a push to an un-imported repo is a no-op —
// never auto-create on a webhook). On a fast-forward it advances native + emits a
// push.landed carrying Origin (so push-to-deploy fires AND the outbound mirror
// suppresses the echo). On a divergence it records a conflict and leaves native
// UNCHANGED. On an equal tip (the loop echo) it is a no-op.
func (githubImporter) InboundSync(ctx context.Context, req cloud.GitInboundReq) (cloud.GitSyncResult, error) {
s := mounted.Load()
if s == nil {
return cloud.GitSyncResult{}, fmt.Errorf("git: not mounted")
}
name := normalizeName(req.Repo)
if !nameRE.MatchString(name) {
return cloud.GitSyncResult{}, fmt.Errorf("git: invalid repo name")
}
// A FULL ref, so branches and tags are the same path. The suffix still goes
// through branchRE: it is what keeps a ref from escaping its namespace or
// carrying a traversal, and that property matters identically for a tag.
if !refRE.MatchString(req.Ref) {
return cloud.GitSyncResult{}, fmt.Errorf("git: invalid ref")
}
project := req.Project
store, err := storeFor(s, req.Org)
if err != nil {
return cloud.GitSyncResult{}, err
}
// Only sync imported repos. An un-imported push is a no-op (the user imports
// explicitly; a webhook never provisions a new native repo behind their back).
if _, err := store.Get(ctx, req.Org, project, name); err != nil {
if errors.Is(err, errNotFound) {
return cloud.GitSyncResult{NoOp: true, Detail: "repo not imported"}, nil
}
return cloud.GitSyncResult{}, err
}
src, err := mirrorSource(req.CloneURL)
if err != nil {
return cloud.GitSyncResult{}, err
}
cred := gitCred{}
if req.Token != "" {
cred = gitCred{User: "x-access-token", Token: req.Token}
}
res, err := s.State.storage.inboundFastForward(ctx, req.Org, project, name, req.Ref, src, cred)
if err != nil {
return cloud.GitSyncResult{}, err
}
switch {
case res.Conflict:
_ = store.RecordConflict(ctx, req.Org, project, name, req.Ref, res.Detail, time.Now().Unix())
s.Log.Warn("git.inbound.conflict",
"org", req.Org, "project", project, "repo", name, "branch", req.Ref,
"origin", req.Origin, "detail", res.Detail)
return cloud.GitSyncResult{Conflict: true, Detail: res.Detail}, nil
case res.Applied:
_ = store.ClearConflict(ctx, req.Org, project, name, req.Ref)
recordUsage(s, context.WithoutCancel(ctx), req.Org, project, name)
// Fan the inbound advance into the lifecycle stream: push-to-deploy fires
// (native content changed) AND the outbound mirror suppresses the echo —
// Origin == the source host it just arrived from, so mirror_out skips that
// target and no ping-pong occurs. Other targets (e.g. gitlab) still receive it.
cloud.EmitLifecycle(ctx, cloud.LifecycleEvent{
Kind: cloud.LifecyclePushLanded, Org: req.Org, Project: project, Repo: name,
Branch: req.Ref, Before: res.Before, After: res.After, Origin: req.Origin,
})
return cloud.GitSyncResult{Applied: true, Before: res.Before, After: res.After}, nil
default:
return cloud.GitSyncResult{NoOp: true, Detail: "up to date"}, nil
}
}
// RepoStatus returns the per-repo import + sync status for names (org-scoped). Two
// queries regardless of name count: the org's repo rows (Imported + LastSyncedAt)
// and the set of repos with an unresolved inbound conflict.
func (githubImporter) RepoStatus(ctx context.Context, org, project string, names []string) (map[string]cloud.GitRepoStatus, error) {
s := mounted.Load()
if s == nil {
return nil, fmt.Errorf("git: not mounted")
}
store, err := storeFor(s, org)
if err != nil {
return nil, err
}
repos, err := store.List(ctx, org, project)
if err != nil {
return nil, err
}
updated := make(map[string]int64, len(repos))
for _, r := range repos {
updated[r.Name] = r.UpdatedAt
}
conflicts, err := store.ConflictRepoSet(ctx, org, project)
if err != nil {
return nil, err
}
out := make(map[string]cloud.GitRepoStatus, len(names))
for _, n := range names {
n = normalizeName(n)
st := cloud.GitRepoStatus{}
if ua, ok := updated[n]; ok {
st.Imported = true
st.LastSyncedAt = ua
}
if conflicts[n] {
st.Conflict = true
}
out[n] = st
}
return out, nil
}
// ensureMirrorTarget registers a downstream outbound mirror for the repo,
// idempotently: a target to the same host already present is a no-op (not an
// error) so a re-import never fails on the existing mirror.
func ensureMirrorTarget(ctx context.Context, store *Store, org, project, repo, rawURL string) error {
target, host, err := validateMirrorTarget(rawURL)
if err != nil {
return err
}
id, err := genID("mir")
if err != nil {
return err
}
err = store.CreateMirror(ctx, MirrorTarget{
ID: id, Org: org, Project: project, Repo: repo,
Host: host, URL: target, CreatedAt: time.Now().Unix(),
})
if errors.Is(err, errConflict) {
return nil // already registered — idempotent
}
return err
}
// ── import: fast-forward every branch (never force-overwrite native) ─────────
// importFetch fast-forward mirror-ins every branch the source advertises, plus its
// tags (create-only) and HEAD, returning the per-branch outcome. It reuses the
// single-branch ff primitive per branch, so a diverged native branch is a Conflict
// (native preserved), never a force-overwrite — the SAME canonical-source rule the
// webhook uses, applied to the whole repo. The first branch's fetch pulls the shared
// history; later branches reuse those objects (cheap negotiation).
func (s *storage) importFetch(ctx context.Context, org, project, name, srcURL string, cred gitCred) (map[string]ffResult, error) {
env := mirrorGitEnv(srcURL, cred)
branches, head, err := s.lsRemoteHeads(ctx, srcURL, env)
if err != nil {
return nil, fmt.Errorf("list source refs: %w", err)
}
out := make(map[string]ffResult, len(branches))
for _, b := range branches {
if !branchRE.MatchString(b) {
continue // skip a source branch whose name can't be a native ref
}
res, err := s.inboundFastForward(ctx, org, project, name, b, srcURL, cred)
if err != nil {
return out, fmt.Errorf("fetch branch %s: %w", b, err)
}
out[b] = res
}
// Tags: create-only (non-forcing), so an existing native tag is never clobbered.
// Best-effort — a tag that can't create must not fail a branch-complete import.
s.fetchTags(ctx, org, project, name, srcURL, env)
// Point HEAD at the source default branch when native now has it (so a
// clone-back resolves the same default the source has).
if head != "" {
s.setHeadIfPresent(ctx, org, project, name, head, env)
}
return out, nil
}
// lsRemoteHeads lists the source's branches (refs/heads/*) and its HEAD symref
// (the default branch) in one bounded ls-remote — the ref list is bounded by ref
// count, not pack size, so it is safe to buffer.
func (s *storage) lsRemoteHeads(ctx context.Context, srcURL string, env []string) (branches []string, head string, err error) {
cmd, err := gitCmd(ctx, env,
"-c", "protocol.version=2", "-c", "credential.helper=",
"ls-remote", "--symref", srcURL)
if err != nil {
return nil, "", err
}
var out bytes.Buffer
stderr := &cappedBuffer{cap: stderrCap}
cmd.Stdout, cmd.Stderr = &out, stderr
if err := cmd.Run(); err != nil {
return nil, "", fmt.Errorf("ls-remote: %w: %s", err, sanitizeGitErr(stderr.String()))
}
for _, line := range strings.Split(out.String(), "\n") {
// "ref: refs/heads/main\tHEAD" — the default-branch symref.
if rest, ok := strings.CutPrefix(line, "ref: "); ok {
if tab := strings.IndexByte(rest, '\t'); tab > 0 {
if strings.TrimSpace(rest[tab+1:]) == "HEAD" {
head = strings.TrimSpace(rest[:tab])
}
}
continue
}
// "<hash>\trefs/heads/<branch>" — a branch tip.
tab := strings.IndexByte(line, '\t')
if tab <= 0 {
continue
}
if b, ok := strings.CutPrefix(strings.TrimSpace(line[tab+1:]), "refs/heads/"); ok && b != "" {
branches = append(branches, b)
}
}
return branches, head, nil
}
// fetchTags fetches the source's tags into native with create-only (non-forcing)
// semantics, so an existing native tag is never clobbered. Best-effort.
func (s *storage) fetchTags(ctx context.Context, org, project, name, srcURL string, env []string) {
bareDir := s.absRepoPath(org, project, name)
args := append(packConfigArgs(""),
"-c", "protocol.version=2", "-c", "credential.helper=",
"--git-dir="+bareDir, "fetch", "--no-write-fetch-head", srcURL, "refs/tags/*:refs/tags/*")
cmd, err := gitCmd(ctx, env, args...)
if err != nil {
return
}
cmd.Stderr = &cappedBuffer{cap: stderrCap}
_ = withPackSlot(ctx, cmd.Run) // a non-ff tag update rejects; native's tag stays
}
// setHeadIfPresent points the bare repo's HEAD at ref (e.g. refs/heads/main) when
// that ref exists in native, so a clone resolves the source's default branch.
func (s *storage) setHeadIfPresent(ctx context.Context, org, project, name, ref string, env []string) {
bareDir := s.absRepoPath(org, project, name)
if s.revParse(ctx, bareDir, ref) == "" {
return // native doesn't have that branch (it diverged/absent) — leave HEAD
}
cmd, err := gitCmd(ctx, env, "--git-dir="+bareDir, "symbolic-ref", "HEAD", ref)
if err != nil {
return
}
cmd.Stderr = &cappedBuffer{cap: stderrCap}
_ = cmd.Run()
}
// ── the fast-forward-only fetch (the split-brain guard) ──────────────────────
// ffResult is the outcome of inboundFastForward.
type ffResult struct {
Applied, NoOp, Conflict bool
Before, After string
Detail string
}
// nonFFRE matches git's rejection of a non-fast-forward fetch. When git refuses to
// update refs/heads/<branch> because the upstream tip is not a descendant of the
// native tip, it prints "! [rejected] <b> -> <b> (non-fast-forward)" and exits
// non-zero WITHOUT changing the ref — that is precisely the divergence we treat as
// a Conflict (native preserved), distinct from a network/auth error.
var nonFFRE = regexp.MustCompile(`(?i)\[rejected\]|non-fast-forward|would clobber existing tag`)
// inboundFastForward advances a ref to the upstream tip IFF it is a
// fast-forward, using a NON-forcing refspec so git itself refuses (and preserves
// native on) a divergence. Returns:
// - Applied the fetch fast-forwarded native (Before != After)
// - NoOp already up to date (the loop echo: upstream tip == native tip)
// - Conflict git rejected the update as non-fast-forward — native UNCHANGED
//
// A non-rejection failure (network/auth) is returned as an error; native is
// unchanged in that case too (a failed fetch never mutates a ref).
func (s *storage) inboundFastForward(ctx context.Context, org, project, name, ref, srcURL string, cred gitCred) (ffResult, error) {
bareDir := s.absRepoPath(org, project, name)
env := mirrorGitEnv(srcURL, cred)
// ref arrives FULL (refs/heads/<branch> or refs/tags/<tag>), so branches and
// tags take the same path. The non-forcing refspec below is what makes that
// safe for tags too: re-pointing an existing tag is not a fast-forward, so git
// rejects it and native keeps the tag it already published.
localRef := ref
before := s.revParse(ctx, bareDir, localRef) // "" when the ref is new to native
// The DESTINATION refspec has NO leading '+', so git enforces fast-forward on
// the ref. protocol.version=2 = cheaper negotiation; credential.
// helper= disables any leaky helper; --no-write-fetch-head keeps the bare repo
// clean; the pack streams to disk under a pack slot (bounded memory).
refspec := localRef + ":" + localRef
args := append(packConfigArgs(""),
"-c", "protocol.version=2", "-c", "credential.helper=",
"--git-dir="+bareDir, "fetch", "--no-write-fetch-head", srcURL, refspec)
cmd, err := gitCmd(ctx, env, args...)
if err != nil {
return ffResult{}, err
}
stderr := &cappedBuffer{cap: stderrCap}
cmd.Stderr = stderr
runErr := withPackSlot(ctx, cmd.Run)
after := s.revParse(ctx, bareDir, localRef)
if runErr == nil {
if after == before {
return ffResult{NoOp: true, Before: before, After: after}, nil
}
return ffResult{Applied: true, Before: before, After: after}, nil
}
// Non-zero exit. A fast-forward rejection is the divergence signal — native is
// preserved (after == before), a Conflict not an error. Anything else is a real
// fetch failure surfaced to the caller (native still unchanged).
msg := sanitizeGitErr(stderr.String())
if nonFFRE.MatchString(msg) {
return ffResult{
Conflict: true, Before: before, After: after,
Detail: "native ref " + ref + " has commits not on the upstream; fast-forward not possible (native preserved)",
}, nil
}
return ffResult{}, fmt.Errorf("git fetch: %w: %s", runErr, msg)
}
// revParse resolves a ref to its commit hash, returning "" when the ref does not
// exist (a new branch) or on any error. `--verify --quiet` prints the hash + exits
// 0 when the ref resolves, and exits non-zero with no output otherwise.
func (s *storage) revParse(ctx context.Context, bareDir, ref string) string {
cmd, err := gitCmd(ctx, nil, "--git-dir="+bareDir, "rev-parse", "--verify", "--quiet", ref)
if err != nil {
return ""
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &cappedBuffer{cap: stderrCap}
if err := cmd.Run(); err != nil {
return ""
}
return trimHash(out.String())
}
// trimHash trims surrounding whitespace/newline from a git rev-parse hash.
func trimHash(s string) string {
return string(bytes.TrimSpace([]byte(s)))
}