diff --git a/internal/cli/output.go b/internal/cli/output.go index 308ba5d..5c35fd1 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -86,12 +86,6 @@ type sshKeyAddResult struct { URI string `json:"uri"` } -type prCheckoutResult struct { - Rkey string `json:"rkey"` - Branch string `json:"branch"` - Directory string `json:"directory"` -} - type authStatusResult struct { Authenticated bool `json:"authenticated"` DID string `json:"did,omitempty"` diff --git a/internal/cli/pr_checkout.go b/internal/cli/pr_checkout.go deleted file mode 100644 index 9623df2..0000000 --- a/internal/cli/pr_checkout.go +++ /dev/null @@ -1,94 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/alyraffauf/tg/internal/gitutil" - "github.com/alyraffauf/tg/tangled" - "github.com/spf13/cobra" -) - -var prCheckoutCmd = &cobra.Command{ - Use: "checkout ", - Short: "Check out a pull request as a detached HEAD", - Long: `Check out a pull request by fetching its target branch and applying -the PR's gzipped patch blob on top, leaving you in a detached HEAD. - -Must be run from inside a cloned Tangled repository.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - prRKey := args[0] - - // No explicit handle/repo: must auto-detect from CWD. - handle, repo, err := resolveTarget(ctx, nil) - if err != nil { - return err - } - - repoDid, err := findRepoDid(ctx, handle, repo) - if err != nil { - return err - } - - pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return fmt.Errorf("list pulls for %q: %w", repo, err) - } - - found, err := findByRKey(pulls.Items, prRKey, "pull request") - if err != nil { - return err - } - var pr tangled.PullRecord - if err := json.Unmarshal(found.Value, &pr); err != nil { - return fmt.Errorf("decode pull request %q: %w", prRKey, err) - } - authorDID := extractDID(found.URI) - - if len(pr.Rounds) == 0 { - return fmt.Errorf("pull request %q has no rounds", prRKey) - } - - // The last round is the latest patch revision. - cid := pr.Rounds[len(pr.Rounds)-1].PatchBlob.Ref.String() - if cid == "" { - return fmt.Errorf("pull request %q has no patch blob", prRKey) - } - - pdsHost, err := resolver.ResolvePDS(ctx, authorDID) - if err != nil { - return fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) - } - - repoDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("get current directory: %w", err) - } - - if err := gitutil.CheckoutPull(ctx, gitutil.CheckoutPullParams{ - RepoDir: repoDir, - PDSHost: pdsHost, - AuthorDID: authorDID, - CID: cid, - TargetHandle: handle, - TargetRepo: repo, - TargetBranch: pr.Target.Branch, - }); err != nil { - return fmt.Errorf("checkout pull %q: %w", prRKey, err) - } - - result := prCheckoutResult{ - Rkey: prRKey, - Branch: pr.Target.Branch, - Directory: repoDir, - } - return output(result, func(checkout prCheckoutResult) { - fmt.Printf("Checked out PR %s as detached HEAD in %s\n", checkout.Rkey, checkout.Directory) - }) - }, -} diff --git a/internal/gitutil/checkout_pull.go b/internal/gitutil/checkout_pull.go deleted file mode 100644 index 392ffcd..0000000 --- a/internal/gitutil/checkout_pull.go +++ /dev/null @@ -1,76 +0,0 @@ -package gitutil - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "os/exec" -) - -// CheckoutPullParams groups the inputs to CheckoutPull. -type CheckoutPullParams struct { - RepoDir string // local git repo to operate in - PDSHost string // author's PDS base URL - AuthorDID string // DID of the PR author - CID string // CID of the patch blob - TargetHandle string // target repo owner handle - TargetRepo string // target repo name - TargetBranch string // target branch to fetch and detach onto -} - -// CheckoutPull fetches the target branch as detached HEAD and applies -// the PR's gzipped patch blob on top, all inside params.RepoDir. -func CheckoutPull(ctx context.Context, params CheckoutPullParams) error { - fetchURL := tangledRemoteURL(params.TargetHandle, params.TargetRepo) - if err := runIn(params.RepoDir, ctx, "git", "fetch", fetchURL, params.TargetBranch); err != nil { - return fmt.Errorf("fetch target branch: %w", err) - } - if err := runIn(params.RepoDir, ctx, "git", "checkout", "--detach", "FETCH_HEAD"); err != nil { - return fmt.Errorf("checkout detached HEAD: %w", err) - } - - blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", params.PDSHost, params.AuthorDID, params.CID) - req, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil) - if err != nil { - return fmt.Errorf("build blob request: %w", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("download patch blob: %w", err) - } - defer resp.Body.Close() - - return applyGzippedPatch(ctx, params.RepoDir, resp.Body) -} - -// applyGzippedPatch decompresses a gzipped patch and applies it via -// `git am` inside repoDir. -func applyGzippedPatch(ctx context.Context, repoDir string, body io.Reader) error { - gunzip := exec.CommandContext(ctx, "gunzip") - gunzip.Stdin = body - - gitAm := exec.CommandContext(ctx, "git", "am") - gitAm.Dir = repoDir - gitAm.Stdout = os.Stdout - gitAm.Stderr = os.Stderr - - pipe, err := gunzip.StdoutPipe() - if err != nil { - return fmt.Errorf("create pipe: %w", err) - } - gitAm.Stdin = pipe - - if err := gunzip.Start(); err != nil { - return fmt.Errorf("start gunzip: %w", err) - } - if err := gitAm.Run(); err != nil { - return fmt.Errorf("git am: %w", err) - } - if err := gunzip.Wait(); err != nil { - return fmt.Errorf("gunzip: %w", err) - } - - return nil -} diff --git a/internal/gitutil/patch.go b/internal/gitutil/patch.go new file mode 100644 index 0000000..686fea1 --- /dev/null +++ b/internal/gitutil/patch.go @@ -0,0 +1,106 @@ +package gitutil + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// GeneratePatch returns a gzipped git format-patch series for commits in head +// that are not in base. The base commit must be an ancestor of head so the +// result can be applied onto the target branch with git am. +func GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, error) { + baseRevision, err := resolveBaseRevision(ctx, repoDir, base) + if err != nil { + return nil, fmt.Errorf("resolve base %q: %w", base, err) + } + headRevision, err := resolveRevision(ctx, repoDir, head) + if err != nil { + return nil, fmt.Errorf("resolve head %q: %w", head, err) + } + + if err := gitCommand(ctx, repoDir, "merge-base", "--is-ancestor", baseRevision, headRevision); err != nil { + return nil, fmt.Errorf("base %q is not an ancestor of head %q", base, head) + } + commitCount, err := gitOutput(ctx, repoDir, "rev-list", "--count", baseRevision+".."+headRevision) + if err != nil { + return nil, fmt.Errorf("count commits from %q to %q: %w", base, head, err) + } + count, err := strconv.Atoi(strings.TrimSpace(string(commitCount))) + if err != nil { + return nil, fmt.Errorf("parse commit count from %q to %q: %w", base, head, err) + } + if count == 0 { + return nil, fmt.Errorf("no commits between base %q and head %q", base, head) + } + + patch, err := gitOutput(ctx, repoDir, "format-patch", "--stdout", "--binary", "--full-index", baseRevision+".."+headRevision) + if err != nil { + return nil, fmt.Errorf("create patch from %q to %q: %w", base, head, err) + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write(patch); err != nil { + return nil, fmt.Errorf("compress patch: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("finish patch compression: %w", err) + } + return compressed.Bytes(), nil +} + +// DefaultBranch returns the branch named by origin's local HEAD reference. +func DefaultBranch(ctx context.Context, repoDir string) (string, error) { + ref, err := gitOutput(ctx, repoDir, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") + if err != nil { + return "", fmt.Errorf("read origin default branch: %w", err) + } + branch, found := strings.CutPrefix(strings.TrimSpace(string(ref)), "origin/") + if !found || branch == "" { + return "", fmt.Errorf("origin default branch reference is invalid: %q", strings.TrimSpace(string(ref))) + } + return branch, nil +} + +func resolveRevision(ctx context.Context, repoDir, revision string) (string, error) { + if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", revision+"^{commit}"); err == nil { + return revision, nil + } + + remoteRevision := "origin/" + revision + if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil { + return remoteRevision, nil + } + return "", fmt.Errorf("commit does not exist locally or at origin") +} + +func resolveBaseRevision(ctx context.Context, repoDir, revision string) (string, error) { + remoteRevision := "origin/" + revision + if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil { + return remoteRevision, nil + } + return resolveRevision(ctx, repoDir, revision) +} + +func gitCommand(ctx context.Context, repoDir string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repoDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil +} + +func gitOutput(ctx context.Context, repoDir string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repoDir + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return output, nil +}