package xrpc import ( "context" "encoding/hex" "encoding/json" "fmt" "net/http" "os/exec" "strings" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" ) func (x *Xrpc) KeepCommit(w http.ResponseWriter, r *http.Request) { var input tangled.GitKeepCommit_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 } if err := gitKeepCommit_Input_Validate(input); err != nil { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) return } if syntax.ATURI(input.Record).RecordKey() == "" { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "record at-uri should have rkey"}) return } output, status, apierr := x.keepCommit(r.Context(), input) if apierr != nil { writeJson(w, status, apierr) return } writeJson(w, status, output) } func (x *Xrpc) keepCommit(ctx context.Context, input tangled.GitKeepCommit_Input) (*tangled.GitKeepCommit_Output, int, *atclient.ErrorBody) { repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) if err != nil { return nil, http.StatusNotFound, &atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", input.Repo)} } record := syntax.ATURI(input.Record) recordIdent, err := x.Resolver.Directory().Lookup(ctx, record.Authority()) if err != nil { return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "Failed to resolve record authority"} } recordDID := recordIdent.DID var commitID string switch { case input.Source.GitKeepCommit_Commit != nil: source := input.Source.GitKeepCommit_Commit if input.Repo == source.Repo { // no-op. we already have that commit } else { // TODO: target repo should own the source commit return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source repo should match the target repo"} // // fetch commit from source repo // if err := x.fetchCommitFrom(ctx, syntax.DID(source.Repo), source.Oid); err != nil { // return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "CommitNotFound", Message: "Failed to fetch commit from source repo"} // } } commitID = source.Oid case input.Source.GitKeepCommit_Patches != nil: // TODO: apply patches to target commit and bring tip commit ID return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "patches source is not supported"} default: return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source should be one of: [commit, patches]"} } // create refs/tngl/keep/{did}/{collection}/{rkey}/{oid} refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "update-ref", refName, commitID) if out, err := cmd.CombinedOutput(); err != nil { x.Logger.Error("failed to keep commit", "err", err, "out", string(out)) return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalServerError", Message: "Failed to keep commit"} } return &tangled.GitKeepCommit_Output{ Commit: commitID, }, http.StatusOK, nil } // lexgen doesn't give Validate() method... func gitKeepCommit_Input_Validate(input tangled.GitKeepCommit_Input) error { if _, err := syntax.ParseDID(input.Repo); err != nil { return fmt.Errorf("repo: invalid repo DID: %w", err) } if _, err := syntax.ParseATURI(input.Record); err != nil { return fmt.Errorf("repo: invalid record at-uri: %w", err) } switch { case input.Source.GitKeepCommit_Commit != nil: if _, err := syntax.ParseDID(input.Source.GitKeepCommit_Commit.Repo); err != nil { return fmt.Errorf("source: commit: invalid repo DID: %w", err) } if ok := IsHash(input.Source.GitKeepCommit_Commit.Oid); !ok { return fmt.Errorf("source: commit: invalid commit OID: %q", input.Source.GitKeepCommit_Commit.Oid) } case input.Source.GitKeepCommit_Patches != nil: return fmt.Errorf("source: patches: patch is not supported yet") // for i, patch := range input.Source.GitKeepCommit_Patches.Patches { // if err := validatePatch(patch); err != nil { // return fmt.Errorf("source: patches: invalid patches at [%d]: %w", i, err) // } // } default: return fmt.Errorf("source should be one of: [commit, patches]") } return nil } func EscapeGitRef(s string) string { var b strings.Builder b.Grow(len(s) * 4 / 3) for i := 0; i < len(s); i++ { c := s[i] if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '_' || c == '.' { b.WriteByte(c) continue } b.WriteByte('%') b.WriteByte("0123456789ABCDEF"[c>>4]) b.WriteByte("0123456789ABCDEF"[c&15]) } return strings.ToLower(b.String()) } func IsHash(s string) bool { switch len(s) { case 40: // SHA1 case 64: // SHA2 default: return false } _, err := hex.DecodeString(s) return err == nil }