From 58ae20503e64c9ac51f9f129b3390ad4e96c428c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Mon, 25 May 2026 15:11:27 +0900 Subject: [PATCH 1/6] api --- packages/code-storage-go/fetch.go | 13 + packages/code-storage-go/repo.go | 142 +++++++++- packages/code-storage-go/repo_test.go | 255 ++++++++++++++++++ packages/code-storage-go/responses.go | 22 +- packages/code-storage-go/types.go | 70 ++++- .../pierre_storage/__init__.py | 8 +- .../pierre_storage/repo.py | 227 ++++++++++++++-- .../pierre_storage/types.py | 73 ++++- .../code-storage-python/tests/test_repo.py | 213 +++++++++++++++ packages/code-storage-typescript/src/fetch.ts | 32 ++- packages/code-storage-typescript/src/index.ts | 188 +++++++++++-- .../code-storage-typescript/src/schemas.ts | 21 ++ packages/code-storage-typescript/src/types.ts | 53 +++- .../tests/index.test.ts | 241 +++++++++++++++++ skills/code-storage/SKILL.md | 105 +++++++- 15 files changed, 1579 insertions(+), 84 deletions(-) diff --git a/packages/code-storage-go/fetch.go b/packages/code-storage-go/fetch.go index 45bc8e5..19876c2 100644 --- a/packages/code-storage-go/fetch.go +++ b/packages/code-storage-go/fetch.go @@ -36,6 +36,8 @@ func (f *apiFetcher) buildURL(path string, params url.Values) string { type requestOptions struct { allowedStatus map[int]bool + // extraHeaders merges request headers on top of SDK defaults; empty values are skipped. + extraHeaders map[string]string } func (f *apiFetcher) request(ctx context.Context, method string, path string, params url.Values, body interface{}, jwt string, opts *requestOptions) (*http.Response, error) { @@ -63,6 +65,13 @@ func (f *apiFetcher) request(ctx context.Context, method string, path string, pa if body != nil { req.Header.Set("Content-Type", "application/json") } + if opts != nil { + for k, v := range opts.extraHeaders { + if v != "" { + req.Header.Set(k, v) + } + } + } resp, err := f.httpClient.Do(req) if err != nil { @@ -116,6 +125,10 @@ func (f *apiFetcher) get(ctx context.Context, path string, params url.Values, jw return f.request(ctx, http.MethodGet, path, params, nil, jwt, opts) } +func (f *apiFetcher) head(ctx context.Context, path string, params url.Values, jwt string, opts *requestOptions) (*http.Response, error) { + return f.request(ctx, http.MethodHead, path, params, nil, jwt, opts) +} + func (f *apiFetcher) post(ctx context.Context, path string, params url.Values, body interface{}, jwt string, opts *requestOptions) (*http.Response, error) { return f.request(ctx, http.MethodPost, path, params, body, jwt, opts) } diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d55ebea..92a4192 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -93,6 +93,7 @@ func (r *Repo) ImportRemoteURL(ctx context.Context, options RemoteURLOptions) (s } // FileStream returns the raw response for streaming file contents. +// 206, 304 and 412 status codes pass through to the caller. func (r *Repo) FileStream(ctx context.Context, options GetFileOptions) (*http.Response, error) { if strings.TrimSpace(options.Path) == "" { return nil, errors.New("getFileStream path is required") @@ -104,6 +105,42 @@ func (r *Repo) FileStream(ctx context.Context, options GetFileOptions) (*http.Re return nil, fmt.Errorf("archive stream generate jwt: %w", err) } + params := buildGetFileParams(options) + reqOpts := buildFileRequestOptions(options.Headers) + + resp, err := r.client.api.get(ctx, "repos/file", params, jwtToken, reqOpts) + if err != nil { + return nil, err + } + + return resp, nil +} + +// HeadFile issues HEAD /repos/file and returns parsed response metadata. +func (r *Repo) HeadFile(ctx context.Context, options HeadFileOptions) (FileMetadata, error) { + if strings.TrimSpace(options.Path) == "" { + return FileMetadata{}, errors.New("headFile path is required") + } + + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) + if err != nil { + return FileMetadata{}, fmt.Errorf("headFile generate jwt: %w", err) + } + + params := buildGetFileParams(options) + reqOpts := buildFileRequestOptions(options.Headers) + + resp, err := r.client.api.head(ctx, "repos/file", params, jwtToken, reqOpts) + if err != nil { + return FileMetadata{}, err + } + defer resp.Body.Close() + + return parseFileMetadataHeaders(resp), nil +} + +func buildGetFileParams(options GetFileOptions) url.Values { params := url.Values{} params.Set("path", options.Path) if options.Ref != "" { @@ -115,13 +152,58 @@ func (r *Repo) FileStream(ctx context.Context, options GetFileOptions) (*http.Re if options.EphemeralBase != nil { params.Set("ephemeral_base", strconv.FormatBool(*options.EphemeralBase)) } + return params +} - resp, err := r.client.api.get(ctx, "repos/file", params, jwtToken, nil) - if err != nil { - return nil, err +func buildFileRequestOptions(headers FileRequestHeaders) *requestOptions { + extra := map[string]string{} + if headers.Range != "" { + extra["Range"] = headers.Range + } + if headers.IfMatch != "" { + extra["If-Match"] = headers.IfMatch + } + if headers.IfNoneMatch != "" { + extra["If-None-Match"] = headers.IfNoneMatch + } + if headers.IfModifiedSince != "" { + extra["If-Modified-Since"] = headers.IfModifiedSince + } + if headers.IfUnmodifiedSince != "" { + extra["If-Unmodified-Since"] = headers.IfUnmodifiedSince + } + if headers.IfRange != "" { + extra["If-Range"] = headers.IfRange } - return resp, nil + allowed := map[int]bool{304: true, 412: true} + opts := &requestOptions{allowedStatus: allowed} + if len(extra) > 0 { + opts.extraHeaders = extra + } + return opts +} + +func parseFileMetadataHeaders(resp *http.Response) FileMetadata { + meta := FileMetadata{ + BlobSHA: resp.Header.Get("X-Blob-Sha"), + LastCommitSHA: resp.Header.Get("X-Last-Commit-Sha"), + ETag: resp.Header.Get("ETag"), + AcceptRanges: resp.Header.Get("Accept-Ranges"), + ContentType: resp.Header.Get("Content-Type"), + RawLastModified: resp.Header.Get("Last-Modified"), + } + if v := resp.Header.Get("Content-Length"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + meta.Size = n + } + } + if meta.RawLastModified != "" { + if t, err := http.ParseTime(meta.RawLastModified); err == nil { + meta.LastModified = t + } + } + return meta } // ArchiveStream returns the raw response for streaming repository archives. @@ -177,6 +259,18 @@ func (r *Repo) ListFiles(ctx context.Context, options ListFilesOptions) (ListFil if options.Ephemeral != nil { params.Set("ephemeral", strconv.FormatBool(*options.Ephemeral)) } + if options.Path != "" { + params.Set("path", options.Path) + } + if options.Recursive != nil { + params.Set("recursive", strconv.FormatBool(*options.Recursive)) + } + if options.Cursor != "" { + params.Set("cursor", options.Cursor) + } + if options.Limit > 0 { + params.Set("limit", itoa(options.Limit)) + } if len(params) == 0 { params = nil } @@ -192,7 +286,23 @@ func (r *Repo) ListFiles(ctx context.Context, options ListFilesOptions) (ListFil return ListFilesResult{}, err } - return ListFilesResult{Paths: payload.Paths, Ref: payload.Ref}, nil + result := ListFilesResult{ + Paths: payload.Paths, + Ref: payload.Ref, + NextCursor: payload.NextCursor, + HasMore: payload.HasMore, + } + if len(payload.Entries) > 0 { + result.Entries = make([]TreeEntry, 0, len(payload.Entries)) + for _, entry := range payload.Entries { + result.Entries = append(result.Entries, TreeEntry{ + Path: entry.Path, + Type: TreeEntryType(entry.Type), + Mode: entry.Mode, + }) + } + } + return result, nil } // ListFilesWithMetadata lists files with mode/size and last commit metadata. @@ -210,6 +320,18 @@ func (r *Repo) ListFilesWithMetadata(ctx context.Context, options ListFilesWithM if options.Ephemeral != nil { params.Set("ephemeral", strconv.FormatBool(*options.Ephemeral)) } + if options.Path != "" { + params.Set("path", options.Path) + } + if options.Recursive != nil { + params.Set("recursive", strconv.FormatBool(*options.Recursive)) + } + if options.Cursor != "" { + params.Set("cursor", options.Cursor) + } + if options.Limit > 0 { + params.Set("limit", itoa(options.Limit)) + } if len(params) == 0 { params = nil } @@ -226,8 +348,10 @@ func (r *Repo) ListFilesWithMetadata(ctx context.Context, options ListFilesWithM } result := ListFilesWithMetadataResult{ - Ref: payload.Ref, - Commits: make(map[string]CommitMetadata, len(payload.Commits)), + Ref: payload.Ref, + Commits: make(map[string]CommitMetadata, len(payload.Commits)), + NextCursor: payload.NextCursor, + HasMore: payload.HasMore, } for _, file := range payload.Files { result.Files = append(result.Files, FileWithMetadata{ @@ -235,6 +359,7 @@ func (r *Repo) ListFilesWithMetadata(ctx context.Context, options ListFilesWithM Mode: file.Mode, Size: file.Size, LastCommitSHA: file.LastCommitSHA, + Type: TreeEntryType(file.Type), }) } for sha, commit := range payload.Commits { @@ -362,6 +487,9 @@ func (r *Repo) ListCommits(ctx context.Context, options ListCommitsOptions) (Lis if options.Ephemeral != nil { params.Set("ephemeral", strconv.FormatBool(*options.Ephemeral)) } + if options.Path != "" { + params.Set("path", options.Path) + } if len(params) == 0 { params = nil } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 7427cff..7f0ff3d 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -180,6 +180,261 @@ func TestListFilesWithMetadataEphemeral(t *testing.T) { } } +func TestListFilesSubtreeAndPagination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/files" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("ref") != "main" || q.Get("path") != "docs" || + q.Get("recursive") != "false" || q.Get("cursor") != "docs/a.md" || + q.Get("limit") != "50" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paths":["docs/guide.md"],"ref":"main","entries":[{"path":"docs/sub","type":"tree","mode":"040000"},{"path":"docs/guide.md","type":"blob","mode":"100644"}],"next_cursor":"docs/zz","has_more":true}`)) + })) + 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} + + recursive := false + result, err := repo.ListFiles(nil, ListFilesOptions{ + Ref: "main", + Path: "docs", + Recursive: &recursive, + Cursor: "docs/a.md", + Limit: 50, + }) + if err != nil { + t.Fatalf("list files error: %v", err) + } + if result.NextCursor != "docs/zz" || !result.HasMore { + t.Fatalf("expected pagination state: %+v", result) + } + if len(result.Entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result.Entries)) + } + if result.Entries[0].Type != TreeEntryTree || result.Entries[0].Path != "docs/sub" || + result.Entries[0].Mode != "040000" { + t.Fatalf("unexpected first entry: %+v", result.Entries[0]) + } + if result.Entries[1].Type != TreeEntryBlob { + t.Fatalf("expected blob, got %s", result.Entries[1].Type) + } +} + +func TestListFilesLegacyResponseDefaults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paths":["README.md"],"ref":"main"}`)) + })) + 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} + + result, err := repo.ListFiles(nil, ListFilesOptions{}) + if err != nil { + t.Fatalf("list files error: %v", err) + } + if len(result.Entries) != 0 { + t.Fatalf("expected no entries, got %d", len(result.Entries)) + } + if result.HasMore { + t.Fatalf("expected has_more=false") + } + if result.NextCursor != "" { + t.Fatalf("expected empty next_cursor") + } +} + +func TestListFilesWithMetadataPaginationAndType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("path") != "src" || q.Get("cursor") != "src/a.ts" || q.Get("limit") != "100" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"files":[{"path":"src/main.ts","mode":"100644","size":42,"last_commit_sha":"deadbeef","type":"blob"}],"commits":{"deadbeef":{"author":"Test","date":"2026-02-19T12:00:00Z","message":"init"}},"ref":"main","next_cursor":"src/zz.ts","has_more":true}`)) + })) + 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} + + result, err := repo.ListFilesWithMetadata(nil, ListFilesWithMetadataOptions{ + Path: "src", + Cursor: "src/a.ts", + Limit: 100, + }) + if err != nil { + t.Fatalf("list files with metadata error: %v", err) + } + if result.Files[0].Type != TreeEntryBlob { + t.Fatalf("expected blob type, got %s", result.Files[0].Type) + } + if result.NextCursor != "src/zz.ts" || !result.HasMore { + t.Fatalf("unexpected pagination: %+v", result) + } +} + +func TestListCommitsPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("branch") != "main" || q.Get("path") != "docs/guide.md" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"commits":[],"next_cursor":"","has_more":false}`)) + })) + 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 _, err := repo.ListCommits(nil, ListCommitsOptions{Branch: "main", Path: "docs/guide.md"}); err != nil { + t.Fatalf("list commits error: %v", err) + } +} + +func TestFileStreamForwardsConditionalHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/file" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Range"); got != "bytes=0-15" { + t.Fatalf("unexpected Range header: %q", got) + } + if got := r.Header.Get("If-None-Match"); got != `"abc"` { + t.Fatalf("unexpected If-None-Match: %q", got) + } + if got := r.Header.Get("If-Modified-Since"); got != "Wed, 21 Oct 2026 07:28:00 GMT" { + t.Fatalf("unexpected If-Modified-Since: %q", got) + } + w.WriteHeader(http.StatusPartialContent) + })) + 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} + + resp, err := repo.FileStream(nil, GetFileOptions{ + Path: "README.md", + Headers: FileRequestHeaders{ + Range: "bytes=0-15", + IfNoneMatch: `"abc"`, + IfModifiedSince: "Wed, 21 Oct 2026 07:28:00 GMT", + }, + }) + if err != nil { + t.Fatalf("file stream error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent { + t.Fatalf("expected 206, got %d", resp.StatusCode) + } +} + +func TestFileStreamPasses304Through(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotModified) + })) + 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} + + resp, err := repo.FileStream(nil, GetFileOptions{ + Path: "README.md", + Headers: FileRequestHeaders{IfNoneMatch: `"abc"`}, + }) + if err != nil { + t.Fatalf("expected 304 not to raise: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotModified { + t.Fatalf("expected 304, got %d", resp.StatusCode) + } +} + +func TestHeadFileParsesMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("expected HEAD, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/file" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("path") != "README.md" { + t.Fatalf("expected path=README.md, got %s", r.URL.RawQuery) + } + w.Header().Set("X-Blob-Sha", "b10b5ha") + w.Header().Set("X-Last-Commit-Sha", "c0mm1tsha") + w.Header().Set("Content-Length", "128") + w.Header().Set("ETag", `"b10b5ha"`) + w.Header().Set("Last-Modified", "Wed, 21 Oct 2026 07:28:00 GMT") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + })) + 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} + + meta, err := repo.HeadFile(nil, HeadFileOptions{Path: "README.md"}) + if err != nil { + t.Fatalf("head file error: %v", err) + } + if meta.BlobSHA != "b10b5ha" || meta.LastCommitSHA != "c0mm1tsha" { + t.Fatalf("unexpected blob/commit sha: %+v", meta) + } + if meta.Size != 128 { + t.Fatalf("expected size 128, got %d", meta.Size) + } + if meta.ETag != `"b10b5ha"` { + t.Fatalf("unexpected etag: %s", meta.ETag) + } + if meta.AcceptRanges != "bytes" { + t.Fatalf("unexpected accept-ranges: %s", meta.AcceptRanges) + } + if meta.ContentType != "application/octet-stream" { + t.Fatalf("unexpected content-type: %s", meta.ContentType) + } + if meta.RawLastModified != "Wed, 21 Oct 2026 07:28:00 GMT" { + t.Fatalf("unexpected raw last-modified: %s", meta.RawLastModified) + } + if meta.LastModified.IsZero() { + t.Fatalf("expected parsed last modified") + } +} + func TestGrepRequestBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/grep" { diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 69e02a5..f4bf33a 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -1,14 +1,25 @@ package storage +type treeEntryRaw struct { + Path string `json:"path"` + Type string `json:"type"` + Mode string `json:"mode"` +} + type listFilesResponse struct { - Paths []string `json:"paths"` - Ref string `json:"ref"` + Paths []string `json:"paths"` + Ref string `json:"ref"` + Entries []treeEntryRaw `json:"entries"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` } type listFilesWithMetadataResponse struct { - Files []fileWithMetadataRaw `json:"files"` - Commits map[string]commitMetadataRaw `json:"commits"` - Ref string `json:"ref"` + Files []fileWithMetadataRaw `json:"files"` + Commits map[string]commitMetadataRaw `json:"commits"` + Ref string `json:"ref"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` } type fileWithMetadataRaw struct { @@ -16,6 +27,7 @@ type fileWithMetadataRaw struct { Mode string `json:"mode"` Size int64 `json:"size"` LastCommitSHA string `json:"last_commit_sha"` + Type string `json:"type,omitempty"` } type commitMetadataRaw struct { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 0edc0dc..fb1eb9c 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -209,6 +209,16 @@ type GitCredential struct { CreatedAt string } +// FileRequestHeaders carries Range / conditional headers forwarded to /repos/file. +type FileRequestHeaders struct { + Range string + IfMatch string + IfNoneMatch string + IfModifiedSince string + IfUnmodifiedSince string + IfRange string +} + // GetFileOptions configures file download. type GetFileOptions struct { InvocationOptions @@ -216,6 +226,22 @@ type GetFileOptions struct { Ref string Ephemeral *bool EphemeralBase *bool + Headers FileRequestHeaders +} + +// HeadFileOptions configures a HEAD request against /repos/file. +type HeadFileOptions = GetFileOptions + +// FileMetadata is the parsed result of a HEAD /repos/file request. +type FileMetadata struct { + BlobSHA string + LastCommitSHA string + Size int64 + ETag string + LastModified time.Time + RawLastModified string + AcceptRanges string + ContentType string } // ArchiveOptions configures repository archive download. @@ -234,17 +260,42 @@ type PullUpstreamOptions struct { Ref string } +// TreeEntryType identifies the kind of object at a tree entry. +type TreeEntryType string + +const ( + TreeEntryBlob TreeEntryType = "blob" + TreeEntryTree TreeEntryType = "tree" + TreeEntrySymlink TreeEntryType = "symlink" + TreeEntrySubmodule TreeEntryType = "submodule" +) + +// TreeEntry is a structured entry returned by ListFiles. +type TreeEntry struct { + Path string + Type TreeEntryType + Mode string +} + // ListFilesOptions configures list files. type ListFilesOptions struct { InvocationOptions Ref string Ephemeral *bool + Path string + // Recursive uses a pointer to distinguish unset from explicit false. + Recursive *bool + Cursor string + Limit int } // ListFilesResult describes file list. type ListFilesResult struct { - Paths []string - Ref string + Paths []string + Ref string + Entries []TreeEntry + NextCursor string + HasMore bool } // ListFilesWithMetadataOptions configures list files with metadata. @@ -252,6 +303,11 @@ type ListFilesWithMetadataOptions struct { InvocationOptions Ref string Ephemeral *bool + Path string + // Recursive is accepted for symmetry with ListFiles; listings are always recursive. + Recursive *bool + Cursor string + Limit int } // FileWithMetadata describes a file metadata entry. @@ -260,6 +316,7 @@ type FileWithMetadata struct { Mode string Size int64 LastCommitSHA string + Type TreeEntryType } // CommitMetadata describes commit metadata for the files metadata response. @@ -272,9 +329,11 @@ type CommitMetadata struct { // ListFilesWithMetadataResult describes files metadata response. type ListFilesWithMetadataResult struct { - Files []FileWithMetadata - Commits map[string]CommitMetadata - Ref string + Files []FileWithMetadata + Commits map[string]CommitMetadata + Ref string + NextCursor string + HasMore bool } // ListBranchesOptions configures list branches. @@ -450,6 +509,7 @@ type ListCommitsOptions struct { Cursor string Limit int Ephemeral *bool + Path string } // CommitInfo describes a commit entry. diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index f895975..50b5f32 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -7,6 +7,7 @@ from pierre_storage.client import GitStorage, create_client from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( + OP_NO_FORCE_PUSH, BaseRepo, BlameLine, BlameResult, @@ -18,8 +19,8 @@ CreateBranchResult, CreateTagResult, DeleteBranchResult, - DeleteTagResult, DeleteRepoResult, + DeleteTagResult, DiffFileState, DiffStats, FileDiff, @@ -29,9 +30,6 @@ GetCommitDiffResult, GetCommitResult, GitStorageOptions, - Op, - OP_NO_FORCE_PUSH, - Ops, GrepFileMatch, GrepLine, GrepResult, @@ -43,6 +41,8 @@ ListTagsResult, NoteReadResult, NoteWriteResult, + Op, + Ops, RefUpdate, Repo, RepoInfo, diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 0426d9f..8b9fc77 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -3,6 +3,7 @@ import contextlib import warnings from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from types import TracebackType from typing import Any, Callable, Dict, List, Literal, Optional from urllib.parse import urlencode @@ -31,6 +32,8 @@ DeleteTagResult, DiffFileState, FileDiff, + FileMetadata, + FileRequestHeaders, FileSource, FileWithMetadata, FilteredFile, @@ -52,6 +55,7 @@ RefUpdate, RestoreCommitResult, TagInfo, + TreeEntry, ) from pierre_storage.version import get_user_agent @@ -62,7 +66,12 @@ class StreamingResponse: """Stream wrapper that keeps the HTTP client alive until closed.""" - def __init__(self, response: httpx.Response, client: httpx.AsyncClient, stream_context: Optional[contextlib.AbstractAsyncContextManager] = None) -> None: + def __init__( + self, + response: httpx.Response, + client: httpx.AsyncClient, + stream_context: Optional[contextlib.AbstractAsyncContextManager] = None, + ) -> None: self._response = response self._client = client self._stream_context = stream_context @@ -136,6 +145,60 @@ def normalize_optional_string(value: Optional[str]) -> Optional[str]: return normalized or None +_CONDITIONAL_HEADER_NAMES = { + "range": "Range", + "if_match": "If-Match", + "if_none_match": "If-None-Match", + "if_modified_since": "If-Modified-Since", + "if_unmodified_since": "If-Unmodified-Since", + "if_range": "If-Range", +} + + +def build_conditional_headers( + headers: Optional[FileRequestHeaders], +) -> Dict[str, str]: + """Convert SDK header dict (snake_case) to HTTP headers (canonical case).""" + if not headers: + return {} + out: Dict[str, str] = {} + for key, http_name in _CONDITIONAL_HEADER_NAMES.items(): + value = headers.get(key) # type: ignore[call-overload] + if isinstance(value, str) and value: + out[http_name] = value + return out + + +def parse_file_metadata_headers(response: httpx.Response) -> FileMetadata: + """Parse FileMetadata from HEAD or GET response headers.""" + headers = response.headers + metadata: FileMetadata = { + "blob_sha": headers.get("x-blob-sha", ""), + "last_commit_sha": headers.get("x-last-commit-sha", ""), + } + content_length = headers.get("content-length") + if content_length is not None and content_length != "": + with contextlib.suppress(TypeError, ValueError): + metadata["size"] = int(content_length) + etag = headers.get("etag") + if etag: + metadata["etag"] = etag + raw_last_modified = headers.get("last-modified") + if raw_last_modified: + metadata["raw_last_modified"] = raw_last_modified + with contextlib.suppress(TypeError, ValueError): + parsed = parsedate_to_datetime(raw_last_modified) + if parsed is not None: + metadata["last_modified"] = parsed + accept_ranges = headers.get("accept-ranges") + if accept_ranges: + metadata["accept_ranges"] = accept_ranges + content_type = headers.get("content-type") + if content_type: + metadata["content_type"] = content_type + return metadata + + def normalize_optional_signature( value: Optional[CommitSignature], field_name: Literal["author", "committer"], @@ -277,6 +340,7 @@ async def get_file_stream( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> StreamingResponse: """Get file content as streaming response. @@ -285,6 +349,8 @@ async def get_file_stream( path: File path to retrieve ref: Git ref (branch, tag, or commit SHA) ephemeral: Whether to read from the ephemeral namespace + headers: Optional ``Range``/conditional headers forwarded to the + server. 206/304/412 are passed through without raising. ttl: Token TTL in seconds Returns: @@ -303,25 +369,74 @@ async def get_file_stream( if params: url += f"?{urlencode(params)}" + request_headers: Dict[str, str] = { + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + } + request_headers.update(build_conditional_headers(headers)) + client = httpx.AsyncClient() try: stream_context = client.stream( "GET", url, - headers={ - "Authorization": f"Bearer {jwt}", - "Code-Storage-Agent": get_user_agent(), - }, + headers=request_headers, timeout=30.0, ) response = await stream_context.__aenter__() - response.raise_for_status() + # 200, 206 are success; 304 and 412 are conditional outcomes that + # callers handle explicitly. Anything else still raises. + if response.status_code not in (200, 206, 304, 412): + response.raise_for_status() except Exception: await client.aclose() raise return StreamingResponse(response, client, stream_context) + async def head_file( + self, + *, + path: str, + ref: Optional[str] = None, + ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, + ttl: Optional[int] = None, + ) -> FileMetadata: + """Issue ``HEAD /repos/file`` and return parsed response metadata. + + Returns blob SHA, last-commit SHA, size, ETag and Last-Modified + without downloading the file body. + """ + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + + params = {"path": path} + if ref: + params["ref"] = ref + if ephemeral is not None: + params["ephemeral"] = "true" if ephemeral else "false" + + url = f"{self.api_base_url}/api/v{self.api_version}/repos/file" + if params: + url += f"?{urlencode(params)}" + + request_headers: Dict[str, str] = { + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + } + request_headers.update(build_conditional_headers(headers)) + + async with httpx.AsyncClient() as client: + response = await client.head( + url, + headers=request_headers, + timeout=30.0, + ) + if response.status_code not in (200, 304, 412): + response.raise_for_status() + return parse_file_metadata_headers(response) + async def get_archive_stream( self, *, @@ -387,6 +502,10 @@ async def list_files( *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesResult: """List files in repository. @@ -394,19 +513,32 @@ async def list_files( Args: ref: Git ref (branch, tag, or commit SHA) ephemeral: Whether to read from the ephemeral namespace + path: Optional repository-relative subtree to list + recursive: When false, only direct children are returned + cursor: Pagination cursor returned by a previous call + limit: Maximum number of entries to return (default 1000, max 5000) ttl: Token TTL in seconds Returns: - List of file paths and ref + Paths, structured ``entries`` (blobs+trees+symlinks+submodules), + resolved ref, and pagination state. """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) - params = {} + params: Dict[str, str] = {} if ref: params["ref"] = ref if ephemeral is not None: params["ephemeral"] = "true" if ephemeral else "false" + if path: + params["path"] = path + if recursive is not None: + params["recursive"] = "true" if recursive else "false" + if cursor: + params["cursor"] = cursor + if limit is not None: + params["limit"] = str(limit) url = f"{self.api_base_url}/api/v{self.api_version}/repos/files" if params: @@ -423,13 +555,37 @@ async def list_files( ) response.raise_for_status() data = response.json() - return {"paths": data["paths"], "ref": data["ref"]} + + entries: List[TreeEntry] = [] + for entry in data.get("entries") or []: + entries.append( + { + "path": entry["path"], + "type": entry["type"], + "mode": entry["mode"], + } + ) + + result: ListFilesResult = { + "paths": list(data.get("paths") or []), + "ref": data["ref"], + "entries": entries, + "has_more": bool(data.get("has_more", False)), + } + next_cursor = data.get("next_cursor") + if next_cursor: + result["next_cursor"] = next_cursor + return result async def list_files_with_metadata( self, *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesWithMetadataResult: """List files with metadata in repository. @@ -437,19 +593,32 @@ async def list_files_with_metadata( Args: ref: Git ref (branch, tag, or commit SHA) ephemeral: Whether to read from the ephemeral namespace + path: Optional repository-relative subtree to list + recursive: Accepted for API symmetry; listings are always recursive + cursor: Pagination cursor from a previous response + limit: Maximum number of files to return (default 200, max 1000) ttl: Token TTL in seconds Returns: - Files with mode/size/last commit metadata and resolved ref + Files with mode/size/type/last commit metadata, resolved ref and + pagination state. """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) - params = {} + params: Dict[str, str] = {} if ref: params["ref"] = ref if ephemeral is not None: params["ephemeral"] = "true" if ephemeral else "false" + if path: + params["path"] = path + if recursive is not None: + params["recursive"] = "true" if recursive else "false" + if cursor: + params["cursor"] = cursor + if limit is not None: + params["limit"] = str(limit) url = f"{self.api_base_url}/api/v{self.api_version}/repos/files/metadata" if params: @@ -467,15 +636,18 @@ async def list_files_with_metadata( response.raise_for_status() data = response.json() - files: List[FileWithMetadata] = [ - { + files: List[FileWithMetadata] = [] + for file in data["files"]: + entry: FileWithMetadata = { "path": file["path"], "mode": file["mode"], "size": file["size"], "last_commit_sha": file["last_commit_sha"], } - for file in data["files"] - ] + file_type = file.get("type") + if file_type: + entry["type"] = file_type + files.append(entry) commits: Dict[str, CommitMetadata] = {} for sha, commit in data["commits"].items(): @@ -491,7 +663,16 @@ async def list_files_with_metadata( "message": commit["message"], } - return {"files": files, "commits": commits, "ref": data["ref"]} + result: ListFilesWithMetadataResult = { + "files": files, + "commits": commits, + "ref": data["ref"], + "has_more": bool(data.get("has_more", False)), + } + next_cursor = data.get("next_cursor") + if next_cursor: + result["next_cursor"] = next_cursor + return result async def list_branches( self, @@ -1022,6 +1203,7 @@ async def list_commits( cursor: Optional[str] = None, limit: Optional[int] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, ttl: Optional[int] = None, ) -> ListCommitsResult: """List commits in repository. @@ -1031,6 +1213,8 @@ async def list_commits( cursor: Pagination cursor limit: Maximum number of commits to return ephemeral: When true, resolve `branch` under the ephemeral namespace + path: Optional repository-relative path to scope the history to + commits that touched that file or subtree. ttl: Token TTL in seconds Returns: @@ -1048,6 +1232,8 @@ async def list_commits( params["limit"] = str(limit) if ephemeral is not None: params["ephemeral"] = "true" if ephemeral else "false" + if path: + params["path"] = path url = f"{self.api_base_url}/api/v{self.api_version}/repos/commits" if params: @@ -1183,10 +1369,7 @@ async def get_blame( if detect_moves: params.append(("detect_moves", "true")) - url = ( - f"{self.api_base_url}/api/v{self.api_version}/repos/blame" - f"?{urlencode(params)}" - ) + url = f"{self.api_base_url}/api/v{self.api_version}/repos/blame?{urlencode(params)}" async with httpx.AsyncClient() as client: response = await client.get( @@ -1211,9 +1394,7 @@ async def get_blame( "original_path": entry["original_path"], "author_name": entry["author_name"], "author_email": entry["author_email"], - "author_time": datetime.fromisoformat( - author_time_raw.replace("Z", "+00:00") - ), + "author_time": datetime.fromisoformat(author_time_raw.replace("Z", "+00:00")), "raw_author_time": author_time_raw, "committer_name": entry["committer_name"], "committer_email": entry["committer_email"], diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 6af1573..26db13f 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -77,7 +77,9 @@ class ForkBaseRepo(TypedDict, total=False): class GenericGitBaseRepo(TypedDict, total=False): """Base repository configuration for generic git providers (GitLab, Bitbucket, etc.).""" - provider: str # required — one of: "gitlab", "bitbucket", "gitea", "forgejo", "codeberg", "sr.ht" + provider: ( + str # required — one of: "gitlab", "bitbucket", "gitea", "forgejo", "codeberg", "sr.ht" + ) owner: str # required name: str # required default_branch: Optional[str] @@ -144,20 +146,35 @@ class ListReposResult(TypedDict): # Removed: ListFilesOptions - now uses **kwargs -class ListFilesResult(TypedDict): +TreeEntryType = Literal["blob", "tree", "symlink", "submodule"] + + +class TreeEntry(TypedDict): + """Tree entry returned by list_files.""" + + path: str + type: TreeEntryType + mode: str + + +class ListFilesResult(TypedDict, total=False): """Result from listing files.""" paths: List[str] ref: str + entries: List[TreeEntry] + next_cursor: Optional[str] + has_more: bool -class FileWithMetadata(TypedDict): +class FileWithMetadata(TypedDict, total=False): """Per-file metadata entry for list_files_with_metadata.""" path: str mode: str size: int last_commit_sha: str + type: TreeEntryType class CommitMetadata(TypedDict): @@ -169,12 +186,38 @@ class CommitMetadata(TypedDict): message: str -class ListFilesWithMetadataResult(TypedDict): +class ListFilesWithMetadataResult(TypedDict, total=False): """Result from listing files with metadata.""" files: List[FileWithMetadata] commits: Dict[str, CommitMetadata] ref: str + next_cursor: Optional[str] + has_more: bool + + +class FileRequestHeaders(TypedDict, total=False): + """Range / conditional headers forwarded to /repos/file.""" + + range: str + if_match: str + if_none_match: str + if_modified_since: str + if_unmodified_since: str + if_range: str + + +class FileMetadata(TypedDict, total=False): + """Parsed response headers from HEAD /repos/file.""" + + blob_sha: str + last_commit_sha: str + size: int + etag: str + last_modified: datetime + raw_last_modified: str + accept_ranges: str + content_type: str # Removed: ListBranchesOptions - now uses **kwargs @@ -639,11 +682,24 @@ async def get_file_stream( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> Any: # httpx.Response """Get a file as a stream.""" ... + async def head_file( + self, + *, + path: str, + ref: Optional[str] = None, + ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, + ttl: Optional[int] = None, + ) -> FileMetadata: + """Issue HEAD /repos/file and return parsed response metadata.""" + ... + async def get_archive_stream( self, *, @@ -662,6 +718,10 @@ async def list_files( *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesResult: """List files in the repository.""" @@ -672,6 +732,10 @@ async def list_files_with_metadata( *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesWithMetadataResult: """List files with metadata in the repository.""" @@ -779,6 +843,7 @@ async def list_commits( cursor: Optional[str] = None, limit: Optional[int] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, ttl: Optional[int] = None, ) -> ListCommitsResult: """List commits in the repository.""" diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 33efc33..c606a9e 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -285,6 +285,183 @@ async def test_list_files_ephemeral_flag(self, git_storage_options: dict) -> Non assert params.get("ephemeral") == ["true"] assert params.get("ref") == ["feature/demo"] + @pytest.mark.asyncio + async def test_list_files_subtree_and_pagination(self, git_storage_options: dict) -> None: + """path/recursive/cursor/limit reach the wire; entries+pagination parsed.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + list_response = MagicMock() + list_response.status_code = 200 + list_response.is_success = True + list_response.json.return_value = { + "paths": ["docs/guide.md"], + "ref": "main", + "entries": [ + {"path": "docs/sub", "type": "tree", "mode": "040000"}, + {"path": "docs/guide.md", "type": "blob", "mode": "100644"}, + ], + "next_cursor": "docs/zz", + "has_more": True, + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock(return_value=list_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.list_files( + ref="main", + path="docs", + recursive=False, + cursor="docs/a.md", + limit=50, + ) + + assert result["paths"] == ["docs/guide.md"] + assert result["entries"] == [ + {"path": "docs/sub", "type": "tree", "mode": "040000"}, + {"path": "docs/guide.md", "type": "blob", "mode": "100644"}, + ] + assert result["next_cursor"] == "docs/zz" + assert result["has_more"] is True + + called_url = client_instance.get.call_args.args[0] + params = parse_qs(urlparse(called_url).query) + assert params.get("path") == ["docs"] + assert params.get("recursive") == ["false"] + assert params.get("cursor") == ["docs/a.md"] + assert params.get("limit") == ["50"] + + @pytest.mark.asyncio + async def test_list_files_legacy_response_defaults( + self, git_storage_options: dict + ) -> None: + """Servers without entries/has_more still produce a valid result.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + list_response = MagicMock() + list_response.status_code = 200 + list_response.is_success = True + list_response.json.return_value = {"paths": ["README.md"], "ref": "main"} + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock(return_value=list_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.list_files() + assert result["paths"] == ["README.md"] + assert result["entries"] == [] + assert result["has_more"] is False + assert "next_cursor" not in result + + @pytest.mark.asyncio + async def test_head_file_parses_response_headers( + self, git_storage_options: dict + ) -> None: + """head_file issues HEAD and returns parsed FileMetadata.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + head_response = MagicMock() + head_response.status_code = 200 + head_response.is_success = True + head_response.headers = { + "x-blob-sha": "b10b5ha", + "x-last-commit-sha": "c0mm1tsha", + "content-length": "128", + "etag": '"b10b5ha"', + "last-modified": "Wed, 21 Oct 2026 07:28:00 GMT", + "accept-ranges": "bytes", + "content-type": "application/octet-stream", + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.head = AsyncMock(return_value=head_response) + + repo = await storage.create_repo(id="test-repo") + meta = await repo.head_file(path="README.md") + + assert meta["blob_sha"] == "b10b5ha" + assert meta["last_commit_sha"] == "c0mm1tsha" + assert meta["size"] == 128 + assert meta["etag"] == '"b10b5ha"' + assert meta["accept_ranges"] == "bytes" + assert meta["content_type"] == "application/octet-stream" + assert meta["raw_last_modified"] == "Wed, 21 Oct 2026 07:28:00 GMT" + assert isinstance(meta["last_modified"], datetime) + + called_url = client_instance.head.call_args.args[0] + assert urlparse(called_url).path.endswith("/repos/file") + assert parse_qs(urlparse(called_url).query).get("path") == ["README.md"] + + @pytest.mark.asyncio + async def test_get_file_stream_forwards_conditional_headers( + self, git_storage_options: dict + ) -> None: + """get_file_stream passes Range/If-* headers through to the server.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + stream_response = MagicMock() + stream_response.status_code = 206 + stream_response.is_success = True + stream_response.raise_for_status = MagicMock() + + stream_cm = MagicMock() + stream_cm.__aenter__ = AsyncMock(return_value=stream_response) + stream_cm.__aexit__ = AsyncMock(return_value=None) + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value + # post() runs under `async with` context for create_repo + client_instance.__aenter__.return_value.post = AsyncMock( + return_value=create_response + ) + # stream() is called on the long-lived client used by get_file_stream + client_instance.stream = MagicMock(return_value=stream_cm) + client_instance.aclose = AsyncMock() + + repo = await storage.create_repo(id="test-repo") + result = await repo.get_file_stream( + path="README.md", + headers={ + "range": "bytes=0-15", + "if_none_match": '"abc"', + "if_modified_since": "Wed, 21 Oct 2026 07:28:00 GMT", + }, + ) + assert result.status_code == 206 + + stream_args = client_instance.stream.call_args + assert stream_args.args[0] == "GET" + sent_headers = stream_args.kwargs["headers"] + assert sent_headers["Range"] == "bytes=0-15" + assert sent_headers["If-None-Match"] == '"abc"' + assert sent_headers["If-Modified-Since"] == "Wed, 21 Oct 2026 07:28:00 GMT" + @pytest.mark.asyncio async def test_list_files_with_metadata_ephemeral_flag(self, git_storage_options: dict) -> None: """Ensure ephemeral flag propagates to list files with metadata.""" @@ -1312,6 +1489,42 @@ async def test_list_commits_ephemeral_query_param( assert "ephemeral=true" in called_url assert "branch=feature" in called_url + @pytest.mark.asyncio + async def test_list_commits_path_query_param( + self, git_storage_options: dict + ) -> None: + """path kwarg appears as `path=` query parameter.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + commits_response = MagicMock() + commits_response.status_code = 200 + commits_response.is_success = True + commits_response.json.return_value = { + "commits": [], + "next_cursor": None, + "has_more": False, + } + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=create_response + ) + mock_get = AsyncMock(return_value=commits_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + repo = await storage.create_repo(id="test-repo") + await repo.list_commits(branch="main", path="docs/guide.md") + + called_url = mock_get.await_args.args[0] + params = parse_qs(urlparse(called_url).query) + assert params.get("branch") == ["main"] + assert params.get("path") == ["docs/guide.md"] + @pytest.mark.asyncio async def test_get_commit(self, git_storage_options: dict) -> None: """Test fetching a single commit's metadata.""" diff --git a/packages/code-storage-typescript/src/fetch.ts b/packages/code-storage-typescript/src/fetch.ts index 1ba8ffd..5418e23 100644 --- a/packages/code-storage-typescript/src/fetch.ts +++ b/packages/code-storage-typescript/src/fetch.ts @@ -4,6 +4,8 @@ import { getUserAgent } from './version'; interface RequestOptions { allowedStatus?: number[]; + /** Extra request headers merged on top of the SDK defaults. */ + extraHeaders?: Record; } export class ApiError extends Error { @@ -70,16 +72,30 @@ export class ApiFetcher { ) { const requestUrl = this.getRequestUrl(path); + const headers: Record = { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + 'Code-Storage-Agent': getUserAgent(), + }; + if (options?.extraHeaders) { + for (const [key, value] of Object.entries(options.extraHeaders)) { + if (typeof value === 'string' && value !== '') { + headers[key] = value; + } + } + } + const requestOptions: RequestInit = { method, - headers: { - Authorization: `Bearer ${jwt}`, - 'Content-Type': 'application/json', - 'Code-Storage-Agent': getUserAgent(), - }, + headers, }; - if (method !== 'GET' && typeof path !== 'string' && path.body) { + if ( + method !== 'GET' && + method !== 'HEAD' && + typeof path !== 'string' && + path.body + ) { requestOptions.body = JSON.stringify(path.body); } @@ -144,6 +160,10 @@ export class ApiFetcher { return this.fetch(path, 'GET', jwt, options); } + async head(path: ValidPath, jwt: string, options?: RequestOptions) { + return this.fetch(path, 'HEAD', jwt, options); + } + async post(path: ValidPath, jwt: string, options?: RequestOptions) { return this.fetch(path, 'POST', jwt, options); } diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index cfcd87f..5ef04e5 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -68,6 +68,7 @@ import type { DeleteRepoOptions, DeleteRepoResult, DiffFileState, + FileMetadata, FileWithMetadata, FileDiff, FilteredFile, @@ -86,6 +87,7 @@ import type { GetCommitResponse, GetCommitResult, GetFileOptions, + HeadFileOptions, GetNoteOptions, GetNoteResult, GetRemoteURLOptions, @@ -125,12 +127,14 @@ import type { RawFileDiff, RawFilteredFile, RawTagInfo, + RawTreeEntry, RefUpdate, RepoOptions, Repo, RestoreCommitOptions, RestoreCommitResult, TagInfo, + TreeEntry, UpdateGitCredentialOptions, ValidAPIVersion, } from './types'; @@ -385,13 +389,25 @@ function transformBlameResult(raw: BlameResponse): BlameResult { }; } -function transformFileWithMetadata(raw: RawFileWithMetadata): FileWithMetadata { +function transformTreeEntry(raw: RawTreeEntry): TreeEntry { return { + path: raw.path, + type: raw.type, + mode: raw.mode, + }; +} + +function transformFileWithMetadata(raw: RawFileWithMetadata): FileWithMetadata { + const file: FileWithMetadata = { path: raw.path, mode: raw.mode, size: raw.size, lastCommitSha: raw.last_commit_sha, }; + if (raw.type) { + file.type = raw.type; + } + return file; } function transformCommitMetadata(raw: RawCommitMetadata): CommitMetadata { @@ -415,6 +431,8 @@ function transformListFilesWithMetadataResult( files: raw.files.map(transformFileWithMetadata), commits, ref: raw.ref, + nextCursor: raw.next_cursor ?? undefined, + hasMore: raw.has_more ?? false, }; } @@ -682,6 +700,98 @@ function buildNoteWriteBody( return body; } +function buildGetFileParams( + options: Pick +): Record { + const params: Record = { + path: options.path, + }; + if (options.ref) { + params.ref = options.ref; + } + if (typeof options.ephemeral === 'boolean') { + params.ephemeral = String(options.ephemeral); + } + if (typeof options.ephemeralBase === 'boolean') { + params.ephemeral_base = String(options.ephemeralBase); + } + return params; +} + +function buildConditionalHeaders( + headers?: GetFileOptions['headers'] +): Record | undefined { + if (!headers) { + return undefined; + } + const out: Record = {}; + if (headers.range) { + out['Range'] = headers.range; + } + if (headers.ifMatch) { + out['If-Match'] = headers.ifMatch; + } + if (headers.ifNoneMatch) { + out['If-None-Match'] = headers.ifNoneMatch; + } + if (headers.ifModifiedSince) { + out['If-Modified-Since'] = headers.ifModifiedSince; + } + if (headers.ifUnmodifiedSince) { + out['If-Unmodified-Since'] = headers.ifUnmodifiedSince; + } + if (headers.ifRange) { + out['If-Range'] = headers.ifRange; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseFileMetadataHeaders(response: Response): FileMetadata { + const headers = response.headers; + const blobSha = headers.get('x-blob-sha') ?? ''; + const lastCommitSha = headers.get('x-last-commit-sha') ?? ''; + const contentLength = headers.get('content-length'); + const size = + contentLength !== null && contentLength !== '' + ? Number(contentLength) + : undefined; + const etag = headers.get('etag') ?? undefined; + const rawLastModified = headers.get('last-modified') ?? undefined; + let lastModified: Date | undefined; + if (rawLastModified) { + const parsed = new Date(rawLastModified); + if (!Number.isNaN(parsed.getTime())) { + lastModified = parsed; + } + } + const acceptRanges = headers.get('accept-ranges') ?? undefined; + const contentType = headers.get('content-type') ?? undefined; + + const metadata: FileMetadata = { + blobSha, + lastCommitSha, + }; + if (typeof size === 'number' && Number.isFinite(size)) { + metadata.size = size; + } + if (etag) { + metadata.etag = etag; + } + if (rawLastModified) { + metadata.rawLastModified = rawLastModified; + } + if (lastModified) { + metadata.lastModified = lastModified; + } + if (acceptRanges) { + metadata.acceptRanges = acceptRanges; + } + if (contentType) { + metadata.contentType = contentType; + } + return metadata; +} + async function parseNoteWriteResponse( response: Response, method: 'POST' | 'DELETE' @@ -789,22 +899,34 @@ class RepoImpl implements Repo { ttl, }); - const params: Record = { - path: options.path, - }; + const params = buildGetFileParams(options); + const extraHeaders = buildConditionalHeaders(options.headers); - if (options.ref) { - params.ref = options.ref; - } - if (typeof options.ephemeral === 'boolean') { - params.ephemeral = String(options.ephemeral); - } - if (typeof options.ephemeralBase === 'boolean') { - params.ephemeral_base = String(options.ephemeralBase); - } + // Allow 304 and 412 to surface as normal responses for conditional callers + return this.api.get( + { path: 'repos/file', params }, + jwt, + { allowedStatus: [304, 412], extraHeaders } + ); + } + + async headFile(options: HeadFileOptions): Promise { + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + + const params = buildGetFileParams(options); + const extraHeaders = buildConditionalHeaders(options.headers); - // Return the raw fetch Response for streaming - return this.api.get({ path: 'repos/file', params }, jwt); + const response = await this.api.head( + { path: 'repos/file', params }, + jwt, + { allowedStatus: [304, 412], extraHeaders } + ); + + return parseFileMetadataHeaders(response); } async getArchiveStream(options: ArchiveOptions = {}): Promise { @@ -855,6 +977,18 @@ class RepoImpl implements Repo { if (typeof options?.ephemeral === 'boolean') { params.ephemeral = String(options.ephemeral); } + if (typeof options?.path === 'string' && options.path !== '') { + params.path = options.path; + } + if (typeof options?.recursive === 'boolean') { + params.recursive = String(options.recursive); + } + if (typeof options?.cursor === 'string' && options.cursor !== '') { + params.cursor = options.cursor; + } + if (typeof options?.limit === 'number') { + params.limit = options.limit.toString(); + } const response = await this.api.get( { path: 'repos/files', @@ -864,7 +998,13 @@ class RepoImpl implements Repo { ); const raw = listFilesResponseSchema.parse(await response.json()); - return { paths: raw.paths, ref: raw.ref }; + return { + paths: raw.paths, + ref: raw.ref, + entries: (raw.entries ?? []).map(transformTreeEntry), + nextCursor: raw.next_cursor ?? undefined, + hasMore: raw.has_more ?? false, + }; } async listFilesWithMetadata( @@ -883,6 +1023,18 @@ class RepoImpl implements Repo { if (typeof options?.ephemeral === 'boolean') { params.ephemeral = String(options.ephemeral); } + if (typeof options?.path === 'string' && options.path !== '') { + params.path = options.path; + } + if (typeof options?.recursive === 'boolean') { + params.recursive = String(options.recursive); + } + if (typeof options?.cursor === 'string' && options.cursor !== '') { + params.cursor = options.cursor; + } + if (typeof options?.limit === 'number') { + params.limit = options.limit.toString(); + } const response = await this.api.get( { path: 'repos/files/metadata', @@ -982,6 +1134,7 @@ class RepoImpl implements Repo { options?.branch || options?.cursor || options?.limit || + options?.path || typeof options?.ephemeral === 'boolean' ) { params = {}; @@ -997,6 +1150,9 @@ class RepoImpl implements Repo { if (typeof options?.ephemeral === 'boolean') { params.ephemeral = String(options.ephemeral); } + if (typeof options?.path === 'string' && options.path !== '') { + params.path = options.path; + } } const response = await this.api.get({ path: 'repos/commits', params }, jwt); diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 53fab38..5372256 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -1,8 +1,24 @@ import { z } from 'zod'; +export const treeEntryTypeSchema = z.enum([ + 'blob', + 'tree', + 'symlink', + 'submodule', +]); + +export const treeEntryRawSchema = z.object({ + path: z.string(), + type: treeEntryTypeSchema, + mode: z.string(), +}); + export const listFilesResponseSchema = z.object({ paths: z.array(z.string()), ref: z.string(), + entries: z.array(treeEntryRawSchema).optional(), + next_cursor: z.string().nullable().optional(), + has_more: z.boolean().optional(), }); export const fileWithMetadataRawSchema = z.object({ @@ -10,6 +26,7 @@ export const fileWithMetadataRawSchema = z.object({ mode: z.string(), size: z.number(), last_commit_sha: z.string(), + type: treeEntryTypeSchema.optional(), }); export const commitMetadataRawSchema = z.object({ @@ -22,6 +39,8 @@ export const listFilesWithMetadataResponseSchema = z.object({ files: z.array(fileWithMetadataRawSchema), commits: z.record(commitMetadataRawSchema), ref: z.string(), + next_cursor: z.string().nullable().optional(), + has_more: z.boolean().optional(), }); export const branchInfoSchema = z.object({ @@ -299,6 +318,8 @@ export const errorEnvelopeSchema = z.object({ }); export type ListFilesResponseRaw = z.infer; +export type RawTreeEntry = z.infer; +export type TreeEntryTypeRaw = z.infer; export type RawFileWithMetadata = z.infer; export type RawCommitMetadata = z.infer; export type ListFilesWithMetadataResponseRaw = z.infer< diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index b09f099..264c43b 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -28,6 +28,8 @@ import type { RawRepoBaseInfo as SchemaRawRepoBaseInfo, RawRepoInfo as SchemaRawRepoInfo, RawTagInfo as SchemaRawTagInfo, + RawTreeEntry as SchemaRawTreeEntry, + TreeEntryTypeRaw as SchemaTreeEntryTypeRaw, } from './schemas'; export interface OverrideableGitStorageOptions { @@ -68,6 +70,7 @@ export interface Repo { getImportRemoteURL(options?: GetRemoteURLOptions): Promise; getFileStream(options: GetFileOptions): Promise; + headFile(options: HeadFileOptions): Promise; getArchiveStream(options?: ArchiveOptions): Promise; listFiles(options?: ListFilesOptions): Promise; listFilesWithMetadata( @@ -98,7 +101,7 @@ export interface Repo { ): Promise; } -export type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; +export type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD'; type SimplePath = string; type ComplexPath = { path: string; @@ -247,11 +250,34 @@ export interface DeleteRepoResult { } // Get File API types +export interface FileRequestHeaders { + range?: string; + ifMatch?: string; + ifNoneMatch?: string; + ifModifiedSince?: string; + ifUnmodifiedSince?: string; + ifRange?: string; +} + export interface GetFileOptions extends GitStorageInvocationOptions { path: string; ref?: string; ephemeral?: boolean; ephemeralBase?: boolean; + headers?: FileRequestHeaders; +} + +export type HeadFileOptions = GetFileOptions; + +export interface FileMetadata { + blobSha: string; + lastCommitSha: string; + size?: number; + etag?: string; + lastModified?: Date; + rawLastModified?: string; + acceptRanges?: string; + contentType?: string; } export interface ArchiveOptions extends GitStorageInvocationOptions { @@ -266,10 +292,23 @@ export interface PullUpstreamOptions extends GitStorageInvocationOptions { ref?: string; } +export type RawTreeEntry = SchemaRawTreeEntry; +export type TreeEntryType = SchemaTreeEntryTypeRaw; + +export interface TreeEntry { + path: string; + type: TreeEntryType; + mode: string; +} + // List Files API types export interface ListFilesOptions extends GitStorageInvocationOptions { ref?: string; ephemeral?: boolean; + path?: string; + recursive?: boolean; + cursor?: string; + limit?: number; } export type ListFilesResponse = ListFilesResponseRaw; @@ -277,11 +316,19 @@ export type ListFilesResponse = ListFilesResponseRaw; export interface ListFilesResult { paths: string[]; ref: string; + entries: TreeEntry[]; + nextCursor?: string; + hasMore: boolean; } export interface ListFilesWithMetadataOptions extends GitStorageInvocationOptions { ref?: string; ephemeral?: boolean; + path?: string; + /** Accepted for symmetry with listFiles; metadata listings are always recursive. */ + recursive?: boolean; + cursor?: string; + limit?: number; } export type RawFileWithMetadata = SchemaRawFileWithMetadata; @@ -291,6 +338,7 @@ export interface FileWithMetadata { mode: string; size: number; lastCommitSha: string; + type?: TreeEntryType; } export type RawCommitMetadata = SchemaRawCommitMetadata; @@ -308,6 +356,8 @@ export interface ListFilesWithMetadataResult { files: FileWithMetadata[]; commits: Record; ref: string; + nextCursor?: string; + hasMore: boolean; } // List Branches API types @@ -417,6 +467,7 @@ export interface ListCommitsOptions extends GitStorageInvocationOptions { cursor?: string; limit?: number; ephemeral?: boolean; + path?: string; } export type RawCommitInfo = SchemaRawCommitInfo; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fb99cb4..b94748d 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -742,6 +742,247 @@ describe('GitStorage', () => { ); }); + it('forwards path/recursive/cursor/limit and parses entries on listFiles', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-files-subtree' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/files')).toBe(true); + expect(requestUrl.searchParams.get('ref')).toBe('main'); + expect(requestUrl.searchParams.get('path')).toBe('docs'); + expect(requestUrl.searchParams.get('recursive')).toBe('false'); + expect(requestUrl.searchParams.get('cursor')).toBe('docs/a.md'); + expect(requestUrl.searchParams.get('limit')).toBe('50'); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as any, + json: async () => ({ + paths: ['docs/guide.md'], + ref: 'main', + entries: [ + { path: 'docs/sub', type: 'tree', mode: '040000' }, + { path: 'docs/guide.md', type: 'blob', mode: '100644' }, + ], + next_cursor: 'docs/zz', + has_more: true, + }), + text: async () => '', + } as any); + }); + + const result = await repo.listFiles({ + ref: 'main', + path: 'docs', + recursive: false, + cursor: 'docs/a.md', + limit: 50, + }); + expect(result.paths).toEqual(['docs/guide.md']); + expect(result.entries).toEqual([ + { path: 'docs/sub', type: 'tree', mode: '040000' }, + { path: 'docs/guide.md', type: 'blob', mode: '100644' }, + ]); + expect(result.nextCursor).toBe('docs/zz'); + expect(result.hasMore).toBe(true); + }); + + it('falls back to empty entries when server omits the field', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-files-legacy' }); + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as any, + json: async () => ({ paths: ['README.md'], ref: 'main' }), + text: async () => '', + } as any) + ); + + const result = await repo.listFiles(); + expect(result.paths).toEqual(['README.md']); + expect(result.entries).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); + + it('forwards type/pagination on listFilesWithMetadata', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-files-meta-page' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/files/metadata')).toBe(true); + expect(requestUrl.searchParams.get('path')).toBe('src'); + expect(requestUrl.searchParams.get('cursor')).toBe('src/a.ts'); + expect(requestUrl.searchParams.get('limit')).toBe('100'); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as any, + json: async () => ({ + files: [ + { + path: 'src/main.ts', + mode: '100644', + size: 42, + last_commit_sha: 'deadbeef', + type: 'blob', + }, + ], + commits: { + deadbeef: { + author: 'Test', + date: '2026-02-19T12:00:00Z', + message: 'init', + }, + }, + ref: 'main', + next_cursor: 'src/zz.ts', + has_more: true, + }), + text: async () => '', + } as any); + }); + + const result = await repo.listFilesWithMetadata({ + path: 'src', + cursor: 'src/a.ts', + limit: 100, + }); + expect(result.files[0].type).toBe('blob'); + expect(result.nextCursor).toBe('src/zz.ts'); + expect(result.hasMore).toBe(true); + }); + + it('forwards path query param on listCommits', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-commits-path' }); + + mockFetch.mockImplementationOnce((url) => { + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/commits')).toBe(true); + expect(requestUrl.searchParams.get('branch')).toBe('main'); + expect(requestUrl.searchParams.get('path')).toBe('docs/guide.md'); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as any, + json: async () => ({ + commits: [], + next_cursor: null, + has_more: false, + }), + text: async () => '', + } as any); + }); + + await repo.listCommits({ branch: 'main', path: 'docs/guide.md' }); + }); + + it('forwards Range and conditional headers on getFileStream', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-headers' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + const headers = init?.headers as Record; + expect(headers['Range']).toBe('bytes=0-15'); + expect(headers['If-None-Match']).toBe('"abc"'); + expect(headers['If-Modified-Since']).toBe('Wed, 21 Oct 2026 07:28:00 GMT'); + return Promise.resolve({ + ok: true, + status: 206, + statusText: 'Partial Content', + headers: { get: () => null } as any, + text: async () => '', + } as any); + }); + + const response = await repo.getFileStream({ + path: 'README.md', + headers: { + range: 'bytes=0-15', + ifNoneMatch: '"abc"', + ifModifiedSince: 'Wed, 21 Oct 2026 07:28:00 GMT', + }, + }); + expect(response.status).toBe(206); + }); + + it('passes 304 through getFileStream without throwing', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-304' }); + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 304, + statusText: 'Not Modified', + headers: { get: () => null } as any, + text: async () => '', + } as any) + ); + + const response = await repo.getFileStream({ + path: 'README.md', + headers: { ifNoneMatch: '"abc"' }, + }); + expect(response.status).toBe(304); + }); + + it('issues HEAD and parses file metadata headers', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-head' }); + + const headerMap: Record = { + 'x-blob-sha': 'b10b5ha', + 'x-last-commit-sha': 'c0mm1tsha', + 'content-length': '128', + etag: '"b10b5ha"', + 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'accept-ranges': 'bytes', + 'content-type': 'application/octet-stream', + }; + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('HEAD'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + expect(requestUrl.searchParams.get('path')).toBe('README.md'); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: (key: string) => headerMap[key.toLowerCase()] ?? null, + } as any, + text: async () => '', + } as any); + }); + + const meta = await repo.headFile({ path: 'README.md' }); + expect(meta.blobSha).toBe('b10b5ha'); + expect(meta.lastCommitSha).toBe('c0mm1tsha'); + expect(meta.size).toBe(128); + expect(meta.etag).toBe('"b10b5ha"'); + expect(meta.acceptRanges).toBe('bytes'); + expect(meta.contentType).toBe('application/octet-stream'); + expect(meta.lastModified).toBeInstanceOf(Date); + expect(meta.rawLastModified).toBe('Wed, 21 Oct 2026 07:28:00 GMT'); + }); + it('posts grep request body and parses response', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-grep' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 8503bbb..ee4b516 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -106,7 +106,7 @@ Username is always `t`. Password is the JWT. | **FILES** | | | | | List files at ref | GET | `/repos/files` | `git:read` | | List files with metadata | GET | `/repos/files/metadata` | `git:read` | -| Get file content (stream) | GET | `/repos/file` | `git:read` | +| Get file content (stream) | GET/HEAD | `/repos/file` | `git:read` | | Blame file at ref | GET | `/repos/blame` | `git:read` | | Search content (grep) | POST | `/repos/grep` | `git:read` | | Download archive (tar.gz) | POST | `/repos/archive` | `git:read` | @@ -375,11 +375,16 @@ Diff must be compatible with `git apply --cached --binary`. Same response schema ## GET /repos/commits — List Commits ```bash -curl "$CODE_STORAGE_BASE_URL/repos/commits?branch=main&limit=20&cursor=CURSOR" \ +curl "$CODE_STORAGE_BASE_URL/repos/commits?branch=main&path=docs/guide.md&limit=20&cursor=CURSOR" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Optional `ephemeral=true` resolves `branch` from the ephemeral namespace (defaults to `false`). +Params: +- `branch` (defaults to repository default branch) +- `ephemeral=true` (resolve `branch` from the ephemeral namespace; defaults to `false`) +- `path` (optional repository-relative file or subtree to scope history to — + only commits that touched that path are returned) +- `cursor`, `limit` (default 20, max 100) Response: `{ "commits": [{ "sha", "message", "author_name", "author_email", "date" }], "next_cursor", "has_more" }` @@ -425,32 +430,106 @@ Response: same schema as commit-pack result. Failed ref updates surface as ## GET /repos/files — List Files ```bash -curl "$CODE_STORAGE_BASE_URL/repos/files?ref=main&ephemeral=false" \ +curl "$CODE_STORAGE_BASE_URL/repos/files?ref=main&path=docs&recursive=false&limit=200" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Params: `ref` (branch/SHA, defaults to default branch), `ephemeral` -Response: `{ "paths": ["README.md", "src/index.js", ...], "ref": "main" }` +Params: +- `ref` (branch/SHA, defaults to the repository default branch) +- `ephemeral` (resolve `ref` from the ephemeral namespace) +- `path` (optional repository-relative subtree; empty means repo root) +- `recursive` (default `true`; set `false` to return only direct children) +- `cursor` + `limit` (opt into paginated response; `limit` defaults to 1000, max 5000) + +Response (paginated shape): +```json +{ + "paths": ["docs/guide.md"], + "entries": [ + { "path": "docs/sub", "type": "tree", "mode": "040000" }, + { "path": "docs/guide.md", "type": "blob", "mode": "100644" } + ], + "ref": "main", + "next_cursor": "docs/zz", + "has_more": true +} +``` +`paths` is a flat blob-only list (convenience for callers that don't need +directory entries). `entries` is the structured tree — branch on `type` +(`blob` / `tree` / `symlink` / `submodule`) rather than checking for a +trailing `/`, since trees do not carry one. Omit both `cursor` and `limit` to +get the unpaginated legacy response. ## GET /repos/files/metadata — List Files with Git Metadata ```bash -curl "$CODE_STORAGE_BASE_URL/repos/files/metadata?ref=main&ephemeral=false" \ +curl "$CODE_STORAGE_BASE_URL/repos/files/metadata?ref=main&path=src&limit=100" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Params: `ref` (branch/SHA, falls back to default branch then `HEAD` then `main`), `ephemeral`. -This endpoint is unpaginated and returns the full file list in one response. -Response: `{ "files": [{ "path", "mode", "size", "last_commit_sha" }], "commits": { "sha": { "author", "date", "message" } }, "ref": "main" }` +Params: +- `ref` (branch/SHA, falls back to default branch → `HEAD` → `main`) +- `ephemeral` (resolve `ref` from the ephemeral namespace) +- `path` (optional repository-relative subtree) +- `recursive` (accepted for symmetry with `/files`; this endpoint is always + recursive) +- `cursor` + `limit` (opt into paginated response; `limit` defaults to 200, + max 1000) + +Response: +```json +{ + "files": [ + { "path": "src/main.ts", "mode": "100644", "size": 42, "type": "blob", "last_commit_sha": "deadbeef" } + ], + "commits": { "deadbeef": { "author": "...", "date": "...", "message": "..." } }, + "ref": "main", + "next_cursor": "src/zz.ts", + "has_more": true +} +``` +`type` is derived from each entry's git mode. Omit both `cursor` and `limit` +for the unpaginated legacy response. -## GET /repos/file — Get File Content +## GET|HEAD /repos/file — Get File Content ```bash +# Stream the full file curl "$CODE_STORAGE_BASE_URL/repos/file?path=src/main.go&ref=main" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" -``` -Response: raw file bytes (streaming), `Content-Type` set appropriately. +# Fetch just metadata (no body) +curl -I "$CODE_STORAGE_BASE_URL/repos/file?path=src/main.go&ref=main" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" + +# Partial read + cached revalidation +curl "$CODE_STORAGE_BASE_URL/repos/file?path=src/main.go&ref=main" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" \ + -H 'Range: bytes=0-1023' \ + -H 'If-None-Match: "b10b5ha"' +``` + +Methods: `GET` returns the file bytes; `HEAD` returns only the headers. +Both accept `Range`, `If-Range`, `If-Match`, `If-None-Match`, +`If-Modified-Since`, and `If-Unmodified-Since`. + +Status codes: +- `200 OK` — full body returned (GET) or metadata-only (HEAD). +- `206 Partial Content` — byte range satisfied; `Content-Range` identifies it. +- `304 Not Modified` — cached representation still valid. +- `412 Precondition Failed` — `If-Match`/`If-Unmodified-Since` failed. +- `416 Requested Range Not Satisfiable` — range outside blob size. + +Response headers always set on 200/206: +- `ETag` — strong validator equal to the quoted Git blob SHA. +- `Last-Modified` — committer date of the most recent commit reachable from + `ref` that touched `path`. +- `Accept-Ranges: bytes`. +- `Content-Type: application/octet-stream`. +- `Content-Length` — full size on 200, range size on 206. +- `Content-Range` — present on 206 responses. +- `X-Blob-Sha` — Git blob SHA of the served file. +- `X-Last-Commit-Sha` — SHA of the most recent commit touching `path`. ## GET /repos/blame — Blame File From d807e0fb82d74f772d2450e44d27baafe1af7053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Mon, 25 May 2026 15:17:45 +0900 Subject: [PATCH 2/6] version --- packages/code-storage-go/version.go | 2 +- packages/code-storage-python/pyproject.toml | 2 +- packages/code-storage-python/uv.lock | 2 +- packages/code-storage-typescript/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 3a2ef0d..e00ce03 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -2,7 +2,7 @@ package storage const ( PackageName = "code-storage-go-sdk" - PackageVersion = "0.8.0" + PackageVersion = "0.9.0" ) func userAgent() string { diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 83d9466..1eab32e 100644 --- a/packages/code-storage-python/pyproject.toml +++ b/packages/code-storage-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pierre-storage" -version = "1.9.0" +version = "1.10.0" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/uv.lock b/packages/code-storage-python/uv.lock index 7c78071..11127c7 100644 --- a/packages/code-storage-python/uv.lock +++ b/packages/code-storage-python/uv.lock @@ -915,7 +915,7 @@ wheels = [ [[package]] name = "pierre-storage" -version = "1.9.0" +version = "1.10.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 52228e6..897fb81 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.8.0", + "version": "1.9.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", From e5bd22b3c3e724a364589da5e2fe6b635146ca93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Thu, 28 May 2026 09:40:02 +0900 Subject: [PATCH 3/6] head request --- packages/code-storage-go/README.md | 18 +++ packages/code-storage-go/repo.go | 2 + packages/code-storage-go/repo_test.go | 103 ++++++++++++++++++ packages/code-storage-go/types.go | 2 + packages/code-storage-python/README.md | 22 ++++ .../pierre_storage/repo.py | 6 +- .../pierre_storage/types.py | 2 + .../code-storage-python/tests/test_repo.py | 82 ++++++++++++++ packages/code-storage-typescript/README.md | 37 +++++++ packages/code-storage-typescript/src/index.ts | 5 + packages/code-storage-typescript/src/types.ts | 2 + .../tests/index.test.ts | 82 ++++++++++++++ skills/code-storage/SKILL.md | 4 + 13 files changed, 366 insertions(+), 1 deletion(-) diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index da221e6..817f2af 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -45,6 +45,24 @@ func main() { } ``` +### Inspect file metadata + +```go +meta, err := repo.HeadFile(context.Background(), storage.HeadFileOptions{ + Path: "README.md", + Ref: "main", + Headers: storage.FileRequestHeaders{ + IfNoneMatch: `"b10b5ha"`, + Range: "bytes=0-1023", + }, +}) +if err != nil { + log.Fatal(err) +} + +fmt.Println(meta.StatusCode, meta.ETag, meta.ContentRange) +``` + ### Download an archive ```go diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 77d6cde..2c83ced 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -186,10 +186,12 @@ func buildFileRequestOptions(headers FileRequestHeaders) *requestOptions { func parseFileMetadataHeaders(resp *http.Response) FileMetadata { meta := FileMetadata{ + StatusCode: resp.StatusCode, BlobSHA: resp.Header.Get("X-Blob-Sha"), LastCommitSHA: resp.Header.Get("X-Last-Commit-Sha"), ETag: resp.Header.Get("ETag"), AcceptRanges: resp.Header.Get("Accept-Ranges"), + ContentRange: resp.Header.Get("Content-Range"), ContentType: resp.Header.Get("Content-Type"), RawLastModified: resp.Header.Get("Last-Modified"), } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 59a6ad2..742c3e4 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -462,6 +462,9 @@ func TestHeadFileParsesMetadata(t *testing.T) { if err != nil { t.Fatalf("head file error: %v", err) } + if meta.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", meta.StatusCode) + } if meta.BlobSHA != "b10b5ha" || meta.LastCommitSHA != "c0mm1tsha" { t.Fatalf("unexpected blob/commit sha: %+v", meta) } @@ -485,6 +488,106 @@ func TestHeadFileParsesMetadata(t *testing.T) { } } +func TestHeadFilePreservesRangeStatusAndContentRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("expected HEAD, got %s", r.Method) + } + if got := r.Header.Get("Range"); got != "bytes=0-15" { + t.Fatalf("unexpected Range header: %q", got) + } + w.Header().Set("Content-Length", "16") + w.Header().Set("Content-Range", "bytes 0-15/128") + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + })) + 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} + + meta, err := repo.HeadFile(nil, HeadFileOptions{ + Path: "README.md", + Headers: FileRequestHeaders{Range: "bytes=0-15"}, + }) + if err != nil { + t.Fatalf("head file error: %v", err) + } + if meta.StatusCode != http.StatusPartialContent { + t.Fatalf("expected status 206, got %d", meta.StatusCode) + } + if meta.Size != 16 { + t.Fatalf("expected size 16, got %d", meta.Size) + } + if meta.ContentRange != "bytes 0-15/128" { + t.Fatalf("unexpected content-range: %s", meta.ContentRange) + } + if meta.AcceptRanges != "bytes" { + t.Fatalf("unexpected accept-ranges: %s", meta.AcceptRanges) + } +} + +func TestHeadFilePreservesConditionalStatus(t *testing.T) { + tests := []struct { + name string + status int + headers FileRequestHeaders + }{ + { + name: "not modified", + status: http.StatusNotModified, + headers: FileRequestHeaders{IfNoneMatch: `"abc"`}, + }, + { + name: "precondition failed", + status: http.StatusPreconditionFailed, + headers: FileRequestHeaders{IfMatch: `"abc"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("expected HEAD, got %s", r.Method) + } + if tt.headers.IfNoneMatch != "" { + if got := r.Header.Get("If-None-Match"); got != tt.headers.IfNoneMatch { + t.Fatalf("unexpected If-None-Match header: %q", got) + } + } + if tt.headers.IfMatch != "" { + if got := r.Header.Get("If-Match"); got != tt.headers.IfMatch { + t.Fatalf("unexpected If-Match header: %q", got) + } + } + w.WriteHeader(tt.status) + })) + 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} + + meta, err := repo.HeadFile(nil, HeadFileOptions{ + Path: "README.md", + Headers: tt.headers, + }) + if err != nil { + t.Fatalf("head file error: %v", err) + } + if meta.StatusCode != tt.status { + t.Fatalf("expected status %d, got %d", tt.status, meta.StatusCode) + } + }) + } +} + func TestGrepRequestBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/grep" { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index eec093d..0f18e8d 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -249,6 +249,7 @@ type HeadFileOptions = GetFileOptions // FileMetadata is the parsed result of a HEAD /repos/file request. type FileMetadata struct { + StatusCode int BlobSHA string LastCommitSHA string Size int64 @@ -256,6 +257,7 @@ type FileMetadata struct { LastModified time.Time RawLastModified string AcceptRanges string + ContentRange string ContentType string } diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index d900f99..2e4c25d 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -163,6 +163,17 @@ response = await repo.get_file_stream( text = await response.aread() print(text.decode()) +# Fetch metadata or validate cached/ranged content without a body +metadata = await repo.head_file( + path="README.md", + ref="main", + headers={ + "if_none_match": '"b10b5ha"', + "range": "bytes=0-1023", + }, +) +print(metadata["status_code"], metadata.get("etag"), metadata.get("content_range")) + # Download repository archive (streaming tar.gz) archive_response = await repo.get_archive_stream( ref="main", @@ -675,9 +686,20 @@ class Repo: path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> Response: ... + async def head_file( + self, + *, + path: str, + ref: Optional[str] = None, + ephemeral: Optional[bool] = None, + headers: Optional[FileRequestHeaders] = None, + ttl: Optional[int] = None, + ) -> FileMetadata: ... + async def get_archive_stream( self, *, diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 06f0d56..bdccc73 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -186,6 +186,7 @@ def parse_file_metadata_headers(response: httpx.Response) -> FileMetadata: """Parse FileMetadata from HEAD or GET response headers.""" headers = response.headers metadata: FileMetadata = { + "status_code": response.status_code, "blob_sha": headers.get("x-blob-sha", ""), "last_commit_sha": headers.get("x-last-commit-sha", ""), } @@ -206,6 +207,9 @@ def parse_file_metadata_headers(response: httpx.Response) -> FileMetadata: accept_ranges = headers.get("accept-ranges") if accept_ranges: metadata["accept_ranges"] = accept_ranges + content_range = headers.get("content-range") + if content_range: + metadata["content_range"] = content_range content_type = headers.get("content-type") if content_type: metadata["content_type"] = content_type @@ -479,7 +483,7 @@ async def head_file( headers=request_headers, timeout=30.0, ) - if response.status_code not in (200, 304, 412): + if response.status_code not in (200, 206, 304, 412): response.raise_for_status() return parse_file_metadata_headers(response) diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 9821133..0a34393 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -221,6 +221,7 @@ class FileRequestHeaders(TypedDict, total=False): class FileMetadata(TypedDict, total=False): """Parsed response headers from HEAD /repos/file.""" + status_code: int blob_sha: str last_commit_sha: str size: int @@ -228,6 +229,7 @@ class FileMetadata(TypedDict, total=False): last_modified: datetime raw_last_modified: str accept_ranges: str + content_range: str content_type: str diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index c606a9e..8fef545 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -400,6 +400,7 @@ async def test_head_file_parses_response_headers( repo = await storage.create_repo(id="test-repo") meta = await repo.head_file(path="README.md") + assert meta["status_code"] == 200 assert meta["blob_sha"] == "b10b5ha" assert meta["last_commit_sha"] == "c0mm1tsha" assert meta["size"] == 128 @@ -413,6 +414,87 @@ async def test_head_file_parses_response_headers( assert urlparse(called_url).path.endswith("/repos/file") assert parse_qs(urlparse(called_url).query).get("path") == ["README.md"] + @pytest.mark.asyncio + async def test_head_file_preserves_range_status_and_content_range( + self, git_storage_options: dict + ) -> None: + """head_file exposes 206 status and Content-Range metadata.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + head_response = MagicMock() + head_response.status_code = 206 + head_response.is_success = True + head_response.headers = { + "content-length": "16", + "content-range": "bytes 0-15/128", + "accept-ranges": "bytes", + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.head = AsyncMock(return_value=head_response) + + repo = await storage.create_repo(id="test-repo") + meta = await repo.head_file( + path="README.md", + headers={"range": "bytes=0-15"}, + ) + + assert meta["status_code"] == 206 + assert meta["size"] == 16 + assert meta["accept_ranges"] == "bytes" + assert meta["content_range"] == "bytes 0-15/128" + sent_headers = client_instance.head.call_args.kwargs["headers"] + assert sent_headers["Range"] == "bytes=0-15" + + @pytest.mark.parametrize( + ("status_code", "headers"), + [ + (304, {"if_none_match": '"abc"'}), + (412, {"if_match": '"abc"'}), + ], + ) + @pytest.mark.asyncio + async def test_head_file_preserves_conditional_status( + self, + git_storage_options: dict, + status_code: int, + headers: dict[str, str], + ) -> None: + """head_file exposes conditional HEAD status without a body.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + head_response = MagicMock() + head_response.status_code = status_code + head_response.is_success = False + head_response.headers = {} + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.head = AsyncMock(return_value=head_response) + + repo = await storage.create_repo(id="test-repo") + meta = await repo.head_file(path="README.md", headers=headers) + + assert meta["status_code"] == status_code + sent_headers = client_instance.head.call_args.kwargs["headers"] + if status_code == 304: + assert sent_headers["If-None-Match"] == '"abc"' + else: + assert sent_headers["If-Match"] == '"abc"' + @pytest.mark.asyncio async def test_get_file_stream_forwards_conditional_headers( self, git_storage_options: dict diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index b0b2108..925caf8 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -183,6 +183,17 @@ const resp = await repo.getFileStream({ const text = await resp.text(); console.log(text); +// Fetch metadata or validate cached/ranged content without a body +const meta = await repo.headFile({ + path: 'README.md', + ref: 'main', + headers: { + ifNoneMatch: '"b10b5ha"', + range: 'bytes=0-1023', + }, +}); +console.log(meta.status, meta.etag, meta.contentRange); + // Download repository archive (streaming tar.gz) const archiveResp = await repo.getArchiveStream({ ref: 'main', @@ -527,6 +538,7 @@ interface Repo { getEphemeralRemoteURL(options?: GetRemoteURLOptions): Promise; getFileStream(options: GetFileOptions): Promise; + headFile(options: HeadFileOptions): Promise; getArchiveStream(options?: ArchiveOptions): Promise; listFiles(options?: ListFilesOptions): Promise; listFilesWithMetadata( @@ -553,11 +565,36 @@ interface GetRemoteURLOptions { interface GetFileOptions { path: string; ref?: string; // Branch, tag, or commit SHA + headers?: FileRequestHeaders; ttl?: number; } // getFileStream() returns a standard Fetch Response for streaming bytes +type HeadFileOptions = GetFileOptions; + +interface FileRequestHeaders { + range?: string; + ifMatch?: string; + ifNoneMatch?: string; + ifModifiedSince?: string; + ifUnmodifiedSince?: string; + ifRange?: string; +} + +interface FileMetadata { + status?: number; // 200, 206, 304, 412, etc. + blobSha: string; + lastCommitSha: string; + size?: number; + etag?: string; + lastModified?: Date; + rawLastModified?: string; + acceptRanges?: string; + contentRange?: string; // Present on 206 ranged HEAD responses + contentType?: string; +} + interface ArchiveOptions { ref?: string; // Branch, tag, or commit SHA (defaults to default branch) includeGlobs?: string[]; diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 6ef228e..6eed2c6 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -767,9 +767,11 @@ function parseFileMetadataHeaders(response: Response): FileMetadata { } } const acceptRanges = headers.get('accept-ranges') ?? undefined; + const contentRange = headers.get('content-range') ?? undefined; const contentType = headers.get('content-type') ?? undefined; const metadata: FileMetadata = { + status: response.status, blobSha, lastCommitSha, }; @@ -788,6 +790,9 @@ function parseFileMetadataHeaders(response: Response): FileMetadata { if (acceptRanges) { metadata.acceptRanges = acceptRanges; } + if (contentRange) { + metadata.contentRange = contentRange; + } if (contentType) { metadata.contentType = contentType; } diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 376b5fd..54a138f 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -292,6 +292,7 @@ export interface GetFileOptions extends GitStorageInvocationOptions { export type HeadFileOptions = GetFileOptions; export interface FileMetadata { + status?: number; blobSha: string; lastCommitSha: string; size?: number; @@ -299,6 +300,7 @@ export interface FileMetadata { lastModified?: Date; rawLastModified?: string; acceptRanges?: string; + contentRange?: string; contentType?: string; } diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index 5f8a72b..d6c62ba 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -973,6 +973,7 @@ describe('GitStorage', () => { }); const meta = await repo.headFile({ path: 'README.md' }); + expect(meta.status).toBe(200); expect(meta.blobSha).toBe('b10b5ha'); expect(meta.lastCommitSha).toBe('c0mm1tsha'); expect(meta.size).toBe(128); @@ -983,6 +984,87 @@ describe('GitStorage', () => { expect(meta.rawLastModified).toBe('Wed, 21 Oct 2026 07:28:00 GMT'); }); + it('preserves ranged HEAD status and content range', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-head-range' }); + + const headerMap: Record = { + 'content-length': '16', + 'content-range': 'bytes 0-15/128', + 'accept-ranges': 'bytes', + }; + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('HEAD'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + const headers = init?.headers as Record; + expect(headers['Range']).toBe('bytes=0-15'); + return Promise.resolve({ + ok: true, + status: 206, + statusText: 'Partial Content', + headers: { + get: (key: string) => headerMap[key.toLowerCase()] ?? null, + } as any, + text: async () => '', + } as any); + }); + + const meta = await repo.headFile({ + path: 'README.md', + headers: { range: 'bytes=0-15' }, + }); + expect(meta.status).toBe(206); + expect(meta.size).toBe(16); + expect(meta.acceptRanges).toBe('bytes'); + expect(meta.contentRange).toBe('bytes 0-15/128'); + }); + + it.each([ + { + name: 'not modified', + status: 304, + statusText: 'Not Modified', + headers: { ifNoneMatch: '"abc"' }, + }, + { + name: 'precondition failed', + status: 412, + statusText: 'Precondition Failed', + headers: { ifMatch: '"abc"' }, + }, + ])( + 'preserves conditional HEAD status for $name', + async ({ status, statusText, headers }) => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: `repo-file-head-${status}` }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('HEAD'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + const sentHeaders = init?.headers as Record; + if ('ifNoneMatch' in headers) { + expect(sentHeaders['If-None-Match']).toBe(headers.ifNoneMatch); + } + if ('ifMatch' in headers) { + expect(sentHeaders['If-Match']).toBe(headers.ifMatch); + } + return Promise.resolve({ + ok: false, + status, + statusText, + headers: { get: () => null } as any, + text: async () => '', + } as any); + }); + + const meta = await repo.headFile({ path: 'README.md', headers }); + expect(meta.status).toBe(status); + } + ); + it('posts grep request body and parses response', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-grep' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 6fa4789..d3bec75 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -574,6 +574,10 @@ Response headers always set on 200/206: - `X-Blob-Sha` — Git blob SHA of the served file. - `X-Last-Commit-Sha` — SHA of the most recent commit touching `path`. +SDK HEAD metadata helpers preserve the HTTP status and ranged metadata: +TypeScript exposes `status` and `contentRange`; Python exposes `status_code` +and `content_range`; Go exposes `StatusCode` and `ContentRange`. + ## GET /repos/blame — Blame File ```bash From b3845bc9f33d010d50153364ff657726549ccc6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Thu, 28 May 2026 10:11:28 +0900 Subject: [PATCH 4/6] fix 429 --- packages/code-storage-go/repo.go | 4 +- packages/code-storage-go/repo_test.go | 71 +++++++++++++++ .../pierre_storage/repo.py | 11 +-- .../code-storage-python/tests/test_repo.py | 90 +++++++++++++++++++ packages/code-storage-typescript/README.md | 4 +- packages/code-storage-typescript/src/index.ts | 12 ++- .../tests/index.test.ts | 69 ++++++++++++++ skills/code-storage/SKILL.md | 4 +- 8 files changed, 251 insertions(+), 14 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 2c83ced..b7aa25a 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -93,7 +93,7 @@ func (r *Repo) ImportRemoteURL(ctx context.Context, options RemoteURLOptions) (s } // FileStream returns the raw response for streaming file contents. -// 206, 304 and 412 status codes pass through to the caller. +// 206, 304, 412 and 416 status codes pass through to the caller. func (r *Repo) FileStream(ctx context.Context, options GetFileOptions) (*http.Response, error) { if strings.TrimSpace(options.Path) == "" { return nil, errors.New("getFileStream path is required") @@ -176,7 +176,7 @@ func buildFileRequestOptions(headers FileRequestHeaders) *requestOptions { extra["If-Range"] = headers.IfRange } - allowed := map[int]bool{304: true, 412: true} + allowed := map[int]bool{304: true, 412: true, 416: true} opts := &requestOptions{allowedStatus: allowed} if len(extra) > 0 { opts.extraHeaders = extra diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 742c3e4..d89d2cb 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -430,6 +430,39 @@ func TestFileStreamPasses304Through(t *testing.T) { } } +func TestFileStreamPasses416ThroughWithContentRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Range"); got != "bytes=256-511" { + t.Fatalf("unexpected Range header: %q", got) + } + w.Header().Set("Content-Range", "bytes */128") + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + })) + 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} + + resp, err := repo.FileStream(nil, GetFileOptions{ + Path: "README.md", + Headers: FileRequestHeaders{Range: "bytes=256-511"}, + }) + if err != nil { + t.Fatalf("expected 416 not to raise: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("expected 416, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Range"); got != "bytes */128" { + t.Fatalf("unexpected content-range: %s", got) + } +} + func TestHeadFileParsesMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { @@ -530,6 +563,44 @@ func TestHeadFilePreservesRangeStatusAndContentRange(t *testing.T) { } } +func TestHeadFilePreservesUnsatisfiedRangeStatusAndContentRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("expected HEAD, got %s", r.Method) + } + if got := r.Header.Get("Range"); got != "bytes=256-511" { + t.Fatalf("unexpected Range header: %q", got) + } + w.Header().Set("Content-Range", "bytes */128") + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + })) + 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} + + meta, err := repo.HeadFile(nil, HeadFileOptions{ + Path: "README.md", + Headers: FileRequestHeaders{Range: "bytes=256-511"}, + }) + if err != nil { + t.Fatalf("head file error: %v", err) + } + if meta.StatusCode != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("expected status 416, got %d", meta.StatusCode) + } + if meta.ContentRange != "bytes */128" { + t.Fatalf("unexpected content-range: %s", meta.ContentRange) + } + if meta.AcceptRanges != "bytes" { + t.Fatalf("unexpected accept-ranges: %s", meta.AcceptRanges) + } +} + func TestHeadFilePreservesConditionalStatus(t *testing.T) { tests := []struct { name string diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index bdccc73..0d8e872 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -167,6 +167,8 @@ def normalize_optional_string(value: Optional[str]) -> Optional[str]: "if_range": "If-Range", } +_FILE_RESPONSE_ALLOWED_STATUS_CODES = (200, 206, 304, 412, 416) + def build_conditional_headers( headers: Optional[FileRequestHeaders], @@ -400,7 +402,7 @@ async def get_file_stream( ref: Git ref (branch, tag, or commit SHA) ephemeral: Whether to read from the ephemeral namespace headers: Optional ``Range``/conditional headers forwarded to the - server. 206/304/412 are passed through without raising. + server. 206/304/412/416 are passed through without raising. ttl: Token TTL in seconds Returns: @@ -434,9 +436,8 @@ async def get_file_stream( timeout=30.0, ) response = await stream_context.__aenter__() - # 200, 206 are success; 304 and 412 are conditional outcomes that - # callers handle explicitly. Anything else still raises. - if response.status_code not in (200, 206, 304, 412): + # Range and conditional outcomes should remain inspectable. + if response.status_code not in _FILE_RESPONSE_ALLOWED_STATUS_CODES: response.raise_for_status() except Exception: await client.aclose() @@ -483,7 +484,7 @@ async def head_file( headers=request_headers, timeout=30.0, ) - if response.status_code not in (200, 206, 304, 412): + if response.status_code not in _FILE_RESPONSE_ALLOWED_STATUS_CODES: response.raise_for_status() return parse_file_metadata_headers(response) diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 8fef545..22bf6f6 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -453,6 +453,45 @@ async def test_head_file_preserves_range_status_and_content_range( sent_headers = client_instance.head.call_args.kwargs["headers"] assert sent_headers["Range"] == "bytes=0-15" + @pytest.mark.asyncio + async def test_head_file_preserves_unsatisfied_range_status_and_content_range( + self, git_storage_options: dict + ) -> None: + """head_file exposes 416 status and unsatisfied Content-Range metadata.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + head_response = MagicMock() + head_response.status_code = 416 + head_response.is_success = False + head_response.headers = { + "content-range": "bytes */128", + "accept-ranges": "bytes", + } + head_response.raise_for_status = MagicMock() + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.head = AsyncMock(return_value=head_response) + + repo = await storage.create_repo(id="test-repo") + meta = await repo.head_file( + path="README.md", + headers={"range": "bytes=256-511"}, + ) + + assert meta["status_code"] == 416 + assert meta["accept_ranges"] == "bytes" + assert meta["content_range"] == "bytes */128" + head_response.raise_for_status.assert_not_called() + sent_headers = client_instance.head.call_args.kwargs["headers"] + assert sent_headers["Range"] == "bytes=256-511" + @pytest.mark.parametrize( ("status_code", "headers"), [ @@ -544,6 +583,57 @@ async def test_get_file_stream_forwards_conditional_headers( assert sent_headers["If-None-Match"] == '"abc"' assert sent_headers["If-Modified-Since"] == "Wed, 21 Oct 2026 07:28:00 GMT" + @pytest.mark.asyncio + async def test_get_file_stream_preserves_unsatisfied_range_response( + self, git_storage_options: dict + ) -> None: + """get_file_stream exposes 416 status and Content-Range metadata.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + stream_response = MagicMock() + stream_response.status_code = 416 + stream_response.is_success = False + stream_response.headers = { + "content-range": "bytes */128", + "accept-ranges": "bytes", + } + stream_response.raise_for_status = MagicMock() + stream_response.aclose = AsyncMock() + + stream_cm = MagicMock() + stream_cm.__aenter__ = AsyncMock(return_value=stream_response) + stream_cm.__aexit__ = AsyncMock(return_value=None) + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value + client_instance.__aenter__.return_value.post = AsyncMock( + return_value=create_response + ) + client_instance.stream = MagicMock(return_value=stream_cm) + client_instance.aclose = AsyncMock() + + repo = await storage.create_repo(id="test-repo") + result = await repo.get_file_stream( + path="README.md", + headers={"range": "bytes=256-511"}, + ) + assert result.status_code == 416 + assert result.headers["content-range"] == "bytes */128" + stream_response.raise_for_status.assert_not_called() + + stream_args = client_instance.stream.call_args + sent_headers = stream_args.kwargs["headers"] + assert sent_headers["Range"] == "bytes=256-511" + + await result.aclose() + stream_response.aclose.assert_awaited_once() + client_instance.aclose.assert_awaited_once() + @pytest.mark.asyncio async def test_list_files_with_metadata_ephemeral_flag(self, git_storage_options: dict) -> None: """Ensure ephemeral flag propagates to list files with metadata.""" diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 925caf8..fed89b3 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -583,7 +583,7 @@ interface FileRequestHeaders { } interface FileMetadata { - status?: number; // 200, 206, 304, 412, etc. + status?: number; // 200, 206, 304, 412, 416, etc. blobSha: string; lastCommitSha: string; size?: number; @@ -591,7 +591,7 @@ interface FileMetadata { lastModified?: Date; rawLastModified?: string; acceptRanges?: string; - contentRange?: string; // Present on 206 ranged HEAD responses + contentRange?: string; // Present on 206 and 416 ranged HEAD responses contentType?: string; } diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 6eed2c6..c65aaf7 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -204,6 +204,12 @@ const NOTE_WRITE_ALLOWED_STATUS = [ 504, // Gateway Timeout - long-running storage operations ] as const; +const FILE_RESPONSE_ALLOWED_STATUS = [ + 304, // Not Modified - cache revalidation + 412, // Precondition Failed - conditional request failed + 416, // Range Not Satisfiable - caller needs Content-Range metadata +] as const; + function resolveInvocationTtlSeconds( options?: { ttl?: number }, defaultValue: number = DEFAULT_TOKEN_TTL_SECONDS @@ -909,11 +915,11 @@ class RepoImpl implements Repo { const params = buildGetFileParams(options); const extraHeaders = buildConditionalHeaders(options.headers); - // Allow 304 and 412 to surface as normal responses for conditional callers + // Allow range and conditional outcomes to surface as normal responses. return this.api.get( { path: 'repos/file', params }, jwt, - { allowedStatus: [304, 412], extraHeaders } + { allowedStatus: [...FILE_RESPONSE_ALLOWED_STATUS], extraHeaders } ); } @@ -930,7 +936,7 @@ class RepoImpl implements Repo { const response = await this.api.head( { path: 'repos/file', params }, jwt, - { allowedStatus: [304, 412], extraHeaders } + { allowedStatus: [...FILE_RESPONSE_ALLOWED_STATUS], extraHeaders } ); return parseFileMetadataHeaders(response); diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index d6c62ba..db527fc 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -942,6 +942,40 @@ describe('GitStorage', () => { expect(response.status).toBe(304); }); + it('passes 416 through getFileStream with range metadata', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-416' }); + + const headerMap: Record = { + 'content-range': 'bytes */128', + 'accept-ranges': 'bytes', + }; + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + const headers = init?.headers as Record; + expect(headers['Range']).toBe('bytes=256-511'); + return Promise.resolve({ + ok: false, + status: 416, + statusText: 'Range Not Satisfiable', + headers: { + get: (key: string) => headerMap[key.toLowerCase()] ?? null, + } as any, + text: async () => '', + } as any); + }); + + const response = await repo.getFileStream({ + path: 'README.md', + headers: { range: 'bytes=256-511' }, + }); + expect(response.status).toBe(416); + expect(response.headers.get('content-range')).toBe('bytes */128'); + }); + it('issues HEAD and parses file metadata headers', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-file-head' }); @@ -1021,6 +1055,41 @@ describe('GitStorage', () => { expect(meta.contentRange).toBe('bytes 0-15/128'); }); + it('preserves unsatisfied range HEAD status and content range', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-file-head-416' }); + + const headerMap: Record = { + 'content-range': 'bytes */128', + 'accept-ranges': 'bytes', + }; + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('HEAD'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/file')).toBe(true); + const headers = init?.headers as Record; + expect(headers['Range']).toBe('bytes=256-511'); + return Promise.resolve({ + ok: false, + status: 416, + statusText: 'Range Not Satisfiable', + headers: { + get: (key: string) => headerMap[key.toLowerCase()] ?? null, + } as any, + text: async () => '', + } as any); + }); + + const meta = await repo.headFile({ + path: 'README.md', + headers: { range: 'bytes=256-511' }, + }); + expect(meta.status).toBe(416); + expect(meta.acceptRanges).toBe('bytes'); + expect(meta.contentRange).toBe('bytes */128'); + }); + it.each([ { name: 'not modified', diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index d3bec75..2a0d831 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -563,14 +563,14 @@ Status codes: - `412 Precondition Failed` — `If-Match`/`If-Unmodified-Since` failed. - `416 Requested Range Not Satisfiable` — range outside blob size. -Response headers always set on 200/206: +Response headers for successful/ranged responses: - `ETag` — strong validator equal to the quoted Git blob SHA. - `Last-Modified` — committer date of the most recent commit reachable from `ref` that touched `path`. - `Accept-Ranges: bytes`. - `Content-Type: application/octet-stream`. - `Content-Length` — full size on 200, range size on 206. -- `Content-Range` — present on 206 responses. +- `Content-Range` — present on 206 responses and 416 unsatisfied ranges. - `X-Blob-Sha` — Git blob SHA of the served file. - `X-Last-Commit-Sha` — SHA of the most recent commit touching `path`. From 9339e7d42003f93aa86b2b0fa87e532faad705f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Thu, 28 May 2026 11:41:22 +0900 Subject: [PATCH 5/6] python ephemeral_base --- packages/code-storage-python/README.md | 4 + .../pierre_storage/repo.py | 7 ++ .../pierre_storage/types.py | 2 + .../code-storage-python/tests/test_repo.py | 85 +++++++++++++++++++ skills/code-storage/SKILL.md | 1 + 5 files changed, 99 insertions(+) diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 2e4c25d..e7e10db 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -159,6 +159,7 @@ response = await repo.get_file_stream( path="README.md", ref="main", # optional, defaults to default branch ephemeral=False, # optional, set to True to read from ephemeral namespace + ephemeral_base=False, # optional, resolve base under ephemeral namespace ) text = await response.aread() print(text.decode()) @@ -167,6 +168,7 @@ print(text.decode()) metadata = await repo.head_file( path="README.md", ref="main", + ephemeral_base=False, headers={ "if_none_match": '"b10b5ha"', "range": "bytes=0-1023", @@ -686,6 +688,7 @@ class Repo: path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> Response: ... @@ -696,6 +699,7 @@ class Repo: path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> FileMetadata: ... diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 0d8e872..f03fd57 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -392,6 +392,7 @@ async def get_file_stream( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> StreamingResponse: @@ -401,6 +402,7 @@ async def get_file_stream( path: File path to retrieve ref: Git ref (branch, tag, or commit SHA) ephemeral: Whether to read from the ephemeral namespace + ephemeral_base: Whether to resolve the base branch under the ephemeral namespace headers: Optional ``Range``/conditional headers forwarded to the server. 206/304/412/416 are passed through without raising. ttl: Token TTL in seconds @@ -416,6 +418,8 @@ async def get_file_stream( params["ref"] = ref if ephemeral is not None: params["ephemeral"] = "true" if ephemeral else "false" + if ephemeral_base is not None: + params["ephemeral_base"] = "true" if ephemeral_base else "false" url = f"{self.api_base_url}/api/v{self.api_version}/repos/file" if params: @@ -451,6 +455,7 @@ async def head_file( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> FileMetadata: @@ -467,6 +472,8 @@ async def head_file( params["ref"] = ref if ephemeral is not None: params["ephemeral"] = "true" if ephemeral else "false" + if ephemeral_base is not None: + params["ephemeral_base"] = "true" if ephemeral_base else "false" url = f"{self.api_base_url}/api/v{self.api_version}/repos/file" if params: diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 0a34393..2d242e6 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -698,6 +698,7 @@ async def get_file_stream( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> Any: # httpx.Response @@ -710,6 +711,7 @@ async def head_file( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, headers: Optional[FileRequestHeaders] = None, ttl: Optional[int] = None, ) -> FileMetadata: diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 22bf6f6..35f2cde 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -159,6 +159,55 @@ async def test_get_file_stream_ephemeral_flag(self, git_storage_options: dict) - file_response.aclose.assert_awaited_once() stream_client.aclose.assert_awaited_once() + @pytest.mark.asyncio + async def test_get_file_stream_ephemeral_base_flag(self, git_storage_options: dict) -> None: + """Ensure ephemeral_base flag propagates to file requests.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + file_response = MagicMock() + file_response.status_code = 200 + file_response.is_success = True + file_response.raise_for_status = MagicMock() + file_response.aclose = AsyncMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + create_client = MagicMock() + create_client.__aenter__.return_value.post = AsyncMock(return_value=create_response) + create_client.__aexit__.return_value = False + + stream_client = MagicMock() + stream_context = MagicMock() + stream_context.__aenter__ = AsyncMock(return_value=file_response) + stream_context.__aexit__ = AsyncMock(return_value=False) + stream_client.stream = MagicMock(return_value=stream_context) + stream_client.aclose = AsyncMock() + + mock_client_cls.side_effect = [create_client, stream_client] + + repo = await storage.create_repo(id="test-repo") + response = await repo.get_file_stream( + path="docs/readme.md", + ref="feature/demo", + ephemeral_base=True, + ) + + assert response.status_code == 200 + called_url = stream_client.stream.call_args.args[1] + parsed = urlparse(called_url) + params = parse_qs(parsed.query) + assert params.get("ephemeral_base") == ["true"] + assert params.get("ref") == ["feature/demo"] + + await response.aclose() + stream_client.stream.assert_called_once() + file_response.aclose.assert_awaited_once() + stream_client.aclose.assert_awaited_once() + @pytest.mark.asyncio async def test_get_archive_stream(self, git_storage_options: dict) -> None: """Ensure archive requests include filters and prefix.""" @@ -414,6 +463,42 @@ async def test_head_file_parses_response_headers( assert urlparse(called_url).path.endswith("/repos/file") assert parse_qs(urlparse(called_url).query).get("path") == ["README.md"] + @pytest.mark.asyncio + async def test_head_file_ephemeral_base_flag(self, git_storage_options: dict) -> None: + """Ensure ephemeral_base flag propagates to HEAD file requests.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + head_response = MagicMock() + head_response.status_code = 200 + head_response.is_success = True + head_response.headers = {} + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.head = AsyncMock(return_value=head_response) + + repo = await storage.create_repo(id="test-repo") + meta = await repo.head_file( + path="README.md", + ref="feature/demo", + ephemeral_base=True, + ) + + assert meta["status_code"] == 200 + called_url = client_instance.head.call_args.args[0] + parsed = urlparse(called_url) + assert parsed.path.endswith("/repos/file") + params = parse_qs(parsed.query) + assert params["path"] == ["README.md"] + assert params["ref"] == ["feature/demo"] + assert params["ephemeral_base"] == ["true"] + @pytest.mark.asyncio async def test_head_file_preserves_range_status_and_content_range( self, git_storage_options: dict diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 2a0d831..dd44178 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -553,6 +553,7 @@ curl "$CODE_STORAGE_BASE_URL/repos/file?path=src/main.go&ref=main" \ ``` Methods: `GET` returns the file bytes; `HEAD` returns only the headers. +Query params: `path` (required), `ref`, `ephemeral`, `ephemeral_base`. Both accept `Range`, `If-Range`, `If-Match`, `If-None-Match`, `If-Modified-Since`, and `If-Unmodified-Since`. From 6b8989f2715caf874a0f84c01d64f5a7e80d6339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Thu, 28 May 2026 11:54:38 +0900 Subject: [PATCH 6/6] legacy staled update on readme --- packages/code-storage-python/README.md | 122 +++++++++++++++++- .../pierre_storage/types.py | 12 ++ packages/code-storage-typescript/README.md | 84 +++++++++++- packages/code-storage-typescript/src/index.ts | 13 +- packages/code-storage-typescript/src/types.ts | 1 + .../tests/index.test.ts | 4 +- skills/code-storage/SKILL.md | 3 - 7 files changed, 217 insertions(+), 22 deletions(-) diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index e7e10db..d466d46 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -666,6 +666,8 @@ class Repo: *, permissions: Optional[List[str]] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, # deprecated; use ref_policies + ref_policies: Optional[Refs] = None, ) -> str: ... async def get_import_remote_url( @@ -673,6 +675,8 @@ class Repo: *, permissions: Optional[List[str]] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, # deprecated; use ref_policies + ref_policies: Optional[Refs] = None, ) -> str: ... async def get_ephemeral_remote_url( @@ -680,6 +684,8 @@ class Repo: *, permissions: Optional[List[str]] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, # deprecated; use ref_policies + ref_policies: Optional[Refs] = None, ) -> str: ... async def get_file_stream( @@ -720,6 +726,10 @@ class Repo: *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesResult: ... @@ -728,6 +738,10 @@ class Repo: *, ref: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, + recursive: Optional[bool] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, ttl: Optional[int] = None, ) -> ListFilesWithMetadataResult: ... @@ -749,6 +763,7 @@ class Repo: base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> CreateBranchResult: ... async def delete_branch( @@ -757,6 +772,7 @@ class Repo: name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> DeleteBranchResult: ... async def merge( @@ -772,9 +788,36 @@ class Repo: author: Optional[CommitSignature] = None, committer: Optional[CommitSignature] = None, allow_unrelated_histories: Optional[bool] = None, + squash: Optional[bool] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> MergeBranchesResult: ... + async def list_tags( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListTagsResult: ... + + async def create_tag( + self, + *, + name: str, + target: str, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> CreateTagResult: ... + + async def delete_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> DeleteTagResult: ... + async def promote_ephemeral_branch( self, *, @@ -790,6 +833,7 @@ class Repo: limit: Optional[int] = None, cursor: Optional[str] = None, ephemeral: Optional[bool] = None, + path: Optional[str] = None, ttl: Optional[int] = None, ) -> ListCommitsResult: ... @@ -811,6 +855,45 @@ class Repo: ttl: Optional[int] = None, ) -> BlameResult: ... + async def get_note( + self, + *, + sha: str, + ttl: Optional[int] = None, + ) -> NoteReadResult: ... + + async def create_note( + self, + *, + sha: str, + note: str, + expected_ref_sha: Optional[str] = None, + author: Optional[CommitSignature] = None, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> NoteWriteResult: ... + + async def append_note( + self, + *, + sha: str, + note: str, + expected_ref_sha: Optional[str] = None, + author: Optional[CommitSignature] = None, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> NoteWriteResult: ... + + async def delete_note( + self, + *, + sha: str, + expected_ref_sha: Optional[str] = None, + author: Optional[CommitSignature] = None, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> NoteWriteResult: ... + async def get_branch_diff( self, *, @@ -818,6 +901,7 @@ class Repo: base: Optional[str] = None, ephemeral: Optional[bool] = None, ephemeral_base: Optional[bool] = None, + paths: Optional[List[str]] = None, ttl: Optional[int] = None, ) -> GetBranchDiffResult: ... @@ -825,13 +909,17 @@ class Repo: self, *, sha: str, + base_sha: Optional[str] = None, + paths: Optional[List[str]] = None, ttl: Optional[int] = None, ) -> GetCommitDiffResult: ... async def pull_upstream( self, *, + ref: Optional[str] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> None: ... async def restore_commit( @@ -839,26 +927,44 @@ class Repo: *, target_branch: str, target_commit_sha: str, - expected_head_sha: Optional[str] = None, - commit_message: str, author: CommitSignature, + commit_message: Optional[str] = None, + expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> RestoreCommitResult: ... def create_commit( self, *, target_branch: str, + commit_message: str, + author: CommitSignature, expected_head_sha: Optional[str] = None, base_branch: Optional[str] = None, ephemeral: Optional[bool] = None, ephemeral_base: Optional[bool] = None, + committer: Optional[CommitSignature] = None, + ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, + ) -> CommitBuilder: ... + + async def create_commit_from_diff( + self, + *, + target_branch: str, commit_message: str, + diff: FileSource, author: CommitSignature, + expected_head_sha: Optional[str] = None, + base_branch: Optional[str] = None, + ephemeral: Optional[bool] = None, + ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ) -> CommitBuilder: ... + ref_policies: Optional[Refs] = None, + ) -> CommitResult: ... ``` ### Type Definitions @@ -868,6 +974,8 @@ Key types are provided via TypedDict for better IDE support: ```python from pierre_storage.types import ( GitStorageOptions, + Refs, + FileSource, BaseRepo, PublicGitHubBaseRepoAuth, GitHubBaseRepo, @@ -877,12 +985,20 @@ from pierre_storage.types import ( ListFilesResult, ListFilesWithMetadataResult, ListBranchesResult, + ListTagsResult, ListCommitsResult, + BlameResult, GetBranchDiffResult, GetCommitDiffResult, CreateBranchResult, DeleteBranchResult, + CreateTagResult, + DeleteTagResult, + MergeBranchesResult, RestoreCommitResult, + CommitResult, + NoteReadResult, + NoteWriteResult, RefUpdate, # ... and more ) diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 2d242e6..9094e2e 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -779,6 +779,7 @@ async def create_branch( base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> CreateBranchResult: """Create or promote a branch. @@ -792,6 +793,7 @@ async def delete_branch( name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> DeleteBranchResult: """Delete a branch.""" ... @@ -811,6 +813,7 @@ async def merge( allow_unrelated_histories: Optional[bool] = None, squash: Optional[bool] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" ... @@ -831,6 +834,7 @@ async def create_tag( name: str, target: str, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> CreateTagResult: """Create a tag.""" ... @@ -840,6 +844,7 @@ async def delete_tag( *, name: str, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> DeleteTagResult: """Delete a tag.""" ... @@ -906,6 +911,7 @@ async def create_note( expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Create a git note.""" ... @@ -918,6 +924,7 @@ async def append_note( expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Append to a git note.""" ... @@ -929,6 +936,7 @@ async def delete_note( expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" ... @@ -986,6 +994,7 @@ async def pull_upstream( *, ref: Optional[str] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> None: """Pull from upstream repository.""" ... @@ -1000,6 +1009,7 @@ async def restore_commit( expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> RestoreCommitResult: """Restore a previous commit.""" ... @@ -1016,6 +1026,7 @@ def create_commit( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> CommitBuilder: """Create a new commit builder.""" ... @@ -1033,6 +1044,7 @@ async def create_commit_from_diff( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ref_policies: Optional[Refs] = None, ) -> CommitResult: """Create a commit by applying a diff.""" ... diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index fed89b3..bb7b2c3 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -341,8 +341,9 @@ const mergeResult = await repo.merge({ author: { name: 'Merge Bot', email: 'merge@example.com' }, // optional committer: { name: 'Merge Bot', email: 'merge@example.com' }, // optional allowUnrelatedHistories: false, // optional + squash: false, // optional; incompatible with ff_only }); -console.log(mergeResult.result); // 'merge_commit', 'fast_forward', 'no_op', or 'unknown' +console.log(mergeResult.result); // 'merge_commit', 'fast_forward', 'no_op', 'squash', or 'unknown' console.log(mergeResult.commitSha, mergeResult.target.newSha); // repo.merge() requires sourceBranch, targetBranch, and strategy. It returns @@ -532,10 +533,20 @@ interface RepoOptions { createdAt?: string; // Defaults to "" } +type MergeResultLabel = + | 'merge_commit' + | 'fast_forward' + | 'no_op' + | 'squash' + | 'unknown'; + interface Repo { id: string; + defaultBranch: string; + createdAt: string; getRemoteURL(options?: GetRemoteURLOptions): Promise; getEphemeralRemoteURL(options?: GetRemoteURLOptions): Promise; + getImportRemoteURL(options?: GetRemoteURLOptions): Promise; getFileStream(options: GetFileOptions): Promise; headFile(options: HeadFileOptions): Promise; @@ -554,17 +565,24 @@ interface Repo { deleteNote(options: DeleteNoteOptions): Promise; getBranchDiff(options: GetBranchDiffOptions): Promise; getCommitDiff(options: GetCommitDiffOptions): Promise; + restoreCommit(options: RestoreCommitOptions): Promise; + merge(options: MergeOptions): Promise; } interface GetRemoteURLOptions { - permissions?: ('git:write' | 'git:read' | 'repo:write')[]; + permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; // Time to live in seconds (default: 31536000 = 1 year) + refPolicies?: Array<{ pattern: string; ops?: string[] }>; + /** @deprecated Use refPolicies instead. */ + ops?: string[]; } // Git operation interfaces interface GetFileOptions { path: string; ref?: string; // Branch, tag, or commit SHA + ephemeral?: boolean; + ephemeralBase?: boolean; headers?: FileRequestHeaders; ttl?: number; } @@ -609,6 +627,10 @@ interface ArchiveOptions { interface ListFilesOptions { ref?: string; // Branch, tag, or commit SHA ephemeral?: boolean; // Resolve ref in ephemeral namespace + path?: string; + recursive?: boolean; + cursor?: string; + limit?: number; ttl?: number; } @@ -617,14 +639,29 @@ interface ListFilesResponse { ref: string; // The resolved reference } +type TreeEntryType = 'blob' | 'tree' | 'symlink' | 'submodule'; + +interface TreeEntry { + path: string; + type: TreeEntryType; + mode: string; +} + interface ListFilesResult { paths: string[]; ref: string; + entries: TreeEntry[]; + nextCursor?: string; + hasMore: boolean; } interface ListFilesWithMetadataOptions { ref?: string; // Branch, tag, or commit SHA ephemeral?: boolean; // Resolve ref in ephemeral namespace + path?: string; + recursive?: boolean; + cursor?: string; + limit?: number; ttl?: number; } @@ -633,6 +670,7 @@ interface FileWithMetadata { mode: string; size: number; lastCommitSha: string; + type?: TreeEntryType; } interface CommitMetadata { @@ -646,6 +684,8 @@ interface ListFilesWithMetadataResult { files: FileWithMetadata[]; commits: Record; ref: string; + nextCursor?: string; + hasMore: boolean; } interface GetNoteOptions { @@ -725,6 +765,7 @@ interface ListCommitsOptions { cursor?: string; limit?: number; ephemeral?: boolean; + path?: string; ttl?: number; } @@ -799,10 +840,13 @@ interface GetBranchDiffOptions { ttl?: number; ephemeral?: boolean; ephemeralBase?: boolean; + paths?: string[]; } interface GetCommitDiffOptions { sha: string; + baseSha?: string; + paths?: string[]; ttl?: number; } @@ -835,6 +879,40 @@ interface GetCommitDiffResult { files: FileDiff[]; filteredFiles: FilteredFile[]; } + +type MergeStrategy = 'merge' | 'ff_only' | 'ff_prefer'; + +interface MergeOptions { + sourceBranch: string; + sourceIsEphemeral?: boolean; + targetBranch: string; + targetIsEphemeral?: boolean; + expectedTargetSha?: string; + commitMessage?: string; + author?: CommitSignature; + committer?: CommitSignature; + strategy: MergeStrategy; + allowUnrelatedHistories?: boolean; + squash?: boolean; // Incompatible with ff_only + ttl?: number; + refPolicies?: Array<{ pattern: string; ops?: string[] }>; +} + +interface MergeResult { + result: MergeResultLabel; + commitSha: string; + treeSha: string; + source: { branch: string; ephemeral: boolean; sha: string }; + target: { + branch: string; + ephemeral: boolean; + oldSha: string; + newSha: string; + }; + mergeBaseSha?: string; + promotedCommits: number; +} + interface DiffStats { files: number; additions: number; @@ -904,6 +982,8 @@ interface RestoreCommitOptions { expectedHeadSha?: string; author: CommitSignature; committer?: CommitSignature; + ttl?: number; + refPolicies?: Array<{ pattern: string; ops?: string[] }>; } interface RestoreCommitResult { diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index c65aaf7..2358266 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -114,7 +114,6 @@ import type { ListReposResponse, ListReposResult, MergeOptions, - MergeResultLabel, MergeResult, ListTagsOptions, ListTagsResponse, @@ -530,19 +529,9 @@ function transformCreateBranchResult( }; } -function normalizeMergeResultLabel( - result: MergeResponseRaw['result'] -): MergeResultLabel { - if (result === 'squash') { - return 'merge_commit'; - } - - return result; -} - function transformMergeResult(raw: MergeResponseRaw): MergeResult { return { - result: normalizeMergeResultLabel(raw.result), + result: raw.result, commitSha: raw.commit_sha, treeSha: raw.tree_sha, source: { diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 54a138f..45e1b67 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -902,6 +902,7 @@ export type MergeResultLabel = | "merge_commit" | "fast_forward" | "no_op" + | "squash" | "unknown"; export interface MergeOptions diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index db527fc..fbe9c67 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2167,7 +2167,7 @@ describe('GitStorage', () => { }); }); - it('normalizes squash merge results to merge_commit for 1.x compatibility', async () => { + it('preserves squash merge results', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = store.repo({ id: 'repo-merge-squash' }); @@ -2208,7 +2208,7 @@ describe('GitStorage', () => { }); expect(result).toEqual({ - result: 'merge_commit', + result: 'squash', commitSha: 'squash-sha', treeSha: 'tree-sha', source: { branch: 'feature', ephemeral: false, sha: 'source-sha' }, diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index dd44178..27ce91d 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -366,9 +366,6 @@ parent is the current target tip. It is incompatible with `ff_only`. Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"squash"|"unknown", "commit_sha", "tree_sha", "source": {branch,ephemeral,sha}, "target": {branch,ephemeral,old_sha,new_sha}, "merge_base_sha?", "promoted_commits" }` -TypeScript SDK 1.x normalizes a raw `result: "squash"` payload to -`result: "merge_commit"` in its exported merge result types for semver -compatibility. Python and Go currently surface the raw label. Conflicts return HTTP 409 with `conflict_paths` and `merge_base_sha` preserved on the body. ## DELETE /repos/branches — Delete Branch