From 2837c1a775345936b6909f85caa0f2403cd03b7b Mon Sep 17 00:00:00 2001 From: Jacob Bednarz Date: Thu, 25 Jun 2026 14:22:37 +1000 Subject: [PATCH 1/2] feat: add support for merge preview Updates the SDK to support the new merge preview functionality which allows you to dry run a merge and determine the conflict status. Docs: https://code.storage/docs/reference/api/branches/preview-merge --- packages/code-storage-go/README.md | 15 ++ packages/code-storage-go/repo.go | 75 ++++++++ packages/code-storage-go/repo_test.go | 92 ++++++++++ packages/code-storage-go/responses.go | 33 ++++ packages/code-storage-go/types.go | 62 +++++++ packages/code-storage-python/README.md | 18 ++ .../pierre_storage/__init__.py | 8 + .../pierre_storage/repo.py | 72 ++++++++ .../pierre_storage/types.py | 54 ++++++ .../code-storage-python/tests/test_repo.py | 138 ++++++++++++--- packages/code-storage-typescript/README.md | 45 +++++ packages/code-storage-typescript/src/index.ts | 61 ++++++- .../code-storage-typescript/src/schemas.ts | 43 +++++ packages/code-storage-typescript/src/types.ts | 44 +++++ .../tests/index.test.ts | 163 ++++++++++++++++++ skills/code-storage/SKILL.md | 18 ++ 16 files changed, 915 insertions(+), 26 deletions(-) diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index cf19fd5..f03d7d0 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -167,6 +167,21 @@ if err != nil { fmt.Println(deletedEphemeral.Ephemeral) ``` +### Preview merge + +```go +includeContent := true +preview, err := repo.PreviewMerge(context.Background(), storage.PreviewMergeOptions{ + SourceBranch: "feature", + TargetBranch: "main", + IncludeContent: &includeContent, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(preview.Status, preview.Result, preview.ConflictPaths) +``` + ### Merge branches ```go diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index c36229e..c15ca5b 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -1197,6 +1197,81 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er }, nil } +// PreviewMerge previews a branch merge without creating commits or updating refs. +func (r *Repo) PreviewMerge(ctx context.Context, options PreviewMergeOptions) (PreviewMergeResult, error) { + sourceBranch := strings.TrimSpace(options.SourceBranch) + if sourceBranch == "" { + return PreviewMergeResult{}, errors.New("previewMerge sourceBranch is required") + } + targetBranch := strings.TrimSpace(options.TargetBranch) + if targetBranch == "" { + return PreviewMergeResult{}, errors.New("previewMerge targetBranch is required") + } + + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) + if err != nil { + return PreviewMergeResult{}, err + } + + params := url.Values{} + params.Set("source_branch", sourceBranch) + params.Set("target_branch", targetBranch) + if options.IncludeContent != nil { + params.Set("include_content", strconv.FormatBool(*options.IncludeContent)) + } + + resp, err := r.client.api.get(ctx, "repos/merge/preview", params, jwtToken, nil) + if err != nil { + return PreviewMergeResult{}, err + } + defer resp.Body.Close() + + var payload previewMergeResponse + if err := decodeJSON(resp, &payload); err != nil { + return PreviewMergeResult{}, err + } + + result := PreviewMergeResult{ + Status: PreviewMergeStatus(payload.Status), + Result: PreviewMergeResultStatus(payload.Result), + SourceBranch: payload.SourceBranch, + TargetBranch: payload.TargetBranch, + SourceTipSHA: payload.SourceTipSHA, + TargetTipSHA: payload.TargetTipSHA, + MergeBaseSHA: payload.MergeBaseSHA, + ConflictPaths: payload.ConflictPaths, + FilteredConflicts: make([]PreviewMergeFilteredConflict, 0, len(payload.FilteredConflicts)), + Conflicts: make([]PreviewMergeConflict, 0, len(payload.Conflicts)), + } + for _, conflict := range payload.Conflicts { + result.Conflicts = append(result.Conflicts, PreviewMergeConflict{ + Path: conflict.Path, + Result: previewMergeBlobResult(conflict.Result), + Base: previewMergeBlobResult(conflict.Base), + Ours: previewMergeBlobResult(conflict.Ours), + Theirs: previewMergeBlobResult(conflict.Theirs), + }) + } + for _, conflict := range payload.FilteredConflicts { + result.FilteredConflicts = append(result.FilteredConflicts, PreviewMergeFilteredConflict{ + Path: conflict.Path, + Reason: conflict.Reason, + }) + } + + return result, nil +} + +func previewMergeBlobResult(raw previewMergeBlob) PreviewMergeBlob { + return PreviewMergeBlob{ + OID: raw.OID, + Content: raw.Content, + Truncated: raw.Truncated, + Binary: raw.Binary, + } +} + func mergeSignaturePayload(field string, signature *CommitSignature) (*authorInfo, error) { name := strings.TrimSpace(signature.Name) email := strings.TrimSpace(signature.Email) diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index d2deff5..02cda05 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -809,6 +810,97 @@ func TestCreateBranchTTL(t *testing.T) { } } +func TestPreviewMergeRequestAndResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/merge/preview" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("source_branch"); got != "feature/preview" { + t.Fatalf("unexpected source_branch: %s", got) + } + if got := r.URL.Query().Get("target_branch"); got != "main" { + t.Fatalf("unexpected target_branch: %s", got) + } + if got := r.URL.Query().Get("include_content"); got != "true" { + t.Fatalf("unexpected include_content: %s", got) + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + claims := parseJWTFromToken(t, token) + scopes, ok := claims["scopes"].([]interface{}) + if !ok || len(scopes) != 1 || scopes[0] != "git:read" { + t.Fatalf("unexpected scopes: %v", claims["scopes"]) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"conflicted","result":"merge_commit","source_branch":"feature/preview","target_branch":"main","source_tip_sha":"source123","target_tip_sha":"target123","merge_base_sha":"base123","conflict_paths":["docs/conflict.txt"],"conflicts":[{"path":"docs/conflict.txt","result":{"oid":"result-oid","content":"<<<<<<< ours","truncated":false,"binary":false},"base":{"oid":"base-oid","truncated":false,"binary":false},"ours":{"oid":"ours-oid","content":"ours","truncated":false,"binary":false},"theirs":{"oid":"theirs-oid","content":"theirs","truncated":false,"binary":false}}],"filtered_conflicts":[{"path":"src/app.go","reason":"max_conflict_files_exceeded"}]}`)) + })) + 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.PreviewMerge(nil, PreviewMergeOptions{ + SourceBranch: " feature/preview ", + TargetBranch: " main ", + IncludeContent: boolPtr(true), + }) + if err != nil { + t.Fatalf("preview merge error: %v", err) + } + + expected := PreviewMergeResult{ + Status: PreviewMergeStatusConflicted, + Result: PreviewMergeResultMergeCommit, + SourceBranch: "feature/preview", + TargetBranch: "main", + SourceTipSHA: "source123", + TargetTipSHA: "target123", + MergeBaseSHA: "base123", + ConflictPaths: []string{"docs/conflict.txt"}, + Conflicts: []PreviewMergeConflict{ + { + Path: "docs/conflict.txt", + Result: PreviewMergeBlob{ + OID: "result-oid", + Content: "<<<<<<< ours", + Truncated: false, + Binary: false, + }, + Base: PreviewMergeBlob{OID: "base-oid", Truncated: false, Binary: false}, + Ours: PreviewMergeBlob{OID: "ours-oid", Content: "ours", Truncated: false, Binary: false}, + Theirs: PreviewMergeBlob{OID: "theirs-oid", Content: "theirs", Truncated: false, Binary: false}, + }, + }, + FilteredConflicts: []PreviewMergeFilteredConflict{ + {Path: "src/app.go", Reason: "max_conflict_files_exceeded"}, + }, + } + if !reflect.DeepEqual(result, expected) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestPreviewMergeValidation(t *testing.T) { + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: "https://api.example.com"}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo", DefaultBranch: "main", client: client} + + if _, err = repo.PreviewMerge(nil, PreviewMergeOptions{TargetBranch: "main"}); err == nil || err.Error() != "previewMerge sourceBranch is required" { + t.Fatalf("unexpected source error: %v", err) + } + if _, err = repo.PreviewMerge(nil, PreviewMergeOptions{SourceBranch: "feature"}); err == nil || err.Error() != "previewMerge targetBranch is required" { + t.Fatalf("unexpected target error: %v", err) + } +} + func TestMergeGuardedTargetTipRequestAndResponse(t *testing.T) { var captured map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index f3e8577..c41f257 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -211,6 +211,39 @@ type mergeTargetRaw struct { NewSHA string `json:"new_sha"` } +type previewMergeResponse struct { + Status string `json:"status"` + Result string `json:"result"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + SourceTipSHA string `json:"source_tip_sha"` + TargetTipSHA string `json:"target_tip_sha"` + MergeBaseSHA string `json:"merge_base_sha"` + ConflictPaths []string `json:"conflict_paths"` + Conflicts []previewMergeConflict `json:"conflicts"` + FilteredConflicts []previewMergeFilteredConflict `json:"filtered_conflicts"` +} + +type previewMergeBlob struct { + OID string `json:"oid"` + Content string `json:"content"` + Truncated bool `json:"truncated"` + Binary bool `json:"binary"` +} + +type previewMergeConflict struct { + Path string `json:"path"` + Result previewMergeBlob `json:"result"` + Base previewMergeBlob `json:"base"` + Ours previewMergeBlob `json:"ours"` + Theirs previewMergeBlob `json:"theirs"` +} + +type previewMergeFilteredConflict struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + type listTagsResponse struct { Tags []tagInfoRaw `json:"tags"` NextCursor string `json:"next_cursor"` diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index da9db97..6f95f91 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -459,6 +459,23 @@ const ( MergeResultUnknown MergeResultStatus = "unknown" ) +// PreviewMergeStatus describes whether a merge preview is clean or conflicted. +type PreviewMergeStatus string + +const ( + PreviewMergeStatusClean PreviewMergeStatus = "clean" + PreviewMergeStatusConflicted PreviewMergeStatus = "conflicted" +) + +// PreviewMergeResultStatus describes the result if the previewed merge were applied. +type PreviewMergeResultStatus string + +const ( + PreviewMergeResultMergeCommit PreviewMergeResultStatus = "merge_commit" + PreviewMergeResultFastForward PreviewMergeResultStatus = "fast_forward" + PreviewMergeResultNoOp PreviewMergeResultStatus = "no_op" +) + // MergeRef describes a merge source ref. type MergeRef struct { Branch string @@ -485,6 +502,51 @@ type MergeResult struct { PromotedCommits int } +// PreviewMergeOptions configures read-only branch merge previews. +type PreviewMergeOptions struct { + InvocationOptions + SourceBranch string + TargetBranch string + IncludeContent *bool +} + +// PreviewMergeBlob describes a conflict stage blob in a merge preview. +type PreviewMergeBlob struct { + OID string + Content string + Truncated bool + Binary bool +} + +// PreviewMergeConflict describes inline conflict content for a path. +type PreviewMergeConflict struct { + Path string + Result PreviewMergeBlob + Base PreviewMergeBlob + Ours PreviewMergeBlob + Theirs PreviewMergeBlob +} + +// PreviewMergeFilteredConflict describes omitted inline conflict content. +type PreviewMergeFilteredConflict struct { + Path string + Reason string +} + +// PreviewMergeResult describes the read-only merge preview result. +type PreviewMergeResult struct { + Status PreviewMergeStatus + Result PreviewMergeResultStatus + SourceBranch string + TargetBranch string + SourceTipSHA string + TargetTipSHA string + MergeBaseSHA string + ConflictPaths []string + Conflicts []PreviewMergeConflict + FilteredConflicts []PreviewMergeFilteredConflict +} + // ListTagsOptions configures list tags. type ListTagsOptions struct { InvocationOptions diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 01a46bf..5c17c8a 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -230,6 +230,15 @@ ephemeral_delete = await repo.delete_branch( ) print(ephemeral_delete["ephemeral"]) # True +# Preview whether a source branch can merge without creating commits or updating refs +preview = await repo.preview_merge( + source_branch="feature/preview", + target_branch="main", + include_content=True, # optional bounded conflict blob content +) +print(preview["status"], preview["result"]) +print(preview["conflict_paths"], preview["filtered_conflicts"]) + # Merge one branch into another merge_result = await repo.merge( source_branch="feature/preview", @@ -803,6 +812,15 @@ class Repo: ref_policies: Optional[Refs] = None, ) -> MergeBranchesResult: ... + async def preview_merge( + self, + *, + source_branch: str, + target_branch: str, + include_content: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> PreviewMergeResult: ... + async def list_tags( self, *, diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index ad92b6c..3a5648f 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -45,6 +45,10 @@ NoteWriteResult, Op, Ops, + PreviewMergeBlob, + PreviewMergeConflict, + PreviewMergeFilteredConflict, + PreviewMergeResult, RefPolicy, Refs, RefUpdate, @@ -114,6 +118,10 @@ "ListTagsResult", "NoteReadResult", "NoteWriteResult", + "PreviewMergeBlob", + "PreviewMergeConflict", + "PreviewMergeFilteredConflict", + "PreviewMergeResult", "RefUpdate", "RepoInfo", "Repo", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 9f0e84a..90af67a 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -52,6 +52,7 @@ MergeStrategy, NoteReadResult, NoteWriteResult, + PreviewMergeResult, Refs, RefUpdate, RestoreCommitResult, @@ -1081,6 +1082,77 @@ async def merge( result["merge_base_sha"] = merge_base_sha return result + async def preview_merge( + self, + *, + source_branch: str, + target_branch: str, + include_content: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> PreviewMergeResult: + """Preview a merge without creating commits or updating refs.""" + source_branch_clean = source_branch.strip() + target_branch_clean = target_branch.strip() + + if not source_branch_clean: + raise ValueError("preview_merge source_branch is required") + if not target_branch_clean: + raise ValueError("preview_merge target_branch is required") + + ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl_value}) + + params: Dict[str, str] = { + "source_branch": source_branch_clean, + "target_branch": target_branch_clean, + } + if include_content is not None: + params["include_content"] = "true" if include_content else "false" + + url = f"{self.api_base_url}/api/v{self.api_version}/repos/merge/preview" + url += f"?{urlencode(params)}" + + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers={ + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + }, + timeout=180.0, + ) + + if response.status_code != 200: + message = "Preview merge failed" + try: + error_data = response.json() + if isinstance(error_data, dict) and error_data.get("message"): + message = str(error_data["message"]) + elif isinstance(error_data, dict) and error_data.get("error"): + message = str(error_data["error"]) + else: + message = f"{message} with HTTP {response.status_code}" + except Exception: + message = f"{message} with HTTP {response.status_code}" + raise ApiError(message, status_code=response.status_code, response=response) + + data = response.json() + result: PreviewMergeResult = { + "status": data["status"], + "result": data["result"], + "source_branch": data["source_branch"], + "target_branch": data["target_branch"], + "source_tip_sha": data["source_tip_sha"], + "target_tip_sha": data["target_tip_sha"], + "conflict_paths": data.get("conflict_paths", []), + "conflicts": data.get("conflicts", []), + "filtered_conflicts": data.get("filtered_conflicts", []), + } + merge_base_sha = data.get("merge_base_sha") + if merge_base_sha: + result["merge_base_sha"] = merge_base_sha + return result + async def list_tags( self, *, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index b47896f..ec6a464 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -525,6 +525,8 @@ class CreateCommitOptions(TypedDict, total=False): MergeStrategy = Literal["merge", "ff_only", "ff_prefer"] MergeResultLabel = Literal["merge_commit", "fast_forward", "no_op", "squash", "unknown"] +PreviewMergeStatus = Literal["clean", "conflicted"] +PreviewMergeResultLabel = Literal["merge_commit", "fast_forward", "no_op"] class MergeBranchesOptions(TypedDict, total=False): @@ -574,6 +576,47 @@ class MergeBranchesResult(TypedDict): promoted_commits: int +class PreviewMergeBlob(TypedDict): + """Blob content returned for a merge preview conflict stage.""" + + oid: NotRequired[str] + content: NotRequired[str] + truncated: bool + binary: bool + + +class PreviewMergeConflict(TypedDict): + """Inline conflict details for a single repository path.""" + + path: str + result: PreviewMergeBlob + base: PreviewMergeBlob + ours: PreviewMergeBlob + theirs: PreviewMergeBlob + + +class PreviewMergeFilteredConflict(TypedDict): + """Conflict omitted from inline preview content.""" + + path: str + reason: str + + +class PreviewMergeResult(TypedDict): + """Read-only result from previewing a branch merge.""" + + status: PreviewMergeStatus + result: PreviewMergeResultLabel + source_branch: str + target_branch: str + source_tip_sha: str + target_tip_sha: str + merge_base_sha: NotRequired[str] + conflict_paths: List[str] + conflicts: List[PreviewMergeConflict] + filtered_conflicts: List[PreviewMergeFilteredConflict] + + # Removed: CommitFileOptions - now uses **kwargs with explicit mode parameter @@ -835,6 +878,17 @@ async def merge( """Merge a source branch into a target branch.""" ... + async def preview_merge( + self, + *, + source_branch: str, + target_branch: str, + include_content: Optional[bool] = None, + ttl: Optional[int] = None, + ) -> PreviewMergeResult: + """Preview whether a source branch can merge into a target branch.""" + ... + async def list_tags( self, *, diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 9ef2505..551bf97 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -388,9 +388,7 @@ async def test_list_files_subtree_and_pagination(self, git_storage_options: dict assert params.get("limit") == ["50"] @pytest.mark.asyncio - async def test_list_files_legacy_response_defaults( - self, git_storage_options: dict - ) -> None: + 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) @@ -417,9 +415,7 @@ async def test_list_files_legacy_response_defaults( assert "next_cursor" not in result @pytest.mark.asyncio - async def test_head_file_parses_response_headers( - self, git_storage_options: dict - ) -> None: + 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) @@ -643,9 +639,7 @@ async def test_get_file_stream_forwards_conditional_headers( 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 - ) + 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() @@ -696,9 +690,7 @@ async def test_get_file_stream_preserves_unsatisfied_range_response( 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.__aenter__.return_value.post = AsyncMock(return_value=create_response) client_instance.stream = MagicMock(return_value=stream_cm) client_instance.aclose = AsyncMock() @@ -1087,9 +1079,7 @@ async def test_list_branches_with_pagination(self, git_storage_options: dict) -> assert result["has_more"] is True @pytest.mark.asyncio - async def test_list_branches_ephemeral_query_param( - self, git_storage_options: dict - ) -> None: + async def test_list_branches_ephemeral_query_param(self, git_storage_options: dict) -> None: """ephemeral=True must surface as ephemeral=true in the query string.""" storage = GitStorage(git_storage_options) @@ -1167,6 +1157,107 @@ async def test_create_branch_prefers_base_ref(self, git_storage_options: dict) - assert payload["target_branch"] == "feature/demo" assert payload["target_is_ephemeral"] is True + @pytest.mark.asyncio + async def test_preview_merge_gets_conflict_content(self, git_storage_options: dict) -> None: + """Test preview_merge sends read-scoped query params and parses conflicts.""" + storage = GitStorage(git_storage_options) + + create_repo_response = MagicMock() + create_repo_response.status_code = 200 + create_repo_response.is_success = True + create_repo_response.json.return_value = {"repo_id": "test-repo"} + + preview_response = MagicMock() + preview_response.status_code = 200 + preview_response.is_success = True + preview_response.json.return_value = { + "status": "conflicted", + "result": "merge_commit", + "source_branch": "feature/preview", + "target_branch": "main", + "source_tip_sha": "source123", + "target_tip_sha": "target123", + "merge_base_sha": "base123", + "conflict_paths": ["docs/conflict.txt"], + "conflicts": [ + { + "path": "docs/conflict.txt", + "result": { + "oid": "result-oid", + "content": "<<<<<<< ours", + "truncated": False, + "binary": False, + }, + "base": {"oid": "base-oid", "truncated": False, "binary": False}, + "ours": { + "oid": "ours-oid", + "content": "ours", + "truncated": False, + "binary": False, + }, + "theirs": { + "oid": "theirs-oid", + "content": "theirs", + "truncated": False, + "binary": False, + }, + } + ], + "filtered_conflicts": [{"path": "src/app.py", "reason": "max_conflict_files_exceeded"}], + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_repo_response) + client_instance.get = AsyncMock(return_value=preview_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.preview_merge( + source_branch=" feature/preview ", + target_branch=" main ", + include_content=True, + ttl=900, + ) + + assert result == preview_response.json.return_value + preview_call = client_instance.get.await_args + parsed = urlparse(preview_call.args[0]) + params = parse_qs(parsed.query) + assert parsed.path.endswith("/api/v1/repos/merge/preview") + assert params["source_branch"] == ["feature/preview"] + assert params["target_branch"] == ["main"] + assert params["include_content"] == ["true"] + + headers = preview_call.kwargs["headers"] + token = headers["Authorization"].replace("Bearer ", "") + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["scopes"] == ["git:read"] + assert payload["exp"] - payload["iat"] == 900 + + @pytest.mark.asyncio + async def test_preview_merge_validation(self, git_storage_options: dict) -> None: + """Test preview_merge validates required branches locally.""" + storage = GitStorage(git_storage_options) + + create_repo_response = MagicMock() + create_repo_response.status_code = 200 + create_repo_response.is_success = True + create_repo_response.json.return_value = {"repo_id": "test-repo"} + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_repo_response) + client_instance.get = AsyncMock() + + repo = await storage.create_repo(id="test-repo") + + with pytest.raises(ValueError, match="source_branch is required"): + await repo.preview_merge(source_branch=" ", target_branch="main") + + with pytest.raises(ValueError, match="target_branch is required"): + await repo.preview_merge(source_branch="feature", target_branch=" ") + + client_instance.get.assert_not_awaited() @pytest.mark.asyncio async def test_merge_posts_body_and_parses_response(self, git_storage_options: dict) -> None: @@ -1357,7 +1448,9 @@ async def test_merge_validation(self, git_storage_options: dict) -> None: with pytest.raises(ValueError, match="strategy is required"): await repo.merge(source_branch="feature", target_branch="main", strategy=" ") - with pytest.raises(ValueError, match="strategy must be one of merge, ff_only, ff_prefer"): + with pytest.raises( + ValueError, match="strategy must be one of merge, ff_only, ff_prefer" + ): await repo.merge(source_branch="feature", target_branch="main", strategy="squash") with pytest.raises(ValueError, match="author name and email are required"): @@ -1377,6 +1470,7 @@ async def test_merge_validation(self, git_storage_options: dict) -> None: ) assert client_instance.post.await_count == 1 + @pytest.mark.asyncio async def test_create_branch_falls_back_to_deprecated_base_branch( self, git_storage_options: dict @@ -1712,9 +1806,7 @@ async def test_list_commits(self, git_storage_options: dict) -> None: assert result["commits"][0]["message"] == "Initial commit" @pytest.mark.asyncio - async def test_list_commits_ephemeral_query_param( - self, git_storage_options: dict - ) -> None: + async def test_list_commits_ephemeral_query_param(self, git_storage_options: dict) -> None: """ephemeral=True must surface as ephemeral=true in the query string.""" storage = GitStorage(git_storage_options) @@ -1747,9 +1839,7 @@ async def test_list_commits_ephemeral_query_param( assert "branch=feature" in called_url @pytest.mark.asyncio - async def test_list_commits_path_query_param( - self, git_storage_options: dict - ) -> None: + 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) @@ -2024,9 +2114,7 @@ async def test_get_blame(self, git_storage_options: dict) -> None: assert first["previous_commit_sha"] == "zzz000" assert first["raw_author_time"] == "2024-01-15T14:32:18Z" assert isinstance(first["author_time"], datetime) - assert first["author_time"] == datetime( - 2024, 1, 15, 14, 32, 18, tzinfo=timezone.utc - ) + assert first["author_time"] == datetime(2024, 1, 15, 14, 32, 18, tzinfo=timezone.utc) second = result["lines"][1] assert second["original_path"] == "src/old.go" diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 17fa8c7..d5b212d 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -345,6 +345,16 @@ console.log(ephemeralDelete.ephemeral); // true // `baseBranch` is still accepted for backwards compatibility, but deprecated. // Prefer `baseRef` for new code. +// Preview whether a source branch can merge into a target branch without +// creating commits or updating refs. Pass includeContent to receive bounded +// conflict blob content when the preview is conflicted. +const preview = await repo.previewMerge({ + sourceBranch: 'feature/demo', + targetBranch: 'main', + includeContent: true, +}); +console.log(preview.status, preview.result); +console.log(preview.conflictPaths, preview.conflicts, preview.filteredConflicts); // Merge one branch into another. Source and target can independently be // ephemeral branches. @@ -912,6 +922,41 @@ interface GetCommitDiffResult { type MergeStrategy = 'merge' | 'ff_only' | 'ff_prefer'; +interface PreviewMergeOptions { + sourceBranch: string; + targetBranch: string; + includeContent?: boolean; + ttl?: number; +} + +interface PreviewMergeBlob { + oid?: string; + content?: string; + truncated: boolean; + binary: boolean; +} + +interface PreviewMergeConflict { + path: string; + result: PreviewMergeBlob; + base: PreviewMergeBlob; + ours: PreviewMergeBlob; + theirs: PreviewMergeBlob; +} + +interface PreviewMergeResult { + status: 'clean' | 'conflicted'; + result: 'merge_commit' | 'fast_forward' | 'no_op'; + sourceBranch: string; + targetBranch: string; + sourceTipSha: string; + targetTipSha: string; + mergeBaseSha?: string; + conflictPaths: string[]; + conflicts: PreviewMergeConflict[]; + filteredConflicts: Array<{ path: string; reason: string }>; +} + interface MergeOptions { sourceBranch: string; sourceIsEphemeral?: boolean; diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 94b862e..8ae330a 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -15,7 +15,11 @@ import { import { FetchDiffCommitTransport, sendCommitFromDiff } from './diff-commit'; import { RefUpdateError } from './errors'; import { ApiError, ApiFetcher } from './fetch'; -import type { MergeResponseRaw, RestoreCommitAckRaw } from './schemas'; +import type { + MergeResponseRaw, + PreviewMergeResponseRaw, + RestoreCommitAckRaw, +} from './schemas'; import { branchDiffResponseSchema, commitDiffResponseSchema, @@ -33,6 +37,7 @@ import { listFilesWithMetadataResponseSchema, listReposResponseSchema, mergeResponseSchema, + previewMergeResponseSchema, listTagsResponseSchema, noteReadResponseSchema, noteWriteResponseSchema, @@ -114,6 +119,8 @@ import type { ListReposResponse, ListReposResult, MergeOptions, + PreviewMergeOptions, + PreviewMergeResult, MergeResult, ListTagsOptions, ListTagsResponse, @@ -559,6 +566,23 @@ function transformMergeResult(raw: MergeResponseRaw): MergeResult { }; } +function transformPreviewMergeResult( + raw: PreviewMergeResponseRaw +): PreviewMergeResult { + return { + status: raw.status, + result: raw.result, + sourceBranch: raw.source_branch, + targetBranch: raw.target_branch, + sourceTipSha: raw.source_tip_sha, + targetTipSha: raw.target_tip_sha, + mergeBaseSha: raw.merge_base_sha ?? undefined, + conflictPaths: raw.conflict_paths, + conflicts: raw.conflicts, + filteredConflicts: raw.filtered_conflicts, + }; +} + function transformTagInfo(raw: RawTagInfo): TagInfo { return { cursor: raw.cursor, @@ -1660,6 +1684,41 @@ class RepoImpl implements Repo { return transformDeleteBranchResult(raw); } + async previewMerge( + options: PreviewMergeOptions + ): Promise { + const sourceBranch = options?.sourceBranch?.trim(); + if (!sourceBranch) { + throw new Error('previewMerge sourceBranch is required'); + } + + const targetBranch = options?.targetBranch?.trim(); + if (!targetBranch) { + throw new Error('previewMerge targetBranch is required'); + } + + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + + const params: Record = { + source_branch: sourceBranch, + target_branch: targetBranch, + }; + if (typeof options.includeContent === 'boolean') { + params.include_content = String(options.includeContent); + } + + const response = await this.api.get( + { path: 'repos/merge/preview', params }, + jwt + ); + const raw = previewMergeResponseSchema.parse(await response.json()); + return transformPreviewMergeResult(raw); + } + async merge(options: MergeOptions): Promise { const sourceBranch = options?.sourceBranch?.trim(); if (!sourceBranch) { diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 555918e..40cccc9 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -214,6 +214,39 @@ export const mergeResponseSchema = z.object({ promoted_commits: z.number(), }); +export const previewMergeBlobSchema = z.object({ + oid: z.string().optional(), + content: z.string().optional(), + truncated: z.boolean(), + binary: z.boolean(), +}); + +export const previewMergeConflictSchema = z.object({ + path: z.string(), + result: previewMergeBlobSchema, + base: previewMergeBlobSchema, + ours: previewMergeBlobSchema, + theirs: previewMergeBlobSchema, +}); + +export const previewMergeFilteredConflictSchema = z.object({ + path: z.string(), + reason: z.string(), +}); + +export const previewMergeResponseSchema = z.object({ + status: z.enum(['clean', 'conflicted']), + result: z.enum(['merge_commit', 'fast_forward', 'no_op']), + source_branch: z.string(), + target_branch: z.string(), + source_tip_sha: z.string(), + target_tip_sha: z.string(), + merge_base_sha: z.string().optional(), + conflict_paths: z.array(z.string()).optional().default([]), + conflicts: z.array(previewMergeConflictSchema).optional().default([]), + filtered_conflicts: z.array(previewMergeFilteredConflictSchema).optional().default([]), +}); + export const tagInfoSchema = z.object({ cursor: z.string(), name: z.string(), @@ -355,6 +388,16 @@ export type CreateBranchResponseRaw = z.infer< typeof createBranchResponseSchema >; export type MergeResponseRaw = z.infer; +export type PreviewMergeBlobRaw = z.infer; +export type PreviewMergeConflictRaw = z.infer< + typeof previewMergeConflictSchema +>; +export type PreviewMergeFilteredConflictRaw = z.infer< + typeof previewMergeFilteredConflictSchema +>; +export type PreviewMergeResponseRaw = z.infer< + typeof previewMergeResponseSchema +>; export type RawTagInfo = z.infer; export type ListTagsResponseRaw = z.infer; export type CreateTagResponseRaw = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index df8dcad..0240ba4 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -16,6 +16,10 @@ import type { ListFilesWithMetadataResponseRaw, ListReposResponseRaw, MergeResponseRaw, + PreviewMergeBlobRaw as SchemaPreviewMergeBlob, + PreviewMergeConflictRaw as SchemaPreviewMergeConflict, + PreviewMergeFilteredConflictRaw as SchemaPreviewMergeFilteredConflict, + PreviewMergeResponseRaw, ListTagsResponseRaw, NoteReadResponseRaw, NoteWriteResponseRaw, @@ -131,6 +135,7 @@ export interface Repo { grep(options: GrepOptions): Promise; pullUpstream(options?: PullUpstreamOptions): Promise; restoreCommit(options: RestoreCommitOptions): Promise; + previewMerge(options: PreviewMergeOptions): Promise; merge(options: MergeOptions): Promise; createBranch(options: CreateBranchOptions): Promise; deleteBranch(options: DeleteBranchOptions): Promise; @@ -960,6 +965,45 @@ export type MergeResponse = Omit & { result: MergeResultLabel; }; +export type PreviewMergeStatus = "clean" | "conflicted"; + +export type PreviewMergeResultLabel = "merge_commit" | "fast_forward" | "no_op"; + +export interface PreviewMergeOptions extends GitStorageInvocationOptions { + sourceBranch: string; + targetBranch: string; + includeContent?: boolean; +} + +export type PreviewMergeBlob = SchemaPreviewMergeBlob; + +export interface PreviewMergeConflict + extends Omit { + result: PreviewMergeBlob; + base: PreviewMergeBlob; + ours: PreviewMergeBlob; + theirs: PreviewMergeBlob; +} + +export type PreviewMergeFilteredConflict = SchemaPreviewMergeFilteredConflict; + +export type PreviewMergeResponse = Omit & { + result: PreviewMergeResultLabel; +}; + +export interface PreviewMergeResult { + status: PreviewMergeStatus; + result: PreviewMergeResultLabel; + sourceBranch: string; + targetBranch: string; + sourceTipSha: string; + targetTipSha: string; + mergeBaseSha?: string; + conflictPaths: string[]; + conflicts: PreviewMergeConflict[]; + filteredConflicts: PreviewMergeFilteredConflict[]; +} + export interface MergeSourceResult { branch: string; ephemeral: boolean; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index d8bb801..988e74d 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2136,6 +2136,169 @@ describe('GitStorage', () => { }); }); + describe('Repo previewMerge', () => { + it('gets merge preview with read scope and returns conflict content', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = store.repo({ id: 'repo-preview-merge' }); + + mockFetch.mockImplementationOnce((url, init) => { + const requestUrl = new URL(url as string); + expect(requestUrl.pathname).toBe('/api/v1/repos/merge/preview'); + expect(requestUrl.searchParams.get('source_branch')).toBe('feature/preview'); + expect(requestUrl.searchParams.get('target_branch')).toBe('main'); + expect(requestUrl.searchParams.get('include_content')).toBe('true'); + + const requestInit = init as RequestInit; + expect(requestInit.method).toBe('GET'); + expect(requestInit.body).toBeUndefined(); + + const headers = requestInit.headers as Record; + const payload = decodeJwtPayload(stripBearer(headers.Authorization)); + expect(payload.scopes).toEqual(['git:read']); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + status: 'conflicted', + result: 'merge_commit', + source_branch: 'feature/preview', + target_branch: 'main', + source_tip_sha: 'source-sha', + target_tip_sha: 'target-sha', + merge_base_sha: 'base-sha', + conflict_paths: ['docs/conflict.txt'], + conflicts: [ + { + path: 'docs/conflict.txt', + result: { + oid: 'result-oid', + content: '<<<<<<< ours', + truncated: false, + binary: false, + }, + base: { oid: 'base-oid', truncated: false, binary: false }, + ours: { + oid: 'ours-oid', + content: 'ours', + truncated: false, + binary: false, + }, + theirs: { + oid: 'theirs-oid', + content: 'theirs', + truncated: false, + binary: false, + }, + }, + ], + filtered_conflicts: [ + { path: 'src/app.ts', reason: 'max_conflict_files_exceeded' }, + ], + }), + } as any); + }); + + const result = await repo.previewMerge({ + sourceBranch: ' feature/preview ', + targetBranch: ' main ', + includeContent: true, + }); + + expect(result).toEqual({ + status: 'conflicted', + result: 'merge_commit', + sourceBranch: 'feature/preview', + targetBranch: 'main', + sourceTipSha: 'source-sha', + targetTipSha: 'target-sha', + mergeBaseSha: 'base-sha', + conflictPaths: ['docs/conflict.txt'], + conflicts: [ + { + path: 'docs/conflict.txt', + result: { + oid: 'result-oid', + content: '<<<<<<< ours', + truncated: false, + binary: false, + }, + base: { oid: 'base-oid', truncated: false, binary: false }, + ours: { + oid: 'ours-oid', + content: 'ours', + truncated: false, + binary: false, + }, + theirs: { + oid: 'theirs-oid', + content: 'theirs', + truncated: false, + binary: false, + }, + }, + ], + filteredConflicts: [ + { path: 'src/app.ts', reason: 'max_conflict_files_exceeded' }, + ], + }); + }); + + it('defaults omitted preview merge conflict arrays to empty lists', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = store.repo({ id: 'repo-preview-merge-clean' }); + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + status: 'clean', + result: 'fast_forward', + source_branch: 'feature/preview', + target_branch: 'main', + source_tip_sha: 'source-sha', + target_tip_sha: 'target-sha', + merge_base_sha: 'base-sha', + }), + } as any) + ); + + const result = await repo.previewMerge({ + sourceBranch: 'feature/preview', + targetBranch: 'main', + }); + + expect(result).toEqual({ + status: 'clean', + result: 'fast_forward', + sourceBranch: 'feature/preview', + targetBranch: 'main', + sourceTipSha: 'source-sha', + targetTipSha: 'target-sha', + mergeBaseSha: 'base-sha', + conflictPaths: [], + conflicts: [], + filteredConflicts: [], + }); + }); + + it('validates preview merge inputs locally', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = store.repo({ id: 'repo-preview-merge-validation' }); + + await expect( + repo.previewMerge({ sourceBranch: '', targetBranch: 'main' }) + ).rejects.toThrow('previewMerge sourceBranch is required'); + await expect( + repo.previewMerge({ sourceBranch: 'feature', targetBranch: '' }) + ).rejects.toThrow('previewMerge targetBranch is required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + describe('Repo merge', () => { it('posts guarded target-tip merge request and returns transformed response', async () => { const store = new GitStorage({ name: 'v0', key }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 3ca81bc..3e9d924 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -138,6 +138,7 @@ Username is always `t`. Password is the JWT. | Create branch | POST | `/repos/branches/create` | `git:write` | | List branches | GET | `/repos/branches` | `git:read` | | Get branch diff | GET | `/repos/branches/diff` | `git:read` | +| Preview merge | GET | `/repos/merge/preview` | `git:read` | | Merge branches | POST | `/repos/merge` | `git:write` | | Delete branch | DELETE | `/repos/branches` | `git:write` | | **COMMITS** | | | | @@ -377,6 +378,22 @@ Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"squash"|"unknown", "target": {branch,ephemeral,old_sha,new_sha}, "merge_base_sha?", "promoted_commits" }` Conflicts return HTTP 409 with `conflict_paths` and `merge_base_sha` preserved on the body. +## GET /repos/merge/preview — Preview Merge + +```bash +curl "$CODE_STORAGE_BASE_URL/repos/merge/preview?source_branch=feature/demo&target_branch=main&include_content=true" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" +``` + +Required params: `source_branch`, `target_branch`. +Optional: `include_content=true` to include bounded conflict blob content. +Scope: `git:read`. + +Clean previews return HTTP 200 with `status: "clean"` and `result` set to +`merge_commit`, `fast_forward`, or `no_op`. Conflicted previews also return HTTP +200 with `status: "conflicted"`, `conflict_paths`, optional `conflicts` content, +and `filtered_conflicts` for omitted content such as `max_conflict_files_exceeded`. + ## DELETE /repos/branches — Delete Branch ```bash @@ -1042,3 +1059,4 @@ git push origin feature-branch | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | | Policy ops | JWT-level guards via `refPolicies` (per-ref, first match wins, preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. `verify-sig` (`OP_VERIFY_SIG`/`OpVerifySig`) blocks pushes introducing commits not signed by a registered signing key. Top-level `ops` is a legacy alias on URL-minting methods only. | | Merge endpoint | `POST /repos/merge`. Strategies: `merge`, `ff_only`, `ff_prefer`. Optional `expected_target_sha` guards the target tip (409 if moved); omit it to merge into the current target tip. Optional `squash` (not with `ff_only`). 409 on conflict. | +| Merge preview | `GET /repos/merge/preview?source_branch=...&target_branch=...&include_content=true`. Requires `git:read`; never creates commits or updates refs. Conflicts return HTTP 200 with `status:conflicted`. | From 16d488af44ac3b5074f9e606c519afb4b9d9b237 Mon Sep 17 00:00:00 2001 From: Jacob Bednarz Date: Fri, 26 Jun 2026 10:49:37 +1000 Subject: [PATCH 2/2] bump package versions --- 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 4f73482..5e3366b 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.10.0" + PackageVersion = "0.11.0" ) func userAgent() string { diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 5edc200..8196507 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.11.0" +version = "1.12.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 126db4b..070e4a9 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.11.0" +version = "1.12.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 5e15c87..fd4592a 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", -"version": "1.12.0", +"version": "1.13.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git",