From b47709964d93de9adbe0ecb4b69ee1aa6d967039 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:24:32 -0700 Subject: [PATCH] sdk: add exact branch and tag lookup --- RELEASE_NOTES.md | 6 + packages/code-storage-go/README.md | 16 +- .../code-storage-go/named_ref_lookup_test.go | 159 ++++++++++++++++++ packages/code-storage-go/repo.go | 54 ++++++ packages/code-storage-go/responses.go | 15 ++ .../code-storage-go/route_contract_test.go | 4 + packages/code-storage-go/types.go | 26 +++ packages/code-storage-python/README.md | 25 +++ .../pierre_storage/__init__.py | 4 + .../pierre_storage/repo.py | 56 ++++++ .../pierre_storage/types.py | 34 ++++ .../tests/test_named_ref_lookup.py | 148 ++++++++++++++++ .../tests/test_route_contract.py | 14 ++ packages/code-storage-typescript/README.md | 33 ++++ packages/code-storage-typescript/src/index.ts | 43 +++++ .../code-storage-typescript/src/schemas.ts | 17 ++ packages/code-storage-typescript/src/types.ts | 28 +++ .../tests/named-ref-lookup.test.ts | 154 +++++++++++++++++ .../tests/route-contract.test.ts | 14 ++ skills/code-storage/SKILL.md | 23 ++- 20 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 packages/code-storage-go/named_ref_lookup_test.go create mode 100644 packages/code-storage-python/tests/test_named_ref_lookup.py create mode 100644 packages/code-storage-typescript/tests/named-ref-lookup.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9802678..a2e5b36 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,12 @@ ## Unreleased +### Exact branch and tag lookup + +- Added exact branch and tag lookup methods to the TypeScript, Python, and Go SDKs. +- Added ephemeral branch lookup and the preferred repository-scoped REST routes. +- Exact results omit list cursors and the private tag object SHA. + ### Preferred REST routes - Changed preferred SDK calls to use the unversioned `/api` collection and diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index 8f782ea..e15d52b 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -164,15 +164,29 @@ to blame the whole file. The top-level `CommitSHA` is the SHA `Ref` resolved to; each `BlameLine` carries its authoring commit's metadata inline, with `PreviousCommitSHA` empty when the line has no prior version. -### Manage tags +### Read branches and manage tags ```go +branch, err := repo.GetBranch(context.Background(), storage.GetBranchOptions{ + Name: "feature/preview", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(branch.HeadSHA, branch.CreatedAt) + tags, err := repo.ListTags(context.Background(), storage.ListTagsOptions{Limit: 10}) if err != nil { log.Fatal(err) } fmt.Println(tags.Tags) +tag, err := repo.GetTag(context.Background(), storage.GetTagOptions{Name: "v1.0.0"}) +if err != nil { + log.Fatal(err) +} +fmt.Println(tag.SHA) + createdTag, err := repo.CreateTag(context.Background(), storage.CreateTagOptions{ Name: "v1.0.0", Ref: "0123456789abcdef0123456789abcdef01234567", diff --git a/packages/code-storage-go/named_ref_lookup_test.go b/packages/code-storage-go/named_ref_lookup_test.go new file mode 100644 index 0000000..c85f2e2 --- /dev/null +++ b/packages/code-storage-go/named_ref_lookup_test.go @@ -0,0 +1,159 @@ +package storage + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetBranchRequestAndResult(t *testing.T) { + tests := []struct { + name string + ephemeral *bool + expectedQuery string + responseBranch string + }{ + { + name: "true", + ephemeral: boolPtr(true), + expectedQuery: "ephemeral=true&name=attempt%2F7", + responseBranch: "attempt/7", + }, + { + name: "false", + ephemeral: boolPtr(false), + expectedQuery: "ephemeral=false&name=attempt%2F7", + responseBranch: "attempt/7", + }, + { + name: "omitted", + expectedQuery: "name=attempt%2F7", + responseBranch: "attempt/7", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/repos/owner/repo/branch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.RawQuery != test.expectedQuery { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + if strings.Contains(r.URL.RawQuery, "%252F") { + t.Fatalf("name was encoded twice: %s", r.URL.RawQuery) + } + assertGitReadScope(t, r) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"branch":{"name":"attempt/7","head_sha":"abc123","created_at":"2026-08-29T10:00:00Z","cursor":"private-cursor"}}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "owner/repo", DefaultBranch: "main", client: client} + + result, err := repo.GetBranch(context.Background(), GetBranchOptions{ + Name: "attempt/7", + Ephemeral: test.ephemeral, + }) + if err != nil { + t.Fatalf("get branch error: %v", err) + } + expected := GetBranchResult{ + Name: test.responseBranch, + HeadSHA: "abc123", + CreatedAt: "2026-08-29T10:00:00Z", + } + if result != expected { + t.Fatalf("unexpected result: %+v", result) + } + }) + } +} + +func TestGetTagRequestAndResult(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/repos/owner/repo/tag" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.RawQuery != "name=releases%2Fv1.4.0" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + if strings.Contains(r.URL.RawQuery, "%252F") { + t.Fatalf("name was encoded twice: %s", r.URL.RawQuery) + } + assertGitReadScope(t, r) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag":{"name":"releases/v1.4.0","sha":"commit123","object_sha":"tag-object-123","cursor":"private-cursor"}}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "owner/repo", DefaultBranch: "main", client: client} + + result, err := repo.GetTag(context.Background(), GetTagOptions{Name: "releases/v1.4.0"}) + if err != nil { + t.Fatalf("get tag error: %v", err) + } + if result != (GetTagResult{Name: "releases/v1.4.0", SHA: "commit123"}) { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestNamedRefLookupNotFound(t *testing.T) { + for _, resource := range []string{"branch", "tag"} { + t.Run(resource, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"ref not found"}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo", DefaultBranch: "main", client: client} + + if resource == "branch" { + _, err = repo.GetBranch(context.Background(), GetBranchOptions{Name: "missing/ref"}) + } else { + _, err = repo.GetTag(context.Background(), GetTagOptions{Name: "missing/ref"}) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.Status != http.StatusNotFound || apiErr.Message != "ref not found" { + t.Fatalf("unexpected error: %+v", apiErr) + } + }) + } +} + +func assertGitReadScope(t *testing.T, r *http.Request) { + t.Helper() + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + claims := parseJWTFromToken(t, token) + scopes, ok := claims["scopes"].([]interface{}) + if !ok || len(scopes) != 1 || scopes[0] != "git:read" { + t.Fatalf("unexpected scopes: %v", claims["scopes"]) + } +} diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 8bbd6ee..f11e89f 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -429,6 +429,37 @@ func (r *Repo) ListBranches(ctx context.Context, options ListBranchesOptions) (L return result, nil } +// GetBranch gets one branch by its exact name. +func (r *Repo) GetBranch(ctx context.Context, options GetBranchOptions) (GetBranchResult, error) { + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) + if err != nil { + return GetBranchResult{}, err + } + + params := url.Values{} + params.Set("name", options.Name) + if options.Ephemeral != nil { + params.Set("ephemeral", strconv.FormatBool(*options.Ephemeral)) + } + + resp, err := r.client.api.get(ctx, r.apiPath("branch"), params, jwtToken, nil) + if err != nil { + return GetBranchResult{}, err + } + defer resp.Body.Close() + + var payload getBranchResponse + if err := decodeJSON(resp, &payload); err != nil { + return GetBranchResult{}, err + } + return GetBranchResult{ + Name: payload.Branch.Name, + HeadSHA: payload.Branch.HeadSHA, + CreatedAt: payload.Branch.CreatedAt, + }, nil +} + // ListTags lists tags. func (r *Repo) ListTags(ctx context.Context, options ListTagsOptions) (ListTagsResult, error) { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) @@ -473,6 +504,29 @@ func (r *Repo) ListTags(ctx context.Context, options ListTagsOptions) (ListTagsR return result, nil } +// GetTag gets one tag by its exact name. +func (r *Repo) GetTag(ctx context.Context, options GetTagOptions) (GetTagResult, error) { + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) + if err != nil { + return GetTagResult{}, err + } + + params := url.Values{} + params.Set("name", options.Name) + resp, err := r.client.api.get(ctx, r.apiPath("tag"), params, jwtToken, nil) + if err != nil { + return GetTagResult{}, err + } + defer resp.Body.Close() + + var payload getTagResponse + if err := decodeJSON(resp, &payload); err != nil { + return GetTagResult{}, err + } + return GetTagResult{Name: payload.Tag.Name, SHA: payload.Tag.SHA}, nil +} + // ListCommits lists commits. func (r *Repo) ListCommits(ctx context.Context, options ListCommitsOptions) (ListCommitsResult, error) { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index ab9a325..26420c2 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -49,6 +49,14 @@ type branchInfoRaw struct { CreatedAt string `json:"created_at"` } +type getBranchResponse struct { + Branch struct { + Name string `json:"name"` + HeadSHA string `json:"head_sha"` + CreatedAt string `json:"created_at"` + } `json:"branch"` +} + type listCommitsResponse struct { Commits []commitInfoRaw `json:"commits"` NextCursor string `json:"next_cursor"` @@ -274,6 +282,13 @@ type tagInfoRaw struct { SHA string `json:"sha"` } +type getTagResponse struct { + Tag struct { + Name string `json:"name"` + SHA string `json:"sha"` + } `json:"tag"` +} + type createTagResponse struct { Name string `json:"name"` SHA string `json:"sha"` diff --git a/packages/code-storage-go/route_contract_test.go b/packages/code-storage-go/route_contract_test.go index 5914c31..dc1295a 100644 --- a/packages/code-storage-go/route_contract_test.go +++ b/packages/code-storage-go/route_contract_test.go @@ -61,7 +61,11 @@ func TestPreferredRESTRouteContract(t *testing.T) { {name: "ListFiles", method: "GET", path: "/api/repos/owner%2Fname/files", invoke: func(_ *Client, r *Repo) { _, _ = r.ListFiles(ctx, ListFilesOptions{}) }}, {name: "ListFilesWithMetadata", method: "GET", path: "/api/repos/owner%2Fname/files/metadata", invoke: func(_ *Client, r *Repo) { _, _ = r.ListFilesWithMetadata(ctx, ListFilesWithMetadataOptions{}) }}, {name: "ListBranches", method: "GET", path: "/api/repos/owner%2Fname/branches", invoke: func(_ *Client, r *Repo) { _, _ = r.ListBranches(ctx, ListBranchesOptions{}) }}, + {name: "GetBranch", method: "GET", path: "/api/repos/owner%2Fname/branch", query: url.Values{"name": {"feature/one"}, "ephemeral": {"false"}}, invoke: func(_ *Client, r *Repo) { + _, _ = r.GetBranch(ctx, GetBranchOptions{Name: "feature/one", Ephemeral: boolPtr(false)}) + }}, {name: "ListTags", method: "GET", path: "/api/repos/owner%2Fname/tags", invoke: func(_ *Client, r *Repo) { _, _ = r.ListTags(ctx, ListTagsOptions{}) }}, + {name: "GetTag", method: "GET", path: "/api/repos/owner%2Fname/tag", query: url.Values{"name": {"release/v1"}}, invoke: func(_ *Client, r *Repo) { _, _ = r.GetTag(ctx, GetTagOptions{Name: "release/v1"}) }}, {name: "ListCommits", method: "GET", path: "/api/repos/owner%2Fname/commits", invoke: func(_ *Client, r *Repo) { _, _ = r.ListCommits(ctx, ListCommitsOptions{}) }}, {name: "GetCommit", method: "GET", path: "/api/repos/owner%2Fname/commit", invoke: func(_ *Client, r *Repo) { _, _ = r.GetCommit(ctx, GetCommitOptions{Ref: "main"}) }}, {name: "GetBlame", method: "GET", path: "/api/repos/owner%2Fname/blame", invoke: func(_ *Client, r *Repo) { _, _ = r.GetBlame(ctx, BlameOptions{Path: "README.md"}) }}, diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index b55a80a..d707885 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -398,6 +398,20 @@ type ListBranchesResult struct { HasMore bool } +// GetBranchOptions configures an exact branch lookup. +type GetBranchOptions struct { + InvocationOptions + Name string + Ephemeral *bool +} + +// GetBranchResult describes one branch. +type GetBranchResult struct { + Name string + HeadSHA string + CreatedAt string +} + // CreateBranchOptions configures branch creation. type CreateBranchOptions struct { InvocationOptions @@ -593,6 +607,18 @@ type ListTagsResult struct { HasMore bool } +// GetTagOptions configures an exact tag lookup. +type GetTagOptions struct { + InvocationOptions + Name string +} + +// GetTagResult describes one tag. +type GetTagResult struct { + Name string + SHA string +} + // CreateTagOptions configures tag creation. type CreateTagOptions struct { InvocationOptions diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 9cb13e0..f346c93 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -246,6 +246,10 @@ branches = await repo.list_branches( ) print(branches["branches"]) +# Get one branch when you know its exact name +branch = await repo.get_branch(name="feature/preview") +print(branch["head_sha"], branch["created_at"]) + # Create or promote a branch (synchronous Temporal workflow) branch_result = await repo.create_branch( base_ref="main", @@ -302,6 +306,10 @@ print(merge_result["source"]["sha"], merge_result["target"]["new_sha"]) tags = await repo.list_tags(limit=10) print(tags["tags"]) +# Get one tag when you know its exact name +tag = await repo.get_tag(name="v1.0.0") +print(tag["sha"]) + # Create a lightweight tag at a commit SHA tag_result = await repo.create_tag( name="v1.0.0", @@ -841,6 +849,14 @@ class Repo: ttl: Optional[int] = None, ) -> ListBranchesResult: ... + async def get_branch( + self, + *, + name: str, + ephemeral: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> GetBranchResult: ... + async def create_branch( self, *, @@ -897,6 +913,13 @@ class Repo: ttl: Optional[int] = None, ) -> ListTagsResult: ... + async def get_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ) -> GetTagResult: ... + async def create_tag( self, *, @@ -1097,7 +1120,9 @@ from pierre_storage.types import ( ListFilesResult, ListFilesWithMetadataResult, ListBranchesResult, + GetBranchResult, ListTagsResult, + GetTagResult, ListCommitsResult, BlameResult, GetBranchDiffResult, diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index 6852549..0b51a6d 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -29,8 +29,10 @@ FileWithMetadata, FilteredFile, GetBranchDiffResult, + GetBranchResult, GetCommitDiffResult, GetCommitResult, + GetTagResult, GitStorageOptions, GrepFileMatch, GrepLine, @@ -99,8 +101,10 @@ "FileDiff", "FilteredFile", "GetBranchDiffResult", + "GetBranchResult", "GetCommitDiffResult", "GetCommitResult", + "GetTagResult", "GrepFileMatch", "GrepLine", "GrepResult", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 454e30f..89a08bc 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -38,8 +38,10 @@ FileWithMetadata, FilteredFile, GetBranchDiffResult, + GetBranchResult, GetCommitDiffResult, GetCommitResult, + GetTagResult, GrepFileMatch, GrepLine, GrepResult, @@ -835,6 +837,37 @@ async def list_branches( "has_more": data["has_more"], } + async def get_branch( + self, + *, + name: str, + ephemeral: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> GetBranchResult: + """Get one branch by its exact name.""" + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + params = {"name": name} + if ephemeral is not None: + params["ephemeral"] = "true" if ephemeral else "false" + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._url('branch')}?{urlencode(params)}", + headers={ + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + }, + timeout=30.0, + ) + response.raise_for_status() + branch = response.json()["branch"] + return { + "name": branch["name"], + "head_sha": branch["head_sha"], + "created_at": branch["created_at"], + } + async def create_branch( self, *, @@ -1249,6 +1282,29 @@ async def list_tags( "has_more": data["has_more"], } + async def get_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ) -> GetTagResult: + """Get one tag by its exact name.""" + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._url('tag')}?{urlencode({'name': name})}", + headers={ + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + }, + timeout=30.0, + ) + response.raise_for_status() + tag = response.json()["tag"] + return {"name": tag["name"], "sha": tag["sha"]} + async def create_tag( self, *, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index f0636f6..50a200d 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -264,6 +264,14 @@ class ListBranchesResult(TypedDict): has_more: bool +class GetBranchResult(TypedDict): + """Exact branch lookup result.""" + + name: str + head_sha: str + created_at: str + + class CreateBranchResult(TypedDict): """Result from creating a branch.""" @@ -289,6 +297,13 @@ class ListTagsResult(TypedDict): has_more: bool +class GetTagResult(TypedDict): + """Exact tag lookup result.""" + + name: str + sha: str + + class CreateTagResult(TypedDict): """Result from creating a tag.""" @@ -873,6 +888,16 @@ async def list_branches( """List branches in the repository.""" ... + async def get_branch( + self, + *, + name: str, + ephemeral: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> GetBranchResult: + """Get one branch by its exact name.""" + ... + async def create_branch( self, *, @@ -944,6 +969,15 @@ async def list_tags( """List tags in the repository.""" ... + async def get_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ) -> GetTagResult: + """Get one tag by its exact name.""" + ... + async def create_tag( self, *, diff --git a/packages/code-storage-python/tests/test_named_ref_lookup.py b/packages/code-storage-python/tests/test_named_ref_lookup.py new file mode 100644 index 0000000..d0e76bd --- /dev/null +++ b/packages/code-storage-python/tests/test_named_ref_lookup.py @@ -0,0 +1,148 @@ +"""Tests for exact branch and tag lookups.""" + +from typing import Any, Optional +from unittest.mock import AsyncMock, patch + +import httpx +import jwt +import pytest + +from pierre_storage import GitStorage + + +def decode_scopes(authorization: str) -> list[str]: + """Read scopes from a test token without signature verification.""" + token = authorization.removeprefix("Bearer ") + payload = jwt.decode(token, options={"verify_signature": False}) + return payload["scopes"] + + +def response(payload: dict[str, Any], status: int = 200) -> httpx.Response: + """Build one HTTP response for a mocked request.""" + return httpx.Response( + status, + json=payload, + request=httpx.Request("GET", "https://api.test.code.storage"), + ) + + +@pytest.mark.asyncio +async def test_get_branch_uses_preferred_route_and_git_read_scope( + git_storage_options: dict, +) -> None: + """Get one decoded branch without list fields or a list request.""" + repo = GitStorage(git_storage_options).repo(id="owner/repo") + get = AsyncMock( + return_value=response( + { + "branch": { + "name": "attempt/7", + "head_sha": "abc123", + "created_at": "2026-08-29T10:00:00Z", + "cursor": "private-cursor", + } + } + ) + ) + + with ( + patch("httpx.AsyncClient") as client, + patch.object( + repo, "list_branches", AsyncMock(side_effect=AssertionError("unexpected list")) + ), + ): + client.return_value.__aenter__.return_value.get = get + result = await repo.get_branch(name="attempt/7", ephemeral=True) + + url = get.await_args.args[0] + assert url.endswith("/api/repos/owner%2Frepo/branch?name=attempt%2F7&ephemeral=true") + assert "%252F" not in url + assert decode_scopes(get.await_args.kwargs["headers"]["Authorization"]) == ["git:read"] + assert result == { + "name": "attempt/7", + "head_sha": "abc123", + "created_at": "2026-08-29T10:00:00Z", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("ephemeral", "expected_query"), + [(False, "name=feature%2Ffalse&ephemeral=false"), (None, "name=feature%2Fomitted")], +) +async def test_get_branch_preserves_ephemeral_value( + git_storage_options: dict, + ephemeral: Optional[bool], + expected_query: str, +) -> None: + """Keep false and omit an absent ephemeral value.""" + repo = GitStorage(git_storage_options).repo(id="repo") + name = "feature/false" if ephemeral is False else "feature/omitted" + get = AsyncMock( + return_value=response( + { + "branch": { + "name": name, + "head_sha": "abc123", + "created_at": "2026-08-29T10:00:00Z", + } + } + ) + ) + + with patch("httpx.AsyncClient") as client: + client.return_value.__aenter__.return_value.get = get + await repo.get_branch(name=name, ephemeral=ephemeral) + + assert get.await_args.args[0].endswith(f"/branch?{expected_query}") + + +@pytest.mark.asyncio +async def test_get_tag_uses_preferred_route_and_hides_private_fields( + git_storage_options: dict, +) -> None: + """Return the public commit SHA without list or storage-only fields.""" + repo = GitStorage(git_storage_options).repo(id="owner/repo") + get = AsyncMock( + return_value=response( + { + "tag": { + "name": "releases/v1.4.0", + "sha": "commit123", + "object_sha": "tag-object-123", + "cursor": "private-cursor", + } + } + ) + ) + + with ( + patch("httpx.AsyncClient") as client, + patch.object(repo, "list_tags", AsyncMock(side_effect=AssertionError("unexpected list"))), + ): + client.return_value.__aenter__.return_value.get = get + result = await repo.get_tag(name="releases/v1.4.0") + + url = get.await_args.args[0] + assert url.endswith("/api/repos/owner%2Frepo/tag?name=releases%2Fv1.4.0") + assert "%252F" not in url + assert decode_scopes(get.await_args.kwargs["headers"]["Authorization"]) == ["git:read"] + assert result == {"name": "releases/v1.4.0", "sha": "commit123"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["get_branch", "get_tag"]) +async def test_named_ref_not_found_uses_normal_http_error( + git_storage_options: dict, + method: str, +) -> None: + """Return the standard HTTP error with its 404 status.""" + repo = GitStorage(git_storage_options).repo(id="repo") + get = AsyncMock(return_value=response({"error": "ref not found"}, status=404)) + + with patch("httpx.AsyncClient") as client: + client.return_value.__aenter__.return_value.get = get + with pytest.raises(httpx.HTTPStatusError) as caught: + await getattr(repo, method)(name="missing/ref") + + assert caught.value.response.status_code == 404 diff --git a/packages/code-storage-python/tests/test_route_contract.py b/packages/code-storage-python/tests/test_route_contract.py index 326c7c9..f76202e 100644 --- a/packages/code-storage-python/tests/test_route_contract.py +++ b/packages/code-storage-python/tests/test_route_contract.py @@ -159,7 +159,21 @@ async def test_preferred_rest_route_contract(monkeypatch: pytest.MonkeyPatch) -> repo.list_files_with_metadata, ), _RouteCase("list_branches", "GET", "/api/repos/owner%2Fname/branches", repo.list_branches), + _RouteCase( + "get_branch", + "GET", + "/api/repos/owner%2Fname/branch", + lambda: repo.get_branch(name="feature/one", ephemeral=False), + query={"name": "feature/one", "ephemeral": "false"}, + ), _RouteCase("list_tags", "GET", "/api/repos/owner%2Fname/tags", repo.list_tags), + _RouteCase( + "get_tag", + "GET", + "/api/repos/owner%2Fname/tag", + lambda: repo.get_tag(name="release/v1"), + query={"name": "release/v1"}, + ), _RouteCase("list_commits", "GET", "/api/repos/owner%2Fname/commits", repo.list_commits), _RouteCase( "get_commit", diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index f952681..0a6e590 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -271,6 +271,10 @@ const branches = await repo.listBranches({ }); console.log(branches.branches); +// Get one branch when you know its exact name +const branch = await repo.getBranch({ name: 'feature/preview' }); +console.log(branch.headSha, branch.createdAt); + // List tags const tags = await repo.listTags({ limit: 10, @@ -278,6 +282,10 @@ const tags = await repo.listTags({ }); console.log(tags.tags); +// Get one tag when you know its exact name +const tag = await repo.getTag({ name: 'v1.0.0' }); +console.log(tag.sha); + // Create a lightweight tag at a commit SHA const createdTag = await repo.createTag({ name: 'v1.0.0', @@ -658,6 +666,9 @@ interface Repo { options?: ListFilesWithMetadataOptions ): Promise; listBranches(options?: ListBranchesOptions): Promise; + getBranch(options: GetBranchOptions): Promise; + listTags(options?: ListTagsOptions): Promise; + getTag(options: GetTagOptions): Promise; listCommits(options?: ListCommitsOptions): Promise; getCommit(options: GetCommitOptions): Promise; getBlame(options: BlameOptions): Promise; @@ -887,6 +898,28 @@ interface BranchInfo { createdAt: string; } +interface GetBranchOptions { + name: string; + ephemeral?: boolean; + ttl?: number; +} + +interface GetBranchResult { + name: string; + headSha: string; + createdAt: string; +} + +interface GetTagOptions { + name: string; + ttl?: number; +} + +interface GetTagResult { + name: string; + sha: string; +} + interface ListCommitsOptions { ref?: string; cursor?: string; diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 518b27c..4ef8f4f 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -30,6 +30,8 @@ import { errorEnvelopeSchema, blameResponseSchema, getCommitResponseSchema, + getBranchResponseSchema, + getTagResponseSchema, grepResponseSchema, listBranchesResponseSchema, listCommitsResponseSchema, @@ -85,6 +87,8 @@ import type { GetBranchDiffOptions, GetBranchDiffResponse, GetBranchDiffResult, + GetBranchOptions, + GetBranchResult, GetCommitDiffOptions, GetCommitDiffResponse, GetCommitDiffResult, @@ -99,6 +103,8 @@ import type { GetNoteOptions, GetNoteResult, GetRemoteURLOptions, + GetTagOptions, + GetTagResult, GitCredential, GitHubBaseRepo, GitStorageOptions, @@ -1204,6 +1210,29 @@ class RepoImpl implements Repo { }); } + async getBranch(options: GetBranchOptions): Promise { + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + const params: Record = { name: options.name }; + if (typeof options.ephemeral === 'boolean') { + params.ephemeral = String(options.ephemeral); + } + + const response = await this.api.get( + { path: this.repoPath('branch'), params }, + jwt + ); + const raw = getBranchResponseSchema.parse(await response.json()).branch; + return { + name: raw.name, + headSha: raw.head_sha, + createdAt: raw.created_at, + }; + } + async listTags(options?: ListTagsOptions): Promise { const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); const jwt = await this.generateJWT(this.id, { @@ -1237,6 +1266,20 @@ class RepoImpl implements Repo { }); } + async getTag(options: GetTagOptions): Promise { + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + const response = await this.api.get( + { path: this.repoPath('tag'), params: { name: options.name } }, + jwt + ); + const raw = getTagResponseSchema.parse(await response.json()).tag; + return { name: raw.name, sha: raw.sha }; + } + async listCommits(options?: ListCommitsOptions): Promise { const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); const jwt = await this.generateJWT(this.id, { diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index d40e3b2..a412fbe 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -56,6 +56,14 @@ export const listBranchesResponseSchema = z.object({ has_more: z.boolean(), }); +export const getBranchResponseSchema = z.object({ + branch: z.object({ + name: z.string(), + head_sha: z.string(), + created_at: z.string(), + }), +}); + export const commitInfoRawSchema = z.object({ sha: z.string(), parent_shas: z.array(z.string()), @@ -277,6 +285,13 @@ export const listTagsResponseSchema = z.object({ has_more: z.boolean(), }); +export const getTagResponseSchema = z.object({ + tag: z.object({ + name: z.string(), + sha: z.string(), + }), +}); + export const createTagResponseSchema = z.object({ name: z.string(), sha: z.string(), @@ -388,6 +403,7 @@ export type RawBranchInfo = z.infer; export type ListBranchesResponseRaw = z.infer< typeof listBranchesResponseSchema >; +export type GetBranchResponseRaw = z.infer; export type RawCommitInfo = z.infer; export type RawCommitInfoWithSignature = z.infer< typeof commitInfoWithSignatureRawSchema @@ -425,6 +441,7 @@ export type PreviewMergeResponseRaw = z.infer< >; export type RawTagInfo = z.infer; export type ListTagsResponseRaw = z.infer; +export type GetTagResponseRaw = z.infer; export type CreateTagResponseRaw = z.infer; export type DeleteTagResponseRaw = z.infer; export type DeleteBranchResponseRaw = z.infer< diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index bc919ba..2b344c4 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -8,6 +8,7 @@ import type { DeleteTagResponseRaw, BlameResponseRaw, GetBranchDiffResponseRaw, + GetBranchResponseRaw, GetCommitDiffResponseRaw, GetCommitResponseRaw, ListBranchesResponseRaw, @@ -21,6 +22,7 @@ import type { PreviewMergeFilteredConflictRaw as SchemaPreviewMergeFilteredConflict, PreviewMergeResponseRaw, ListTagsResponseRaw, + GetTagResponseRaw, ListNotesRefsResponseRaw, NoteReadResponseRaw, NoteWriteResponseRaw, @@ -127,7 +129,9 @@ export interface Repo { options?: ListFilesWithMetadataOptions, ): Promise; listBranches(options?: ListBranchesOptions): Promise; + getBranch(options: GetBranchOptions): Promise; listTags(options?: ListTagsOptions): Promise; + getTag(options: GetTagOptions): Promise; listCommits(options?: ListCommitsOptions): Promise; getCommit(options: GetCommitOptions): Promise; getBlame(options: BlameOptions): Promise; @@ -461,6 +465,19 @@ export interface ListBranchesResult { hasMore: boolean; } +export interface GetBranchOptions extends GitStorageInvocationOptions { + name: string; + ephemeral?: boolean; +} + +export type GetBranchResponse = GetBranchResponseRaw; + +export interface GetBranchResult { + name: string; + headSha: string; + createdAt: string; +} + // Create Branch API types export interface CreateBranchOptions extends GitStorageInvocationOptions, PolicyOptions { @@ -520,6 +537,17 @@ export interface ListTagsResult { hasMore: boolean; } +export interface GetTagOptions extends GitStorageInvocationOptions { + name: string; +} + +export type GetTagResponse = GetTagResponseRaw; + +export interface GetTagResult { + name: string; + sha: string; +} + export interface CreateTagOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; diff --git a/packages/code-storage-typescript/tests/named-ref-lookup.test.ts b/packages/code-storage-typescript/tests/named-ref-lookup.test.ts new file mode 100644 index 0000000..773dc48 --- /dev/null +++ b/packages/code-storage-typescript/tests/named-ref-lookup.test.ts @@ -0,0 +1,154 @@ +import { exportPKCS8, generateKeyPair } from 'jose'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError, GitStorage } from '../src/index'; + +let key: string; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function decodeToken(value: string): Record { + const token = value.replace(/^Bearer\s+/i, ''); + return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); +} + +function repo() { + const store = new GitStorage({ + name: 'test', + key, + apiBaseUrl: 'https://api.test.code.storage', + storageBaseUrl: 'test.code.storage', + }); + return store.repo({ id: 'owner/repo' }); +} + +describe('named ref lookup', () => { + beforeAll(async () => { + const { privateKey } = await generateKeyPair('ES256', { extractable: true }); + key = await exportPKCS8(privateKey); + }); + + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('gets one branch on the preferred route with git:read scope', async () => { + const testRepo = repo(); + const listBranches = vi.spyOn(testRepo, 'listBranches'); + + mockFetch.mockImplementationOnce((input, init) => { + const url = new URL(input as string); + expect(url.pathname).toBe('/api/repos/owner%2Frepo/branch'); + expect(url.search).toBe('?name=attempt%2F7&ephemeral=true'); + expect(decodeToken((init?.headers as Record).Authorization).scopes).toEqual([ + 'git:read', + ]); + + return Promise.resolve( + new Response( + JSON.stringify({ + branch: { + name: 'attempt/7', + head_sha: 'abc123', + created_at: '2026-08-29T10:00:00Z', + cursor: 'private-cursor', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + }); + + await expect( + testRepo.getBranch({ name: 'attempt/7', ephemeral: true }) + ).resolves.toEqual({ + name: 'attempt/7', + headSha: 'abc123', + createdAt: '2026-08-29T10:00:00Z', + }); + expect(listBranches).not.toHaveBeenCalled(); + }); + + it.each([ + { ephemeral: false, search: '?name=feature%2Ffalse&ephemeral=false' }, + { ephemeral: undefined, search: '?name=feature%2Fomitted' }, + ])('preserves the branch ephemeral value', async ({ ephemeral, search }) => { + const testRepo = repo(); + mockFetch.mockImplementationOnce((input) => { + expect(new URL(input as string).search).toBe(search); + return Promise.resolve( + new Response( + JSON.stringify({ + branch: { + name: ephemeral === false ? 'feature/false' : 'feature/omitted', + head_sha: 'abc123', + created_at: '2026-08-29T10:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + }); + + await testRepo.getBranch({ + name: ephemeral === false ? 'feature/false' : 'feature/omitted', + ephemeral, + }); + }); + + it('gets one tag without private list fields', async () => { + const testRepo = repo(); + const listTags = vi.spyOn(testRepo, 'listTags'); + + mockFetch.mockImplementationOnce((input, init) => { + const url = new URL(input as string); + expect(url.pathname).toBe('/api/repos/owner%2Frepo/tag'); + expect(url.search).toBe('?name=releases%2Fv1.4.0'); + expect(decodeToken((init?.headers as Record).Authorization).scopes).toEqual([ + 'git:read', + ]); + + return Promise.resolve( + new Response( + JSON.stringify({ + tag: { + name: 'releases/v1.4.0', + sha: 'commit123', + object_sha: 'tag-object-123', + cursor: 'private-cursor', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + }); + + await expect(testRepo.getTag({ name: 'releases/v1.4.0' })).resolves.toEqual({ + name: 'releases/v1.4.0', + sha: 'commit123', + }); + expect(listTags).not.toHaveBeenCalled(); + }); + + it.each(['getBranch', 'getTag'] as const)( + 'uses the normal not-found error for %s', + async (method) => { + const testRepo = repo(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'ref not found' }), { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + }) + ); + + const promise = testRepo[method]({ name: 'missing/ref' }); + await expect(promise).rejects.toMatchObject({ + name: 'ApiError', + status: 404, + message: 'ref not found', + }); + } + ); +}); diff --git a/packages/code-storage-typescript/tests/route-contract.test.ts b/packages/code-storage-typescript/tests/route-contract.test.ts index 9995e32..38204f3 100644 --- a/packages/code-storage-typescript/tests/route-contract.test.ts +++ b/packages/code-storage-typescript/tests/route-contract.test.ts @@ -118,12 +118,26 @@ describe('preferred REST route contract', () => { path: '/api/repos/owner%2Fname/branches', invoke: () => repo.listBranches(), }, + { + name: 'getBranch', + method: 'GET', + path: '/api/repos/owner%2Fname/branch', + query: { name: 'feature/one', ephemeral: 'false' }, + invoke: () => repo.getBranch({ name: 'feature/one', ephemeral: false }), + }, { name: 'listTags', method: 'GET', path: '/api/repos/owner%2Fname/tags', invoke: () => repo.listTags(), }, + { + name: 'getTag', + method: 'GET', + path: '/api/repos/owner%2Fname/tag', + query: { name: 'release/v1' }, + invoke: () => repo.getTag({ name: 'release/v1' }), + }, { name: 'listCommits', method: 'GET', diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 1059230..dc38dcf 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -141,6 +141,7 @@ Username is always `t`. Password is the JWT. | **BRANCHES** | | | | | Create branch | POST | `/repos/{repo_name}/branches/create` | `git:write` | | List branches | GET | `/repos/{repo_name}/branches` | `git:read` | +| Get branch by name | GET | `/repos/{repo_name}/branch?name=...` | `git:read` | | Get branch diff | GET | `/repos/{repo_name}/branches/diff` | `git:read` | | Preview merge | GET | `/repos/{repo_name}/merge/preview` | `git:read` | | Merge branches | POST | `/repos/{repo_name}/merge` | `git:write` | @@ -162,6 +163,7 @@ Username is always `t`. Password is the JWT. | **TAGS** | | | | | Create tag | POST | `/repos/{repo_name}/tags` | `git:write` | | List tags | GET | `/repos/{repo_name}/tags` | `git:read` | +| Get tag by name | GET | `/repos/{repo_name}/tag?name=...` | `git:read` | | Delete tag | DELETE | `/repos/{repo_name}/tags/{tag_name}` | `git:read`+`git:write` | | **NOTES** | | | | | Create note on commit | POST | `/repos/{repo_name}/notes` (action:"add") | `git:write` | @@ -347,6 +349,17 @@ Optional `ephemeral=true` lists branches under the ephemeral namespace instead o Response: `{ "branches": [{ "name", "head_sha", "created_at" }], "next_cursor", "has_more" }` +## GET /repos/{repo_name}/branch — Get Branch by Name + +```bash +curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/branch?name=feature%2Fpreview" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" +``` + +Use `ephemeral=true` to read from the ephemeral namespace. The exact response is `{ "branch": { "name", "head_sha", "created_at" } }`. It has no cursor. + +SDK methods: TypeScript `getBranch`, Python `get_branch`, and Go `GetBranch`. + ## GET /repos/{repo_name}/branches/diff — Get Branch Diff ```bash @@ -698,7 +711,7 @@ curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/archive" -X POST \ Response: streaming `tar.gz`. Headers: `Content-Type: application/gzip`. -## Tags Endpoints (POST/GET /repos/{repo_name}/tags, DELETE /repos/{repo_name}/tags/{tag_name}) +## Tags Endpoints (POST/GET /repos/{repo_name}/tags, GET /repos/{repo_name}/tag, DELETE /repos/{repo_name}/tags/{tag_name}) ```bash # Create lightweight tag @@ -710,13 +723,19 @@ curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/tags" -X POST \ curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/tags?limit=20&cursor=CURSOR" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" +# Get one tag by its exact name +curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/tag?name=releases%2Fv1.0.0" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" + # Delete tag curl "$CODE_STORAGE_BASE_URL/repos/$REPO_NAME_ENCODED/tags/v1.0.0" -X DELETE \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` Tag names must not start with `refs/`. `ref` must be a resolvable revision. -Create uses `git:write`; list uses `git:read`; delete requires both `git:read` and `git:write`. +Create uses `git:write`; list and exact lookup use `git:read`; delete requires both `git:read` and `git:write`. +The exact response is `{ "tag": { "name", "sha" } }`. The SHA is the dereferenced commit SHA. The response has no cursor or `object_sha`. +SDK methods: TypeScript `getTag`, Python `get_tag`, and Go `GetTag`. If the repository is synced to GitHub, tag create/delete triggers sync automatically. ## Notes Endpoints (POST/GET/DELETE /repos/{repo_name}/notes)