Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ if err != nil {
defer resp.Body.Close()
```

Archives stream as gzipped tar (`Content-Type: application/gzip`, suggested
filename `<name>-<ref>.tar.gz`) unless you ask for a different container. Set
`Format` to `storage.ArchiveFormatZip` for a zip archive
(`Content-Type: application/zip`, suggested filename `<name>-<ref>.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
Expand Down
7 changes: 6 additions & 1 deletion packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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
}

Expand Down
77 changes: 77 additions & 0 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
1 change: 1 addition & 0 deletions packages/code-storage-go/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
18 changes: 14 additions & 4 deletions packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
11 changes: 10 additions & 1 deletion packages/code-storage-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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 <name>-<ref>.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
Expand Down Expand Up @@ -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: ...
Expand Down
7 changes: 6 additions & 1 deletion packages/code-storage-python/pierre_storage/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from pierre_storage.errors import ApiError, RefUpdateError, infer_ref_update_reason
from pierre_storage.types import (
ArchiveFormat,
BlameLine,
BlameResult,
BranchInfo,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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()}

Expand Down
3 changes: 3 additions & 0 deletions packages/code-storage-python/pierre_storage/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ class ListReposResult(TypedDict):
# Removed: ListFilesOptions - now uses **kwargs


ArchiveFormat = Literal["tar.gz", "zip"]

TreeEntryType = Literal["blob", "tree", "symlink", "submodule"]


Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions packages/code-storage-python/tests/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
17 changes: 15 additions & 2 deletions packages/code-storage-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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 <name>-<ref>.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
Expand Down Expand Up @@ -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 <name>-<ref>.tar.gz,
// 'zip' responds with Content-Type application/zip and filename <name>-<ref>.zip

interface ListFilesOptions {
ref?: string; // Branch, tag, or commit SHA
Expand Down
Loading
Loading