package xrpc import ( "context" "crypto/sha256" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/dgraph-io/ristretto" "tangled.org/core/api/tangled" ) type MergeInput struct { TargetRepo syntax.DID TargetBranch string SourceRepo syntax.DID SourceCommit string } type MergeCheckCache struct { cache *ristretto.Cache } func (m *MergeCheckCache) cacheKey(input MergeInput) string { raw := strings.Join([]string{ input.TargetRepo.String(), input.TargetBranch, input.SourceRepo.String(), input.SourceCommit, }, "\x00") sum := sha256.Sum256([]byte(raw)) return fmt.Sprintf("%x", sum) } func (m *MergeCheckCache) cacheVal(out *tangled.GitMergeCheck_Output) any { return *out } func (m *MergeCheckCache) Set(input MergeInput, mergeCheck *tangled.GitMergeCheck_Output) { key := m.cacheKey(input) val := m.cacheVal(mergeCheck) m.cache.Set(key, val, 0) } func (m *MergeCheckCache) Get(input MergeInput) (tangled.GitMergeCheck_Output, bool) { key := m.cacheKey(input) if val, ok := m.cache.Get(key); ok { if out, ok := val.(tangled.GitMergeCheck_Output); ok { // cache hit return out, true } } // cache miss return tangled.GitMergeCheck_Output{}, false } var mergeCheckCache MergeCheckCache func init() { cache, _ := ristretto.NewCache(&ristretto.Config{ NumCounters: 1e7, MaxCost: 1 << 30, BufferItems: 64, TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days }) mergeCheckCache = MergeCheckCache{cache} } func (x *Xrpc) GitMergeCheck(w http.ResponseWriter, r *http.Request) { var input tangled.GitMergeCheck_Input if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) return } l := x.Logger.With("handler", "MergeCheck2", "input", input) l.Debug("request") if err := gitMergeCheck_Input_Validate(input); err != nil { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "InvalidRequest", Message: err.Error()}) return } mergeInput := MergeInput{ TargetRepo: syntax.DID(input.Repo), TargetBranch: input.Branch, SourceRepo: syntax.DID(input.Source.Repo), SourceCommit: input.Source.Commit, } // check cache if cached, ok := mergeCheckCache.Get(mergeInput); ok { l.Debug("cache hit") writeJson(w, http.StatusOK, cached) return } output, status, apierr := x.mergeCheck(r.Context(), input) if apierr != nil { l.Error("failed", "kind", apierr.Name, "error", apierr.Message) writeJson(w, status, apierr) return } // update cache mergeCheckCache.Set(mergeInput, &output) writeJson(w, status, output) } func (x *Xrpc) mergeCheck(ctx context.Context, input tangled.GitMergeCheck_Input) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { l := x.Logger.With("handler", "mergeCheck") fail := func(status int, name, clientMsg string, detail ...any) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { l.Error(clientMsg, append([]any{"name", name}, detail...)...) return tangled.GitMergeCheck_Output{}, status, &atclient.ErrorBody{Name: name, Message: clientMsg} } baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Repo, "err", err) } sourceRepoDid := syntax.DID(input.Source.Repo) var sourceRepoUrl string var sourceRepoPath string // non-empty only when the source is local to this knot if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil { sourceRepoPath = p sourceRepoUrl = "file://" + p } else { ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err) } sourceKnot := ident.GetServiceEndpoint("atproto_pds") u, err := url.Parse(sourceKnot) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err) } sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String() } env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") // 1. create temp repo with git alternate to the base repo's objects. tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Branch) if err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge check", "err", err) } defer cleanup() runGit := func(args ...string) ([]byte, []byte, error) { args = append([]string{"-C", tmpRepoPath}, args...) return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...) } // 2. fetch source commit and pin it to a "tracking" branch. fetchPaths := []string{tmpRepoPath} if sourceRepoPath != "" { fetchPaths = append(fetchPaths, sourceRepoPath) } if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil { return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) } if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) } // 3. populate the working tree on the base branch. if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) } // 4. attempt a 3-way merge without committing. if _, stderr, err := runGit("merge", "--no-commit", "--no-ff", "tracking"); err != nil { lsOut, _, _ := runGit("ls-files", "--unmerged") files := parseUnmergedFiles(lsOut) if len(files) == 0 { return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "merge", "err", err, "stderr", strings.TrimSpace(string(stderr))) } conflicts := make([]*tangled.GitMergeCheck_ConflictInfo, 0, len(files)) for _, f := range files { conflicts = append(conflicts, &tangled.GitMergeCheck_ConflictInfo{ Filename: f, Reason: "merge conflict", }) } msg := strings.TrimSpace(string(stderr)) l.Debug("merge check found conflicts", "files", files) return tangled.GitMergeCheck_Output{ IsConflicted: true, Conflicts: conflicts, Message: &msg, }, http.StatusOK, nil } return tangled.GitMergeCheck_Output{IsConflicted: false}, http.StatusOK, nil } // lexgen doesn't give Validate() method... func gitMergeCheck_Input_Validate(input tangled.GitMergeCheck_Input) error { if _, err := syntax.ParseDID(input.Repo); err != nil { return fmt.Errorf("repo: invalid DID: %w", err) } if input.Branch == "" { return fmt.Errorf("branch: required") } if input.Source == nil { return fmt.Errorf("source: required") } if _, err := syntax.ParseDID(input.Source.Repo); err != nil { return fmt.Errorf("source.repo: invalid DID: %w", err) } if input.Source.Commit == "" { return fmt.Errorf("source.commit: required") } return nil }