diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index c048afd..5bea6c8 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -80,6 +80,24 @@ if err != nil { defer resp.Body.Close() ``` +Archives stream as gzipped tar (`Content-Type: application/gzip`, suggested +filename `-.tar.gz`) unless you ask for a different container. Set +`Format` to `storage.ArchiveFormatZip` for a zip archive +(`Content-Type: application/zip`, suggested filename `-.zip`): + +```go +resp, err := repo.ArchiveStream(context.Background(), storage.ArchiveOptions{ + Ref: "main", + Format: storage.ArchiveFormatZip, +}) +if err != nil { + log.Fatal(err) +} +defer resp.Body.Close() +``` + +Leaving `Format` empty keeps the default `storage.ArchiveFormatTarGz` behaviour. + ### List files with metadata ```go diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index bf57087..6ed44ef 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -209,6 +209,8 @@ func parseFileMetadataHeaders(resp *http.Response) FileMetadata { } // ArchiveStream returns the raw response for streaming repository archives. +// Options.Format selects the container: ArchiveFormatTarGz (the default) or +// ArchiveFormatZip. func (r *Repo) ArchiveStream(ctx context.Context, options ArchiveOptions) (*http.Response, error) { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) @@ -229,12 +231,15 @@ func (r *Repo) ArchiveStream(ctx context.Context, options ArchiveOptions) (*http if options.MaxBlobSize != nil { req.MaxBlobSize = options.MaxBlobSize } + if format := ArchiveFormat(strings.TrimSpace(string(options.Format))); format != "" { + req.Format = format + } if prefix := strings.TrimSpace(options.ArchivePrefix); prefix != "" { req.Archive = &archiveOptions{Prefix: prefix} } var body interface{} - if req.Ref != "" || len(req.IncludeGlobs) > 0 || len(req.ExcludeGlobs) > 0 || req.MaxBlobSize != nil || req.Archive != nil { + if req.Ref != "" || len(req.IncludeGlobs) > 0 || len(req.ExcludeGlobs) > 0 || req.MaxBlobSize != nil || req.Format != "" || req.Archive != nil { body = req } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 2c8bc0c..44178ba 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -2057,6 +2057,83 @@ func TestArchiveStream(t *testing.T) { _ = resp.Body.Close() } +func TestArchiveStreamZipFormat(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/archive" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload["format"] != "zip" { + t.Fatalf("unexpected format: %v", payload["format"]) + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="repo-main.zip"`) + _, _ = w.Write([]byte("PK")) + })) + 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.ArchiveStream(nil, ArchiveOptions{Ref: "main", Format: ArchiveFormatZip}) + if err != nil { + t.Fatalf("archive stream error: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("Content-Type"); got != "application/zip" { + t.Fatalf("unexpected content type: %s", got) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + if string(body) != "PK" { + t.Fatalf("unexpected body: %q", string(body)) + } +} + +func TestArchiveStreamOmitsFormatWhenUnset(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if _, ok := payload["format"]; ok { + t.Fatalf("format should be absent, got body: %s", string(raw)) + } + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write([]byte("ok")) + })) + 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.ArchiveStream(nil, ArchiveOptions{Ref: "main"}) + if err != nil { + t.Fatalf("archive stream error: %v", err) + } + _ = resp.Body.Close() +} + func TestListCommitsDateParsing(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/commits" { diff --git a/packages/code-storage-go/requests.go b/packages/code-storage-go/requests.go index 9439b9f..2239d08 100644 --- a/packages/code-storage-go/requests.go +++ b/packages/code-storage-go/requests.go @@ -87,6 +87,7 @@ type archiveRequest struct { IncludeGlobs []string `json:"include_globs,omitempty"` ExcludeGlobs []string `json:"exclude_globs,omitempty"` MaxBlobSize *int64 `json:"max_blob_size,omitempty"` + Format ArchiveFormat `json:"format,omitempty"` Archive *archiveOptions `json:"archive,omitempty"` } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index e4fa91d..3d2422a 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -265,13 +265,23 @@ type FileMetadata struct { ContentType string } +// ArchiveFormat identifies the archive container returned by ArchiveStream. +type ArchiveFormat string + +const ( + ArchiveFormatTarGz ArchiveFormat = "tar.gz" + ArchiveFormatZip ArchiveFormat = "zip" +) + // ArchiveOptions configures repository archive download. type ArchiveOptions struct { InvocationOptions - Ref string - IncludeGlobs []string - ExcludeGlobs []string - MaxBlobSize *int64 + Ref string + IncludeGlobs []string + ExcludeGlobs []string + MaxBlobSize *int64 + // Format selects the archive container. Empty defaults to ArchiveFormatTarGz. + Format ArchiveFormat ArchivePrefix string } diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 2357f83..b63dae3 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -176,7 +176,7 @@ metadata = await repo.head_file( ) print(metadata["status_code"], metadata.get("etag"), metadata.get("content_range")) -# Download repository archive (streaming tar.gz) +# Download repository archive (streaming tar.gz by default) archive_response = await repo.get_archive_stream( ref="main", include_globs=["README.md"], @@ -187,6 +187,14 @@ archive_response = await repo.get_archive_stream( archive_bytes = await archive_response.aread() print(len(archive_bytes)) +# Download the same archive as a zip instead +# (Content-Type: application/zip, suggested filename -.zip) +zip_response = await repo.get_archive_stream( + ref="main", + format="zip", # omit for the default "tar.gz" +) +print(zip_response.headers["content-type"]) + # List all files in the repository files = await repo.list_files( ref="main", # optional, defaults to default branch @@ -758,6 +766,7 @@ class Repo: include_globs: Optional[List[str]] = None, exclude_globs: Optional[List[str]] = None, max_blob_size: Optional[int] = None, + format: Optional[Literal["tar.gz", "zip"]] = None, # default: "tar.gz" archive_prefix: Optional[str] = None, ttl: Optional[int] = None, ) -> Response: ... diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 240273e..f4e733e 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -17,6 +17,7 @@ ) from pierre_storage.errors import ApiError, RefUpdateError, infer_ref_update_reason from pierre_storage.types import ( + ArchiveFormat, BlameLine, BlameResult, BranchInfo, @@ -505,6 +506,7 @@ async def get_archive_stream( include_globs: Optional[List[str]] = None, exclude_globs: Optional[List[str]] = None, max_blob_size: Optional[int] = None, + format: Optional[ArchiveFormat] = None, archive_prefix: Optional[str] = None, ttl: Optional[int] = None, ) -> StreamingResponse: @@ -515,7 +517,8 @@ async def get_archive_stream( include_globs: Optional include globs for archived files exclude_globs: Optional exclude globs for archived files max_blob_size: Optional max blob size in bytes - archive_prefix: Optional archive prefix for tar entries + format: Optional archive container, ``"tar.gz"`` (default) or ``"zip"`` + archive_prefix: Optional archive prefix for archive entries ttl: Token TTL in seconds Returns: @@ -533,6 +536,8 @@ async def get_archive_stream( body["exclude_globs"] = exclude_globs if max_blob_size is not None: body["max_blob_size"] = max_blob_size + if format and format.strip(): + body["format"] = format.strip() if archive_prefix and archive_prefix.strip(): body["archive"] = {"prefix": archive_prefix.strip()} diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index c96a4d2..f7dbe9b 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -161,6 +161,8 @@ class ListReposResult(TypedDict): # Removed: ListFilesOptions - now uses **kwargs +ArchiveFormat = Literal["tar.gz", "zip"] + TreeEntryType = Literal["blob", "tree", "symlink", "submodule"] @@ -804,6 +806,7 @@ async def get_archive_stream( include_globs: Optional[list[str]] = None, exclude_globs: Optional[list[str]] = None, max_blob_size: Optional[int] = None, + format: Optional[ArchiveFormat] = None, archive_prefix: Optional[str] = None, ttl: Optional[int] = None, ) -> Any: # httpx.Response diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 7453ac2..9cba979 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -266,6 +266,90 @@ async def test_get_archive_stream(self, git_storage_options: dict) -> None: archive_response.aclose.assert_awaited_once() stream_client.aclose.assert_awaited_once() + @pytest.mark.asyncio + async def test_get_archive_stream_zip_format(self, git_storage_options: dict) -> None: + """Ensure the zip format is sent and the zip response is returned.""" + 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"} + + archive_response = MagicMock() + archive_response.status_code = 200 + archive_response.is_success = True + archive_response.raise_for_status = MagicMock() + archive_response.aclose = AsyncMock() + archive_response.headers = { + "content-type": "application/zip", + "content-disposition": 'attachment; filename="test-repo-main.zip"', + } + + 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=archive_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_archive_stream(ref="main", format="zip") + + payload = stream_client.stream.call_args.kwargs["json"] + assert payload == {"ref": "main", "format": "zip"} + assert response.headers["content-type"] == "application/zip" + + await response.aclose() + + @pytest.mark.asyncio + async def test_get_archive_stream_omits_format_when_unset( + self, git_storage_options: dict + ) -> None: + """Ensure no format key is sent when the caller does not request one.""" + 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"} + + archive_response = MagicMock() + archive_response.status_code = 200 + archive_response.is_success = True + archive_response.raise_for_status = MagicMock() + archive_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=archive_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_archive_stream(ref="main") + + payload = stream_client.stream.call_args.kwargs["json"] + assert payload == {"ref": "main"} + assert "format" not in payload + + await response.aclose() + @pytest.mark.asyncio async def test_list_files(self, git_storage_options: dict) -> None: """Test listing files in repository.""" diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 6682af6..dac54b1 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -206,7 +206,7 @@ const meta = await repo.headFile({ }); console.log(meta.status, meta.etag, meta.contentRange); -// Download repository archive (streaming tar.gz) +// Download repository archive (streaming tar.gz by default) const archiveResp = await repo.getArchiveStream({ ref: 'main', includeGlobs: ['README.md'], @@ -217,6 +217,14 @@ const archiveResp = await repo.getArchiveStream({ const archiveBytes = new Uint8Array(await archiveResp.arrayBuffer()); console.log(archiveBytes.length); +// Download the same archive as a zip instead +// (Content-Type: application/zip, suggested filename -.zip) +const zipResp = await repo.getArchiveStream({ + ref: 'main', + format: 'zip', // omit for the default 'tar.gz' +}); +console.log(zipResp.headers.get('content-type')); + // List all files in the repository const files = await repo.listFiles({ ref: 'main', // optional, defaults to default branch @@ -671,16 +679,21 @@ interface FileMetadata { contentType?: string; } +type ArchiveFormat = 'tar.gz' | 'zip'; + interface ArchiveOptions { ref?: string; // Branch, tag, or commit SHA (defaults to default branch) includeGlobs?: string[]; excludeGlobs?: string[]; maxBlobSize?: number; // Optional max file size in bytes + format?: ArchiveFormat; // Archive container (default: 'tar.gz') archivePrefix?: string; ttl?: number; } -// getArchiveStream() returns a standard Fetch Response for streaming tar.gz bytes +// getArchiveStream() returns a standard Fetch Response for streaming archive bytes: +// 'tar.gz' responds with Content-Type application/gzip and filename -.tar.gz, +// 'zip' responds with Content-Type application/zip and filename -.zip interface ListFilesOptions { ref?: string; // Branch, tag, or commit SHA diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 3da6827..9a54c72 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1020,6 +1020,12 @@ class RepoImpl implements Repo { if (typeof options.maxBlobSize === 'number' && Number.isFinite(options.maxBlobSize)) { body.max_blob_size = options.maxBlobSize; } + if (typeof options.format === 'string') { + const format = options.format.trim(); + if (format) { + body.format = format; + } + } if (typeof options.archivePrefix === 'string') { const prefix = options.archivePrefix.trim(); if (prefix) { diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 47320c9..e35b62e 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -329,11 +329,15 @@ export interface FileMetadata { contentType?: string; } +export type ArchiveFormat = "tar.gz" | "zip"; + export interface ArchiveOptions extends GitStorageInvocationOptions { ref?: string; includeGlobs?: string[]; excludeGlobs?: string[]; maxBlobSize?: number; + /** Archive container to return. Defaults to `"tar.gz"`. */ + format?: ArchiveFormat; archivePrefix?: string; } diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index 041807c..c53d0d3 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -891,6 +891,58 @@ describe('GitStorage', () => { expect(response.status).toBe(200); }); + it('posts the zip archive format and returns the zip response', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-archive-zip' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('POST'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/archive')).toBe(true); + const payload = JSON.parse(init?.body as string); + expect(payload).toEqual({ ref: 'main', format: 'zip' }); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: (name: string) => + name.toLowerCase() === 'content-type' ? 'application/zip' : null, + } as any, + json: async () => ({}), + text: async () => '', + } as any); + }); + + const response = await repo.getArchiveStream({ ref: 'main', format: 'zip' }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toBe('application/zip'); + }); + + it('omits the archive format when it is not set', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-archive-default' }); + + mockFetch.mockImplementationOnce((url, init) => { + const payload = JSON.parse(init?.body as string); + expect(payload).toEqual({ ref: 'main' }); + expect('format' in payload).toBe(false); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as any, + json: async () => ({}), + text: async () => '', + } as any); + }); + + const response = await repo.getArchiveStream({ ref: 'main' }); + + expect(response.ok).toBe(true); + }); + it('passes ephemeral flag to listFiles', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-ephemeral-list' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 7192127..94597f4 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -154,7 +154,7 @@ Username is always `t`. Password is the JWT. | 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` | +| Download archive (tar.gz/zip) | POST | `/repos/archive` | `git:read` | | **TAGS** | | | | | Create tag | POST | `/repos/tags` | `git:write` | | List tags | GET | `/repos/tags` | `git:read` | @@ -681,7 +681,22 @@ curl "$CODE_STORAGE_BASE_URL/repos/archive" -X POST \ -o repo.tar.gz ``` -Response: streaming `tar.gz`. Headers: `Content-Type: application/gzip`. +Response: streaming `tar.gz`. Headers: `Content-Type: application/gzip`, suggested +filename `-.tar.gz`. + +Optional `"format"` selects the container: `"tar.gz"` (the default) or `"zip"`. + +```bash +curl "$CODE_STORAGE_BASE_URL/repos/archive" -X POST \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"ref":"main","format":"zip"}' \ + -o repo.zip +``` + +With `"format":"zip"` the response is `Content-Type: application/zip` and the +suggested filename is `-.zip`. Omit `format` for the unchanged +`tar.gz` behaviour. ## Tags Endpoints (POST/GET/DELETE /repos/tags)