Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,17 @@ if err != nil {
log.Fatal(err)
}
fmt.Println(deletedBranch.Message)

// Set Ephemeral to delete a branch from the ephemeral namespace
ephemeral := true
deletedEphemeral, err := repo.DeleteBranch(context.Background(), storage.DeleteBranchOptions{
Name: "merge/123e4567-e89b-12d3-a456-426614174000",
Ephemeral: &ephemeral,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(deletedEphemeral.Ephemeral)
```

### Merge branches
Expand Down
7 changes: 4 additions & 3 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D
return DeleteBranchResult{}, err
}

body := &deleteBranchRequest{Name: name}
body := &deleteBranchRequest{Name: name, Ephemeral: options.Ephemeral}
resp, err := r.client.api.delete(ctx, "repos/branches", nil, body, jwtToken, nil)
if err != nil {
return DeleteBranchResult{}, err
Expand All @@ -962,8 +962,9 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D
}

return DeleteBranchResult{
Name: payload.Name,
Message: payload.Message,
Name: payload.Name,
Message: payload.Message,
Ephemeral: payload.Ephemeral,
}, nil
}

Expand Down
52 changes: 49 additions & 3 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package storage

import (
"context"
"encoding/json"
"errors"
"io"
Expand Down Expand Up @@ -956,14 +957,55 @@ func TestDeleteBranch(t *testing.T) {
if body.Name != "feature/old-onboarding" {
t.Fatalf("unexpected delete branch payload: %+v", body)
}
if body.Ephemeral != nil {
t.Fatalf("expected omitted ephemeral, got %#v", body.Ephemeral)
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
claims := parseJWTFromToken(t, token)
scopes, ok := claims["scopes"].([]interface{})
if !ok || len(scopes) != 1 || scopes[0] != string(PermissionGitWrite) {
t.Fatalf("unexpected scopes: %#v", claims["scopes"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"feature/old-onboarding","message":"branch deleted"}`))
_, _ = w.Write([]byte(`{"name":"feature/old-onboarding","message":"branch deleted","ephemeral":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}

result, err := repo.DeleteBranch(context.Background(), DeleteBranchOptions{Name: "feature/old-onboarding"})
if err != nil {
t.Fatalf("delete branch error: %v", err)
}
if result.Name != "feature/old-onboarding" || result.Message != "branch deleted" || result.Ephemeral {
t.Fatalf("unexpected delete branch result: %+v", result)
}
}

func TestDeleteBranchEphemeral(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/branches" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
var body deleteBranchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Name != "merge/123e4567-e89b-12d3-a456-426614174000" {
t.Fatalf("unexpected delete branch name: %s", body.Name)
}
if body.Ephemeral == nil || !*body.Ephemeral {
t.Fatalf("expected ephemeral=true, got %#v", body.Ephemeral)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"merge/123e4567-e89b-12d3-a456-426614174000","message":"branch deleted","ephemeral":true}`))
}))
defer server.Close()

Expand All @@ -973,11 +1015,15 @@ func TestDeleteBranch(t *testing.T) {
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

result, err := repo.DeleteBranch(nil, DeleteBranchOptions{Name: "feature/old-onboarding"})
ephemeral := true
result, err := repo.DeleteBranch(context.Background(), DeleteBranchOptions{
Name: "merge/123e4567-e89b-12d3-a456-426614174000",
Ephemeral: &ephemeral,
})
if err != nil {
t.Fatalf("delete branch error: %v", err)
}
if result.Name != "feature/old-onboarding" || result.Message != "branch deleted" {
if result.Name != "merge/123e4567-e89b-12d3-a456-426614174000" || result.Message != "branch deleted" || !result.Ephemeral {
t.Fatalf("unexpected delete branch result: %+v", result)
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/code-storage-go/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ type deleteTagRequest struct {
}

type deleteBranchRequest struct {
Name string `json:"name"`
Name string `json:"name"`
Ephemeral *bool `json:"ephemeral,omitempty"`
}

// commitMetadataPayload is the JSON body for commit metadata.
Expand Down
5 changes: 3 additions & 2 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,9 @@ type deleteTagResponse struct {
}

type deleteBranchResponse struct {
Name string `json:"name"`
Message string `json:"message"`
Name string `json:"name"`
Message string `json:"message"`
Ephemeral bool `json:"ephemeral"`
}

type grepResponse struct {
Expand Down
8 changes: 5 additions & 3 deletions packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,15 @@ type CreateBranchResult struct {
// DeleteBranchOptions configures branch deletion.
type DeleteBranchOptions struct {
InvocationOptions
Name string
Name string
Ephemeral *bool
}

// DeleteBranchResult describes branch deletion result.
type DeleteBranchResult struct {
Name string
Message string
Name string
Message string
Ephemeral bool
}

// MergeStrategy selects how Repo.Merge reconciles source into target.
Expand Down
8 changes: 8 additions & 0 deletions packages/code-storage-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ print(branch_result["target_branch"], branch_result.get("commit_sha"))
delete_branch_result = await repo.delete_branch(name="feature/old-onboarding")
print(delete_branch_result["message"])

# Pass ephemeral=True to delete a branch from the ephemeral namespace
ephemeral_delete = await repo.delete_branch(
name="merge/123e4567-e89b-12d3-a456-426614174000",
ephemeral=True,
)
print(ephemeral_delete["ephemeral"]) # True

# Merge one branch into another
merge_result = await repo.merge(
source_branch="feature/preview",
Expand Down Expand Up @@ -722,6 +729,7 @@ class Repo:
self,
*,
name: str,
ephemeral: Optional[bool] = None,
ttl: Optional[int] = None,
) -> DeleteBranchResult: ...

Expand Down
14 changes: 12 additions & 2 deletions packages/code-storage-python/pierre_storage/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,9 +650,14 @@ async def delete_branch(
self,
*,
name: str,
ephemeral: Optional[bool] = None,
ttl: Optional[int] = None,
) -> DeleteBranchResult:
"""Delete a branch."""
"""Delete a branch.

When ``ephemeral`` is true, the branch is resolved and removed under the
repository's ephemeral namespace rather than the persistent one.
"""
name_clean = name.strip()
if not name_clean:
raise ValueError("delete_branch name is required")
Expand All @@ -667,6 +672,10 @@ async def delete_branch(

url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches"

body: dict[str, object] = {"name": name_clean}
if ephemeral is not None:
body["ephemeral"] = bool(ephemeral)

async with httpx.AsyncClient() as client:
response = await client.request(
"DELETE",
Expand All @@ -676,7 +685,7 @@ async def delete_branch(
"Content-Type": "application/json",
"Code-Storage-Agent": get_user_agent(),
},
json={"name": name_clean},
json=body,
timeout=30.0,
)

Expand All @@ -698,6 +707,7 @@ async def delete_branch(
return {
"name": data["name"],
"message": data["message"],
"ephemeral": bool(data.get("ephemeral", False)),
}

async def merge(
Expand Down
2 changes: 2 additions & 0 deletions packages/code-storage-python/pierre_storage/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ class DeleteBranchResult(TypedDict):

name: str
message: str
ephemeral: bool


# Removed: ListCommitsOptions - now uses **kwargs
Expand Down Expand Up @@ -706,6 +707,7 @@ async def delete_branch(
self,
*,
name: str,
ephemeral: Optional[bool] = None,
ttl: Optional[int] = None,
) -> DeleteBranchResult:
"""Delete a branch."""
Expand Down
44 changes: 44 additions & 0 deletions packages/code-storage-python/tests/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,7 @@ async def test_delete_branch(self, git_storage_options: dict) -> None:
delete_branch_response.json.return_value = {
"name": "feature/old-onboarding",
"message": "branch deleted",
"ephemeral": False,
}

with patch("httpx.AsyncClient") as mock_client:
Expand All @@ -1882,13 +1883,56 @@ async def test_delete_branch(self, git_storage_options: dict) -> None:
assert result == {
"name": "feature/old-onboarding",
"message": "branch deleted",
"ephemeral": False,
}

delete_call = client_instance.request.call_args_list[0]
assert delete_call.args[0] == "DELETE"
assert delete_call.args[1].endswith("/repos/branches")
assert delete_call.kwargs["json"] == {"name": "feature/old-onboarding"}

@pytest.mark.asyncio
async def test_delete_branch_ephemeral(self, git_storage_options: dict) -> None:
"""Test that delete_branch forwards the ephemeral flag and surfaces it."""
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"}

delete_branch_response = MagicMock()
delete_branch_response.status_code = 200
delete_branch_response.is_success = True
delete_branch_response.json.return_value = {
"name": "merge/123e4567-e89b-12d3-a456-426614174000",
"message": "branch deleted",
"ephemeral": 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.request = AsyncMock(return_value=delete_branch_response)

repo = await storage.create_repo(id="test-repo")

result = await repo.delete_branch(
name="merge/123e4567-e89b-12d3-a456-426614174000",
ephemeral=True,
)
assert result == {
"name": "merge/123e4567-e89b-12d3-a456-426614174000",
"message": "branch deleted",
"ephemeral": True,
}

delete_call = client_instance.request.call_args_list[0]
assert delete_call.kwargs["json"] == {
"name": "merge/123e4567-e89b-12d3-a456-426614174000",
"ephemeral": True,
}

@pytest.mark.asyncio
async def test_delete_branch_validates_name(self, git_storage_options: dict) -> None:
"""Test that delete_branch validates the branch name."""
Expand Down
7 changes: 7 additions & 0 deletions packages/code-storage-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,13 @@ console.log(branch.targetBranch, branch.commitSha);
const deletedBranch = await repo.deleteBranch({ name: 'feature/demo' });
console.log(deletedBranch.message);

// Pass `ephemeral: true` to delete a branch from the ephemeral namespace
const ephemeralDelete = await repo.deleteBranch({
name: 'merge/123e4567-e89b-12d3-a456-426614174000',
ephemeral: true,
});
console.log(ephemeralDelete.ephemeral); // true

// `baseBranch` is still accepted for backwards compatibility, but deprecated.
// Prefer `baseRef` for new code.

Expand Down
8 changes: 7 additions & 1 deletion packages/code-storage-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ function transformDeleteBranchResult(
return {
name: raw.name,
message: raw.message,
ephemeral: raw.ephemeral ?? false,
};
}

Expand Down Expand Up @@ -1463,8 +1464,13 @@ class RepoImpl implements Repo {
ttl,
});

const body: Record<string, unknown> = { name };
if (typeof options.ephemeral === 'boolean') {
body.ephemeral = options.ephemeral;
}

const response = await this.api.delete(
{ path: 'repos/branches', body: { name } },
{ path: 'repos/branches', body },
jwt
);
const raw = deleteBranchResponseSchema.parse(await response.json());
Expand Down
1 change: 1 addition & 0 deletions packages/code-storage-typescript/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const deleteTagResponseSchema = z.object({
export const deleteBranchResponseSchema = z.object({
name: z.string(),
message: z.string(),
ephemeral: z.boolean().optional(),
});

export const refUpdateResultSchema = z.object({
Expand Down
2 changes: 2 additions & 0 deletions packages/code-storage-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,13 +350,15 @@ export interface CreateBranchResult {

export interface DeleteBranchOptions extends GitStorageInvocationOptions {
name: string;
ephemeral?: boolean;
}

export type DeleteBranchResponse = DeleteBranchResponseRaw;

export interface DeleteBranchResult {
name: string;
message: string;
ephemeral: boolean;
}

export interface ListTagsOptions extends GitStorageInvocationOptions {
Expand Down
Loading
Loading