Skip to content

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

Merged
asadali214 merged 30 commits into
devfrom
asadali214/custom-code-redesign
Nov 4, 2026
Merged

asadali214 merged 30 commits into
devfrom
asadali214/custom-code-redesign

Conversation

@asadali214

@asadali214 asadali214 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Keep your edits when regenerating an SDK. sdk generate --git-init makes the SDK directory a git repository of its own and records each generation under a hidden ref; every later run merges the fresh generation over your changes with git's three-way merge, and asks how a conflict is settled. A clone fetches the snapshot it lacks, a pre-push hook shares the snapshots with every push, and sdk publish keeps the edits too, uploading only what git would commit.

A redesign of #445

This branch rebuilds the feature of #445 (asadali214/custom-code-injection) from dev, phase by phase, under the rules that PR's review set out (.ai/plans/custom-code-phases.md, section 2): functional core and imperative shell, no private instance methods, no middlemen, Promise<Result> with early returns, one context for the SDK directory under git. Nothing from #445 is reused as code; its commits were the reference for behaviour, wording and tests, and its design document is back as .ai/plans/custom-code.md, made true for this layout. #445 can be closed in favour of this one.

What a developer can do, by phase

Phase Commit A developer can
0 d8a90a3 generate into the SDK directory itself; --api-version and --zip are gone, --destination is the directory
1 bc51663 make the SDK directory a repository with --git-init, and have the first generation recorded
2 82fd92d, 7fcd38f regenerate and have their edits merged, when nothing conflicts
3 b2af733, 2e65e27 choose how a conflict is settled, or resolve it by hand; the run ends as Action required, exit 1
4 b2892d9 rerun after leaving conflicts, and be asked what to do with them
5 0eec5e8 regenerate in a clone, which fetches the snapshot it lacks
6 48eaccf push, and have the snapshots shared by a pre-push hook
7 f56d740 publish an SDK that keeps edits, uploading only what git would commit
8 c64b480 read the docs for all of it

Breaking changes

sdk generate no longer takes --api-version or --zip, and --destination names the SDK directory itself rather than a parent that gets <language> below it. --force on both SDK commands never asks: conflicts are resolved using the generator's changes, and an SDK that cannot be merged is replaced, with a warning.

Reading it

  • .ai/plans/custom-code.md: the design, the decisions, the merge table, conflicts, the lab cases, the files and the architecture.
  • .ai/plans/custom-code-phases.md: how it was built, phase by phase, with the questions each phase raised and their answers.
  • CONTEXT.md: the terms.
  • apimatic-docs' CLI Reference and Customize SDKs page already carry this wording, and the flag descriptions here match their rows word for word. Two docs rows are stale against the CLI (sdk publish --destination defaults to <input>/sdk/<language>, and api transform --force differs by a word) and belong to the docs repo.

Verification

Each phase ended with tsc on src and test, ESLint, Prettier on the touched files, its own suites and one full run; the last full run is 2295 passing, 79 pending. The behaviour tests drive the actions with a stubbed generation service over real git repositories, bare remotes and a pre-receive that refuses the ref included.

🤖 Generated with Claude Code

https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o

asadali214 and others added 3 commits October 30, 2026 17:18
…ion and --zip

Phase 0 of the custom-code redesign: the SDK directory is the
--destination itself, else <input>/sdk/<language>, derived in one
place for both SDK commands.

- sdk generate: --api-version, --zip and the versioned build lookup
  are gone; a destination holding src is refused since the write
  empties it; without a terminal the overwrite question is not asked
  and the run fails naming --force.
- sdk publish: passes the language, stability and directory through;
  a dry run generates into the SDK directory, not a numbered folder
  under Downloads.
- SdkContext reduced to the directory, TempContext unzips, a terminal
  check replaces process.stdin.isTTY; BuildConfig, the Downloads
  helper and the zip save are removed; README regenerated.
- The phased plan in .ai/plans/custom-code-phases.md.

BREAKING CHANGE: sdk generate no longer takes --api-version or --zip,
and --destination names the SDK directory itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`sdk generate --git-init` makes the SDK directory a git repository of its own after the save
and records the generation under refs/apimatic/pristine, chained onto the last snapshot, so a
later run has a base to merge against. A directory that is already a repository of its own is
recorded into as well; one that only encloses the SDK is the developer's and is left alone.

Git is looked for only when the directory has a .git of its own or the flag was given. Without
a usable git, none on PATH or older than 2.38, the SDK is written as before with a warning that
says which; a run that ends without a repository gives the steps that make one. A repository
git will not inspect fails the run before generating; a failed init or snapshot is a warning,
since the SDK is on disk.

Phase 1 of the custom-code redesign (.ai/plans/custom-code-phases.md): GitService over git's
plumbing, GitVersion with none, GitContext answering hasGitDirectory, isRepository(git) and
recordSnapshot, and the prompts for the warnings, the initialised repository, a failed init or
snapshot, and a repository git would not inspect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A run into an SDK directory recorded under git merges the fresh generation over the
directory as it is, with git's merge-tree against the recorded snapshot, and writes only
what changed: your edits stay, the generator's changes land beside them, and the overwrite
question is not asked. A file in conflict is listed with what it is and nothing is written;
asking about it comes in the next phase. Each file keeps the line ending it has, else the
generator's, a binary file is never merged line by line, and a folder a removal leaves
empty goes with it.

Before the write the directory is kept, byte for byte, under refs/apimatic/previous. A
write that fails names the file, the directory is put back from that snapshot, and if that
fails too the restore command is given. The new generation is then recorded under
refs/apimatic/pristine, chained onto the base, with .apimatic/generation.json naming it; an
identical generation keeps the last snapshot. A record for another generation, or a
snapshot the repository no longer holds, is recorded afresh after the overwrite question,
with a warning for the missing snapshot; --force replaces a recorded SDK it cannot merge
into and says so.

Phase 2 of the custom-code redesign (.ai/plans/custom-code-phases.md): GitContext answering
generationRecord (none for no record), hasSnapshot, merge, keepPrevious, write and restore;
GitService over the plumbing with the same settings on every call and trees normalised
through a temporary attributes file; MergePlan from git's merge and diffs; GenerationRecord;
the prompts for the summary, the conflicts, a failed write and its undo, and a replaced or
unrecorded SDK. SdkContext is gone, GitContext.save keeping .git in its place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@asadali214 asadali214 self-assigned this Nov 2, 2026
Shield-Jaguar and others added 6 commits November 2, 2026 23:18
….exe

The diff and restore commands were built by hand around f.relativePath,
whose single quotes are not quoting to cmd.exe. Render them through
f.cmdAlt with a double-quoted path, as the plugin prompts do.

Also tidy the prompts: sdkMerged lists the kinds in straight-line code
instead of two lookup tables, plural moves to string-utils, and the git
version is interpolated without an explicit toString.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rows for sdk generate

The portal build spinner, the plugin publish contents line and the
generation timeout message each spelt out their own singular/plural
ternary; they now use plural from string-utils, with the same output.

The skill reference tables still described the SDK generate prompts and
action by the version selection this branch removed; the rows now name
what the files do today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A merge that conflicts now asks what should happen: resolve it yourself,
with the files left as git merged them and markers labelled "Your
changes" and "Generated by APIMatic"; resolve using the generator's
changes or yours, where the overlapping lines go to that side and the
rest of the merge stands; or cancel, writing nothing. A file a side
deleted or a binary one is taken whole; a file the generator no longer
produces stays yours for either answer, so an edit is never thrown away
by a choice that keeps the rest. Without a terminal the conflicts are
listed and nothing is written; --force resolves them for the generator.

A merge left to the developer marks the conflicted paths in the SDK
directory's own index as a merge would, so git status and a merge tool
see them, lists the next steps and ends as "Action required" with exit
code 1, a fourth outcome beside success, failure and cancellation. sdk
publish ends the same way instead of uploading markers.

Git's facts become objects in one place: each conflict carries its own
stage entries and knows how it lands for a side, and a path git names
that the merge leaves nowhere, such as the old name of a file it moved
into a directory the generator renamed, is neither settled nor counted.
merge.directoryRenames is pinned beside merge.conflictStyle so a
developer's global config cannot change where such a file lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…regenerating

A run into an SDK directory whose last merge was left to the developer finds the paths git
still holds as unmerged before it reads the record, whatever the record says. Without a
terminal and without --force they are listed and the run ends as Action required; --force
settles them for the generator; in a terminal the developer chooses to finish by hand (Action
required), to settle for the generator or for themselves, or backs out. Settling reads the
disk and the two sides git kept in the index: markers still there settle in place, a file a
side lacks or a binary one takes the chosen side whole with the generator's side keeping yours
where it has none, and text whose markers are gone was resolved by hand and stays. The files
then leave the unmerged state, and one more look stops the run if any is still unmerged. A
repository in the middle of a merge, rebase, cherry-pick or revert of the developer's own is
refused first, naming the operation and how to abort it.

Phase 4 of the custom-code redesign (.ai/plans/custom-code-phases.md), refactored together
with Phase 3: GitContext.inspect answers every question a run asks of the repository as one
RepositoryState, so the action reads a state instead of five Results; GitOperation is a value,
none-able like GitVersion; every plan carries the bytes that reach the disk, line endings
decided by merge and settlementFor, so write(plan) lands a merge and a settlement alike and
UnresolvedFile.settledFor answers a Settlement rather than bytes or undefined; the prompts
render what they are handed, with sdkMerged given its counts and the steps as two prompts the
action picks between; the context no longer forwards stageConflicts to the service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…generating

A run into a clone of an SDK repository finds a record naming a snapshot the clone does not hold,
since refs under refs/apimatic/ are not cloned. Once nothing is in the way, the run fetches the
pristine chain from the remote the branch pushes to, else origin, with no prompts, a batch-mode ssh
unless the developer's own client is configured, and a 30-second limit. The fetched chain is adopted
when there is none here or it continues the local one, kept when it is behind, and joined with a
two-parent commit when the two diverged. The snapshot here afterwards: the run merges. Still absent:
the warning says there is no remote, that the remote lacks it, or why the fetch failed in git's
words, and the generation is recorded afresh. Every recording also writes .apimatic/.gitattributes
beside the record, merging it as binary and marking it generated, so a pull that meets a teammate's
record leaves no markers in it.

Phase 5 of the custom-code redesign (.ai/plans/custom-code-phases.md). The fetch is a step of the
run after the refusals and the unresolved block, GitContext.fetchSnapshot, so nothing reaches the
network before a refusal; RepositoryState carries a RecordedSnapshot, the snapshot the record names
with whether it is held and, once the remote was tried, where else it was looked for as a
SnapshotFetch, which the one snapshotMissing prompt words through match; GitService gains remoteOf,
fetchRef and isAncestor, with commitTree taking any number of parents for the join; the chain rule is
meetChains, facts first, proven through git in the context's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After every recording the run writes the script apimatic-pre-push into git's own hooks directory
and makes the pre-push hook git runs call it: a new shell hook when there is none, the call right
after the shebang of a shell hook that is there, and nothing when the hook calls the script already.
Where git runs hooks from elsewhere (core.hooksPath, husky's layout included) or the hook is in
another language, nothing is written into it and the run names the file and the one line to add,
with a commit step when that file is in the repository. The script pushes the pristine ref with
--no-verify; when the remote's chain moved on it fetches that chain to a tracking ref, joins the
two and pushes again; a second failure prints one line in the server's words, and the push never
fails. A push that carries the ref itself is left to itself. The run says once that it added the
hook, or what is still needed, or that it could not be written; later runs are silent. Every git
call clears GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE, so a hook of the developer's own that runs
the CLI cannot hand it another repository.

Phase 6 of the custom-code redesign (.ai/plans/custom-code-phases.md). ShareHook holds the call
line, the markers and the script's name beside the rule, found, which reads the facts first and
answers one of five outcomes, each holding the one call to its handler for match, and the content
for the hook file through toWrite; GitContext.shareOnPush, a step of the run after each recording,
writes the script (its text beside the record's attributes) and the hook as the rule says and
answers how the hook was left, a hook that could not be written included, since nothing about it
stops the run; GitService.hooksDirectory and hooksRunFrom; FileService.makeExecutable; the one
shareHook prompt wording each outcome, a hook already calling the script as nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/infrastructure/file-service.ts Fixed
asadali214 and others added 2 commits November 3, 2026 14:39
…ould commit

sdk publish run with flags always passes --git-init, so the SDK directory becomes a repository of
its own and a later publish merges the generation over your edits, as sdk generate does; the
guided run (no flags, or --git-init alone) takes the flag and passes it on. The upload holds what
git would commit: not .git, not the CLI's record and, inside a repository of its own, not what the
SDK's own ignore rules keep out, such as node_modules; outside one, every file but those two, with
git never asked. The dry run says that the directory holds exactly what a real run uploads. --force
on both SDK commands now says what it does: never ask, conflicts resolved using the generator's
changes, an SDK that cannot be merged replaced, with a warning.

Phase 7 of the custom-code redesign (.ai/plans/custom-code-phases.md). GitContext.archiveForUpload
stands beside save as its mirror (a save keeps .git, an upload leaves .git and the record out),
reading the git version the publish action hands it, as generate does; keptOutOfUpload names the
rest. GitService.ignoredBelow reads ls-files for what the ignore rules keep out, a directory
ignored whole as one entry; RelativeDirectoryPath and RelativePaths carry it; ZipService.archive
and TempContext.zip take what to leave out, nothing by default, with the zip service's helpers as
module functions. SdkPublish.isGuided holds the guided-mode rule; FlagsProvider.sdkForce words
--force for the SDK commands as the docs' CLI Reference does; dryRunNotice and sdkNotZipped
reworded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
… clear the leftovers

CONTEXT.md names the SDK directory, the pristine snapshot, the generation record and custom code.
The design document .ai/plans/custom-code.md is back from the first attempt and made true for this
branch: the phases and their commits, the files, the architecture, and every name the first
attempt had replaced by the current one, with the one-context decision amended and dated. The four
SDK flag descriptions now say what their rows in the docs' CLI Reference say, both commands carry
the docs' sentence on keeping your changes, and the README names git 2.38 under Requirements and
is regenerated. The conventions the phases followed are in .ai/instructions.md and the skills:
Promise<Result> with early returns, no private instance methods, no middlemen, functional core and
imperative shell, one prompts class per action, flag wording that matches the docs.

Phase 8 of the custom-code redesign (.ai/plans/custom-code-phases.md). An ESLint rule keeps
src/types/sdk, src/types/git and src/types/file free of file, process and infrastructure imports.
The leftovers of the first attempt: terminal.isInteractive() is the one TTY check (the plugin
action, the not-found hooks and portal serve read it; the plugin prompts' canAsk is gone);
PluginConfigContext.hasNoSourceRepository, which only a test called, is removed; ServiceError
drops the error map nothing read, so badRequest takes the message alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
@asadali214 asadali214 changed the title feat(sdk): merge a regeneration into an SDK that keeps your edits feat(sdk)!: keep your edits when regenerating an SDK Nov 3, 2026
@asadali214
asadali214 marked this pull request as ready for review November 3, 2026 09:39
asadali214 and others added 13 commits November 3, 2026 16:09
v4 is the one generator, so the flag chose nothing: it was parsed and
dropped. A run that still passes it is told the flag is unknown. The
generation record never named the version on this branch, and keeps not
to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pre-push hook and the script it calls were set to 0o755 whatever mode they had. SonarCloud
flags that as world-accessible, which failed the PR's quality gate, and on a developer's own
owner-only hook it widened who may read and run it. makeExecutable now adds an execute bit
beside each read bit and keeps the rest of the mode, so a new hook is 0o755 under the usual
umask and an owner-only hook stays owner-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
When a merge's write fails and the directory cannot be put back, the
command the developer is given now reads
`git -C "<sdk>" restore --source=refs/apimatic/previous --worktree -- .`,
the ref the run kept the directory under, as the design names it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A regeneration that recorded the same snapshot rewrote
`.apimatic/generation.json` with the same bytes, so editors and file
watchers saw a change. It now goes through the same if-changed write as
the attributes beside it, as the design said it should.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ions

The error a git that will not answer stops the run with reads as it did
before the redesign: "The SDK directory could not be inspected for your
customizations: <git's words>".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…"…: ."

A conflict settled as git placed it, or by removing the file again,
landed on disk but counted nothing, so a run whose only change was one
of those summed up as "Regenerated the SDK at <sdk>: .". The placed file
now counts as updated and the removed one as removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s fetched

A clone that fetched the remote's snapshot chain failed the whole run
when a git here could not join it to its own, or look for the snapshot
once joined. Those are now a failed fetch, as before the redesign: the
warning names the remote and git's words, and the generation is
recorded afresh. A git that fails before the remote is asked still fails
the run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-push hook

The script reads what git lists on stdin, to leave a push that carries
the snapshot ref alone, and the block runs first, so the rest of an
existing hook (git-lfs, git's own sample) read nothing. The call line
now keeps a copy of stdin in the git directory, runs the script on it,
hands the copy to the rest of the hook as its stdin and removes it.
`command exec` and one `&&` chain keep a `sh -e` hook from exiting on a
redirection error or a git missing from its PATH.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… ones included

An upload asked git about the SDK's ignore rules only when git could
also merge (2.38 or newer), so with an older git the files `.gitignore`
keeps out, `node_modules` among them, went up. `ls-files` is as old as
git; any git that is found now reads the rules, and only a directory
without a `.git` of its own, or with no git at all, is zipped unasked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he phase notes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a public method

`isRootOfRepository` and the `.git`-on-disk check it starts with are
module functions taking the services and the directory, as the other
helpers in the context do; `hasGitDirectory` and `isRepository` are the
shell over them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`eslint .`, which `pnpm test` runs after the suite, refused the unused
import on CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
asadali214 and others added 6 commits November 3, 2026 17:16
…esting

A merged file's markers are read through markerOf, which says which
marker a line is, and a table of where each marker stands; the opened
flag goes, and the label check splits on the colon instead of a regex
that backtracks. A diff-tree record becomes a Change in changeOf, so the
rename test is written once. The summary's removed and kept lists are
filters over the inputs. The conflict choice a run takes without asking
is a pure unaskedChoice, so execute asks only when the choice is open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hook rewrote a `help:<topic>` suggestion to the help command with
the topic as its argument, then ran the suggestion as first spelt, so a
confirmed "Did you mean help sdk?" ran the command `help:sdk`, which
does not exist, and the hook asked the same question again. The
rewritten command is what runs now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The publish command reads the SDK directory into a const before the
action is chosen; the force flags are readonly statics; the share hook's
two printf lines are raw templates; the test environment helper imports
through node:; an unresolved file asks its markers through an optional
chain; a line ending is swapped with replaceAll; and the test helpers
hash the stage entries and read a snapshot's files at once instead of
one after another.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-redesign

Brings in Node 22.14 support (#462) and the README's pinned install
command (#461).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Remove 72 tests, 4 of them folded into the test beside them, that
another test already fails for:

- conflict and repository-state unit tests, which merge-plan and
  GitContext.inspect already pin
- GitContext merge, write and hook cases that the action tests, the
  GitService tests or the ShareHook unit tests assert, and one that
  only proved git's own merge=binary
- action tests whose branch the action only hands down (the side
  chosen, the hook's kind, a failed fetch), and table rows that run
  the same code
- trivial equality checks, a copy of the hook-call constant, and
  prompt tests pinning whole sentences of messages with no logic

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

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

@asadali214 asadali214 changed the title feat(sdk)!: keep your edits when regenerating an SDK feat(sdk): keep your edits when regenerating an SDK Nov 4, 2026
@asadali214
asadali214 merged commit a290a0a into dev Nov 4, 2026
18 checks passed
@asadali214 asadali214 mentioned this pull request Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.2.0-beta.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants