diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index fbf6e01..da221e6 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -235,8 +235,8 @@ fmt.Println(repo.ID) Because this Go module lives in a monorepo, git tags must be prefixed with the module's subdirectory path: ```bash -git tag packages/code-storage-go/v0.7.0 -git push origin packages/code-storage-go/v0.7.0 +git tag packages/code-storage-go/v0.8.0 +git push origin packages/code-storage-go/v0.8.0 ``` Make sure the version in `version.go` (`PackageVersion`) matches the tag before tagging. diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index d217839..deeaca0 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -202,6 +202,9 @@ func (c *Client) ListRepos(ctx context.Context, options ListReposOptions) (ListR if options.Limit > 0 { params.Set("limit", itoa(options.Limit)) } + if q := strings.TrimSpace(options.Q); q != "" { + params.Set("q", q) + } if len(params) == 0 { params = nil } diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index ad9cd6e..d55ebea 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -985,6 +985,9 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er if strategy != MergeStrategyMerge && strategy != MergeStrategyFFOnly && strategy != MergeStrategyFFPrefer { return MergeResult{}, errors.New("merge strategy is invalid") } + if options.Squash && strategy == MergeStrategyFFOnly { + return MergeResult{}, errors.New("merge squash is incompatible with the ff_only strategy") + } body := &mergeRequest{ SourceBranch: sourceBranch, @@ -993,6 +996,7 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er TargetIsEphemeral: options.TargetIsEphemeral, Strategy: string(strategy), AllowUnrelatedHistories: options.AllowUnrelatedHistories, + Squash: options.Squash, } if expectedTargetSHA := strings.TrimSpace(options.ExpectedTargetSHA); expectedTargetSHA != "" { body.ExpectedTargetSHA = expectedTargetSHA diff --git a/packages/code-storage-go/requests.go b/packages/code-storage-go/requests.go index 0637fa2..a3f1476 100644 --- a/packages/code-storage-go/requests.go +++ b/packages/code-storage-go/requests.go @@ -114,6 +114,7 @@ type mergeRequest struct { Committer *authorInfo `json:"committer,omitempty"` Strategy string `json:"strategy"` AllowUnrelatedHistories bool `json:"allow_unrelated_histories,omitempty"` + Squash bool `json:"squash,omitempty"` } type createTagRequest struct { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index e5182b7..0edc0dc 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -149,6 +149,9 @@ type ListReposOptions struct { InvocationOptions Cursor string Limit int + // Q is a case-insensitive substring matched against the repository URL. + // Trimmed before matching; empty after trim is treated as omitted. + Q string } // ListReposResult returns paginated repos. @@ -352,6 +355,8 @@ type MergeOptions struct { Committer *CommitSignature Strategy MergeStrategy AllowUnrelatedHistories bool + // Squash is incompatible with MergeStrategyFFOnly. + Squash bool } // MergeResultStatus describes a merge operation outcome. @@ -361,6 +366,7 @@ const ( MergeResultMergeCommit MergeResultStatus = "merge_commit" MergeResultFastForward MergeResultStatus = "fast_forward" MergeResultNoOp MergeResultStatus = "no_op" + MergeResultSquash MergeResultStatus = "squash" MergeResultUnknown MergeResultStatus = "unknown" ) diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 2371325..3a2ef0d 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.7.0" + PackageVersion = "0.8.0" ) func userAgent() string { diff --git a/packages/code-storage-python/pierre_storage/client.py b/packages/code-storage-python/pierre_storage/client.py index 176745a..236037c 100644 --- a/packages/code-storage-python/pierre_storage/client.py +++ b/packages/code-storage-python/pierre_storage/client.py @@ -217,9 +217,14 @@ async def list_repos( *, cursor: Optional[str] = None, limit: Optional[int] = None, + q: Optional[str] = None, ttl: Optional[int] = None, ) -> ListReposResult: - """List repositories for the organization.""" + """List repositories for the organization. + + Pass ``q`` to filter by a case-insensitive substring match against the + repository ``url``. Empty/whitespace ``q`` is treated as omitted. + """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS jwt = self._generate_jwt( "org", @@ -231,6 +236,10 @@ async def list_repos( params["cursor"] = cursor if limit is not None: params["limit"] = str(limit) + if q is not None: + q_clean = q.strip() + if q_clean: + params["q"] = q_clean url = f"{self.options['api_base_url']}/api/v{self.options['api_version']}/repos" if params: diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 3dac06d..0426d9f 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -723,6 +723,7 @@ async def merge( author: Optional[CommitSignature] = None, committer: Optional[CommitSignature] = None, allow_unrelated_histories: Optional[bool] = None, + squash: Optional[bool] = None, ttl: Optional[int] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" @@ -738,6 +739,8 @@ async def merge( raise ValueError("merge strategy is required") if strategy_clean not in {"merge", "ff_only", "ff_prefer"}: raise ValueError("merge strategy must be one of merge, ff_only, ff_prefer") + if squash is True and strategy_clean == "ff_only": + raise ValueError("merge squash is incompatible with the ff_only strategy") payload: Dict[str, Any] = { "source_branch": source_branch_clean, @@ -768,6 +771,9 @@ async def merge( if allow_unrelated_histories is not None: payload["allow_unrelated_histories"] = bool(allow_unrelated_histories) + if squash is not None: + payload["squash"] = bool(squash) + ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 73f449b..6af1573 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -453,7 +453,7 @@ class CreateCommitOptions(TypedDict, total=False): MergeStrategy = Literal["merge", "ff_only", "ff_prefer"] -MergeResultLabel = Literal["merge_commit", "fast_forward", "no_op", "unknown"] +MergeResultLabel = Literal["merge_commit", "fast_forward", "no_op", "squash", "unknown"] class MergeBranchesOptions(TypedDict, total=False): @@ -469,6 +469,7 @@ class MergeBranchesOptions(TypedDict, total=False): committer: CommitSignature strategy: MergeStrategy # required allow_unrelated_histories: bool + squash: bool class MergeSourceResult(TypedDict): @@ -726,6 +727,7 @@ async def merge( author: Optional[CommitSignature] = None, committer: Optional[CommitSignature] = None, allow_unrelated_histories: Optional[bool] = None, + squash: Optional[bool] = None, ttl: Optional[int] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index 2655e5b..c678598 100644 --- a/packages/code-storage-python/pierre_storage/version.py +++ b/packages/code-storage-python/pierre_storage/version.py @@ -1,7 +1,7 @@ """Version information for Pierre Storage SDK.""" PACKAGE_NAME = "code-storage-py-sdk" -PACKAGE_VERSION = "1.8.0" +PACKAGE_VERSION = "1.9.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index f0742e8..83d9466 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.8.0" +version = "1.9.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 335b207..7c78071 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.8.0" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 2457f0d..52228e6 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.7.0", + "version": "1.8.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 9aa82b1..cfcd87f 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -14,7 +14,7 @@ import { import { FetchDiffCommitTransport, sendCommitFromDiff } from './diff-commit'; import { RefUpdateError } from './errors'; import { ApiError, ApiFetcher } from './fetch'; -import type { RestoreCommitAckRaw } from './schemas'; +import type { MergeResponseRaw, RestoreCommitAckRaw } from './schemas'; import { branchDiffResponseSchema, commitDiffResponseSchema, @@ -111,7 +111,7 @@ import type { ListReposResponse, ListReposResult, MergeOptions, - MergeResponse, + MergeResultLabel, MergeResult, ListTagsOptions, ListTagsResponse, @@ -504,9 +504,19 @@ function transformCreateBranchResult( }; } -function transformMergeResult(raw: MergeResponse): MergeResult { +function normalizeMergeResultLabel( + result: MergeResponseRaw['result'] +): MergeResultLabel { + if (result === 'squash') { + return 'merge_commit'; + } + + return result; +} + +function transformMergeResult(raw: MergeResponseRaw): MergeResult { return { - result: raw.result, + result: normalizeMergeResultLabel(raw.result), commitSha: raw.commit_sha, treeSha: raw.tree_sha, source: { @@ -1495,6 +1505,9 @@ class RepoImpl implements Repo { if (strategy !== 'merge' && strategy !== 'ff_only' && strategy !== 'ff_prefer') { throw new Error('merge strategy must be merge, ff_only, or ff_prefer'); } + if (options.squash === true && strategy === 'ff_only') { + throw new Error('merge squash is incompatible with the ff_only strategy'); + } const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); const jwt = await this.generateJWT(this.id, { @@ -1549,6 +1562,10 @@ class RepoImpl implements Repo { body.allow_unrelated_histories = options.allowUnrelatedHistories; } + if (typeof options.squash === 'boolean') { + body.squash = options.squash; + } + const response = await this.api.post({ path: 'repos/merge', body }, jwt); const raw = mergeResponseSchema.parse(await response.json()); return transformMergeResult(raw); @@ -1894,15 +1911,19 @@ export class GitStorage { ttl, }); + const trimmedQ = options?.q?.trim(); let params: Record | undefined; - if (options?.cursor || typeof options?.limit === 'number') { + if (options?.cursor || typeof options?.limit === 'number' || trimmedQ) { params = {}; - if (options.cursor) { + if (options?.cursor) { params.cursor = options.cursor; } - if (typeof options.limit === 'number') { + if (typeof options?.limit === 'number') { params.limit = options.limit.toString(); } + if (trimmedQ) { + params.q = trimmedQ; + } } const response = await this.api.get({ path: 'repos', params }, jwt); diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 3876af2..53fab38 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -181,7 +181,7 @@ export const mergeTargetSchema = z.object({ }); export const mergeResponseSchema = z.object({ - result: z.enum(['merge_commit', 'fast_forward', 'no_op', 'unknown']), + result: z.enum(['merge_commit', 'fast_forward', 'no_op', 'squash', 'unknown']), commit_sha: z.string(), tree_sha: z.string(), source: mergeRefSchema, diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 7f39956..b09f099 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -198,6 +198,11 @@ export interface GitCredential { export interface ListReposOptions extends GitStorageInvocationOptions { cursor?: string; limit?: number; + /** + * Case-insensitive substring matched against repository `url`. Trimmed before + * matching; empty after trim is treated as omitted. + */ + q?: string; } export type RawRepoBaseInfo = SchemaRawRepoBaseInfo; @@ -827,9 +832,13 @@ export interface MergeOptions extends GitStorageInvocationOptions { committer?: CommitSignature; strategy: MergeStrategy; allowUnrelatedHistories?: boolean; + /** Incompatible with the `ff_only` strategy. */ + squash?: boolean; } -export type MergeResponse = MergeResponseRaw; +export type MergeResponse = Omit & { + result: MergeResultLabel; +}; export interface MergeSourceResult { branch: string; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index f266de8..fb99cb4 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -1775,6 +1775,62 @@ describe('GitStorage', () => { }); }); + it('normalizes squash merge results to merge_commit for 1.x compatibility', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = store.repo({ id: 'repo-merge-squash' }); + + mockFetch.mockImplementationOnce((_url, init) => { + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ + source_branch: 'feature', + target_branch: 'main', + strategy: 'merge', + squash: true, + }); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + result: 'squash', + commit_sha: 'squash-sha', + tree_sha: 'tree-sha', + source: { branch: 'feature', ephemeral: false, sha: 'source-sha' }, + target: { + branch: 'main', + ephemeral: false, + old_sha: 'old-sha', + new_sha: 'squash-sha', + }, + promoted_commits: 3, + }), + } as any); + }); + + const result = await repo.merge({ + sourceBranch: 'feature', + targetBranch: 'main', + strategy: 'merge', + squash: true, + }); + + expect(result).toEqual({ + result: 'merge_commit', + commitSha: 'squash-sha', + treeSha: 'tree-sha', + source: { branch: 'feature', ephemeral: false, sha: 'source-sha' }, + target: { + branch: 'main', + ephemeral: false, + oldSha: 'old-sha', + newSha: 'squash-sha', + }, + mergeBaseSha: undefined, + promotedCommits: 3, + }); + }); + it('validates merge inputs locally', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = store.repo({ id: 'repo-merge-validation' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 9f58b38..8503bbb 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -231,11 +231,13 @@ GitHub App sync does not use this endpoint. ## GET /repos — List Repositories ```bash -curl "$CODE_STORAGE_BASE_URL/repos?limit=20&cursor=CURSOR" \ +curl "$CODE_STORAGE_BASE_URL/repos?limit=20&cursor=CURSOR&q=sdk" \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Params: `cursor` (pagination), `limit` (default 20, max 100) +Params: `cursor` (pagination), `limit` (default 20, max 100), `q` (optional +case-insensitive substring matched against the repository `url`; trimmed before +matching; empty/whitespace is treated as omitted) Scope: `org:read` Response: `{ "repos": [...], "next_cursor": "...", "has_more": true }` @@ -315,10 +317,15 @@ curl "$CODE_STORAGE_BASE_URL/repos/merge" -X POST \ Required: `source_branch`, `target_branch`, `strategy` (`merge` | `ff_only` | `ff_prefer`). Optional: `source_is_ephemeral`, `target_is_ephemeral`, `expected_target_sha`, -`commit_message`, `author`, `committer`, `allow_unrelated_histories`. -Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"unknown", +`commit_message`, `author`, `committer`, `allow_unrelated_histories`, `squash`. +Set `squash: true` to collapse the source into a single new commit whose only +parent is the current target tip; incompatible with `ff_only`. +Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"squash"|"unknown", "commit_sha", "tree_sha", "source": {branch,ephemeral,sha}, "target": {branch,ephemeral,old_sha,new_sha}, "merge_base_sha?", "promoted_commits" }` +TypeScript SDK 1.x normalizes a raw `result: "squash"` payload to +`result: "merge_commit"` in its exported merge result types for semver +compatibility; Python and Go currently surface the raw label. Conflicts return HTTP 409 with `conflict_paths` and `merge_base_sha` preserved on the body. ## DELETE /repos/branches — Delete Branch @@ -891,4 +898,4 @@ git push origin feature-branch | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | | Policy ops (`ops`) | JWT-level guards. `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. | -| Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`. 409 on conflict. | +| Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`; optional `squash` (not with `ff_only`). 409 on conflict. |