Skip to content

feat(sdk): keep your edits when regenerating an SDK - #445

Open
asadali214 wants to merge 28 commits into
devfrom
asadali214/custom-code-injection
Open

asadali214 wants to merge 28 commits into
devfrom
asadali214/custom-code-injection

Conversation

@asadali214

@asadali214 asadali214 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

apimatic sdk generate keeps 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 markers are gone.

Design: .ai/plans/custom-code.md in this branch. Tracking: apimatic/codegen-v2#675.

How it works

  • Git 2.38+ on PATH is the engine. Without it the command behaves exactly as before, writes no .git and no .apimatic/, and warns that customizations will not persist.
  • Snapshots as hidden refs. Each run records the SDK exactly as generated as a commit under refs/apimatic/pristine/<sdk-path> in the repository that encloses the SDK directory (the CLI runs git init in the SDK directory on the first run when nothing encloses it, and says so). The directory as it was before the run goes under refs/apimatic/previous/<sdk-path>. No branch is created or checked out, and no commit is needed before regenerating.
  • Shared by a pre-push hook. Every recorded run quietly writes the CLI's script, .git/hooks/apimatic-pre-push, and a three-line block in .git/hooks/pre-push that calls it (fresh, or after the shebang of an existing shell hook). From then on every git push also pushes refs/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 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. With core.hooksPath set (husky) 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.
  • The record. <sdk>/.apimatic/generation.json names the pristine commit the directory was written from, so a branch, worktree or clone merges against the right base. When that commit is neither local nor on the remote, the CLI says which commit is missing and how to share it, then asks before replacing.
  • The merge is git's. git merge-tree --write-tree between two temporary commits (disk and fresh generation) on the pristine base, run with the CLI's own -c settings 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, so git status shows them as both modified and git mergetool works.
  • Conflicts ask. Before anything is written, a run with conflicts lists them and asks: resolve by hand (markers, exit 1; the --force and CI 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 and your other edits in the same file survive), 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/<path> holds it). Files the CLI settles leave git's unmerged state. The choice rides on the telemetry event.
  • --force is unchanged and still means never ask. --zip is removed (breaking): it wrote the server's archive and nothing else, which custom code could never apply to. portal generate --zip is untouched.

Shape of the change

Command → Action → Application → Infrastructure/Types, per .ai/instructions.md:

  • SdkPrepareAction decides before the upload how the run will write (SdkDestination: overwrite, record, or merge), fetching the base when a record names a commit the repository lacks, and asks the overwrite question only when there is nothing to merge against.
  • SdkWriteAction writes the downloaded SDK that way, records and shares the snapshot, and reports: the generator's files as counts, the developer's files by path, conflicts by kind.
  • SdkRepositoryContext (types) is the SDK directory with its repository: inspect, snapshots, record, merge, apply, staging, installing the hook. SdkContext hands one back. ShareHook (types/sdk) is the script, the calling block, and how the block goes into an existing hook.
  • GitService (infrastructure) is the only place that spells git commands and parses their output, including the remote ones.
  • MergePlan and the conflict markers (src/types/sdk/) are pure: conflict kinds, takingGenerated, marker relabelling and their generator-side resolution, what to report. terminal.isInteractive (src/utils/) is the one check behind every question.
  • RelativeFilePath (types/file) is a path as git names it inside a tree, with in(directory) to the file on disk.

Verification

  • 1970 tests, of which ~250 are new, most over real git repositories: every row of the plan's merge table, both rename directions, CRLF, binary, index staging, bare remotes with --no-local clones for the fetch, and the hook driven by real pushes (shared, joined, refused, alongside an existing hook), and the action flows with a stubbed generation service.
  • A 30-case lab run against the hosted generator with the real CLI (the Speakeasy lab's cases, thirteen edge cases, seven of them about sharing, and one case per answer to the two conflict questions, answered by a harness in place of a terminal), recorded with every command's exact output: APIMatic Customization Lab (private; ask for access).
  • pnpm lint, pnpm build, and tsc --noUnusedLocals --noUnusedParameters clean for the new code. The 7 commitlint-config test failures on this machine are a local plugin-resolution issue that predates this branch.

Still to decide (plan §11)

  • A file the developer deleted comes back as a listed conflict when the generator changes it (git semantics), rather than a remembered deletion.
  • git init on the first run tells the developer afterwards rather than asking.
  • Review items: SdkContext.directory() accessor (the context skill's exception case).

🤖 Generated with Claude Code

asadali214 and others added 20 commits October 20, 2026 10:52
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>
Comment thread src/types/sdk-repository-context.ts Fixed
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>
@asadali214 asadali214 self-assigned this Oct 22, 2026
@asadali214
asadali214 marked this pull request as ready for review October 22, 2026 12:37
asadali214 and others added 4 commits October 23, 2026 09:41
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>
asadali214 and others added 3 commits October 23, 2026 11:55
An SDK at the repository root keeps its snapshots in refs/apimatic/pristine
itself, a ref rather than a directory of refs. The pre-push script listed
refs/apimatic/pristine/ and fetched refs/apimatic/pristine/*, and neither
matches that ref, so for such a repository the script found nothing, exited
quietly, and the snapshot never reached the remote. It now lists
refs/apimatic/pristine, which matches the ref and every ref below it as
the CLI's own listing does, and fetches refs/apimatic/pristine*, whose *
may match nothing. The CLI rewrites the script on its next run, so
existing repositories pick this up without a new hook block.

- push and join tests for an SDK at the root; `ready` takes the SDK path
  and an origin to share, which also shortens the existing join test
- the script's comments and the design record name the ref, not only the
  refs below it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on the two conflict questions. The first run now asks:
I'll resolve them myself, Resolve using generator changes, Resolve using
my changes, or Cancel this run. The run after, while files are still
unresolved, asks: I'll finish resolving them, or resolve the remaining
conflicts using generator changes or using my changes. Undo and cancel
are not on the second question; the first already cancels before
anything is written.

- "my changes" reruns git's merge with `-X ours`, so your lines win where
  both sides changed the same ones and the generator's other changes in
  the file still apply; what is left in conflict stays as it is on disk
  (`MergePlan.taking(side)`, `favouring(side, ...)`)
- the second run settles each file from the two sides git kept in the
  index (`GitService.unmergedStages`, pure `settledContent`): markers in
  place for either side (`resolveMarkers`), a file one side deleted stays
  or goes with that side, a binary takes that side's version, and a text
  file with no markers left was resolved by hand and stays
- `restorePrevious`, `previousRestored` and `GitService.checkoutTree`
  removed; the previous ref is still saved, for a restore by hand
- the steps end with "Or rerun and let the CLI resolve what is left,
  using either side." instead of pointing at the undo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s regenerated over

`--destination` was treated as a parent directory: the CLI added `<language>`
below it, and `<api-version>` too for a versioned build. Pointed at a clone of
an SDK repository, the run wrote a fresh SDK into `<clone>/<language>` instead
of merging over the clone and keeping its customizations. A named destination
is now the SDK directory itself, so `-d ./sdk` writes into `./sdk` rather than
`./sdk/python`. Without one the directory is as before: `<input>/sdk/<language>`,
or `<input>/sdk/<api-version>/<language>`.

- `ProjectContext.sdk(language, destination?)` decides the directory;
  `SdkContext` takes it as given and adds nothing below it
- a destination that is or holds `src` is refused (`destinationContainsSource`),
  since the SDK directory is emptied before a write that does not merge
- `sdk publish` picks the directory up front (`--destination`, else
  `<input>/sdk/<language>`) and generates and zips that same directory; the
  interactive flow asks for it after the language, so it can suggest it
- a test merges over a clone named as the destination, keeping an edit and
  creating no language directory in it
- README regenerated with `oclif readme`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants