A terminal client for Tangled.
tui go tangled cli
8

Configure Feed

Select the types of activity you want to include in your feed.

cli/pr: re-add checkout command

Aly Raffauf (Jul 15, 2026, 1:32 PM EDT) 50d5697c 31e4a8e5

+338 -31
+1 -10
README.md
··· 67 67 68 68 # Create, comment on, inspect, and update pull requests 69 69 tg pr create --title "Add feature" --base main 70 + tg pr checkout <rkey> 70 71 tg pr diff <rkey> 71 72 tg pr comment <rkey> --body "Looks good" 72 73 tg pr close <rkey> ··· 77 78 78 79 `tg repo edit`, `tg repo set-default-branch`, `tg repo delete --yes`, `tg repo fork`, 79 80 `tg ssh-key delete`, `tg browse`, `tg completion`, `tg auth token`, and `tg api` are available. 80 - 81 - Changing scopes requires existing users to run `tg auth login` again. 82 - 83 - ## Platform limits 84 - 85 - Tangled currently has no API for releases, gists, workflows/runs, repository archive/sync/rename, 86 - PR checks, projects, codespaces, a status dashboard, or global text search. `tg pr checkout` is 87 - deferred until PRs expose an immutable base commit/ref, allowing a safe isolated-worktree 88 - implementation. `auth switch` is deferred because the local auth store currently supports one 89 - account. 90 81 91 82 ## Architecture 92 83
+114
internal/cli/pr_checkout.go
··· 1 + package cli 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/alyraffauf/tg/internal/gitutil" 9 + "github.com/alyraffauf/tg/tangled" 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + var ( 14 + prCheckoutRepo string 15 + prCheckoutBranch string 16 + prCheckoutForce bool 17 + ) 18 + 19 + var prCheckoutCmd = &cobra.Command{ 20 + Use: "checkout <rkey>", 21 + Short: "Check out a pull request in Git", 22 + Args: cobra.ExactArgs(1), 23 + RunE: func(cmd *cobra.Command, args []string) error { 24 + ctx := cmd.Context() 25 + repoDir, err := os.Getwd() 26 + if err != nil { 27 + return fmt.Errorf("get current directory: %w", err) 28 + } 29 + local, err := gitutil.DetectRepoFromCWD(ctx) 30 + if err != nil { 31 + return fmt.Errorf("detect local repository: %w", err) 32 + } 33 + localRecord, err := resolveRepoRecord(ctx, local.Handle, local.Repo) 34 + if err != nil { 35 + return err 36 + } 37 + 38 + handle, name := local.Handle, local.Repo 39 + if prCheckoutRepo != "" { 40 + handle, name, err = parseHandleRepo(prCheckoutRepo) 41 + if err != nil { 42 + return err 43 + } 44 + } 45 + targetRecord := localRecord 46 + if handle != local.Handle || name != local.Repo { 47 + targetRecord, err = resolveRepoRecord(ctx, handle, name) 48 + if err != nil { 49 + return err 50 + } 51 + } 52 + if targetRecord.Value.RepoDid != localRecord.Value.RepoDid { 53 + return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, name) 54 + } 55 + 56 + pulls, err := client.ListPulls(ctx, targetRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 57 + if err != nil { 58 + return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err) 59 + } 60 + pull, err := findByRKey(pulls.Items, args[0], "pull request") 61 + if err != nil { 62 + return err 63 + } 64 + var record tangled.PullRecord 65 + if err := json.Unmarshal(pull.Value, &record); err != nil { 66 + return fmt.Errorf("decode pull request %q: %w", args[0], err) 67 + } 68 + if len(record.Rounds) == 0 { 69 + return fmt.Errorf("pull request %q has no rounds", args[0]) 70 + } 71 + if record.Target.Branch == "" { 72 + return fmt.Errorf("pull request %q has no target branch", args[0]) 73 + } 74 + 75 + latestRound := record.Rounds[len(record.Rounds)-1] 76 + cid := latestRound.PatchBlob.Ref.String() 77 + if cid == "" { 78 + return fmt.Errorf("pull request %q has no patch blob", args[0]) 79 + } 80 + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) 81 + if err != nil { 82 + return err 83 + } 84 + branch := prCheckoutBranch 85 + if branch == "" { 86 + branch = "pr-" + args[0] 87 + } 88 + 89 + if err := gitutil.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{ 90 + RepoDir: repoDir, 91 + Branch: branch, 92 + TargetBranch: record.Target.Branch, 93 + Patch: patch, 94 + Force: prCheckoutForce, 95 + }); err != nil { 96 + return err 97 + } 98 + result := prCheckoutResult{Rkey: args[0], Branch: branch} 99 + return output(result, func(result prCheckoutResult) { 100 + fmt.Printf("Checked out pull request %s as branch %s\n", result.Rkey, result.Branch) 101 + }) 102 + }, 103 + } 104 + 105 + type prCheckoutResult struct { 106 + Rkey string `json:"rkey"` 107 + Branch string `json:"branch"` 108 + } 109 + 110 + func init() { 111 + prCheckoutCmd.Flags().StringVarP(&prCheckoutRepo, "repo", "R", "", "Target repository as handle/repo") 112 + prCheckoutCmd.Flags().StringVarP(&prCheckoutBranch, "branch", "b", "", "Local branch name (default: pr-<rkey>)") 113 + prCheckoutCmd.Flags().BoolVarP(&prCheckoutForce, "force", "f", false, "Reset an existing checkout branch") 114 + }
+35 -11
internal/cli/pr_diff.go
··· 1 1 package cli 2 2 3 3 import ( 4 + "bytes" 4 5 "compress/gzip" 5 6 "context" 6 7 "encoding/json" ··· 12 13 "github.com/alyraffauf/tg/tangled" 13 14 "github.com/spf13/cobra" 14 15 ) 16 + 17 + const maxPullPatchSize = 100 << 20 15 18 16 19 var prDiffRepo string 17 20 ··· 52 55 if cid == "" { 53 56 return fmt.Errorf("pull request %q has no patch blob", args[0]) 54 57 } 55 - return printPullPatch(ctx, extractDID(pull.URI), cid) 58 + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) 59 + if err != nil { 60 + return err 61 + } 62 + _, err = os.Stdout.Write(patch) 63 + return err 56 64 }, 57 65 } 58 66 ··· 60 68 prDiffCmd.Flags().StringVarP(&prDiffRepo, "repo", "R", "", "Target repository as handle/repo") 61 69 } 62 70 63 - func printPullPatch(ctx context.Context, authorDID, cid string) error { 71 + func downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) { 64 72 pdsHost, err := resolver.ResolvePDS(ctx, authorDID) 65 73 if err != nil { 66 - return fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) 74 + return nil, fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) 67 75 } 68 76 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsHost, authorDID, cid) 69 77 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 70 78 if err != nil { 71 - return fmt.Errorf("build patch download request: %w", err) 79 + return nil, fmt.Errorf("build patch download request: %w", err) 72 80 } 73 81 resp, err := http.DefaultClient.Do(req) 74 82 if err != nil { 75 - return fmt.Errorf("download patch: %w", err) 83 + return nil, fmt.Errorf("download patch: %w", err) 76 84 } 77 85 defer resp.Body.Close() 78 86 if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 79 - return fmt.Errorf("download patch: PDS returned HTTP %d", resp.StatusCode) 87 + return nil, fmt.Errorf("download patch: PDS returned HTTP %d", resp.StatusCode) 80 88 } 81 89 82 - patch, err := gzip.NewReader(resp.Body) 90 + compressed, err := readLimited(resp.Body, maxPullPatchSize) 83 91 if err != nil { 84 - return fmt.Errorf("decompress patch: %w", err) 92 + return nil, fmt.Errorf("download patch: %w", err) 93 + } 94 + patch, err := gzip.NewReader(bytes.NewReader(compressed)) 95 + if err != nil { 96 + return nil, fmt.Errorf("decompress patch: %w", err) 85 97 } 86 98 defer patch.Close() 87 - if _, err := io.Copy(os.Stdout, patch); err != nil { 88 - return fmt.Errorf("write patch: %w", err) 99 + contents, err := readLimited(patch, maxPullPatchSize) 100 + if err != nil { 101 + return nil, fmt.Errorf("decompress patch: %w", err) 102 + } 103 + return contents, nil 104 + } 105 + 106 + func readLimited(reader io.Reader, limit int64) ([]byte, error) { 107 + contents, err := io.ReadAll(io.LimitReader(reader, limit+1)) 108 + if err != nil { 109 + return nil, err 110 + } 111 + if int64(len(contents)) > limit { 112 + return nil, fmt.Errorf("patch exceeds %d bytes", limit) 89 113 } 90 - return nil 114 + return contents, nil 91 115 }
-10
internal/cli/repo_records.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "errors" 6 5 "fmt" 7 - "net/http" 8 6 9 7 "github.com/alyraffauf/tg/tangled" 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 8 ) 12 9 13 10 // resolveRepoRecord finds a repository record, including legacy records whose ··· 24 21 repo.URI = recordURI 25 22 } 26 23 return repo, nil 27 - } else if !isNotFoundError(err) { 28 - return nil, fmt.Errorf("get repository %q: %w", name, err) 29 24 } 30 25 31 26 repos, err := client.ListRepos(ctx, ident.DID.String()) ··· 39 34 } 40 35 } 41 36 return nil, fmt.Errorf("repo %q not found for handle %q", name, handle) 42 - } 43 - 44 - func isNotFoundError(err error) bool { 45 - var apiError *atclient.APIError 46 - return errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound 47 37 } 48 38 49 39 func requireOwnedRepo(ctx context.Context, handle, name, did string) (*tangled.Repo, error) {
+1
internal/cli/root.go
··· 63 63 prCmd.AddCommand(prCreateCmd) 64 64 prCmd.AddCommand(prCommentCmd) 65 65 prCmd.AddCommand(prDiffCmd) 66 + prCmd.AddCommand(prCheckoutCmd) 66 67 prCmd.AddCommand(prCloseCmd) 67 68 prCmd.AddCommand(prReopenCmd) 68 69 prCmd.AddCommand(prEditCmd)
+75
internal/gitutil/checkout.go
··· 1 + package gitutil 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "os/exec" 8 + "strings" 9 + ) 10 + 11 + type CheckoutPatchParams struct { 12 + RepoDir string 13 + Branch string 14 + TargetBranch string 15 + Patch []byte 16 + Force bool 17 + } 18 + 19 + // CheckoutPatch creates a branch at the current target branch and applies a 20 + // pull request patch series to it. 21 + func CheckoutPatch(ctx context.Context, params CheckoutPatchParams) error { 22 + if err := requireCleanWorktree(ctx, params.RepoDir); err != nil { 23 + return err 24 + } 25 + if err := validateBranch(ctx, params.RepoDir, params.Branch); err != nil { 26 + return fmt.Errorf("invalid checkout branch %q: %w", params.Branch, err) 27 + } 28 + if err := validateBranch(ctx, params.RepoDir, params.TargetBranch); err != nil { 29 + return fmt.Errorf("invalid target branch %q: %w", params.TargetBranch, err) 30 + } 31 + 32 + targetRef := "refs/remotes/origin/" + params.TargetBranch 33 + refspec := "+refs/heads/" + params.TargetBranch + ":" + targetRef 34 + if err := gitCommand(ctx, params.RepoDir, "fetch", "origin", refspec); err != nil { 35 + return fmt.Errorf("fetch target branch %q: %w", params.TargetBranch, err) 36 + } 37 + 38 + switchFlag := "-c" 39 + if params.Force { 40 + switchFlag = "-C" 41 + } 42 + if err := gitCommand(ctx, params.RepoDir, "switch", switchFlag, params.Branch, targetRef); err != nil { 43 + return fmt.Errorf("check out branch %q: %w", params.Branch, err) 44 + } 45 + if err := applyPatch(ctx, params.RepoDir, params.Patch); err != nil { 46 + return fmt.Errorf("apply pull request patch; resolve conflicts with git am --continue or undo with git am --abort: %w", err) 47 + } 48 + return nil 49 + } 50 + 51 + func validateBranch(ctx context.Context, repoDir, branch string) error { 52 + return gitCommand(ctx, repoDir, "check-ref-format", "--branch", branch) 53 + } 54 + 55 + func requireCleanWorktree(ctx context.Context, repoDir string) error { 56 + status, err := gitOutput(ctx, repoDir, "status", "--porcelain") 57 + if err != nil { 58 + return fmt.Errorf("inspect worktree: %w", err) 59 + } 60 + if len(bytes.TrimSpace(status)) != 0 { 61 + return fmt.Errorf("worktree has uncommitted changes") 62 + } 63 + return nil 64 + } 65 + 66 + func applyPatch(ctx context.Context, repoDir string, patch []byte) error { 67 + cmd := exec.CommandContext(ctx, "git", "am", "--3way") 68 + cmd.Dir = repoDir 69 + cmd.Stdin = bytes.NewReader(patch) 70 + output, err := cmd.CombinedOutput() 71 + if err != nil { 72 + return fmt.Errorf("git am --3way: %w: %s", err, strings.TrimSpace(string(output))) 73 + } 74 + return nil 75 + }
+112
internal/gitutil/checkout_test.go
··· 1 + package gitutil 2 + 3 + import ( 4 + "bytes" 5 + "compress/gzip" 6 + "context" 7 + "io" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strings" 12 + "testing" 13 + ) 14 + 15 + func TestGenerateAndCheckoutPatch(t *testing.T) { 16 + ctx := context.Background() 17 + tempDir := t.TempDir() 18 + originDir := filepath.Join(tempDir, "origin.git") 19 + repoDir := filepath.Join(tempDir, "repo") 20 + runGit(t, tempDir, "init", "--bare", originDir) 21 + runGit(t, tempDir, "clone", originDir, repoDir) 22 + runGit(t, repoDir, "config", "user.name", "Test User") 23 + runGit(t, repoDir, "config", "user.email", "test@example.com") 24 + runGit(t, repoDir, "switch", "-c", "main") 25 + 26 + writeTestFile(t, filepath.Join(repoDir, "message.txt"), "base\n") 27 + runGit(t, repoDir, "add", "message.txt") 28 + runGit(t, repoDir, "commit", "-m", "base") 29 + runGit(t, repoDir, "push", "-u", "origin", "main") 30 + 31 + runGit(t, repoDir, "switch", "-c", "feature") 32 + writeTestFile(t, filepath.Join(repoDir, "message.txt"), "feature\n") 33 + runGit(t, repoDir, "add", "message.txt") 34 + runGit(t, repoDir, "commit", "-m", "feature") 35 + compressedPatch, err := GeneratePatch(ctx, repoDir, "main", "feature") 36 + if err != nil { 37 + t.Fatalf("GeneratePatch() error = %v", err) 38 + } 39 + patch := decompressTestPatch(t, compressedPatch) 40 + 41 + err = CheckoutPatch(ctx, CheckoutPatchParams{ 42 + RepoDir: repoDir, 43 + Branch: "review", 44 + TargetBranch: "main", 45 + Patch: patch, 46 + }) 47 + if err != nil { 48 + t.Fatalf("CheckoutPatch() error = %v", err) 49 + } 50 + if branch := gitOutputForTest(t, repoDir, "branch", "--show-current"); branch != "review" { 51 + t.Fatalf("checked out branch = %q, want review", branch) 52 + } 53 + contents, err := os.ReadFile(filepath.Join(repoDir, "message.txt")) 54 + if err != nil { 55 + t.Fatal(err) 56 + } 57 + if string(contents) != "feature\n" { 58 + t.Fatalf("checked out contents = %q, want feature", contents) 59 + } 60 + } 61 + 62 + func TestCheckoutPatchRejectsDirtyWorktree(t *testing.T) { 63 + repoDir := t.TempDir() 64 + runGit(t, repoDir, "init") 65 + writeTestFile(t, filepath.Join(repoDir, "untracked.txt"), "dirty\n") 66 + 67 + err := CheckoutPatch(context.Background(), CheckoutPatchParams{RepoDir: repoDir}) 68 + if err == nil || !strings.Contains(err.Error(), "uncommitted changes") { 69 + t.Fatalf("CheckoutPatch() error = %v, want uncommitted changes error", err) 70 + } 71 + } 72 + 73 + func runGit(t *testing.T, dir string, args ...string) { 74 + t.Helper() 75 + cmd := exec.Command("git", args...) 76 + cmd.Dir = dir 77 + if output, err := cmd.CombinedOutput(); err != nil { 78 + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output) 79 + } 80 + } 81 + 82 + func gitOutputForTest(t *testing.T, dir string, args ...string) string { 83 + t.Helper() 84 + cmd := exec.Command("git", args...) 85 + cmd.Dir = dir 86 + output, err := cmd.CombinedOutput() 87 + if err != nil { 88 + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output) 89 + } 90 + return strings.TrimSpace(string(output)) 91 + } 92 + 93 + func writeTestFile(t *testing.T, path, contents string) { 94 + t.Helper() 95 + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { 96 + t.Fatal(err) 97 + } 98 + } 99 + 100 + func decompressTestPatch(t *testing.T, compressed []byte) []byte { 101 + t.Helper() 102 + reader, err := gzip.NewReader(bytes.NewReader(compressed)) 103 + if err != nil { 104 + t.Fatal(err) 105 + } 106 + defer reader.Close() 107 + patch, err := io.ReadAll(reader) 108 + if err != nil { 109 + t.Fatal(err) 110 + } 111 + return patch 112 + }