Repository navigation
feat(sdk)!: keep your edits when regenerating an SDK - #445
Closed
asadali214 wants to merge 69 commits into
Closed
asadali214 wants to merge 69 commits into
asadali214 wants to merge 69 commits into
Conversation
Snapshots of an SDK directory as commits under hidden refs of the repository enclosing it: the pristine chain, one commit per generation, and the previous state before a run writes. A generation record in .apimatic/generation.json names the snapshot the directory was written from. Nothing calls it yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the isomorphic-git pristine store with a GitService that drives the installed git (2.38 or newer) through its plumbing, and add the three-way merge of a regenerated SDK over the developer's edits: `git merge-tree --write-tree` between two temporary commits on the pristine base, its output parsed into a merge plan of writes, deletions, renames and conflicts by kind, with the markers relabelled for the developer. Every git call carries fixed settings so the developer's own configuration cannot change a snapshot or a merge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`sdk generate` now merges a regeneration over the developer's changes instead of emptying the directory. Before the upload, SdkPrepareAction checks for git 2.38 or newer, inspects the SDK directory through the pristine store and decides how the run will write: as before, when git is absent (with a warning that customizations will not persist) or with --zip; written and recorded, when there is no base yet; or merged against the snapshot the generation record names. After the download, SdkWriteAction writes it that way, initialises a repository in the SDK directory when nothing encloses it, records the pristine snapshot and `.apimatic/generation.json`, and reports conflicts with markers, a list of files and exit code 1. An overwrite keeps the directory's `.git`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A conflicted regeneration now puts git's three versions of each conflicted path into the repository's index through `update-index --index-info`, so `git status` reports them as both modified and `git mergetool` opens them, and the command tracks a telemetry event with the counts by kind. An SDK whose recorded snapshot the repository does not hold is told which commit is missing and how a teammate pushes and the reader fetches the `refs/apimatic/pristine/*` refs before it is replaced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The plan behind keeping edits across regenerations, in `.ai/plans/`, the four terms it introduces in `CONTEXT.md`, a README section and the git requirement, and one more sentence in the command's description. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous ref's tree is rooted at the SDK directory, so the restore needs the work tree pointed there, as the lab verified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The summary after a merge names the files that were merged with the developer's changes and the ones kept as edited, as paths relative to where the command ran, and counts the generator's own files and the conflicts; the conflict block lists paths the same way and its steps match the kinds present. A generation identical to the last keeps the last pristine commit instead of chaining a new one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path inside a git tree, as `merge-tree`, `diff-tree` and the generation record name it, is now a value object: validated once where git's output and the record are parsed, turned into a file on disk through `in(directory)`, re-rooted for the index through `below`, and never a bare string in the merge plan, the services or the prompts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The merge, its application to disk and the index staging work on the same repository, base and trees the snapshots do, so they are methods of the store; the write action holds one service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The methods of the pristine store all took the repository as their first argument and read none of the store's own state, so they are the repository's: SdkRepositoryContext holds the git and file services the way SdkContext does, and SdkContext hands one back (found or initialised). The store and the SdkRepository value object are gone; the two tests that read snapshots use a small helper over GitService. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/application/ held four files with one caller, and both Types and Infrastructure imported from it, against the layer order. MergePlan and its types move to src/types/sdk/merge-plan.ts, with planMerge as MergePlan.from, and the conflict markers move beside it. GitService reads merge-tree's output itself, as diffTrees and listTree already do, and diffTrees returns the shared Change type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every recorded run the CLI pushes refs/apimatic/pristine/<path> to the branch's remote (or origin) and says so when it sent something. A run whose record names a commit the repository lacks fetches the ref first, so a clone or a CI checkout merges by itself. Chains that diverged because two machines generated are joined with a two-parent commit; nothing is ever forced. Remote commands run without prompts and with a 30-second timeout, so a missing credential, a refused push or a dead remote is a warning with git's reason and the manual push, never a stuck or failed run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SDK prompts took SdkRepositoryContext, decided which conflicts leave markers, and built git refspecs themselves. They now take the directory and refs they print; MergePlan answers hasMarkedConflicts and hasUnmarkedConflicts and names the generator's files and yours; and SnapshotRef gives its refspecs, which GitService.pushRef uses too. What the prompts print is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CommitId, TreeId and GitVersion know nothing of SDKs, and the shapes GitService reads from diff-tree and merge-tree (Change, StageEntry, MergeMessage, MergeTreeOutput) sat in merge-plan.ts, so the git service imported its own output types from an SDK file. They move to src/types/git/, the four shapes as merge-tree.ts, where StageEntry names its stages ours and theirs as git does. SnapshotRef, the conflict markers and MergePlan stay in src/types/sdk/: they name the SDK's snapshots, your side and the generator's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The write action set its own closing line for a run left with conflicts; the wording now lives in the write prompts, which the action imports. Comments in the prepare and write actions are cut to one line each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missing snapshot, a refused push, a nested SDK directory, a generation at another stability and the conflict steps no longer show commit ids, ref names, git's status words or the manual commands; the README keeps the manual push and the undo command. "Pushed" is one line whether or not chains were joined, and the fetch is no longer announced. The data the prompts no longer need went with it: the missing snapshot's commit, where a base was fetched from, the joined push outcome, and the nested refusal's prose reason, which is now a NestedSdkDirectory naming the other directory and whether it encloses this one. Remote errors carry the server's own words. The description of two generations side by side lives with the generation type, so the prompts stay wording. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The flag wrote the archive the server sent and nothing else, which custom code could never apply to, and nobody needs the CLI to zip a directory. SdkContext.save only copies now and cannot fail with a result, so the "could not be saved" prompt and SdkSaveProblem go with it; the destination's overwrite kind has no zip field. The server still delivers a zip, which the CLI unpacks into a temp directory, and portal generate --zip is untouched. SdkWriteAction's three destinations are written out in its switch instead of three private methods. BREAKING CHANGE: `apimatic sdk generate --zip` is no longer accepted. Generate into a directory and archive it yourself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…om the CLI The CLI no longer pushes. After every recorded run it writes its script, .git/hooks/apimatic-pre-push, and a three-line block in .git/hooks/pre-push that calls it, quietly. Every git push then also pushes refs/apimatic/pristine/* to the remote being pushed to; a chain another machine pushed first is fetched into refs/apimatic/remotes/<remote>/pristine/* and joined, and a server that refuses the ref gets one line in its own words while the branch still lands. The snapshot and the record that names it now reach the remote together, so a remote attached after generating, or a push that once failed, no longer leaves clones without a base. When git runs hooks from elsewhere (core.hooksPath, as husky sets it) or the existing hook is not a shell script, the run shows a next-steps note with the one line to add; the note stops once that file runs the script. The CLI's only remote operation left is the fetch for a record whose commit is missing locally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Before anything is written, a run with conflicts lists them and asks: resolve by hand (markers, exit 1; the --force and no-terminal default), the generator's changes where they conflict (the merge run again with --strategy-option=theirs, so only the overlapping hunks go the generator's way; a file one side deleted is settled whole), or keep the SDK as it is (nothing written, nothing recorded). The next run, while markers remain, asks again: finish resolving, the generator's side inside the remaining markers (settled in place, no network), or undo the last generation (the directory back as refs/apimatic/previous holds it, through a temporary index). Files the CLI settles leave git's unmerged state. The choice rides on the conflict telemetry event. The summary lists files you added under "Kept as yours", next to the ones you edited; the record directory is never listed. The README's custom-code section is gone: the CLI's own messages carry what a developer needs, and the feature docs belong in the v4 docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SDK run is one `GenerateAction.execute`, read top to bottom, and the only place that speaks: every question and message is a prompts call in it, and the action has no other method. The prompts word the data they are handed and decide nothing. What a state means is pure and lives in `src/types/sdk/`: `SdkDestination` carries its reason, `destinationFor` maps a repository state to it (`unresolved` included), `replacesDirectory` is what the overwrite question is about, and `Settlement` (`resolving`, `favouringGenerator`) is the two ways a conflicted merge becomes writable, remembering the files to name once they are written. The I/O is on the contexts: `SdkContext.destination`, and `SdkRepositoryContext.regenerate` and `write`. `SdkPrepareAction`, `SdkWriteAction` and their prompts are folded away; `SdkMergeConflictedEvent`, `ConflictOutcome` and the result payload are removed, so `execute` returns a plain `ActionResult`. The two action tests drive `GenerateAction` through `test/helpers/sdk-generation.ts`; the merge lab runs through `regenerate` and `write`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three differences between a developer's machine and the runners failed the same tests on every OS, before the previous commit as well: - A bare origin made with `git init --bare` has `master` as HEAD on a runner, which nothing pushes, so a clone of it checks out nothing and every clone test saw no record. The tests now say `--initial-branch=main`. - `os.tmpdir()` is an 8.3 alias on the Windows runner and a symlink on macOS; git answers with the real spelling, which the code already canonicalises. The tests now canonicalise their root the same way. - `RelativeFilePath` refused `C:/a.ts` only on Windows, since `path.isAbsolute` is the platform's; it now asks both platforms. The share hook is written as 0o700: only the user who pushes runs it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The second run asked the record for the files the last run left in conflict and scanned them for the CLI's marker lines. Git's own rule is simpler and is what every tool follows: a conflict is resolved when the file is staged. The run now asks `ls-files -u` below the SDK directory and nothing else, so an unstaged resolution still counts as unresolved, a line that merely looks like a marker does not, and the record no longer carries a local, transient state in a committed file. - `GitService.unmergedPaths`; `RelativeFilePath.inside` - `GenerationRecord` drops `conflicts` (older records still parse) - `hasConflictMarkers` removed; `restorePrevious` takes the paths - `resolveForGenerator` leaves an unmerged file without markers as it is - second-run messages say "still unresolved"; first-run steps say to stage each file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`execute` stays the one place that names every prompt, but four steps now live below the class as free functions that take the prompts they may use as arguments, the way `versionToBuild` takes `selectVersion`: `settleMarkers`, `settleConflicts`, `recordSnapshot` (once, for both the record and the merge branch) and `closeMerge`. A free function has no `this`, so it has no other way to speak. For that, no prompt method may use `this` any more: `attentionTitle`, `conflictLines` and `steps` moved to module scope beside `whereItWasLookedFor`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n speak for itself - `download` takes the upload, the generation and the unzip out of the `withDirPath` callback; the spinner is handed over as the exact prompt, the service call as a closure over what only `execute` holds. - `settleMarkers` is now `settleUnresolvedConflicts`: since unresolved conflicts are read from git's index, the step no longer looks at markers. - Delegates are passed as `this.prompts.x` at each call site, so the prompt a step may use is named in full where it is handed over. - The action carries no comments; its names say what they did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Follow-up in 88aa107: |
…ch kept The merge commit a904c21 recorded the merge-base version of src/types/project-context.ts, which no longer compiled against the callers on either side. This is the three-way result: dev's buildZip, pluginBuildZip and stagedBuild on this branch's two-argument context, without the versioned reading helper the branch had removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ores The archive used to take the list of files to include, read from git's view of what it would commit, with a second directory walk in the SDK context for the case without a repository. The zip service now walks the directory itself and leaves out what it is told to: `.git`, the CLI's record and, inside a repository, what the ignore rules keep out, listed by `git ls-files --ignored --directory`. A wholly ignored directory is pruned as one entry, while an ignored file beside something tracked is left out on its own. Directories get their own RelativeDirectoryPath beside RelativeFilePath, and RelativePaths carries the two kinds together where git lists them in one call and the zip service excludes both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ypes where they belong The four effectful stores, the merge engine and the on-disk helpers move to src/types/sdk-repository/; GitProblem, GitOperation, TreeEntry and the .git name move to src/types/git/ so infrastructure imports from types, not the reverse; the terminal adapter moves to infrastructure. Contexts reach the disk through FileService only, SdkRepositoryContext composes every part in its constructor, and an ESLint rule keeps src/types/sdk/ free of fs and infrastructure imports. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mGit that throws
SdkRepositoryProblem becomes RepositoryProblem { reason }, which a GitProblem
already is, so nothing relabels git's words; only write can answer NotApplied,
and archive hands a git failure on as itself rather than as zipFailed.
GenerationRecordFile reads and writes plainly, wrapped by the context. Every
fromGit validates and returns undefined, and GitService turns that into an
err; topLevel and readBlobs return early instead of composing with andThen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ds around BlobId and RemoteName join CommitId and TreeId; RelativeFilePath.under and within take a RelativeDirectoryPath, SdkPath holds one and spells its strip direction within; GitService takes an SdkPath where it means one, lists refs as SnapshotRefs, and answers a canonical topLevel. A ValueMap keyed by what a value spells replaces the string-keyed maps in MergePlan and the stores. Field aliases and assign-then-return locals left by the earlier passes are inlined. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the core classify and afterFetch hold the precedence a run decides the repository's state by, reconcile how two pristine chains meet, and destinationFor builds every destination including overwrite and noRepository; the contexts only gather. SdkDestination is generic over the handle the shell writes through, so the core never names a context, and SdkRepositoryContext.create is total over an SdkPath instead of answering undefined. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…run cannot ask writeModeFor turns the destination and how the run was started into one value the action dispatches on in a single switch, carrying the --force refusal and the one question each mode still needs; the command decides whether a run may ask and passes it in, so a non-interactive run without --force now refuses at the overwrite, repository and conflict questions instead of hanging, saving without a repository or writing markers. The force flag's help says what the run does; the closing line of a conflicted merge is spoken by the prompts; write and stageConflicts take the settlement and regeneration whole, and inspect resolves its own remote. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mean SdkGeneration is a value the command's flags build once, with no fallback generator, and it owns equality, its descriptions and the flag that would make two generations match; recordFresh snapshots and records in one step and recordingNotice turns the outcome into one thing to say; the next steps, the hook's remaining steps and the restore command come from the core as data the prompts word through tables. The hook outcome names the directory git runs hooks from as a path, and every prompt that took a reason string takes the problem. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and the plan as amended The binary test, the latin1 round-trip and the line-ending rule live once in the core and the store only reads the sample; the hook decision is one pure update; chosen names the side it picked; the conflict style the markers rely on is a shared constant, and the hook script and attributes text interpolate the constants they repeated. The merge is settled by one function with one cancellation, merge-tree output is parsed in one place into a closed union with the stages named by role, and the markers parser and hook block are no longer exported for tests alone. GitService's mode parameters become named methods and its two runners one; the zip and temp contexts split the same way. The publish destination is a DirectoryPath from the command and its duplicate containment check is gone, each prompts class keeps its own wording, stale comments go, and the plan's sections 2, 9 and 10 say what the code now does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…now uses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Shield-Jaguar
requested changes
Oct 29, 2026
| await fsExtra.writeFile(filePath.toString(), content); | ||
| } | ||
|
|
||
| public async chmod(filePath: FilePath, mode: number): Promise<void> { |
Contributor
There was a problem hiding this comment.
i don't think this belongs here. This is not related to files.
Contributor
Author
There was a problem hiding this comment.
I have renamed this function, to make it clear that it belongs here
…TTY comparison Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The question that offered to create a repository is a flag: --git-init makes the SDK directory a repository of its own, and without it the run writes the SDK and warns, whether or not it could have asked. sdk publish always passes it when run with flags, and takes --git-init in its step-by-step experience. Only the SDK directory's own repository counts; one that merely encloses it is the developer's project and is left alone, and the run says once when it found a repository the developer already had. --force settles conflicts for the generator, this run's and those the last run left unresolved. Resolving by hand succeeds, with steps that offer a merge tool, since the conflicts are in git's index, and name the files with markers and without; finishing the last run's conflicts later cancels. The conflict labels say what happened and nothing about how the file is written, and the warning about what the directory holds is gone. With one SDK directory per repository, what served one below a root goes: SdkPath, per-path and percent-encoded refs, the nested-SDK refusal, SdkRepositoryContext.create, the sub-path branches of the tree snapshot and of unmergedStages, and RelativeFilePath.under. The hook pushes the one pristine ref, and the restore command runs git -C in the SDK directory, which now holds the ref. The plan, CONTEXT.md and the README say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FileService.chmod becomes makeExecutable, which owns the owner-only mode the share hook needs, since that is the only reason anything calls it. Gone, with the tests that only exercised them: FileService .getAvailableDirectoryPath, getDownloadsDirectory and its os-extensions module, PluginConfigState.hasNoSourceRepository, ServiceError.getError with the errors map only it read, ApimaticConfigContext.exists, PortalArtifacts.none, SpecContext.save, ValueMap.keys, RelativeDirectoryPath.isEqual, RelativeFilePath.compare, and isEqual on BlobId, RemoteName and CommitId. Tests that compared commit ids with isEqual now compare them with deep equality. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into asadali214/custom-code-injection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith a warning A recorded SDK whose record names another generation, or whose snapshot is missing, has no base to merge against. --force refused it before the upload; it now replaces it, as it does any SDK it cannot merge into, and warns after the write that the customizations were not carried over: the committed ones are still in its git history, the uncommitted ones are gone. The run succeeds and records a fresh snapshot, so the next run merges again. The flag's help, the README and the plan say so. terminal.isInteractive returns false rather than undefined when stdin is not a TTY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save keeps only .git, but the overwrite question skipped every name starting with a dot, so a first recording run in a repository of the developer's own that held only .github or .husky removed them without asking. SdkContext.exists now asks FileService.hasEntriesExcluding, which counts what cleanDirectoryExcluding would remove; a directory holding only .git still needs no question. The other commands keep directoryEmpty. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that left conflicts behind ended as whatever path it took: "I'll resolve them myself" succeeded, "I'll finish resolving them" was cancelled, and a run without a terminal facing the last run's unresolved files failed. ActionResult.conflicted() closes all three in yellow, exit 1 as git merge gives conflicts, so a command chained after it does not run. Backing out of the unresolved question now cancels, as it does at every other question. sdk publish checked the generation only for a failure or a cancellation, so a conflicted one was zipped, uploaded with its markers and recorded as published. It now stops before zipping and ends Conflicted too. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that wrote the SDK into a directory that is not a git repository of its own warned before the upload that customizations would not persist. It now ends with a Next steps box after the write instead: changes are lost on the next regeneration; to keep them, rerun with --git-init before changing anything, then customize. A run that fails before writing says nothing about git, since nothing was written. The save write mode's warnNoRepository becomes suggestGitInit. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The box after a run without a repository of its own spoke as though the developer would change the SDK. It now starts "To customize ... and keep your changes when it is regenerated:", says --git-init needs git 2.38 or newer, and then says to make the changes. The steps after "I'll resolve them myself" end at "2. Rerun the command." They no longer say nothing needs committing first, or offer a third step to let the CLI resolve what is left: the rerun asks that. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
A run that leaves the developer something to do before its work is done ended as Conflicted. The state is now named Action required, so it is not tied to conflicts: ActionResult.actionRequired() and isActionRequired(), ResultType.ActionRequired, mapAll's onActionRequired and format.outroActionRequired. It is still yellow and still exits 1. sdk publish checks it under the new name and keeps saying the SDK still has conflicts, since conflicts are the only way there. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
…or --git-init makes one Every run began with git --version, so a machine without git, with one too old or with one that did not answer warned or failed even when the SDK directory had no repository and none was asked for, where git has nothing to keep. SdkContext.destination now takes gitInit and first reads from disk whether the SDK directory has a .git of its own, a folder or a worktree's file. Without one and without --git-init it answers noRepository without running git, and the run ends with the Next steps. SdkContext.repository() takes the same check first, so archive asks git about ignore rules only inside a repository of its own. With a .git or --git-init, git is checked as before. sdk publish run with flags passes --git-init, so it still checks. The plan says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
|
Contributor
Author
|
This work is redesigned in this PR: #465 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What
apimatic sdk generatekeeps the developer's edits to a generated SDK across regenerations. Edit any file, any line; change the spec; run the same command again, and the generator's changes are merged into the edited files, new files are added, and files the generator stopped producing are removed when they were not edited. Overlapping changes become git-style conflicts: markers in the file, the files listed, exit code 1, and a refusal to run again until the files are resolved and staged.Design:
.ai/plans/custom-code.mdin this branch. Tracking: apimatic/codegen-v2#675.Breaking changes
sdk generate --zipis removed. It wrote the archive the server sent and nothing else, which custom code could never apply to. Generate into a directory and archive it yourself.portal generate --zipis untouched.--destinationnames the SDK directory itself, forsdk generateandsdk publish. Before, the CLI added<language>below it, so-d ./sdks -l pythonwrote./sdks/python; now it writes./sdks, so a clone of an SDK repository can be named and regenerated over. Without the flag the directory is<input>/sdk/<language>, as before. Two languages need two destinations.sdk generate --api-versionis removed. The command no longer readsAPIMATIC-BUILD.jsonto pick a versioned build; every SDK is built from the project's source directory.How it works
.gitand no.apimatic/, and warns that customizations will not persist.refs/apimatic/pristine/<sdk-path>in the repository that encloses the SDK directory, every path segment percent-encoded so any directory name makes a valid ref. When nothing encloses the directory, or the enclosing repository ignores it, a terminal run asks on the first run whether to create a repository there;--forceor no terminal writes the SDK without one and warns that edits will not persist. The directory as it was before the run goes underrefs/apimatic/previous/<sdk-path>. No branch is created or checked out, and no commit is needed before regenerating.pre-pushhook. Every recorded run writes the CLI's script,.git/hooks/apimatic-pre-push, and a three-line block in.git/hooks/pre-pushthat calls it (fresh, or after the shebang of an existing shell hook), and says so the first time the hook file is written. From then on everygit pushalso pushesrefs/apimatic/pristine/*to that remote; a chain another machine pushed first is fetched into tracking refs and joined with a two-parent commit, and a server that refuses the ref gets one line in its own words while the branch still lands. The CLI itself never pushes. Its only remote operation is a fetch, without prompts, with the developer's own SSH client, and with a 30-second timeout, when a record names a commit the repository lacks, so a clone or a CI checkout merges by itself. Withcore.hooksPathset (husky, or a~/.githooksof the developer's own) or a non-shell hook, the run shows a next-steps note with the one line to add to the hook that does run; the note stops once it is there.<sdk>/.apimatic/generation.jsonnames the pristine commit the directory was written from, so a branch, worktree or clone merges against the right base. It holds only what the merge needs, so a regeneration that changed nothing leaves it untouched. It is committed with the SDK, and merges as binary so a pull between two clones that both regenerated never writes markers into it. When that commit is neither local nor on the remote, the CLI says where it looked, then asks before replacing.git merge-tree --write-treebetween two temporary commits (disk and fresh generation) on the pristine base, run with the CLI's own-csettings so the developer's config cannot change the outcome; renames paired by content on both sides; add/add, modify/delete and binary conflicts as git reports them. Conflicted paths are staged into the index at stages 1/2/3, sogit statusshows them as both modified andgit mergetoolworks. Each file is written back with the line ending it has on disk, or the generator's copy's, so CRLF files stay CRLF.--forceand CI default), the generator's changes where they conflict, or your own changes where they conflict, or cancel the run (nothing written, nothing recorded). For either side, each marked block is settled for that side as the file is written, so only the overlapping hunks go that way and the other edits in the same file survive; a file one side deleted, or a binary one, is settled whole, and a customized file the generator no longer produces is kept whichever side is chosen. The next run, while git holds any of the SDK's files as unmerged, asks before generating: finish resolving, or settle what remains for either side, in place and without network. The run refuses outright while agit merge, rebase, cherry-pick or revert is in progress, and never reads git's own markers as the CLI's.--forcestill means never ask, and never replaces an SDK that has a generation record: a record for another generation, or one whose snapshot is missing, fails the run before the upload, with the way out named.sdk publishgenerates or merges into the SDK directory, then uploads the files git would commit below it:.git,.apimatic/and anything the SDK's.gitignoreexcludes stay out. A dry run does the same on the same directory and stops before the upload, so what it opens is what a real run uploads.Shape of the change
Command → Action → Prompts/Types → Infrastructure, per
.ai/instructions.md:GenerateAction.executeis the whole run, read top to bottom; every question and message is in it. It asksSdkContext.destinationhow the run will write (SdkDestination: overwrite, record, or merge, orunresolved), before the upload.SdkRepositoryContext(types) is the SDK directory with its repository: inspect, snapshots, record, merge, write, staging, settling, the hook, the files to share.SdkContexthands one back, and zips the SDK for upload.GitService(infrastructure) is the only place that spells git commands and parses their output, including the remote ones.MergePlan,Settlementand the conflict markers (src/types/sdk/) are pure: conflict kinds,taking(side), marker relabelling and settling, what to report.ShareHookis the script, the calling block, and how the block goes into an existing hook.terminal.isInteractive(src/utils/) is the one check behind every question.RelativeFilePath(types/file) is a path as git names it inside a tree, within(directory)to the file on disk.Verification
--no-localclones for the fetch, the hook driven by real pushes (shared, joined, refused, alongside an existing hook, under~/.githooks), a failed write undone, the publish upload's contents, and the action flows with a stubbed generation service.pnpm lint,pnpm build, andtsc --noUnusedLocals --noUnusedParametersclean for the new code.Still to decide (plan §11)
git initon the first run tells the developer afterwards rather than asking.SdkContext.directory()accessor (the context skill's exception case).🤖 Generated with Claude Code